You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mahout.apache.org by ak...@apache.org on 2026/10/20 17:57:05 UTC
(mahout) branch pr/749-param-optimization created (now ad5e5acdc)
This is an automated email from the ASF dual-hosted git repository.
andrewmusselman pushed a change to branch pr/749-param-optimization
in repository https://gitbox.apache.org/repos/asf/mahout.git
at ad5e5acdc [Docs] Add design note and guide for parameter optimization (#749)
This branch includes the following new commits:
new 2025f479e [Qumat] Fix Qiskit state vector after SWAP and reuse its target (#749)
new 3a4ce89ac [Qumat] Allow one parameter name in several Qiskit gates (#749)
new 240e4bc26 [Qumat] Add parameter-shift gradients and optimizers (#749)
new ad5e5acdc [Docs] Add design note and guide for parameter optimization (#749)
The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(mahout) 03/04: [Qumat] Add parameter-shift gradients and optimizers (#749)
Posted by ak...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
andrewmusselman pushed a commit to branch pr/749-param-optimization
in repository https://gitbox.apache.org/repos/asf/mahout.git
commit 240e4bc26babe844c2e9f62bd5c1d90b2e6142bd
Author: Andrew Musselman <ak...@apache.org>
AuthorDate: Wed Oct 7 22:54:16 2026 -0700
[Qumat] Add parameter-shift gradients and optimizers (#749)
Add qumat/optimize.py, a reference implementation of the
"parameter optimization utilities" requested in #749 and listed as
missing in docs/advanced/gap-analysis.md (Part 1 section 4, Part 2
sections 1, 4 and 5).
Public API (also exported from qumat):
- expectation_z(qumat, params, qubits): exact, shot-free <Z...Z>
computed from get_final_state_vector(). canonical_state_vector()
undoes the backend ordering difference (Qiskit is little-endian,
Cirq/Braket big-endian) so qubit k means the same on all backends.
- parameter_shift_gradient(qumat, cost_fn, params): exact gradient
for RX/RY/RZ (all exp(-i theta P / 2)). A parameter that drives R
gates gets the general 2R-term shift rule (Wierichs et al. 2022),
which reduces to [f(t + pi/2) - f(t - pi/2)] / 2 for R = 1; the
two-term rule alone is wrong for shared parameters.
- optimize_parameters(qumat, cost_fn, initial_params,
method="gradient_descent") as proposed in the issue. Methods:
"gradient_descent" and "adam" (built in), or any scipy.optimize
method name ("COBYLA", "Nelder-Mead", "L-BFGS-B", ...), with
parameter-shift gradients passed as jac where SciPy uses one.
Returns an OptimizationResult (params, cost, history,
n_evaluations, n_iterations, converged, method, message).
A cost function is cost_fn(qumat, params) -> float. Gradient-based
methods assume the cost is linear in the state (an expectation value
or probability); gradient="finite_difference" covers other costs.
Each backend gains count_parameter_occurrences(circuit) so the
gradient knows how many gates share a parameter.
SciPy is imported lazily; it is already a hard transitive dependency
via qiskit, qiskit-aer and cirq-core, so no new dependency is added.
Also list qumat.optimize in docs/api/qumat.yml so the generated API
reference includes it, and filter out its imports there.
Reviewers: the issue asked for a design doc first; the design note
is the next commit and this code is its reference implementation.
Part of #749
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
docs/api/qumat.yml | 2 +
qumat/__init__.py | 15 +-
qumat/amazon_braket_backend.py | 15 +-
qumat/cirq_backend.py | 9 +
qumat/optimize.py | 586 +++++++++++++++++++++++++++++++++++++++++
qumat/qiskit_backend.py | 13 +
testing/qumat/test_optimize.py | 359 +++++++++++++++++++++++++
7 files changed, 997 insertions(+), 2 deletions(-)
diff --git a/docs/api/qumat.yml b/docs/api/qumat.yml
index 614193e75..c8af7b848 100644
--- a/docs/api/qumat.yml
+++ b/docs/api/qumat.yml
@@ -19,6 +19,7 @@ loaders:
- ../..
modules:
- qumat.qumat
+ - qumat.optimize
# TODO: Trim this filter once the Python package exposes a cleaner public
# surface for documentation generation.
@@ -29,6 +30,7 @@ processors:
expression: >-
(not name.startswith('_') or name == '__init__')
and name not in ('annotations', 'Mapping', 'import_module', 'ModuleType', 'Any')
+ and name not in ('math', 'np', 'Callable', 'Sequence', 'dataclass', 'field', 'cache', 'TYPE_CHECKING')
and default()
- type: smart
diff --git a/qumat/__init__.py b/qumat/__init__.py
index eedce6e49..c21d1e303 100644
--- a/qumat/__init__.py
+++ b/qumat/__init__.py
@@ -15,6 +15,19 @@
# limitations under the License.
from . import qdp
+from .optimize import (
+ OptimizationResult,
+ expectation_z,
+ optimize_parameters,
+ parameter_shift_gradient,
+)
from .qumat import QuMat
-__all__: list[str] = ["QuMat", "qdp"]
+__all__: list[str] = [
+ "OptimizationResult",
+ "QuMat",
+ "expectation_z",
+ "optimize_parameters",
+ "parameter_shift_gradient",
+ "qdp",
+]
diff --git a/qumat/amazon_braket_backend.py b/qumat/amazon_braket_backend.py
index fd3d344ab..fbca20494 100644
--- a/qumat/amazon_braket_backend.py
+++ b/qumat/amazon_braket_backend.py
@@ -21,7 +21,7 @@ from typing import Any
import boto3
from braket.aws import AwsDevice, AwsSession
-from braket.circuits import Circuit, FreeParameter
+from braket.circuits import Circuit, FreeParameter, FreeParameterExpression
from braket.devices import LocalSimulator
CircuitLike = Any
@@ -216,6 +216,19 @@ def apply_u_gate(
circuit.rz(qubit_index, phi)
+def count_parameter_occurrences(circuit: CircuitLike) -> dict[str, int]:
+ """Count how many gates in ``circuit`` depend on each named parameter."""
+ counts: dict[str, int] = {}
+ for instruction in circuit.instructions:
+ names: set[str] = set()
+ for value in getattr(instruction.operator, "parameters", []):
+ if isinstance(value, FreeParameterExpression):
+ names.update(str(symbol) for symbol in value.expression.free_symbols)
+ for name in names:
+ counts[name] = counts.get(name, 0) + 1
+ return counts
+
+
def calculate_prob_zero(
results: dict[str, int] | list[dict[str, int]],
ancilla_qubit: int,
diff --git a/qumat/cirq_backend.py b/qumat/cirq_backend.py
index 25ed59361..b4af0ed2d 100644
--- a/qumat/cirq_backend.py
+++ b/qumat/cirq_backend.py
@@ -218,6 +218,15 @@ def get_final_state_vector(
return result.final_state_vector
+def count_parameter_occurrences(circuit: cirq.Circuit) -> dict[str, int]:
+ """Count how many gates in ``circuit`` depend on each named parameter."""
+ counts: dict[str, int] = {}
+ for operation in circuit.all_operations():
+ for name in cirq.parameter_names(operation):
+ counts[name] = counts.get(name, 0) + 1
+ return counts
+
+
def calculate_prob_zero(
results: dict[int, int] | list[dict[int, int]],
ancilla_qubit: int,
diff --git a/qumat/optimize.py b/qumat/optimize.py
new file mode 100644
index 000000000..94e031ccc
--- /dev/null
+++ b/qumat/optimize.py
@@ -0,0 +1,586 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""Gradients and optimizers for parameterized Qumat circuits.
+
+This module removes the boilerplate of hand-written optimization loops
+around :class:`qumat.QuMat` circuits. It provides:
+
+- :func:`canonical_state_vector` and :func:`expectation_z`: exact,
+ shot-free expectation values computed from the final state vector,
+ with one qubit-ordering convention for every backend.
+- :func:`parameter_shift_gradient`: exact gradients via the
+ parameter-shift rule for the RX/RY/RZ gates that Qumat exposes.
+- :func:`optimize_parameters`: gradient descent and Adam driven by
+ parameter-shift gradients, plus a passthrough to ``scipy.optimize``.
+
+A *cost function* has the signature ``cost_fn(qumat, params) -> float``
+where ``params`` maps parameter names to values. It is responsible for
+binding the values and evaluating the circuit, for example::
+
+ def cost_fn(qumat, params):
+ return expectation_z(qumat, params, qubits=0)
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass, field
+from functools import cache
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+
+if TYPE_CHECKING:
+ from .qumat import QuMat
+
+CostFunction = Callable[["QuMat", Mapping[str, float]], float]
+
+# Backends whose state vectors put qubit 0 in the least-significant bit.
+# Cirq and Amazon Braket put qubit 0 in the most-significant bit.
+_LITTLE_ENDIAN_BACKENDS = frozenset({"qiskit"})
+
+_BUILTIN_METHODS = ("gradient_descent", "adam")
+_GRADIENT_METHODS = ("parameter_shift", "finite_difference")
+# SciPy methods that accept a ``jac`` (gradient) argument.
+_SCIPY_JAC_METHODS = frozenset(
+ {"bfgs", "l-bfgs-b", "cg", "slsqp", "tnc", "trust-constr", "newton-cg"}
+)
+# Cirq returns single-precision (complex64) state vectors, so a smaller
+# step would be dominated by round-off error.
+_FINITE_DIFFERENCE_STEP = 1e-3
+
+
+def canonical_state_vector(
+ qumat: QuMat, params: Mapping[str, float] | None = None
+) -> np.ndarray:
+ """Return the final state vector with qubit 0 as the most significant bit.
+
+ Qiskit orders amplitudes little-endian (qubit 0 is the rightmost bit of
+ the basis-state index) while Cirq and Amazon Braket are big-endian
+ (qubit 0 is the leftmost bit). This helper always returns the big-endian
+ ordering, so amplitude ``k`` corresponds to the bit string
+ ``format(k, f"0{n}b")`` read as qubit 0, qubit 1, ... on every backend.
+
+ :param qumat: A QuMat instance with a circuit created via
+ ``create_empty_circuit(num_qubits)``.
+ :param params: Optional parameter values to bind before simulating.
+ :returns: Complex numpy array of length ``2**num_qubits``.
+ :raises ValueError: If the circuit was created without ``num_qubits``.
+ """
+ if qumat.num_qubits is None:
+ raise ValueError(
+ "canonical_state_vector requires a circuit created with "
+ "create_empty_circuit(num_qubits=...)"
+ )
+ if params:
+ qumat.bind_parameters(params)
+ n = qumat.num_qubits
+ state = np.asarray(qumat.get_final_state_vector(), dtype=complex).reshape(-1)
+ if state.size != 2**n:
+ raise RuntimeError(
+ f"expected a state vector of length {2**n}, got {state.size}"
+ )
+ if qumat.backend_name in _LITTLE_ENDIAN_BACKENDS and n > 1:
+ # Axis 0 of the reshaped tensor is the most significant bit, which
+ # is qubit n-1 for a little-endian vector. Reversing the axes puts
+ # qubit 0 first.
+ state = state.reshape([2] * n).transpose().reshape(-1)
+ return state
+
+
+def expectation_z(
+ qumat: QuMat,
+ params: Mapping[str, float] | None = None,
+ qubits: int | Sequence[int] = 0,
+) -> float:
+ """Exact expectation value of a Pauli-Z product, from the state vector.
+
+ For ``qubits=0`` this is ``<Z_0>``; for ``qubits=[0, 1]`` it is
+ ``<Z_0 Z_1>`` (the parity of qubits 0 and 1). The value lies in
+ ``[-1, 1]`` and is computed without sampling, so it is deterministic
+ and suitable for gradient checks.
+
+ :param qumat: A QuMat instance with an initialized circuit.
+ :param params: Optional parameter values to bind before simulating.
+ :param qubits: A qubit index or a sequence of qubit indices.
+ :returns: The expectation value as a float.
+ :raises ValueError: If a qubit index is out of range or no qubits are given.
+ """
+ targets = [qubits] if isinstance(qubits, int) else list(qubits)
+ if not targets:
+ raise ValueError("qubits must contain at least one qubit index")
+ n = qumat.num_qubits
+ for q in targets:
+ if not isinstance(q, int) or isinstance(q, bool):
+ raise TypeError(f"qubit indices must be integers, got {q!r}")
+ if n is not None and not 0 <= q < n:
+ raise ValueError(f"qubit index {q} out of range for {n} qubits")
+ state = canonical_state_vector(qumat, params)
+ n = int(np.log2(state.size))
+
+ probabilities = np.abs(state) ** 2
+ indices = np.arange(state.size)
+ parity = np.zeros(state.size, dtype=np.int64)
+ for q in targets:
+ parity ^= (indices >> (n - 1 - q)) & 1
+ signs = 1 - 2 * parity
+ return float(np.dot(probabilities, signs))
+
+
+@cache
+def _shift_rule(frequency: int) -> tuple[tuple[float, float], ...]:
+ """Return ``(shift, coefficient)`` pairs of the parameter-shift rule.
+
+ When a parameter feeds ``R`` Pauli-rotation gates, the cost is a
+ trigonometric polynomial in that parameter with integer frequencies
+ ``0..R``. Its exact derivative is (Wierichs et al., Quantum 6, 677,
+ 2022, eq. 16)::
+
+ f'(t) = sum_{mu=1}^{2R} f(t + x_mu) (-1)^(mu-1) / (4R sin^2(x_mu/2))
+ x_mu = (2 mu - 1) pi / (2R)
+
+ For ``R = 1`` this is the familiar two-term rule
+ ``f'(t) = [f(t + pi/2) - f(t - pi/2)] / 2``. Shifts are wrapped into
+ ``(-pi, pi]``, which is exact because ``f`` is 2*pi-periodic.
+ """
+ pairs = []
+ for mu in range(1, 2 * frequency + 1):
+ x = (2 * mu - 1) * math.pi / (2 * frequency)
+ coefficient = (-1) ** (mu - 1) / (4 * frequency * math.sin(x / 2) ** 2)
+ if x > math.pi:
+ x -= 2 * math.pi
+ pairs.append((x, coefficient))
+ return tuple(pairs)
+
+
+def _parameter_occurrences(qumat: QuMat) -> dict[str, int]:
+ """Count how many gates in the circuit use each named parameter."""
+ qumat._ensure_circuit_initialized()
+ return qumat.backend_module.count_parameter_occurrences(qumat.circuit)
+
+
+def _validate_names(qumat: QuMat, names: Sequence[str]) -> None:
+ unknown = [name for name in names if name not in qumat.parameters]
+ if unknown:
+ raise ValueError(
+ f"parameters {unknown} not found in circuit. "
+ f"available parameters: {list(qumat.parameters)}"
+ )
+
+
+def _as_float_dict(params: Mapping[str, float]) -> dict[str, float]:
+ return {name: float(value) for name, value in params.items()}
+
+
+def parameter_shift_gradient(
+ qumat: QuMat,
+ cost_fn: CostFunction,
+ params: Mapping[str, float],
+ param_names: Sequence[str] | None = None,
+) -> dict[str, float]:
+ """Exact gradient of ``cost_fn`` via the parameter-shift rule.
+
+ Every parameterized gate in Qumat (RX, RY, RZ) has the form
+ ``exp(-i * theta * P / 2)`` for a Pauli matrix ``P``. For a cost that is
+ an expectation value (or any quantity *linear* in the final state, such
+ as an outcome probability), the derivative with respect to ``theta`` is
+ exactly ``[f(theta + pi/2) - f(theta - pi/2)] / 2``. No step size is
+ involved, unlike finite differences.
+
+ If a parameter name is used by several gates, shifting the name moves
+ all of those gates at once, and the two-term rule above would be wrong
+ (for ``RY(a) RY(a)``, it returns 0 instead of ``-2 sin 2a``). This
+ function counts the gates that use each parameter and applies the
+ generalized rule with ``2R`` evaluations for ``R`` gates, which is
+ exact for any ``R``.
+
+ The rule does **not** hold for costs that are non-linear in the state,
+ such as ``(<Z> - y) ** 2``. Differentiate the linear part (``<Z>``) with
+ this function and apply the chain rule classically, or use
+ ``gradient="finite_difference"`` in :func:`optimize_parameters`.
+
+ :param qumat: A QuMat instance with a parameterized circuit.
+ :param cost_fn: Callable ``cost_fn(qumat, params) -> float``.
+ :param params: Current values of the parameters being differentiated.
+ :param param_names: Subset of ``params`` to differentiate. Defaults to
+ all keys of ``params``.
+ :returns: Mapping from parameter name to partial derivative.
+ :raises ValueError: If a name is not a parameter of the circuit or not
+ present in ``params``.
+ """
+ values = _as_float_dict(params)
+ names = list(values) if param_names is None else list(param_names)
+ missing = [name for name in names if name not in values]
+ if missing:
+ raise ValueError(f"no value given for parameters {missing}")
+ _validate_names(qumat, names)
+
+ occurrences = _parameter_occurrences(qumat)
+ gradient: dict[str, float] = {}
+ saved = dict(qumat.parameters)
+ try:
+ for name in names:
+ frequency = occurrences.get(name, 0)
+ if frequency == 0:
+ # Registered but used by no gate: the cost cannot depend on it.
+ gradient[name] = 0.0
+ continue
+ total = 0.0
+ for shift, coefficient in _shift_rule(frequency):
+ shifted = dict(values)
+ shifted[name] = values[name] + shift
+ total += coefficient * float(cost_fn(qumat, shifted))
+ gradient[name] = total
+ finally:
+ # cost_fn binds shifted values; leave the circuit as we found it.
+ qumat.parameters.update(saved)
+ return gradient
+
+
+def finite_difference_gradient(
+ qumat: QuMat,
+ cost_fn: CostFunction,
+ params: Mapping[str, float],
+ param_names: Sequence[str] | None = None,
+ step: float = _FINITE_DIFFERENCE_STEP,
+) -> dict[str, float]:
+ """Central finite-difference gradient of ``cost_fn``.
+
+ Works for any differentiable cost, including non-linear ones, but is
+ approximate and very sensitive to shot noise. Prefer
+ :func:`parameter_shift_gradient` for expectation values.
+ """
+ values = _as_float_dict(params)
+ names = list(values) if param_names is None else list(param_names)
+ _validate_names(qumat, names)
+ missing = [name for name in names if name not in values]
+ if missing:
+ raise ValueError(f"no value given for parameters {missing}")
+ gradient: dict[str, float] = {}
+ saved = dict(qumat.parameters)
+ try:
+ for name in names:
+ plus = dict(values)
+ minus = dict(values)
+ plus[name] += step
+ minus[name] -= step
+ gradient[name] = (
+ float(cost_fn(qumat, plus)) - float(cost_fn(qumat, minus))
+ ) / (2 * step)
+ finally:
+ qumat.parameters.update(saved)
+ return gradient
+
+
+@dataclass
+class OptimizationResult:
+ """Outcome of :func:`optimize_parameters`.
+
+ :ivar params: Best parameter values found (lowest cost seen).
+ :ivar cost: Cost at ``params``.
+ :ivar history: Cost after each iteration (the first entry is the cost
+ at the initial parameters).
+ :ivar n_evaluations: Number of ``cost_fn`` calls, including the extra
+ calls needed for gradients.
+ :ivar n_iterations: Number of optimizer iterations performed.
+ :ivar converged: Whether the stopping tolerance was reached (or, for
+ SciPy, whether SciPy reported success).
+ :ivar method: The optimization method used.
+ :ivar message: Human-readable termination reason.
+ """
+
+ params: dict[str, float]
+ cost: float
+ history: list[float] = field(default_factory=list)
+ n_evaluations: int = 0
+ n_iterations: int = 0
+ converged: bool = False
+ method: str = ""
+ message: str = ""
+
+
+class _GradientDescent:
+ """Plain gradient descent: ``x <- x - lr * g``."""
+
+ def __init__(self, learning_rate: float) -> None:
+ self.learning_rate = learning_rate
+
+ def step(self, x: np.ndarray, grad: np.ndarray) -> np.ndarray:
+ return x - self.learning_rate * grad
+
+
+class _Adam:
+ """Adam (Kingma & Ba, 2015) with bias-corrected moment estimates."""
+
+ def __init__(
+ self,
+ learning_rate: float,
+ beta1: float = 0.9,
+ beta2: float = 0.999,
+ epsilon: float = 1e-8,
+ ) -> None:
+ self.learning_rate = learning_rate
+ self.beta1 = beta1
+ self.beta2 = beta2
+ self.epsilon = epsilon
+ self._m: np.ndarray | None = None
+ self._v: np.ndarray | None = None
+ self._t = 0
+
+ def step(self, x: np.ndarray, grad: np.ndarray) -> np.ndarray:
+ if self._m is None or self._v is None:
+ self._m = np.zeros_like(x)
+ self._v = np.zeros_like(x)
+ self._t += 1
+ self._m = self.beta1 * self._m + (1 - self.beta1) * grad
+ self._v = self.beta2 * self._v + (1 - self.beta2) * grad**2
+ m_hat = self._m / (1 - self.beta1**self._t)
+ v_hat = self._v / (1 - self.beta2**self._t)
+ return x - self.learning_rate * m_hat / (np.sqrt(v_hat) + self.epsilon)
+
+
+def _make_step_optimizer(
+ method: str, learning_rate: float, options: Mapping[str, Any] | None = None
+) -> _GradientDescent | _Adam:
+ """Build a built-in first-order optimizer (shared with the trainer)."""
+ if learning_rate <= 0:
+ raise ValueError(f"learning_rate must be positive, got {learning_rate}")
+ options = dict(options or {})
+ if method == "gradient_descent":
+ if options:
+ raise ValueError(
+ f"unsupported options for gradient_descent: {sorted(options)}"
+ )
+ return _GradientDescent(learning_rate)
+ if method == "adam":
+ allowed = {"beta1", "beta2", "epsilon"}
+ extra = set(options) - allowed
+ if extra:
+ raise ValueError(f"unsupported options for adam: {sorted(extra)}")
+ return _Adam(learning_rate, **options)
+ raise ValueError(
+ f"unknown optimizer {method!r}; expected one of {_BUILTIN_METHODS}"
+ )
+
+
+def _gradient_function(
+ gradient: str,
+) -> Callable[..., dict[str, float]]:
+ if gradient == "parameter_shift":
+ return parameter_shift_gradient
+ if gradient == "finite_difference":
+ return finite_difference_gradient
+ raise ValueError(
+ f"unknown gradient {gradient!r}; expected one of {_GRADIENT_METHODS}"
+ )
+
+
+def optimize_parameters(
+ qumat: QuMat,
+ cost_fn: CostFunction,
+ initial_params: Mapping[str, float],
+ method: str = "gradient_descent",
+ *,
+ learning_rate: float = 0.1,
+ max_iter: int = 100,
+ tol: float | None = 1e-6,
+ gradient: str = "parameter_shift",
+ callback: Callable[[int, dict[str, float], float], None] | None = None,
+ options: Mapping[str, Any] | None = None,
+) -> OptimizationResult:
+ """Minimize ``cost_fn`` over the circuit parameters in ``initial_params``.
+
+ :param qumat: A QuMat instance whose circuit contains the parameters.
+ :param cost_fn: Callable ``cost_fn(qumat, params) -> float`` to minimize.
+ :param initial_params: Starting values. Only these parameters are
+ optimized; any other circuit parameter must already be bound.
+ :param method: ``"gradient_descent"`` or ``"adam"`` (built-in, using
+ ``gradient``), or any ``scipy.optimize.minimize`` method name such
+ as ``"COBYLA"``, ``"Nelder-Mead"`` or ``"L-BFGS-B"``. Gradient-based
+ SciPy methods receive the ``gradient`` as ``jac``.
+ :param learning_rate: Step size for the built-in methods.
+ :param max_iter: Maximum number of iterations.
+ :param tol: Built-in methods stop when the cost changes by at most
+ ``tol`` between iterations; ``None`` always runs ``max_iter``
+ iterations. Passed to SciPy as ``tol``.
+ :param gradient: ``"parameter_shift"`` (exact for expectation values)
+ or ``"finite_difference"`` (for non-linear costs).
+ :param callback: Optional ``callback(iteration, params, cost)`` called
+ after each iteration.
+ :param options: Extra options: ``beta1``/``beta2``/``epsilon`` for Adam,
+ or the ``options`` dict forwarded to ``scipy.optimize.minimize``.
+ :returns: An :class:`OptimizationResult`. The best parameters are also
+ bound on ``qumat`` when this function returns.
+ :raises ValueError: On unknown parameters, unbound extra parameters,
+ invalid settings or a non-finite cost.
+ :raises ImportError: If a SciPy method is requested and SciPy is missing.
+ """
+ if not initial_params:
+ raise ValueError("initial_params must contain at least one parameter")
+ if max_iter < 1:
+ raise ValueError(f"max_iter must be at least 1, got {max_iter}")
+ names = list(initial_params)
+ _validate_names(qumat, names)
+ unbound = [
+ name
+ for name, value in qumat.parameters.items()
+ if value is None and name not in initial_params
+ ]
+ if unbound:
+ raise ValueError(
+ f"circuit parameters {unbound} are neither in initial_params nor "
+ f"bound; bind them with qumat.bind_parameters() first"
+ )
+ grad_fn = _gradient_function(gradient)
+
+ n_evaluations = 0
+
+ def counted_cost(qm: QuMat, values: Mapping[str, float]) -> float:
+ nonlocal n_evaluations
+ n_evaluations += 1
+ value = float(cost_fn(qm, values))
+ if not math.isfinite(value):
+ raise ValueError(f"cost_fn returned a non-finite value: {value}")
+ return value
+
+ def to_dict(x: np.ndarray) -> dict[str, float]:
+ return {name: float(v) for name, v in zip(names, x)}
+
+ x0 = np.array([float(initial_params[name]) for name in names])
+
+ if method in _BUILTIN_METHODS:
+ optimizer = _make_step_optimizer(method, learning_rate, options)
+ x = x0
+ cost = counted_cost(qumat, to_dict(x))
+ history = [cost]
+ best_x, best_cost = x.copy(), cost
+ converged = False
+ iteration = 0
+ for iteration in range(1, max_iter + 1):
+ grad = grad_fn(qumat, counted_cost, to_dict(x), names)
+ x = optimizer.step(x, np.array([grad[name] for name in names]))
+ new_cost = counted_cost(qumat, to_dict(x))
+ history.append(new_cost)
+ if new_cost < best_cost:
+ best_x, best_cost = x.copy(), new_cost
+ if callback is not None:
+ callback(iteration, to_dict(x), new_cost)
+ if tol is not None and abs(new_cost - cost) <= tol:
+ converged = True
+ cost = new_cost
+ break
+ cost = new_cost
+ message = "cost change below tol" if converged else "maximum iterations reached"
+ best = to_dict(best_x)
+ qumat.bind_parameters(best)
+ return OptimizationResult(
+ params=best,
+ cost=best_cost,
+ history=history,
+ n_evaluations=n_evaluations,
+ n_iterations=iteration,
+ converged=converged,
+ method=method,
+ message=message,
+ )
+
+ return _optimize_with_scipy(
+ qumat,
+ counted_cost,
+ names,
+ x0,
+ method,
+ max_iter=max_iter,
+ tol=tol,
+ grad_fn=grad_fn,
+ callback=callback,
+ options=options,
+ evaluations=lambda: n_evaluations,
+ )
+
+
+def _optimize_with_scipy(
+ qumat: QuMat,
+ cost: CostFunction,
+ names: list[str],
+ x0: np.ndarray,
+ method: str,
+ *,
+ max_iter: int,
+ tol: float | None,
+ grad_fn: Callable[..., dict[str, float]],
+ callback: Callable[[int, dict[str, float], float], None] | None,
+ options: Mapping[str, Any] | None,
+ evaluations: Callable[[], int],
+) -> OptimizationResult:
+ try:
+ from scipy.optimize import minimize
+ except ImportError as exc: # pragma: no cover - scipy ships with qiskit
+ raise ImportError(
+ f"method {method!r} requires scipy; install it or use one of "
+ f"{_BUILTIN_METHODS}"
+ ) from exc
+
+ def to_dict(x: np.ndarray) -> dict[str, float]:
+ return {name: float(v) for name, v in zip(names, x)}
+
+ # Remember costs by point so the per-iteration callback does not need
+ # to re-run the circuit.
+ seen: dict[tuple[float, ...], float] = {}
+
+ def fun(x: np.ndarray) -> float:
+ value = cost(qumat, to_dict(x))
+ seen[tuple(np.asarray(x, dtype=float))] = value
+ return value
+
+ def jac(x: np.ndarray) -> np.ndarray:
+ grad = grad_fn(qumat, cost, to_dict(x), names)
+ return np.array([grad[name] for name in names])
+
+ history = [fun(x0)]
+
+ def on_iteration(xk: np.ndarray) -> None:
+ key = tuple(np.asarray(xk, dtype=float))
+ value = seen[key] if key in seen else fun(xk)
+ history.append(value)
+ if callback is not None:
+ callback(len(history) - 1, to_dict(xk), value)
+
+ scipy_options = {"maxiter": max_iter, **dict(options or {})}
+ result = minimize(
+ fun,
+ x0,
+ method=method,
+ jac=jac if method.lower() in _SCIPY_JAC_METHODS else None,
+ tol=tol,
+ callback=on_iteration,
+ options=scipy_options,
+ )
+ best = to_dict(np.atleast_1d(result.x))
+ best_cost = float(result.fun)
+ qumat.bind_parameters(best)
+ return OptimizationResult(
+ params=best,
+ cost=best_cost,
+ history=history,
+ n_evaluations=evaluations(),
+ n_iterations=int(getattr(result, "nit", len(history) - 1)),
+ converged=bool(result.success),
+ method=method,
+ message=str(result.message),
+ )
diff --git a/qumat/qiskit_backend.py b/qumat/qiskit_backend.py
index c78264264..0f932f711 100644
--- a/qumat/qiskit_backend.py
+++ b/qumat/qiskit_backend.py
@@ -262,6 +262,19 @@ def apply_u_gate(
circuit.u(theta, phi, lambd, qubit_index)
+def count_parameter_occurrences(circuit: qiskit.QuantumCircuit) -> dict[str, int]:
+ """Count how many gates in ``circuit`` depend on each named parameter."""
+ counts: dict[str, int] = {}
+ for instruction in circuit.data:
+ names: set[str] = set()
+ for value in instruction.operation.params:
+ if isinstance(value, qiskit.circuit.ParameterExpression):
+ names.update(param.name for param in value.parameters)
+ for name in names:
+ counts[name] = counts.get(name, 0) + 1
+ return counts
+
+
def calculate_prob_zero(
results: dict[str, int] | list[dict[str, int]],
ancilla_qubit: int,
diff --git a/testing/qumat/test_optimize.py b/testing/qumat/test_optimize.py
new file mode 100644
index 000000000..21579c1b1
--- /dev/null
+++ b/testing/qumat/test_optimize.py
@@ -0,0 +1,359 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""Tests for qumat.optimize (parameter-shift gradients and optimizers)."""
+
+import math
+
+import pytest
+
+from qumat import (
+ OptimizationResult,
+ QuMat,
+ expectation_z,
+ optimize_parameters,
+ parameter_shift_gradient,
+)
+from qumat.optimize import canonical_state_vector, finite_difference_gradient
+
+from ..utils import TESTING_BACKENDS
+
+# Cirq simulates in single precision (complex64); allow for that.
+ATOL = 1e-5
+
+# Fast, exact backends for the optimizer tests.
+OPTIMIZER_BACKENDS = ["qiskit", "cirq"]
+
+
+def make_qumat(backend_name, num_qubits):
+ configs = {
+ "qiskit": {"simulator_type": "statevector_simulator", "shots": 1},
+ "cirq": {"simulator_type": "default", "shots": 1},
+ "amazon_braket": {"simulator_type": "local", "shots": 1},
+ }
+ qumat = QuMat(
+ {"backend_name": backend_name, "backend_options": configs[backend_name]}
+ )
+ qumat.create_empty_circuit(num_qubits=num_qubits)
+ return qumat
+
+
+def z0_cost(qumat, params):
+ return expectation_z(qumat, params, qubits=0)
+
+
+class TestExpectationZ:
+ @pytest.mark.parametrize("backend_name", TESTING_BACKENDS)
+ def test_qubit_ordering_is_consistent(self, backend_name):
+ """X on qubit 0 must flip <Z_0> only, whatever the backend's ordering."""
+ qumat = make_qumat(backend_name, 3)
+ qumat.apply_pauli_x_gate(0)
+
+ state = canonical_state_vector(qumat)
+ # Canonical order is big-endian: |q0 q1 q2> = |100> is index 4.
+ assert abs(state[4]) == pytest.approx(1.0, abs=ATOL)
+
+ assert expectation_z(qumat, qubits=0) == pytest.approx(-1.0, abs=ATOL)
+ assert expectation_z(qumat, qubits=1) == pytest.approx(1.0, abs=ATOL)
+ assert expectation_z(qumat, qubits=2) == pytest.approx(1.0, abs=ATOL)
+ assert expectation_z(qumat, qubits=[0, 1]) == pytest.approx(-1.0, abs=ATOL)
+
+ @pytest.mark.parametrize("backend_name", TESTING_BACKENDS)
+ def test_bell_state_parity(self, backend_name):
+ qumat = make_qumat(backend_name, 2)
+ qumat.apply_hadamard_gate(0)
+ qumat.apply_cnot_gate(0, 1)
+
+ assert expectation_z(qumat, qubits=0) == pytest.approx(0.0, abs=ATOL)
+ assert expectation_z(qumat, qubits=[0, 1]) == pytest.approx(1.0, abs=ATOL)
+
+ @pytest.mark.parametrize("backend_name", TESTING_BACKENDS)
+ def test_binds_params(self, backend_name):
+ qumat = make_qumat(backend_name, 1)
+ qumat.apply_ry_gate(0, "theta")
+ value = expectation_z(qumat, {"theta": 0.7})
+ assert value == pytest.approx(math.cos(0.7), abs=ATOL)
+ assert qumat.parameters["theta"] == 0.7
+
+ def test_invalid_qubits(self):
+ qumat = make_qumat("cirq", 2)
+ with pytest.raises(ValueError, match="out of range"):
+ expectation_z(qumat, qubits=2)
+ with pytest.raises(ValueError, match="at least one"):
+ expectation_z(qumat, qubits=[])
+ with pytest.raises(TypeError, match="integers"):
+ expectation_z(qumat, qubits=[0.5]) # type: ignore[invalid-argument-type]
+
+ def test_requires_num_qubits(self):
+ qumat = QuMat({"backend_name": "cirq", "backend_options": {}})
+ qumat.create_empty_circuit()
+ with pytest.raises(ValueError, match="num_qubits"):
+ canonical_state_vector(qumat)
+
+
+class TestParameterShiftGradient:
+ @pytest.mark.parametrize("backend_name", TESTING_BACKENDS)
+ @pytest.mark.parametrize("theta", [0.0, 0.4, math.pi / 2, 2.5, -1.3])
+ def test_ry_matches_analytic(self, backend_name, theta):
+ """For RY(theta)|0>, <Z> = cos(theta) so d<Z>/dtheta = -sin(theta)."""
+ qumat = make_qumat(backend_name, 1)
+ qumat.apply_ry_gate(0, "theta")
+
+ grad = parameter_shift_gradient(qumat, z0_cost, {"theta": theta})
+
+ assert grad["theta"] == pytest.approx(-math.sin(theta), abs=ATOL)
+
+ @pytest.mark.parametrize("backend_name", TESTING_BACKENDS)
+ def test_rx_and_rz_match_analytic(self, backend_name):
+ """RX(a)|0> gives <Z> = cos(a); H RZ(c) H acts like RX(c)."""
+ qumat = make_qumat(backend_name, 2)
+ qumat.apply_rx_gate(0, "alpha")
+ qumat.apply_hadamard_gate(1)
+ qumat.apply_rz_gate(1, "gamma")
+ qumat.apply_hadamard_gate(1)
+
+ def cost(qm, params):
+ return expectation_z(qm, params, 0) + 2 * expectation_z(qm, params, 1)
+
+ grad = parameter_shift_gradient(qumat, cost, {"alpha": 0.3, "gamma": 1.9})
+
+ assert grad["alpha"] == pytest.approx(-math.sin(0.3), abs=ATOL)
+ assert grad["gamma"] == pytest.approx(-2 * math.sin(1.9), abs=ATOL)
+
+ @pytest.mark.parametrize("backend_name", TESTING_BACKENDS)
+ def test_matches_finite_differences(self, backend_name):
+ """Entangling circuit using all three rotation gates."""
+ qumat = make_qumat(backend_name, 2)
+ qumat.apply_ry_gate(0, "t0")
+ qumat.apply_rx_gate(1, "t1")
+ qumat.apply_cnot_gate(0, 1)
+ qumat.apply_rz_gate(1, "t2")
+ qumat.apply_hadamard_gate(1)
+ qumat.apply_ry_gate(1, "t3")
+
+ def cost(qm, params):
+ return expectation_z(qm, params, [0, 1]) - 0.5 * expectation_z(
+ qm, params, 1
+ )
+
+ params = {"t0": 0.3, "t1": -0.8, "t2": 1.2, "t3": 0.5}
+ shift = parameter_shift_gradient(qumat, cost, params)
+ finite = finite_difference_gradient(qumat, cost, params)
+
+ for name in params:
+ assert shift[name] == pytest.approx(finite[name], abs=1e-3), name
+ # Restores the caller's binding state.
+ assert all(qumat.parameters[name] is None for name in params)
+
+ @pytest.mark.parametrize("backend_name", TESTING_BACKENDS)
+ def test_shared_parameter(self, backend_name):
+ """A parameter used by two gates needs the generalized rule.
+
+ RY(a) RY(a) = RY(2a): <Z> = cos(2a), derivative -2 sin(2a). The plain
+ two-term rule would give [cos(2a + pi) - cos(2a - pi)] / 2 = 0.
+ """
+ qumat = make_qumat(backend_name, 1)
+ qumat.apply_ry_gate(0, "a")
+ qumat.apply_ry_gate(0, "a")
+
+ grad = parameter_shift_gradient(qumat, z0_cost, {"a": 0.4})
+
+ assert grad["a"] == pytest.approx(-2 * math.sin(0.8), abs=ATOL)
+
+ def test_shared_parameter_across_gate_types(self):
+ qumat = make_qumat("cirq", 2)
+ qumat.apply_ry_gate(0, "a")
+ qumat.apply_rx_gate(1, "a")
+ qumat.apply_rz_gate(1, "a")
+ qumat.apply_cnot_gate(0, 1)
+ qumat.apply_ry_gate(1, "a")
+
+ def cost(qm, params):
+ return expectation_z(qm, params, [0, 1])
+
+ shift = parameter_shift_gradient(qumat, cost, {"a": 0.9})
+ finite = finite_difference_gradient(qumat, cost, {"a": 0.9})
+ assert shift["a"] == pytest.approx(finite["a"], abs=1e-3)
+
+ def test_param_names_subset(self):
+ qumat = make_qumat("cirq", 1)
+ qumat.apply_ry_gate(0, "x")
+ qumat.apply_rx_gate(0, "y")
+ grad = parameter_shift_gradient(
+ qumat, z0_cost, {"x": 0.1, "y": 0.2}, param_names=["y"]
+ )
+ assert list(grad) == ["y"]
+
+ def test_errors(self):
+ qumat = make_qumat("cirq", 1)
+ qumat.apply_ry_gate(0, "theta")
+ with pytest.raises(ValueError, match="not found in circuit"):
+ parameter_shift_gradient(qumat, z0_cost, {"theta": 0.1, "phi": 0.2})
+ with pytest.raises(ValueError, match="no value given"):
+ parameter_shift_gradient(qumat, z0_cost, {}, param_names=["theta"])
+
+
+class TestOptimizeParameters:
+ @pytest.mark.parametrize("backend_name", OPTIMIZER_BACKENDS)
+ @pytest.mark.parametrize(
+ ("method", "learning_rate"), [("gradient_descent", 0.4), ("adam", 0.2)]
+ )
+ def test_builtin_methods_converge(self, backend_name, method, learning_rate):
+ """Minimize <Z_0> + <Z_1>; the minimum is -2 (both qubits in |1>)."""
+ qumat = make_qumat(backend_name, 2)
+ qumat.apply_ry_gate(0, "a")
+ qumat.apply_cnot_gate(0, 1)
+ qumat.apply_ry_gate(1, "c")
+
+ def cost(qm, params):
+ return expectation_z(qm, params, 0) + expectation_z(qm, params, 1)
+
+ result = optimize_parameters(
+ qumat,
+ cost,
+ {"a": 0.3, "c": 0.2},
+ method=method,
+ learning_rate=learning_rate,
+ max_iter=80,
+ tol=1e-7,
+ )
+
+ assert isinstance(result, OptimizationResult)
+ assert result.cost == pytest.approx(-2.0, abs=1e-3)
+ assert result.history[0] > result.history[-1]
+ assert result.history[0] == pytest.approx(
+ math.cos(0.3) + math.cos(0.3) * math.cos(0.2), abs=ATOL
+ )
+ # Best params are bound on the circuit when optimization ends.
+ assert qumat.parameters == result.params
+ assert expectation_z(qumat) == pytest.approx(-1.0, abs=1e-3)
+
+ def test_evaluation_count_gradient_descent(self):
+ """One initial call, then per iteration 2 shift calls per parameter + 1."""
+ qumat = make_qumat("cirq", 1)
+ qumat.apply_ry_gate(0, "theta")
+
+ seen = []
+ result = optimize_parameters(
+ qumat,
+ z0_cost,
+ {"theta": 0.5},
+ learning_rate=0.1,
+ max_iter=5,
+ tol=None,
+ callback=lambda i, params, cost: seen.append((i, cost)),
+ )
+
+ assert result.n_iterations == 5
+ assert result.n_evaluations == 1 + 5 * (2 + 1)
+ assert len(result.history) == 6
+ assert [i for i, _ in seen] == [1, 2, 3, 4, 5]
+ assert not result.converged
+ # Hand-rolled gradient descent on cos(theta) for comparison.
+ theta = 0.5
+ for _ in range(5):
+ theta += 0.1 * math.sin(theta)
+ assert result.params["theta"] == pytest.approx(theta, abs=ATOL)
+
+ def test_finite_difference_for_nonlinear_cost(self):
+ """Squared error is not linear in the state: use finite differences."""
+ qumat = make_qumat("cirq", 1)
+ qumat.apply_ry_gate(0, "theta")
+ target = 0.25
+
+ def cost(qm, params):
+ return (expectation_z(qm, params) - target) ** 2
+
+ result = optimize_parameters(
+ qumat,
+ cost,
+ {"theta": 0.1},
+ method="adam",
+ learning_rate=0.1,
+ max_iter=200,
+ gradient="finite_difference",
+ )
+ assert result.cost < 1e-4
+ assert math.cos(result.params["theta"]) == pytest.approx(target, abs=0.02)
+
+ @pytest.mark.parametrize("method", ["COBYLA", "Nelder-Mead", "L-BFGS-B"])
+ def test_scipy_methods(self, method):
+ pytest.importorskip("scipy")
+ qumat = make_qumat("cirq", 2)
+ qumat.apply_ry_gate(0, "a")
+ qumat.apply_rx_gate(1, "c")
+
+ def cost(qm, params):
+ return expectation_z(qm, params, 0) + expectation_z(qm, params, 1)
+
+ result = optimize_parameters(
+ qumat, cost, {"a": 0.4, "c": -0.3}, method=method, max_iter=200
+ )
+
+ assert result.method == method
+ assert result.cost == pytest.approx(-2.0, abs=1e-3)
+ assert result.n_evaluations > 0
+ assert len(result.history) >= 2
+ assert qumat.parameters == result.params
+
+ def test_optimizes_subset_with_other_params_bound(self):
+ qumat = make_qumat("cirq", 1)
+ qumat.apply_ry_gate(0, "fixed")
+ qumat.apply_ry_gate(0, "free")
+ qumat.bind_parameters({"fixed": 0.5})
+
+ result = optimize_parameters(
+ qumat, z0_cost, {"free": 0.0}, method="L-BFGS-B", max_iter=50
+ )
+
+ assert result.params["free"] + 0.5 == pytest.approx(math.pi, abs=1e-3)
+ assert qumat.parameters["fixed"] == 0.5
+
+ def test_error_paths(self):
+ qumat = make_qumat("cirq", 1)
+ qumat.apply_ry_gate(0, "theta")
+ qumat.apply_ry_gate(0, "phi")
+
+ with pytest.raises(ValueError, match="at least one parameter"):
+ optimize_parameters(qumat, z0_cost, {})
+ with pytest.raises(ValueError, match="not found in circuit"):
+ optimize_parameters(qumat, z0_cost, {"theta": 0, "nope": 0, "phi": 0})
+ with pytest.raises(ValueError, match="neither in initial_params nor"):
+ optimize_parameters(qumat, z0_cost, {"theta": 0.0})
+
+ params = {"theta": 0.1, "phi": 0.1}
+ with pytest.raises(ValueError, match="learning_rate"):
+ optimize_parameters(qumat, z0_cost, params, learning_rate=0)
+ with pytest.raises(ValueError, match="max_iter"):
+ optimize_parameters(qumat, z0_cost, params, max_iter=0)
+ with pytest.raises(ValueError, match="unknown gradient"):
+ optimize_parameters(qumat, z0_cost, params, gradient="autograd")
+ with pytest.raises(ValueError, match="unsupported options for adam"):
+ optimize_parameters(
+ qumat, z0_cost, params, method="adam", options={"momentum": 0.9}
+ )
+ with pytest.raises(ValueError, match="non-finite"):
+ optimize_parameters(qumat, lambda qm, p: float("nan"), params)
+ # Unknown names fall through to scipy, which rejects them.
+ with pytest.raises(ValueError, match="(?i)unknown solver"):
+ optimize_parameters(qumat, z0_cost, params, method="not-a-method")
+
+ def test_does_not_mutate_initial_params(self):
+ qumat = make_qumat("cirq", 1)
+ qumat.apply_ry_gate(0, "theta")
+ initial = {"theta": 0.2}
+ optimize_parameters(qumat, z0_cost, initial, max_iter=3)
+ assert initial == {"theta": 0.2}
(mahout) 04/04: [Docs] Add design note and guide for parameter optimization (#749)
Posted by ak...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
andrewmusselman pushed a commit to branch pr/749-param-optimization
in repository https://gitbox.apache.org/repos/asf/mahout.git
commit ad5e5acdc7e3df992eae4458b05980e869d63805
Author: Andrew Musselman <ak...@apache.org>
AuthorDate: Wed Oct 7 22:56:15 2026 -0700
[Docs] Add design note and guide for parameter optimization (#749)
Reviewers on #749 asked for a design document before code. Add
docs/advanced/parameter-optimization.md with the motivation (the
gaps listed in docs/advanced/gap-analysis.md), goals and non-goals,
the cost-function contract, the parameter-shift rule including the
generalized rule for parameters shared by several gates, qubit
ordering, the optimizer table, the two backend fixes found along the
way, alternatives considered and open questions. The code in
qumat/optimize.py is the reference implementation of this design.
Also add a "Built-in Optimization" section to
docs/qumat/parameterized-circuits.md showing optimize_parameters()
next to the existing hand-written loop, link the note from the PQC
Guides index, and add it to the Resources > PQC Guides sidebar.
Note: the issue text cites docs/qumat_gap_analysis_for_pqc.md; that
file now lives at docs/advanced/gap-analysis.md.
Part of #749
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
docs/advanced/index.md | 1 +
docs/advanced/parameter-optimization.md | 201 ++++++++++++++++++++++++++++++++
docs/qumat/parameterized-circuits.md | 47 ++++++++
website/sidebars.ts | 1 +
4 files changed, 250 insertions(+)
diff --git a/docs/advanced/index.md b/docs/advanced/index.md
index 560eb08c3..7b5050234 100644
--- a/docs/advanced/index.md
+++ b/docs/advanced/index.md
@@ -9,3 +9,4 @@ In-depth material on parameterized quantum circuits (PQCs) in Qumat. Start with
- [PQC Developer's Guide](./pqc): the theory, design, and applications of PQCs, written for developers implementing them from scratch
- [Qumat PQC Gap Analysis](./gap-analysis): what the Qumat codebase needs for a minimally viable and then a feature-complete PQC implementation
+- [Parameter Optimization Design](./parameter-optimization): design note for the gradient and optimizer utilities in `qumat.optimize`
diff --git a/docs/advanced/parameter-optimization.md b/docs/advanced/parameter-optimization.md
new file mode 100644
index 000000000..cadba5d0d
--- /dev/null
+++ b/docs/advanced/parameter-optimization.md
@@ -0,0 +1,201 @@
+---
+title: "Design Note: Parameter Optimization Utilities"
+sidebar_label: Parameter Optimization Design
+---
+
+# Design Note: Parameter Optimization Utilities
+
+**Status:** proposal with a reference implementation (`qumat/optimize.py`).
+**Issue:** [#749](https://github.com/apache/mahout/issues/749).
+
+Reviewers asked for a design document before code, because this is a
+large addition to Qumat. This page is that document. The accompanying
+code is a reference implementation of the design so that it can be
+reviewed concretely; any part of it can change.
+
+## Motivation
+
+The [PQC gap analysis](./gap-analysis) lists the following as missing:
+
+- Part 1, section 4: no functionality for optimizing parameters.
+- Part 2, section 1: a parameter-shift rule for exact gradients.
+- Part 2, section 4: an optimization module with gradient-based and
+ gradient-free algorithms.
+- Part 2, section 5: expectation values of observables.
+
+Today, users write their own loop: bind parameters, run the circuit,
+turn counts into a cost, and call `scipy.optimize.minimize` or update
+the values by hand.
+
+## Goals
+
+1. One call to optimize a parameterized circuit:
+ `optimize_parameters(qumat, cost_fn, initial_params, method=...)`.
+2. Exact gradients for every parameterized gate Qumat exposes
+ (RX, RY, RZ), on every backend.
+3. Exact, shot-free expectation values for tests and small simulations.
+4. No new dependencies, and no changes to the `QuMat` class API.
+
+## Non-goals
+
+- Automatic differentiation through PyTorch, JAX or TensorFlow.
+- Shot-noise-aware optimizers such as SPSA (a natural follow-up).
+- General observables beyond products of Pauli Z.
+- Hardware execution; the helpers use `get_final_state_vector()`.
+
+## API
+
+```python
+from qumat import QuMat, expectation_z, optimize_parameters
+
+qm = QuMat({"backend_name": "cirq", "backend_options": {}})
+qm.create_empty_circuit(num_qubits=2)
+qm.apply_ry_gate(0, "a")
+qm.apply_cnot_gate(0, 1)
+qm.apply_ry_gate(1, "c")
+
+def cost(qumat, params):
+ return expectation_z(qumat, params, 0) + expectation_z(qumat, params, 1)
+
+result = optimize_parameters(qm, cost, {"a": 0.3, "c": 0.2},
+ method="adam", learning_rate=0.2)
+result.params, result.cost, result.history, result.n_evaluations
+```
+
+| Function | Purpose |
+|---|---|
+| `expectation_z(qumat, params, qubits)` | Exact `<Z_i>` or `<Z_i Z_j ...>` |
+| `parameter_shift_gradient(qumat, cost_fn, params)` | Exact gradient |
+| `optimize_parameters(...)` | Run an optimizer, return `OptimizationResult` |
+
+### The cost function contract
+
+A cost function has the signature `cost_fn(qumat, params) -> float`.
+It receives the `QuMat` instance and a `dict` of parameter values and
+is responsible for binding them and evaluating the circuit. This shape
+was chosen because:
+
+- it lets the cost use any Qumat output (counts from
+ `execute_circuit()`, or a state vector);
+- the optimizer can call it with shifted values without knowing how
+ the cost is computed;
+- named parameters match how Qumat already identifies them.
+
+Parameters not listed in `initial_params` must already be bound. They
+stay fixed.
+
+## Parameter-shift rule
+
+Every rotation in Qumat has the form `U(t) = exp(-i t P / 2)` with `P`
+a Pauli matrix, whose eigenvalues are ±1. If the cost is an expectation
+value `f(t) = <psi| U(t)^† A U(t) |psi>` (or any other quantity linear
+in the final state, such as a probability), then `f` is a sinusoid of
+frequency 1 in `t`, `f(t) = a + b cos t + c sin t`, and
+
+```text
+f'(t) = [ f(t + pi/2) - f(t - pi/2) ] / 2
+```
+
+holds exactly, with no step size to choose. That is what
+`parameter_shift_gradient` computes for each parameter.
+
+### Shared parameters
+
+Qumat identifies a parameter by name, so the same name may appear in
+several gates. If one name drives `R` gates, shifting the name shifts
+all `R` gates at once and `f` has frequencies up to `R`. The two-term
+rule is then wrong. For `RY(a) RY(a) = RY(2a)` it gives
+
+```text
+[cos(2a + pi) - cos(2a - pi)] / 2 = 0 (true value: -2 sin 2a)
+```
+
+There are two standard fixes:
+
+1. Shift each *occurrence* separately and add the results (product
+ rule). This needs per-gate shifting, which Qumat's backends do not
+ expose.
+2. Use the general rule for a trigonometric polynomial of degree `R`
+ (Wierichs, Izaac, Wang, Lin, *Quantum* 6, 677 (2022), eq. 16):
+
+```text
+f'(t) = sum_{mu=1..2R} f(t + x_mu) (-1)^(mu-1) / (4R sin^2(x_mu/2))
+x_mu = (2 mu - 1) pi / (2R)
+```
+
+The reference implementation uses option 2. Each backend gains a small
+`count_parameter_occurrences(circuit)` function that reports `R` for
+every parameter. The cost is `2R` evaluations per parameter, the same
+as option 1.
+
+### Non-linear costs
+
+The rule needs a cost that is linear in the state. A squared error such
+as `(<Z> - y)^2` is not. For those, either differentiate the linear part
+and apply the chain rule yourself (the QML trainer in #1409 does this),
+or pass `gradient="finite_difference"`.
+
+## Qubit ordering
+
+`get_final_state_vector()` returns the backend's native ordering:
+Qiskit is little-endian (qubit 0 is the least significant bit of the
+amplitude index), Cirq and Braket are big-endian. `expectation_z`
+calls `canonical_state_vector()`, which converts Qiskit vectors to the
+big-endian order so that `qubits=0` refers to the same qubit
+everywhere. The tests check this with an `X` on qubit 0 of a 3-qubit
+register on all three backends.
+
+## Optimizers
+
+| `method` | Kind | Gradient |
+|---|---|---|
+| `"gradient_descent"` | built in | `gradient` (default parameter shift) |
+| `"adam"` | built in | `gradient` |
+| any `scipy.optimize.minimize` method | SciPy | passed as `jac` where used |
+
+The built-in methods are a few lines each and need no extra
+dependencies. They stop after `max_iter` iterations or when the cost
+changes by at most `tol`. SciPy is imported only when a SciPy method is
+requested. It is already a hard dependency of `qiskit`, `qiskit-aer`
+and `cirq-core`, so no dependency is added to `pyproject.toml`.
+
+`OptimizationResult` reports the best parameters seen, their cost, the
+cost history, the number of cost evaluations (including gradient
+evaluations), the iteration count and a convergence flag. The best
+parameters are also bound on the `QuMat` instance.
+
+## Backend fixes found while building this
+
+1. **Qiskit state vector after SWAP.** `get_final_state_vector()`
+ transpiled at the default optimization level, which elides a SWAP
+ into a qubit relabelling that the saved state vector does not
+ undo. The fix transpiles at level 1 and caches the simulator
+ target (also about 5x faster per call).
+2. **Shared parameters on Qiskit.** Using one name in two gates raised
+ `CircuitError` on Qiskit only. The fix reuses the existing
+ `Parameter` object.
+
+## Alternatives considered
+
+- **Methods on `QuMat`** (for example `qumat.optimize(...)`). Rejected
+ for now to keep `QuMat` focused on circuits; a module of functions is
+ easier to review and can be wrapped later.
+- **Only wrap SciPy.** Simple, but gives no gradient story and no
+ Adam, which is the usual choice for variational circuits.
+- **Expectation values from shot counts.** Works on real hardware but
+ makes gradients noisy and tests flaky. Can be added later as
+ `expectation_z(..., shots=N)`.
+- **Per-gate shifting** for shared parameters. Needs backend support
+ for shifting a single gate; the generalized rule gives exact results
+ with the current backend interface.
+
+## Open questions
+
+- Should the optimizer API accept an array-based cost (`f(x)`) in
+ addition to the named-dict form?
+- Should observables be a small class (`PauliString`) rather than
+ `expectation_z` only?
+- Should `optimize_parameters` restore the caller's original bindings
+ instead of binding the best parameters?
+- Is the `count_parameter_occurrences` backend hook acceptable, or
+ should parameter bookkeeping move into `QuMat` itself?
diff --git a/docs/qumat/parameterized-circuits.md b/docs/qumat/parameterized-circuits.md
index af7d97e31..503cf4401 100644
--- a/docs/qumat/parameterized-circuits.md
+++ b/docs/qumat/parameterized-circuits.md
@@ -212,6 +212,53 @@ result = minimize(cost_function, [0.5, 0.5], method='COBYLA')
print(f"Optimized: {result.x}, cost: {result.fun}")
```
+## Built-in Optimization
+
+`qumat` ships helpers that replace the hand-written loop above. They
+compute exact expectation values from the state vector, exact
+gradients with the parameter-shift rule, and run an optimizer.
+
+```python
+from qumat import QuMat, expectation_z, optimize_parameters
+
+backend_config = {
+ 'backend_name': 'qiskit',
+ 'backend_options': {'simulator_type': 'statevector_simulator', 'shots': 1}
+}
+qumat = QuMat(backend_config)
+qumat.create_empty_circuit(num_qubits=2)
+qumat.apply_ry_gate(0, angle="theta")
+qumat.apply_cnot_gate(0, 1)
+qumat.apply_ry_gate(1, angle="phi")
+
+# Cost: <Z_0> + <Z_1>, minimized (-2) when both qubits are |1>
+def cost(qm, params):
+ return expectation_z(qm, params, qubits=0) + expectation_z(qm, params, qubits=1)
+
+result = optimize_parameters(
+ qumat, cost, {"theta": 0.3, "phi": 0.2}, method="adam", learning_rate=0.2
+)
+print(result.params, result.cost, result.n_evaluations)
+```
+
+- **Cost functions** take `(qumat, params)` and return a float.
+ `expectation_z(qumat, params, qubits)` binds `params` and returns the
+ exact `<Z>` (or `<Z Z ...>` for a list of qubits) with the same qubit
+ numbering on every backend.
+- **Methods**: `"gradient_descent"` and `"adam"` use parameter-shift
+ gradients. Any `scipy.optimize.minimize` method name such as
+ `"COBYLA"`, `"Nelder-Mead"` or `"L-BFGS-B"` is passed through to SciPy.
+- **Gradients**: `parameter_shift_gradient(qumat, cost, params)` is
+ exact for costs that are linear in the state (expectation values,
+ probabilities). For other costs, such as a squared error, pass
+ `gradient="finite_difference"` to `optimize_parameters`.
+- **Result**: `result.params`, `result.cost`, `result.history` (cost per
+ iteration) and `result.n_evaluations`. The best parameters are also
+ bound on `qumat`.
+
+See the [parameter optimization design note](../advanced/parameter-optimization)
+for how the gradient handles parameters shared by several gates.
+
## Key Points
- **Parameter creation**: Use string names (e.g., `"theta"`) in rotation gates to create parameters
diff --git a/website/sidebars.ts b/website/sidebars.ts
index 777772c63..e34dbcf63 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -61,6 +61,7 @@ const sidebars: SidebarsConfig = {
items: [
'advanced/pqc',
'advanced/gap-analysis',
+ 'advanced/parameter-optimization',
],
},
{
(mahout) 02/04: [Qumat] Allow one parameter name in several Qiskit gates (#749)
Posted by ak...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
andrewmusselman pushed a commit to branch pr/749-param-optimization
in repository https://gitbox.apache.org/repos/asf/mahout.git
commit 3a4ce89ac59344d0128649c68f263159e6743921
Author: Andrew Musselman <ak...@apache.org>
AuthorDate: Wed Oct 7 22:50:20 2026 -0700
[Qumat] Allow one parameter name in several Qiskit gates (#749)
Qumat registers a parameter by name, and on Cirq (sympy.Symbol) and
Braket (FreeParameter) two gates that use the same name share the
parameter. The Qiskit backend created a new qiskit Parameter object
on every apply_r*_gate call, and Qiskit rejects two distinct objects
with the same name in one circuit:
CircuitError: "name conflict adding parameter 'theta'"
Look the name up with QuantumCircuit.get_parameter() and reuse the
existing object when there is one. Shared parameters are common in
ansatz and data-reuploading circuits, and the parameter-shift
gradient added next handles them explicitly, so all three backends
need to accept them.
Add a cross-backend test: RY(theta) RY(theta) with theta = pi/2 must
flip |0> to |1>. It fails on Qiskit without this change.
Part of #749
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
qumat/qiskit_backend.py | 21 ++++++++++++++++++---
testing/qumat/test_parameter_binding.py | 24 ++++++++++++++++++++++++
2 files changed, 42 insertions(+), 3 deletions(-)
diff --git a/qumat/qiskit_backend.py b/qumat/qiskit_backend.py
index e8df0c694..c78264264 100644
--- a/qumat/qiskit_backend.py
+++ b/qumat/qiskit_backend.py
@@ -209,12 +209,27 @@ def draw_circuit(circuit: qiskit.QuantumCircuit) -> Any:
return circuit.draw()
+def _resolve_angle(
+ circuit: qiskit.QuantumCircuit,
+ angle: float | str | qiskit.circuit.Parameter,
+) -> Any:
+ """Return a Qiskit angle, reusing an existing Parameter with the same name.
+
+ Qiskit refuses two distinct ``Parameter`` objects with the same name in
+ one circuit, so a name used by several gates must map to one object.
+ """
+ if not isinstance(angle, str):
+ return angle
+ existing = circuit.get_parameter(angle, None)
+ return existing if existing is not None else qiskit.circuit.Parameter(angle)
+
+
def apply_rx_gate(
circuit: qiskit.QuantumCircuit,
qubit_index: int,
angle: float | str | qiskit.circuit.Parameter,
) -> None:
- param = qiskit.circuit.Parameter(angle) if isinstance(angle, str) else angle
+ param = _resolve_angle(circuit, angle)
circuit.rx(param, qubit_index)
@@ -223,7 +238,7 @@ def apply_ry_gate(
qubit_index: int,
angle: float | str | qiskit.circuit.Parameter,
) -> None:
- param = qiskit.circuit.Parameter(angle) if isinstance(angle, str) else angle
+ param = _resolve_angle(circuit, angle)
circuit.ry(param, qubit_index)
@@ -232,7 +247,7 @@ def apply_rz_gate(
qubit_index: int,
angle: float | str | qiskit.circuit.Parameter,
) -> None:
- param = qiskit.circuit.Parameter(angle) if isinstance(angle, str) else angle
+ param = _resolve_angle(circuit, angle)
circuit.rz(param, qubit_index)
diff --git a/testing/qumat/test_parameter_binding.py b/testing/qumat/test_parameter_binding.py
index 7a7976cde..eab180d1b 100644
--- a/testing/qumat/test_parameter_binding.py
+++ b/testing/qumat/test_parameter_binding.py
@@ -256,3 +256,27 @@ class TestParameterBinding:
assert backend_config == original_config, (
f"backend_config was mutated by get_final_state_vector in {backend_name}."
)
+
+ @pytest.mark.parametrize("backend_name", TESTING_BACKENDS)
+ def test_parameter_reused_across_gates(self, backend_name):
+ """One parameter name may drive several gates (shared parameter).
+
+ RY(theta) applied twice equals RY(2 * theta), which takes |0> to |1>
+ with probability sin^2(theta). With theta = pi/2 that is 1, whereas a
+ single RY(pi/2) would give 0.5. Qiskit used to raise CircuitError
+ ("name conflict adding parameter") here.
+ """
+ backend_config = get_backend_config(backend_name)
+ qumat = QuMat(backend_config)
+ qumat.create_empty_circuit(num_qubits=1)
+ qumat.apply_ry_gate(0, "theta")
+ qumat.apply_ry_gate(0, "theta")
+ assert list(qumat.parameters) == ["theta"]
+
+ results = qumat.execute_circuit(parameter_values={"theta": math.pi / 2})
+
+ prob = get_state_probability(results, "1", num_qubits=1)
+ assert prob > 0.95, (
+ f"Expected |1> after RY(theta)RY(theta), theta=pi/2, in "
+ f"{backend_name}, got probability {prob:.4f}"
+ )
(mahout) 01/04: [Qumat] Fix Qiskit state vector after SWAP and reuse its target (#749)
Posted by ak...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
andrewmusselman pushed a commit to branch pr/749-param-optimization
in repository https://gitbox.apache.org/repos/asf/mahout.git
commit 2025f479e7edde564a79ded053e6352ca8e3d190
Author: Andrew Musselman <ak...@apache.org>
AuthorDate: Wed Oct 7 22:46:36 2026 -0700
[Qumat] Fix Qiskit state vector after SWAP and reuse its target (#749)
get_final_state_vector() on the Qiskit backend transpiled with the
default optimization level (2). That level runs ElidePermutations,
which replaces a SWAP with a virtual qubit relabelling. Aer's
save_statevector does not undo the relabelling, so the returned
amplitudes were those *before* the SWAP: X(0); SWAP(0, 1) returned
|q0=1> instead of |q1=1>. Cirq and Braket were already correct.
Transpile at optimization_level=1 instead. Level 1 does not elide
permutations and still produces bit-identical amplitudes to the old
path for the existing teleportation fixtures (level 0 differs in the
last bits of the floating-point result, which would break the
assert_array_equal comparisons in test_final_quantum_states.py).
Also build the statevector AerSimulator and its transpiler Target once
and reuse them. Rebuilding the Target dominated each call (~120 ms ->
~25 ms per call on a laptop CPU), which matters for the optimizers in
the following commits because they evaluate the state many times.
Add a regression test that checks the state after X; SWAP (with and
without a trailing H) on all three backends. Without this change the
two Qiskit cases fail.
Part of #749
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
qumat/qiskit_backend.py | 24 +++++++++++++++++----
testing/qumat/test_final_quantum_states.py | 34 ++++++++++++++++++++++++++++++
2 files changed, 54 insertions(+), 4 deletions(-)
diff --git a/qumat/qiskit_backend.py b/qumat/qiskit_backend.py
index 6f723ede5..e8df0c694 100644
--- a/qumat/qiskit_backend.py
+++ b/qumat/qiskit_backend.py
@@ -17,6 +17,7 @@
from __future__ import annotations
from collections.abc import Mapping, MutableMapping
+from functools import lru_cache
from typing import Any
import qiskit
@@ -159,7 +160,17 @@ def execute_circuit(
return result.get_counts()
-# placeholder method for use in the testing suite
+@lru_cache(maxsize=1)
+def _statevector_simulator() -> tuple[AerSimulator, Any]:
+ """Return a shared statevector simulator and its transpiler target.
+
+ Building ``AerSimulator.target`` dominates the cost of a state-vector
+ call, so it is built once and reused.
+ """
+ simulator = AerSimulator(method="statevector")
+ return simulator, simulator.target
+
+
def get_final_state_vector(
circuit: qiskit.QuantumCircuit,
backend: AerSimulator,
@@ -167,7 +178,7 @@ def get_final_state_vector(
) -> Any:
working_circuit = circuit.copy()
- simulator = AerSimulator(method="statevector")
+ simulator, target = _statevector_simulator()
# Add save_statevector instruction
working_circuit.save_statevector()
@@ -180,8 +191,13 @@ def get_final_state_vector(
}
working_circuit = working_circuit.assign_parameters(parameter_bindings)
- # Simulate the circuit
- transpiled_circuit = qiskit.transpile(working_circuit, simulator)
+ # Simulate the circuit. Keep optimization_level at 1: levels 2 and 3
+ # (the default is 2) run ElidePermutations, which replaces a SWAP with
+ # a virtual qubit relabelling that save_statevector does not undo, so
+ # the reported amplitudes would be those *before* the SWAP.
+ transpiled_circuit = qiskit.transpile(
+ working_circuit, target=target, optimization_level=1
+ )
job = simulator.run(transpiled_circuit)
result = job.result()
diff --git a/testing/qumat/test_final_quantum_states.py b/testing/qumat/test_final_quantum_states.py
index 8c3a0ef93..75dc5ea9d 100644
--- a/testing/qumat/test_final_quantum_states.py
+++ b/testing/qumat/test_final_quantum_states.py
@@ -20,6 +20,8 @@ from importlib import import_module
import numpy as np
import pytest
+from qumat import QuMat
+
from ..utils import TESTING_BACKENDS
from ..utils.qumat_helpers import get_qumat_example_final_state_vector
@@ -75,3 +77,35 @@ class TestFinalQuantumStatesConsistency:
results[backend_name] = get_qumat_example_final_state_vector(
qumat_backend_config, initial_ket_str
)
+
+
+@pytest.mark.parametrize("backend_name", TESTING_BACKENDS)
+@pytest.mark.parametrize("trailing_h", [False, True])
+def test_final_state_vector_after_swap(backend_name, trailing_h):
+ """A SWAP must move amplitudes, not just relabel qubits.
+
+ Regression test: Qiskit's default transpiler can replace a SWAP with a
+ virtual qubit permutation, so ``save_statevector`` used to report the
+ state *before* the swap.
+ """
+ backend_module = import_module(f".{backend_name}_helpers", package="testing.utils")
+ qumat = QuMat(backend_module.get_qumat_backend_config("get_final_state_vector"))
+ qumat.create_empty_circuit(num_qubits=2)
+ qumat.apply_pauli_x_gate(0)
+ qumat.apply_swap_gate(0, 1)
+ if trailing_h:
+ qumat.apply_hadamard_gate(0)
+ state = np.asarray(qumat.get_final_state_vector())
+
+ # After X(0) and SWAP(0, 1) the register is |q0 q1> = |01>.
+ # Qiskit indexes amplitudes little-endian (qubit 0 = least significant
+ # bit), Cirq and Braket big-endian (qubit 0 = most significant bit).
+ one_on_qubit1 = 2 if backend_name == "qiskit" else 1
+ expected = np.zeros(4, dtype=complex)
+ if trailing_h:
+ # H on qubit 0 (which is |0>) gives (|01> + |11>)/sqrt(2).
+ one_on_both = 3
+ expected[one_on_qubit1] = expected[one_on_both] = 1 / np.sqrt(2)
+ else:
+ expected[one_on_qubit1] = 1.0
+ np.testing.assert_allclose(state, expected, atol=1e-6)