From a1fd9ce00a1a26cf055773eb82ea6d528d48239f Mon Sep 17 00:00:00 2001 From: stan Date: Sat, 26 Sep 2026 12:50:48 +0800 Subject: [PATCH] fix(QAOA): honor current angle depth on repeated circuit runs --- pyqpanda-algorithm/pyqpanda_alg/QAOA/qaoa.py | 11 +- test/QAOA/Test_qaoa_repeated_depth.py | 101 +++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 test/QAOA/Test_qaoa_repeated_depth.py diff --git a/pyqpanda-algorithm/pyqpanda_alg/QAOA/qaoa.py b/pyqpanda-algorithm/pyqpanda_alg/QAOA/qaoa.py index 688e01e3..f0c41158 100644 --- a/pyqpanda-algorithm/pyqpanda_alg/QAOA/qaoa.py +++ b/pyqpanda-algorithm/pyqpanda_alg/QAOA/qaoa.py @@ -445,8 +445,9 @@ def _qaoa_circuit(self, qlist, gammas, betas): pyqpanda Circuit\n """ - if self.layer is None: - self.layer = len(gammas) + if len(gammas) != len(betas): + raise ValueError('gammas and betas must have the same length') + self.layer = len(gammas) circuit = QCircuit() circuit << self._init_circuit(qlist) for i in range(self.layer): @@ -458,6 +459,12 @@ def run_qaoa_circuit(self, gammas, betas, shots=-1): """ Given parameters, run the qaoa circuit and get the theoretical probability distribution. + Each call uses one layer per gamma/beta pair and updates ``self.layer``. + The two sequences must have the same length; unequal lengths raise + ``ValueError`` without changing the layer or successful circuit count. + Empty sequences run only the initial-state circuit (zero layers). + Reusing this instance with a different depth does not reuse old angles. + Parameters gammas : ``array-like``\n Parameter gamma for QAOA phase circuit\n diff --git a/test/QAOA/Test_qaoa_repeated_depth.py b/test/QAOA/Test_qaoa_repeated_depth.py new file mode 100644 index 00000000..6cb6e795 --- /dev/null +++ b/test/QAOA/Test_qaoa_repeated_depth.py @@ -0,0 +1,101 @@ +"""Changing QAOA depth must use every angle on each call, independent of history.""" + +import numpy as np +import pytest +import sympy as sp +from pyqpanda3.hamiltonian import PauliOperator +from pyqpanda_alg.QAOA.qaoa import QAOA + +GAMMAS = [0.31, -0.47, 0.23] +BETAS = [0.17, 0.29, -0.41] +# Basis order is |x1 x0>; compute the cost independently of the library. +ENERGIES = np.array( + [0.2 + 0.7 * (i & 1) - 1.1 * (i >> 1) + 1.3 * (i & 1) * (i >> 1) for i in range(4)] +) + + +def _problem(representation: str = "symbolic") -> QAOA: + """Construct equivalent full-width costs without relying on pending fixes.""" + if representation == "pauli": + return QAOA( + PauliOperator({"": 0.325, "Z0": -0.675, "Z1": 0.225, "Z0 Z1": 0.325}) + ) + x0, x1 = sp.symbols("x0 x1") + return QAOA(0.2 + 0.7 * x0 - 1.1 * x1 + 1.3 * x0 * x1) + + +def _reference(gammas: list[float], betas: list[float]) -> np.ndarray: + """Evolve with analytic diagonal costs and dense RX matrices, without SDK.""" + state = np.ones(4, dtype=complex) / 2 + for gamma, beta in zip(gammas, betas, strict=True): + state *= np.exp(-1j * gamma * ENERGIES) + rx = np.array( + [[np.cos(beta), 1j * np.sin(beta)], [1j * np.sin(beta), np.cos(beta)]] + ) + state = np.kron(rx, rx) @ state + return np.abs(state) ** 2 + + +def _assert_distribution( + actual: dict[str, float], gammas: list[float], betas: list[float] +) -> None: + """Compare every probability, including states omitted from SDK output.""" + values = [actual.get(format(i, "02b"), 0.0) for i in range(4)] + np.testing.assert_allclose(values, _reference(gammas, betas), atol=1e-12, rtol=0) + + +@pytest.mark.parametrize("representation", ["symbolic", "pauli"]) +@pytest.mark.parametrize("depths", [(1, 2, 3, 1), (3, 2, 1, 3), (0, 2, 0, 1)]) +def test_depth_sweep_matches_dense_evolution( + representation: str, depths: tuple[int, ...] +) -> None: + """Growing, shrinking and zero-layer calls use current angles, not history.""" + problem = _problem(representation) + for count, depth in enumerate(depths, start=1): + gammas, betas = GAMMAS[:depth], BETAS[:depth] + # Exercise existing list, tuple and NumPy input support. + actual = problem.run_qaoa_circuit(np.array(gammas), tuple(betas), shots=-1) + _assert_distribution(actual, gammas, betas) + assert problem.layer == depth + assert problem.circuit_iter == count + + +@pytest.mark.parametrize("warmup", [False, True]) +@pytest.mark.parametrize("lengths", [(1, 2), (2, 1), (0, 1), (1, 0)]) +def test_unpaired_angles_rejected_without_changing_depth( + warmup: bool, lengths: tuple[int, int] +) -> None: + """Reject unused or missing angles without damaging the next valid run.""" + problem = _problem() + if warmup: + problem.run_qaoa_circuit(GAMMAS[:1], BETAS[:1]) + old_layer, old_count = problem.layer, problem.circuit_iter + with pytest.raises(ValueError, match="same length"): + problem.run_qaoa_circuit(GAMMAS[: lengths[0]], BETAS[: lengths[1]]) + assert problem.layer == old_layer + assert problem.circuit_iter == old_count + actual = problem.run_qaoa_circuit(GAMMAS[:2], BETAS[:2]) + _assert_distribution(actual, GAMMAS[:2], BETAS[:2]) + + +@pytest.mark.parametrize("mode", ["default", "interp"]) +def test_optimizer_and_direct_calls_can_share_one_instance(mode: str) -> None: + """Actual optimizers retain final depth and allow a subsequent direct sweep.""" + problem = _problem() + problem.run_qaoa_circuit(GAMMAS, BETAS) + initial = [0.31, 0.17] if mode == "interp" else [0.31, -0.47, 0.17, 0.29] + distribution, parameters, loss = problem.run( + layer=2, + initial_para=initial, + optimize_type=mode, + optimizer="SLSQP", + optimizer_option={"options": {"maxiter": 1}}, + shots=-1, + ) + gammas, betas = list(parameters[:2]), list(parameters[2:]) + assert problem.layer == 2 + assert len(parameters) == 4 + _assert_distribution(distribution, gammas, betas) + assert loss == pytest.approx(float(_reference(gammas, betas) @ ENERGIES), abs=1e-12) + actual = problem.run_qaoa_circuit(GAMMAS[:1], BETAS[:1]) + _assert_distribution(actual, GAMMAS[:1], BETAS[:1])