Source code for qarp.algorithms._composite.ssvqe

from copy import deepcopy
from typing import Dict, List, Mapping, Optional, Union

import numpy as np
from sympy import Symbol

from qarp.operators import QubitOperator

from ...blocks import AnyBlock, CompositeBlock
from ...engines import Engine
from ...engines._gradients import gradient_method_from_flag
from ...optimizers import Optimizer, ScipyOptimizer
from .. import PrimitiveAlgorithm, StateVector
from . import CompositeAlgorithm
from ._params import resolve_initial_parameters


[docs] class SSVQE(CompositeAlgorithm): def __init__( self, operator: Union[QubitOperator, AnyBlock], basis_state_blocks: List[AnyBlock], ansatz_block: AnyBlock, weights: List[float], initial_parameters: Optional[Union[Mapping, np.typing.NDArray[np.float64]]] = None, optimizer: Optional[Optimizer] = None, verbose: bool = True, gradient: Union[bool, str] = False, primitive: Optional[PrimitiveAlgorithm] = None, engine: Optional[Engine] = None, ): """Subspace-Search Variational Quantum Eigensolver (SSVQE) for multiple eigenstates. SSVQE is a variational quantum algorithm that simultaneously optimizes multiple quantum states to find several eigenstates of a Hamiltonian. It constructs trial states by applying a shared parameterized ansatz to different basis states, then minimizes a weighted sum of their energies. This approach enables efficient computation of excited states and spectral properties by exploring orthogonal subspaces of the Hilbert space within a single optimization procedure. Args: operator: The Hamiltonian operator or Block to minimize. basis_state_blocks: List of basis state blocks to which the ansatz is applied. ansatz_block: Parameterized ansatz block applied to each basis state. weights: Weight coefficients for each state's energy in the objective function. initial_parameters: Starting parameters — a ``{symbol: value}`` mapping (order-proof, preferred) or a vector positional against ``ansatz_block.symbols``. If None, initialized uniformly at random in [0, 2π). optimizer: Optimizer for parameter optimization (defaults to Scipy's conjugate gradient). verbose: Whether to print progress information. gradient: ``True`` for the engine's default analytic gradient, ``False`` for none, or a method name accepted by ``Engine.run_gradient``. primitive: Algorithm for computing energy expectation values. engine: Execution engine for running algorithms. """ if optimizer is None: optimizer = ScipyOptimizer("CG") if primitive is None: primitive = StateVector() super().__init__(engine=engine, primitive=primitive) self.operator = operator self.basis_state_blocks = basis_state_blocks self.weights = weights self.primitive = primitive self.optimizer = optimizer self.verbose = verbose self.gradient_method = gradient_method_from_flag(gradient) self.gradient = self.gradient_method is not None self.primitives: list[PrimitiveAlgorithm] = [] self.ansatz_block = ansatz_block self.energies = [None] * len(basis_state_blocks) if not ansatz_block.symbols: raise ValueError( "Cannot build SSVQE: ansatz_block has no parameters to optimize " "(symbols is None or empty). Pass a parameterized ansatz " "(e.g. UCCBlock), not a bare state-preparation block." ) if initial_parameters is None: self.initial_parameters = np.random.uniform(0, 2 * np.pi, len(ansatz_block.symbols)) else: self.initial_parameters = resolve_initial_parameters( ansatz_block.symbols, initial_parameters ) self.result = None
[docs] def build(self): kets = [] for i in range(len(self.basis_state_blocks)): basis_state_block = self.basis_state_blocks[i] ket = CompositeBlock([basis_state_block, self.ansatz_block], basis_state_block.n_qubits) ket.build() kets += [ket] primitive = deepcopy(self.primitive) primitive.bra = ket primitive.operator = self.operator primitive.ket = ket self.primitives += [primitive] # engine.build() builds each primitive itself — no pre-build needed. self.engine.build(self.primitives) if self.verbose: if self.gradient: gradstr = f"analytic ({self.gradient_method}) via {self.engine}" else: gradstr = "No analytic gradients" print("SSVQE Build:") print(f"\tTarget Extraction: {self.primitive}.") print(f"\tEngine: {self.engine}.") print("\tGradient: " + gradstr + ".") print(f"\tOptimizer: {self.optimizer}.") return self
@property def optimal_parameters(self) -> Dict[Symbol, float]: """Optimized parameters keyed by symbol — the order-proof surface. Use this (not manual ``zip`` against a symbol list) to evaluate properties of the optimized states. """ if self.result is None: raise RuntimeError("No optimization result — call run() first.") return self.ansatz_block.parameter_map(self.result.x)
[docs] def get_final_state_block(self, index: int) -> AnyBlock: """Built block for optimized state ``index``: basis state + ansatz bound at the optimal parameters. No symbol handling required — evaluate ⟨N⟩, ⟨S²⟩, etc. directly on the returned block.""" basis_state_block = self.basis_state_blocks[index] ket = CompositeBlock([basis_state_block, self.ansatz_block], basis_state_block.n_qubits) ket.build() bound = ket.set_symbols(self.optimal_parameters) bound.build() return bound
[docs] def objective(self, x): parameter_map = dict(zip(self.ansatz_block.symbols, x, strict=True)) self.energies = self.engine.run(parameter_map) return sum(np.array(self.weights) * np.array(self.energies)).real
[docs] def objective_gradient(self, x): parameter_map = dict(zip(self.ansatz_block.symbols, x, strict=True)) gradients = np.zeros_like(x, dtype=float) g = self.engine.run_gradient(parameter_map, method=self.gradient_method or "default") for i, term in enumerate(g): # The objective takes the real part of each energy; so does its gradient. gradients += self.weights[i] * np.real(term) return gradients
[docs] def run(self): """Run the SSVQE algorithm to find multiple eigenstates. Returns: A tuple containing: - List of computed energies for each eigenstate. - Optimized parameters as a NumPy array. """ def objective_function(x): val = float(self.objective(x)) self._last_objective = val return val gradient_function = None if self.gradient: def gradient_function(x): grad = self.objective_gradient(x) self._last_gradnorm = float(np.linalg.norm(grad)) return grad def callback(x): # Reads the last-evaluation caches — no extra quantum work. energy = self._last_objective gradnorm = self._last_gradnorm if self.gradient else None dE = 0.0 if callback.previous_energy is None else energy - callback.previous_energy step_size = ( 0.0 if callback.previous_theta is None else float(np.linalg.norm(np.asarray(x) - callback.previous_theta)) ) self._log_iteration(callback.iter, energy, dE, step_size, gradnorm, label="SSVQE") callback.previous_energy = energy callback.previous_theta = np.asarray(x, dtype=float).copy() callback.iter += 1 callback.iter = 0 callback.previous_energy = None callback.previous_theta = None self.result = self._minimize( objective_function, self.initial_parameters, self.optimizer, gradient=gradient_function, callback=callback if self.verbose else None, success_label=None, ) # Re-evaluate at result.x so energies holds the per-state energies AT # the returned parameters, not scipy's last probe point. self.objective(self.result.x) return self.energies, self.result.x