Source code for qc_executor.qulacs.qulacs_executor

"""Qulacs executor implementation."""

from __future__ import annotations

import re
from collections import Counter
from itertools import product
from typing import Any, List, Tuple, cast

import numpy as np
from qulacs import ParametricQuantumCircuit  # pylint: disable=no-name-in-module
from qulacs import QuantumState  # pylint: disable=no-name-in-module

from ..base import ExecutorBase, QuantumCircuitBase, QuantumOperatorBase
from ..parameters import Parameter, Parameters
from ..quantum_circuit import QuantumCircuit
from ..utils.data_preprocessing import adjust_features, resolve_parameter_batch_size, to_tuple
from .qulacs_circuit import QulacsCircuit
from .qulacs_operator import QulacsObservableBatch, QulacsOperator


[docs] class QulacsExecutor(ExecutorBase): """Qulacs backend executor implementation. Args: shots (int | None, optional): Number of shots for sampling. seed (int | None, optional): Random seed for reproducibility. log_file (str | None, optional): Path to the log file. log_level (str, optional): Logging level. caching (bool | None, optional): Whether to use in-memory caching. cache_dir (str, optional): Directory for caching. max_cache_size (int | None, optional): Maximum number of entries kept in each in-memory cache; ``None`` makes them unbounded. """ _native_circuit_class = QulacsCircuit _native_operator_class = QulacsOperator def __init__( self, shots: int | None = None, seed: int | None = None, log_file: str | None = None, log_level: str = "WARNING", caching: bool | None = None, cache_dir: str = "cache", max_cache_size: int | None = 4096, ): super().__init__( shots=shots, seed=seed, log_file=log_file, log_level=log_level, caching=caching, cache_dir=cache_dir, max_cache_size=max_cache_size, ) self._circuit_cache = self._make_cache() self._operator_cache = self._make_cache() self.result_container = {} if seed is not None: self._random = np.random.default_rng(seed) else: self._random = np.random.default_rng() @property def shots(self) -> int | None: """Return the number of shots.""" return self._shots @shots.setter def shots(self, value: int | None) -> None: """Set the number of shots for future ``sample()`` calls. Qulacs has no persistent device object to keep in sync - the expectation-value and derivative paths are fully analytic and unaffected by this value; only ``_sample`` reads ``self._shots``, directly, at call time. """ self._shots = value @property def remote(self) -> bool: """Return True if the execution access a remote backend.""" return False def _preprocess_circuits( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase] ) -> Tuple[List[QulacsCircuit], bool]: """Preprocess the circuit(s) and convert them to Qulacs format. Args: circuit (QuantumCircuitBase | List[QuantumCircuitBase]): The quantum circuit(s) to preprocess. Returns: Tuple[List[QulacsCircuit], bool]: A tuple containing a list of QulacsCircuit objects and a boolean indicating whether multiple circuits were provided. """ multiple_circuits = True circuits: List[QuantumCircuitBase] = circuit if isinstance(circuit, list) else [circuit] if not isinstance(circuit, list): multiple_circuits = False qulacs_circuits = [] # Check the cache for already converted circuits. Circuits hash by # content, so an in-place mutated circuit never hits a stale entry. for circ in circuits: if isinstance(circ, self._native_circuit_class): qulacs_circuits.append(circ) continue if circ in self._circuit_cache: self._logger.debug("Circuit cache hit for %s", circ) qulacs_circuits.append(self._circuit_cache[circ]) else: self._logger.debug("Circuit cache miss – converting circuit %s", circ) qulacs_circuit = QulacsCircuit.from_quantum_circuit(cast(QuantumCircuit, circ)) self._circuit_cache[circ] = qulacs_circuit qulacs_circuits.append(qulacs_circuit) return qulacs_circuits, multiple_circuits def _preprocess_operators( self, operator: QuantumOperatorBase | List[QuantumOperatorBase] ) -> Tuple[List[QulacsOperator], bool]: """Preprocess the operator(s) and convert them to Qulacs format. Args: operator (QuantumOperatorBase | List[QuantumOperatorBase]): The quantum operator(s) to preprocess. Returns: Tuple[List[QulacsOperator], bool]: A tuple containing a list of QulacsOperator objects and a boolean indicating whether multiple operators were provided. """ multiple_operators = True operators: List[QuantumOperatorBase] = ( operator if isinstance(operator, list) else [operator] ) if not isinstance(operator, list): multiple_operators = False qulacs_operators = [] for op in operators: if isinstance(op, self._native_operator_class): qulacs_operators.append(op) continue if op in self._operator_cache: self._logger.debug("Operator cache hit for %s", op) qulacs_operators.append(self._operator_cache[op]) else: self._logger.debug("Operator cache miss – converting operator %s", op) qulacs_operator = QulacsOperator.from_quantum_operator(op) self._operator_cache[op] = qulacs_operator qulacs_operators.append(qulacs_operator) return qulacs_operators, multiple_operators def _expectation_value( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase], observable: QuantumOperatorBase | List[QuantumOperatorBase], **parameter_values, ) -> float | np.ndarray: """ Calculate the expectation value of the observable with respect to the circuit. Args: circuit (QuantumCircuitBase | List[QuantumCircuitBase]): The quantum circuit(s). observable (QuantumOperatorBase | List[QuantumOperatorBase]): The quantum observable(s). Returns: float | np.ndarray: The expectation value(s). """ qulacs_circuits, multiple_circuits = self._preprocess_circuits(circuit) qulacs_observables, multiple_observables = self._preprocess_operators(observable) values = [] # Multiple circuits, multiple observables, and multiple parameter # sets (a batch, per named parameter) are all supported and handled # by the nested loops below - see the shape handling after the loop. for qulacs_circuit in qulacs_circuits: circuit_parameters = [] multiple_circuit_parameters = [] circuit_parameters_dimension = [] circuit_values = [] for param in qulacs_circuit.parameter_names: if param not in parameter_values: raise ValueError( f"Parameter '{param}' not found in provided parameter values." ) param_values, multiple_params = adjust_features( parameter_values[param], qulacs_circuit.parameter_dimensions[param] ) circuit_parameters.append(param_values) multiple_circuit_parameters.append(multiple_params) circuit_parameters_dimension.append(qulacs_circuit.parameter_dimensions[param]) circuit_parameter_tuples = product(*circuit_parameters) for cp in circuit_parameter_tuples: cp_values = [] qulacs_circuit_object = qulacs_circuit.get_circuit_func()(*cp) state = QuantumState(qulacs_circuit.num_qubits) qulacs_circuit_object.update_quantum_state(state) for qulacs_observable in qulacs_observables: observable_values = [] observable_parameters = [] multiple_observable_parameters = [] observable_parameters_dimension = [] for param in qulacs_observable.parameter_names: if param not in parameter_values: raise ValueError( f"Parameter '{param}' not found in provided parameter values." ) param_values, multiple_params = adjust_features( parameter_values[param], qulacs_observable.parameter_dimensions[param] ) observable_parameters.append(param_values) multiple_observable_parameters.append(multiple_params) observable_parameters_dimension.append( qulacs_observable.parameter_dimensions[param] ) observable_parameter_tuples = product(*observable_parameters) for obs in observable_parameter_tuples: qulacs_observable_object = QulacsObservableBatch( [qulacs_observable] ).get_operator_func()(*obs[0] if obs else ()) # not sure about the [0] here, but it works for single observables observable_values.append( np.real_if_close( np.array( [ o.get_expectation_value(state) for o in qulacs_observable_object ][0] ) ) ) # check for multiple parameter sets cp_values.append(observable_values) circuit_values.append(cp_values) values.append(circuit_values) values = np.array(values) values = np.moveaxis(values, 1, 2) if values.shape[-1] == 1: values = values[..., 0] if values.shape[-1] == 1: values = values[..., 0] if not multiple_circuits: values = values[0] if not multiple_observables: values = values[0] else: if not multiple_observables: values = values[:, 0, ...] return values def _expectation_value_derivatives( self, circuit: QuantumCircuitBase, observable: QuantumOperatorBase, *values: str | Parameters | Parameter | tuple, **parameter_values, ) -> float | np.ndarray | dict: """ Calculate the derivatives of the expectation value with respect to the parameters Args: circuit (QuantumCircuitBase): The quantum circuit. observable (QuantumOperatorBase): The quantum observable. values: Values for which the derivatives are calculated. Can be strings (e.g. "expectation_value" or the name of parameters), or Parameters vectors and Parameter elements. Tuples are used for higher order derivatives. parameter_values: Parameters to evaluate the circuit and observable given as keyword arguments. Returns: np.array | dict: The derivatives of the expectation value. If a single value is provided, a numpy array is returned. If multiple values are provided, a dictionary with the values as keys and the derivatives as values is returned. """ def evaluate_circuit_gradient( circuit: QulacsCircuit, observable: QulacsObservableBatch, arguments_circuit, arguments_observable, parameters: Parameter | List[Parameter] | None = None, ) -> np.ndarray: """ Function to evaluate the Qulacs Circuits with the given parameters. Computes the gradient of the expectation values of the observables defined in the circuit data structure. Args: circuit (QulacsCircuit): Qulacs circuit to evaluate observable (QulacsObservableBatch): Qulacs observables to evaluate arguments_circuit: Arguments for the circuit arguments_observable: Arguments for the observable parameters (List[float]): List of circuit parameters wrt. the gradient is computed Returns: np.ndarray: Result of the evaluation """ qulacs_circuit = circuit.get_circuit_func(parameters)(*arguments_circuit) outer_jacobian = circuit.get_gradient_outer_jacobian(parameters)(*arguments_circuit) observable_args = arguments_observable[0] if len(arguments_observable) > 0 else [] qulacs_observable = observable.get_operator_func()(*observable_args) if isinstance(parameters, Parameter): parameters = [parameters] parameters = list(parameters) if parameters is not None else [] is_parameterized = len(parameters) if is_parameterized: param_circuit = cast(ParametricQuantumCircuit, qulacs_circuit) param_values = np.array( [ outer_jacobian.T @ np.array(param_circuit.backprop(o)) for o in qulacs_observable ] ) else: param_values = np.array([[]]) values = np.real_if_close(param_values) if len(observable) == 1: return values[0] return values def evaluate_observable_gradient( circuit: QulacsCircuit, observable: QulacsObservableBatch, arguments_circuit, arguments_observable, parameters: Parameter | List[Parameter] | None = None, ) -> np.ndarray: """ Function to evaluate the Qulacs Observables with the given parameters. Computes the gradient of the expectation values of the observables defined in the circuit data structure. Args: circuit (QulacsCircuit): Qulacs circuit to evaluate observable (QulacsObservableBatch): Qulacs observables to evaluate arguments_circuit: Arguments for the circuit arguments_observable: Arguments for the observable parameters (List[float]): List of observable parameters wrt. the gradient is computed Returns: np.ndarray: Result of the evaluation """ qulacs_circuit = circuit.get_circuit_func(parameters)(*arguments_circuit) state = QuantumState(circuit.num_qubits) qulacs_circuit.update_quantum_state(state) operators = observable.get_operators_for_gradient(parameters)(*arguments_observable[0]) outer_jacobian = observable.get_gradient_outer_jacobian_operators_new(parameters)( *arguments_observable ) param_obs_values = [ outer_jacobian[i].T @ np.array( [ o if isinstance(o, float) else o.get_expectation_value(state) for o in operator ] ) for i, operator in enumerate(operators) ] values = np.real_if_close(param_obs_values) if len(observable) == 1: return values[0] return values def remove_brackets(s: str) -> str: return re.sub(r"\[.*?\]", "", s) qulacs_circuits, _ = self._preprocess_circuits(circuit) qulacs_observables, multiple_observables = self._preprocess_operators(observable) # Several observables are evaluated together as a batch. Several # circuits are not: each would need its own state, so refuse rather # than silently returning the first one's gradient. if len(qulacs_circuits) > 1: raise NotImplementedError( "Derivatives for multiple circuits are not supported. " "Please call expectation_value_derivatives with a single circuit." ) qulacs_circuit = qulacs_circuits[0] qulacs_observable = QulacsObservableBatch(qulacs_observables) # preprocess the parameter values circuit_param_arrays = [] for param in qulacs_circuit.parameter_names: if param not in parameter_values: raise ValueError(f"Parameter '{param}' not found in provided parameter values.") param_values, _ = adjust_features( parameter_values[param], qulacs_circuit.parameter_dimensions[param] ) circuit_param_arrays.append((param, param_values)) observable_param_arrays = [] for param in qulacs_observable.parameter_names: if param not in parameter_values: raise ValueError(f"Parameter '{param}' not found in provided parameter values.") param_values, _ = adjust_features( parameter_values[param], qulacs_observable.parameter_dimensions[param] ) observable_param_arrays.append((param, param_values)) # A parameter with more than one row is a genuine batch of parameter # sets; one with exactly one row broadcasts across the batch. All # batched parameters (circuit- and observable-side) must agree on the # batch size - resolved once, up front. batch_size = resolve_parameter_batch_size( arr.shape[0] for _, arr in (*circuit_param_arrays, *observable_param_arrays) ) if values is None or len(values) == 0: values = ("expectation_value",) # Convert and sort the values value_list: list = list(values) indices = np.argsort([str(t) for t in value_list]) value_list = [value_list[i] for i in indices] value_list = [to_tuple(cast(Any, v)) for v in value_list] def evaluate_one(batch_index: int): """Evaluate every requested derivative for a single row of the batch.""" circuit_parameters = [ arr[batch_index] if arr.shape[0] > 1 else arr[0] for _, arr in circuit_param_arrays ] observable_parameters = [ arr[batch_index] if arr.shape[0] > 1 else arr[0] for _, arr in observable_param_arrays ] parameter_values_one = { name: (arr[batch_index] if arr.shape[0] > 1 else arr[0]) for name, arr in (*circuit_param_arrays, *observable_param_arrays) } result_dict = {} # Loop over all requested derivatives for todo in value_list: if len(todo) > 1: raise ValueError( "Higher order derivatives are not supported with qulacs, " "please use pennylane" ) # get the parameter objects for the requested circuit derivatives parameter_vector = [] for param in qulacs_circuit.free_parameters: if isinstance(todo[0], str): if todo[0] == "" or todo[0] == "expectation_value": pass elif todo[0] == remove_brackets(param.name): parameter_vector.append(param) elif todo[0] == param.name: parameter_vector.append(param) elif isinstance(todo[0], Parameter): if param == todo[0]: parameter_vector.append(param) else: raise ValueError("Unknown parameter type:", type(todo[0])) if len(parameter_vector) > 0: # Sort numerically by vector name and element index; a plain # string sort would order theta[10] before theta[2]. parameter_vector = sorted( parameter_vector, key=lambda p: (remove_brackets(p.name), getattr(p, "index", 0)), ) # get the parameter objects for the requested observable derivatives observable_vector = [] for param in qulacs_observable.free_parameters: if isinstance(todo[0], str): if todo[0] == "" or todo[0] == "expectation_value": pass elif todo[0] == remove_brackets(param.name): observable_vector.append(param) elif todo[0] == param.name: observable_vector.append(param) elif isinstance(todo[0], Parameter): if param == todo[0]: observable_vector.append(param) else: raise ValueError("Unknown parameter type:", type(todo[0])) if len(observable_vector) > 0: observable_vector = sorted( observable_vector, key=lambda p: (remove_brackets(p.name), getattr(p, "index", 0)), ) # Compute the requested derivatives if len(parameter_vector) == 0 and len(observable_vector) == 0: if todo[0] == "fischer": raise NotImplementedError( "Fischer information is not implemented for qulacs executor." ) if todo[0] != "" and todo[0] != "expectation_value": raise ValueError(f"Unknown derivative: {todo[0]}") # compute expectation value result = self._expectation_value(circuit, observable, **parameter_values_one) elif len(parameter_vector) > 0 and len(observable_vector) == 0: # compute gradient w.r.t. circuit parameters result = evaluate_circuit_gradient( qulacs_circuit, qulacs_observable, tuple(circuit_parameters), tuple(observable_parameters), parameter_vector, ) elif len(parameter_vector) == 0 and len(observable_vector) > 0: # compute gradient w.r.t. observable parameters result = evaluate_observable_gradient( qulacs_circuit, qulacs_observable, tuple(circuit_parameters), tuple(observable_parameters), observable_vector, ) else: raise ValueError( "Higher order derivatives are not supported with qulacs, " "please use pennylane" ) if len(values) == 1: return result if len(todo) == 1: if todo[0] == "": result_dict["expectation_value"] = result else: result_dict[todo[0]] = result else: result_dict[todo] = result return result_dict if batch_size == 1: return evaluate_one(0) # Every requested derivative, evaluated once per row of the batch and # stacked with a leading batch axis - the same list-batching # convention used for circuits/observables. per_row_results = [evaluate_one(i) for i in range(batch_size)] if isinstance(per_row_results[0], dict): return { key: np.stack([row[key] for row in per_row_results]) for key in per_row_results[0] } return np.stack(per_row_results) def _sample( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase], **parameter_values ) -> dict | List[dict]: """ Sample the circuit. Args: circuit (QuantumCircuitBase): The quantum circuit. Returns: dict: The samples from the circuit. """ statevector = self.statevector(circuit, **parameter_values) # Get the probabilities probabilities = np.square(np.abs(statevector)) # Generate bitstrings corresponding to each basis state assert not isinstance(circuit, list), "_sample only supports a single circuit" num_qubits = circuit.num_qubits bitstrings = [format(i, f"0{num_qubits}b") for i in range(len(statevector))] # Sample from the distribution samples = self._random.choice(bitstrings, size=self._shots, p=probabilities) # Count occurrences counts = dict(Counter(samples)) return counts def _statevector( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase], **parameter_values ) -> np.ndarray: """ Get the statevector of the circuit. Args: circuit (QuantumCircuitBase): The quantum circuit. Returns: np.ndarray: The statevector of the circuit. """ def reverse_bits_array(n: int, num_bits: int) -> np.ndarray: """Return indices that map backend bit order to public API order.""" indices = np.arange(n) reversed_indices = np.zeros_like(indices) for _ in range(num_bits): reversed_indices = (reversed_indices << 1) | (indices & 1) indices >>= 1 return reversed_indices qulacs_circuits, multiple_circuits = self._preprocess_circuits(circuit) state_vectors = [] for qulacs_circuit in qulacs_circuits: circuit_parameters = [] multiple_circuit_parameters = [] circuit_parameters_dimension = [] circuit_values = [] for param in qulacs_circuit.parameter_names: if param not in parameter_values: raise ValueError( f"Parameter '{param}' not found in provided parameter values." ) param_values, multiple_params = adjust_features( parameter_values[param], qulacs_circuit.parameter_dimensions[param] ) circuit_parameters.append(param_values) multiple_circuit_parameters.append(multiple_params) circuit_parameters_dimension.append(qulacs_circuit.parameter_dimensions[param]) circuit_parameter_tuples = product(*circuit_parameters) for cp in circuit_parameter_tuples: qulacs_circuit_object = qulacs_circuit.get_circuit_func()(*cp) state = QuantumState(qulacs_circuit.num_qubits) qulacs_circuit_object.update_quantum_state(state) state_vector = np.array(state.get_vector()) indices = reverse_bits_array(len(state_vector), qulacs_circuit.num_qubits) circuit_values.append(state_vector[indices]) state_vectors.append(circuit_values) state_vectors = np.array(state_vectors) if state_vectors.shape[1] == 1: state_vectors = state_vectors[:, 0, ...] if not multiple_circuits: state_vectors = state_vectors[0] return state_vectors def _transpile_circuit(self, circuit: QuantumCircuitBase) -> QuantumCircuitBase: """Transpile a generic QuantumCircuit to a QulacsCircuit. Args: circuit (QuantumCircuitBase): The generic QuantumCircuit to transpile. Returns: QuantumCircuitBase: The corresponding QulacsCircuit. """ if isinstance(circuit, self._native_circuit_class): return circuit return cast( QuantumCircuitBase, self._native_circuit_class.from_quantum_circuit(cast(QuantumCircuit, circuit)), ) def _transpile_operator( self, operator: QuantumOperatorBase, **_options ) -> QuantumOperatorBase: """Transpile a generic QuantumOperator to a Qulacs QuantumOperator. Args: operator (QuantumOperatorBase): The generic QuantumOperator to transpile. Returns: QuantumOperatorBase: The corresponding QulacsOperator. """ if isinstance(operator, self._native_operator_class): return operator return cast( QuantumOperatorBase, self._native_operator_class.from_quantum_operator(operator) )
[docs] @classmethod def get_accepted_backend_types(cls) -> List[type]: """Return all object types accepted as backend in factory auto-detection. QulacsExecutor does not accept backend objects during initialization. """ return []
[docs] @classmethod def get_accepted_backend_aliases(cls) -> List[str]: """Return string aliases accepted by this executor in ``Executor.create``.""" return []