diff --git a/.pylintdict b/.pylintdict index 68d55842c..7efc6ae26 100644 --- a/.pylintdict +++ b/.pylintdict @@ -77,8 +77,10 @@ cobyla codebase codec coeffs +coles colin combinatorial +computable concha config configs @@ -335,6 +337,7 @@ msg multiclass multinomial multioutput +multipartite mxd mypy nabla @@ -511,7 +514,7 @@ scipy sdg seealso semidefinite -sep +sep seperate seperable serializable diff --git a/qiskit_machine_learning/datasets/entanglement_concentration.py b/qiskit_machine_learning/datasets/entanglement_concentration.py index 9dc8c8463..a53c73d45 100644 --- a/qiskit_machine_learning/datasets/entanglement_concentration.py +++ b/qiskit_machine_learning/datasets/entanglement_concentration.py @@ -46,20 +46,20 @@ def entanglement_concentration_data( | tuple[list[Statevector], np.ndarray, list[Statevector], np.ndarray, np.ndarray] ): r""" - Generates a dataset that comprises of Quantum States with two different - amounts of Concentration Of Entanglement (CE) and their corresponding class labels. + Generates a dataset that comprises Quantum States with two different + amounts of Concentration of Entanglement (CE) and their corresponding class labels. These states are generated by the effect of two different pre-trained ansatz - on fully seperable input states according to the procedure outlined in [1]. Pre-trained - data in courtesy of L Schatzki et el [3]. The datapoints can be fully separated using + on fully separable input states according to the procedure outlined in [1]. Pre-trained + data in courtesy of L Schatzki et al [3]. The datapoints can be fully separated using the SWAP test outlined in [2]. First, input states are randomly generated from a uniform distribution, using a sampling method determined by the ``sampling_method`` - argument. Next, based on the ``mode`` argument, two pre-trained circuits "A" and "B" + argument. Next, based on the ``mode`` argument, two pre-trained circuits, "A" and "B" are used for generating datapoints. CE can be interpreted as a measure of correlation between the different qubits. The ``mode`` argument supports two options. ``"easy"`` gives datapoints with high CE - difference hence being easy to seperate. ``"hard"`` mode gives closer CE values. + difference, hence being easy to separate. ``"hard"`` mode gives closer CE values. The user's classifiers can be benchmarked against these modes for their ability to separate the data into two classes based on CE. @@ -152,21 +152,21 @@ def entanglement_concentration_data( raise ValueError("Invalid sampling method. Must be 'isotropic' or 'cardinal'") if sampling_method == "cardinal" and n_points >= (6**n): raise ValueError( - """Cardinal Sampling cannot generate a large number of unique - datapoints due to the limited number of combinations possible. + """Cardinal Sampling cannot generate a large number of unique + datapoints due to the limited number of combinations possible. Try "isotropic" sampling method""" ) if formatting not in {"statevector", "ndarray"}: raise ValueError( - """Formatting must be "statevector" or "ndarray". Please check for + """Formatting must be "statevector" or "ndarray". Please check for case sensitivity.""" ) # Warnings if sampling_method == "cardinal" and n_points > (3**n): warnings.warn( - """Cardinal Sampling for large number of samples is not recommended - and can lead to an arbitrarily large generation time due to + """Cardinal Sampling for large number of samples is not recommended + and can lead to an arbitrarily large generation time due to repeating datapoints. Try "isotropic" sampling method""", UserWarning, ) @@ -244,7 +244,7 @@ def _assign_parameters( expected = 3 * depth * n_qubits if len(weights) != expected: raise ValueError( - """Parameter mismatch – please reinstall the latest 'qiskit-machine-learning' + """Parameter mismatch – please reinstall the latest 'qiskit-machine-learning' package (or update the model files).""", ) diff --git a/qiskit_machine_learning/gradients/__init__.py b/qiskit_machine_learning/gradients/__init__.py index 5a5636bfa..013a69003 100644 --- a/qiskit_machine_learning/gradients/__init__.py +++ b/qiskit_machine_learning/gradients/__init__.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2022, 2024. +# (C) Copyright IBM 2022, 2025. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -71,6 +71,8 @@ from .base.sampler_gradient_result import SamplerGradientResult from .spsa.spsa_estimator_gradient import SPSAEstimatorGradient from .spsa.spsa_sampler_gradient import SPSASamplerGradient +from .lin_comb.lin_comb_qgt import LinCombQGT +from .qfi import QFI __all__ = [ "BaseEstimatorGradient", @@ -84,4 +86,6 @@ "SamplerGradientResult", "SPSAEstimatorGradient", "SPSASamplerGradient", + "LinCombQGT", + "QFI", ] diff --git a/qiskit_machine_learning/gradients/base/base_qgt.py b/qiskit_machine_learning/gradients/base/base_qgt.py new file mode 100644 index 000000000..7b8c5825e --- /dev/null +++ b/qiskit_machine_learning/gradients/base/base_qgt.py @@ -0,0 +1,383 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2022, 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +Abstract base class of the Quantum Geometric Tensor (QGT). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import Any + +import numpy as np + +from qiskit.circuit import Parameter, ParameterExpression, QuantumCircuit +from qiskit.primitives import BaseEstimatorV2 +from qiskit.transpiler.passes import TranslateParameterizedGates +from qiskit.passmanager import BasePassManager +from qiskit.primitives.utils import _circuit_key + +from .qgt_result import QGTResult +from ..utils import ( + DerivativeType, + GradientCircuit, + _assign_unique_parameters, + _make_gradient_parameters, + _make_gradient_parameter_values, +) + +from ...algorithm_job import AlgorithmJob + + +class BaseQGT(ABC): + r"""Base class to computes the Quantum Geometric Tensor (QGT) given a pure, + parameterized quantum state. QGT is defined as: + + .. math:: + + \mathrm{QGT}_{ij}= \langle \partial_i \psi | \partial_j \psi \rangle + - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle. + """ + + def __init__( + self, + estimator: BaseEstimatorV2, + phase_fix: bool = True, + derivative_type: DerivativeType = DerivativeType.COMPLEX, + precision: float | None = None, + *, + pass_manager: BasePassManager | None = None, + pass_manager_options: dict[str, Any] | None = None, + ): + r""" + Args: + estimator: The estimator used to compute the QGT. + phase_fix: Whether to calculate the second term (phase fix) of the QGT, which is + :math:`\langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle`. + Defaults to ``True``. + derivative_type: The type of derivative. Can be either ``DerivativeType.REAL`` + ``DerivativeType.IMAG``, or ``DerivativeType.COMPLEX``. Defaults to + ``DerivativeType.REAL``. + + - ``DerivativeType.REAL`` computes + + .. math:: + + \mathrm{Re(QGT)}_{ij}= \mathrm{Re}[\langle \partial_i \psi | \partial_j \psi \rangle + - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle]. + + - ``DerivativeType.IMAG`` computes + + .. math:: + + \mathrm{Im(QGT)}_{ij}= \mathrm{Im}[\langle \partial_i \psi | \partial_j \psi \rangle + - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle]. + + - ``DerivativeType.COMPLEX`` computes + + .. math:: + + \mathrm{QGT}_{ij}= [\langle \partial_i \psi | \partial_j \psi \rangle + - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle]. + precision: Precision to be used by the underlying Estimator. If provided, this number + takes precedence over the default precision of the primitive. If None, the default + precision of the primitive is used. + pass_manager: An optional object with a `run` method allowing to transpile the circuits + that are run when using this algorithm. If set to `None`, these won't be + transpiled. + """ + self._estimator: BaseEstimatorV2 = estimator + self._precision = precision + self._phase_fix: bool = phase_fix + self._derivative_type: DerivativeType = derivative_type + self._qgt_circuit_cache: dict[tuple, GradientCircuit] = {} + self._gradient_circuit_cache: dict[tuple, GradientCircuit] = {} + + self._pass_manager = pass_manager + self._pass_manager_options = ( + pass_manager_options if pass_manager_options is not None else {} + ) + + @property + def derivative_type(self) -> DerivativeType: + """The derivative type.""" + return self._derivative_type + + @derivative_type.setter + def derivative_type(self, derivative_type: DerivativeType) -> None: + """Set the derivative type.""" + self._derivative_type = derivative_type + + def run( + self, + circuits: Sequence[QuantumCircuit], + parameter_values: Sequence[Sequence[float]], + parameters: Sequence[Sequence[Parameter] | None] | None = None, + *, + precision: float | Sequence[float] | None = None, + ) -> AlgorithmJob: + """Run the job of the QGTs on the given circuits. + + Args: + circuits: The list of quantum circuits to compute the QGTs. + parameter_values: The list of parameter values to be bound to the circuit. + parameters: The sequence of parameters to calculate only the QGTs of + the specified parameters. Each sequence of parameters corresponds to a circuit in + ``circuits``. Defaults to None, which means that the QGTs of all parameters in + each circuit are calculated. + precision: Precision to be used by the underlying Estimator. If a single float is + provided, this number will be used for all circuits. If a sequence of floats is + provided, they will be used on a per-circuit basis. If not set, the gradient's default + precision will be used for all circuits, and if that is None (not set) then the + underlying primitive's (default) precision will be used for all circuits. + + Returns: + The job object of the QGTs of the expectation values. The i-th result corresponds to + ``circuits[i]`` evaluated with parameters bound as ``parameter_values[i]``. + + Raises: + ValueError: Invalid arguments are given. + """ + if isinstance(circuits, QuantumCircuit): + # Allow a single circuit to be passed in. + circuits = (circuits,) + + if parameters is None: + # If parameters is None, we calculate the gradients of all parameters in each circuit. + parameters = [circuit.parameters for circuit in circuits] + else: + # If parameters is not None, we calculate the gradients of the specified parameters. + # None in parameters means that the gradients of all parameters in the corresponding + # circuit are calculated. + parameters = [ + params if params is not None else circuits[i].parameters + for i, params in enumerate(parameters) + ] + # Validate the arguments. + self._validate_arguments(circuits, parameter_values, parameters) + + if precision is None: + precision = self.precision # May still be None + + job = AlgorithmJob(self._run, circuits, parameter_values, parameters, precision=precision) + job._submit() + return job + + @abstractmethod + def _run( + self, + circuits: Sequence[QuantumCircuit], + parameter_values: Sequence[Sequence[float]], + parameters: Sequence[Sequence[Parameter]], + *, + precision: float | Sequence[float] | None, + ) -> QGTResult: + """Compute the QGTs on the given circuits.""" + raise NotImplementedError() + + def _preprocess( + self, + circuits: Sequence[QuantumCircuit], + parameter_values: Sequence[Sequence[float]], + parameters: Sequence[Sequence[Parameter]], + supported_gates: Sequence[str], + ) -> tuple[Sequence[QuantumCircuit], Sequence[Sequence[float]], Sequence[Sequence[Parameter]]]: + """Preprocess the gradient. This makes a gradient circuit for each circuit. The gradient + circuit is a transpiled circuit by using the supported gates, and has unique parameters. + ``parameter_values`` and ``parameters`` are also updated to match the gradient circuit. + + Args: + circuits: The list of quantum circuits to compute the gradients. + parameter_values: The list of parameter values to be bound to the circuit. + parameters: The sequence of parameters to calculate only the gradients of the specified + parameters. + supported_gates: The supported gates used to transpile the circuit. + + Returns: + The list of gradient circuits, the list of parameter values, and the list of parameters. + parameter_values and parameters are updated to match the gradient circuit. + """ + translator = TranslateParameterizedGates(supported_gates) + g_circuits: list[QuantumCircuit] = [] + g_parameter_values: list[Sequence[float]] = [] + g_parameters: list[Sequence[Parameter]] = [] + for circuit, parameter_value_, parameters_ in zip(circuits, parameter_values, parameters): + circuit_key = _circuit_key(circuit) + if circuit_key not in self._gradient_circuit_cache: + unrolled = translator(circuit) + self._gradient_circuit_cache[circuit_key] = _assign_unique_parameters(unrolled) + gradient_circuit = self._gradient_circuit_cache[circuit_key] + g_circuits.append(gradient_circuit.gradient_circuit) + g_parameter_values.append( + _make_gradient_parameter_values( # type: ignore[arg-type] + circuit, gradient_circuit, parameter_value_ + ) + ) + g_parameters_ = [ + g_param + for g_param in gradient_circuit.gradient_circuit.parameters + if g_param in _make_gradient_parameters(gradient_circuit, parameters_) + ] + g_parameters.append(g_parameters_) + return g_circuits, g_parameter_values, g_parameters + + def _postprocess( + self, + results: QGTResult, + circuits: Sequence[QuantumCircuit], + parameter_values: Sequence[Sequence[float]], + parameters: Sequence[Sequence[Parameter]], + ) -> QGTResult: + """Postprocess the QGTs. This method computes the QGTs of the original circuits + by applying the chain rule to the QGTs of the circuits with unique parameters. + + Args: + results: The computed QGT for the circuits with unique parameters. + circuits: The list of original circuits submitted for gradient computation. + parameter_values: The list of parameter values to be bound to the circuits. + parameters: The sequence of parameters to calculate only the gradients of the specified + parameters. + + Returns: + The QGTs of the original circuits. + """ + qgts, metadata = [], [] + for idx, (circuit, parameter_values_, parameters_) in enumerate( + zip(circuits, parameter_values, parameters) + ): + dtype = complex if self.derivative_type == DerivativeType.COMPLEX else float + qgt: np.ndarray = np.zeros((len(parameters_), len(parameters_)), dtype=dtype) + + gradient_circuit = self._gradient_circuit_cache[_circuit_key(circuit)] + g_parameters = _make_gradient_parameters(gradient_circuit, parameters_) + # Make a map from the gradient parameter to the respective index in the gradient. + # parameters_ = [param for param in circuit.parameters if param in parameters_] + g_parameter_indices = [ + param + for param in gradient_circuit.gradient_circuit.parameters + if param in g_parameters + ] + g_parameter_indices_d = {param: i for i, param in enumerate(g_parameter_indices)} + rows, cols = np.triu_indices(len(parameters_)) + for row, col in zip(rows, cols): + for g_parameter1, coeff1 in gradient_circuit.parameter_map[parameters_[row]]: + for g_parameter2, coeff2 in gradient_circuit.parameter_map[parameters_[col]]: + if isinstance(coeff1, ParameterExpression): + local_map = { + p: parameter_values_[circuit.parameters.data.index(p)] + for p in coeff1.parameters + } + bound_coeff1 = coeff1.bind(local_map) + else: + bound_coeff1 = coeff1 + if isinstance(coeff2, ParameterExpression): + local_map = { + p: parameter_values_[circuit.parameters.data.index(p)] + for p in coeff2.parameters + } + bound_coeff2 = coeff2.bind(local_map) + else: + bound_coeff2 = coeff2 + qgt[row, col] += ( + float(bound_coeff1) + * float(bound_coeff2) + * results.qgts[idx][ + g_parameter_indices_d[g_parameter1], + g_parameter_indices_d[g_parameter2], + ] + ) + + if self.derivative_type == DerivativeType.IMAG: + qgt += -1 * np.triu(qgt, k=1).T + else: + qgt += np.triu(qgt, k=1).conjugate().T + qgts.append(qgt) + metadata.append([{"parameters": parameters_}]) + return QGTResult( + qgts=qgts, + derivative_type=self.derivative_type, + metadata=metadata, + precision=results.precision, + ) + + @staticmethod + def _validate_arguments( + circuits: Sequence[QuantumCircuit], + parameter_values: Sequence[Sequence[float]], + parameters: Sequence[Sequence[Parameter]], + ) -> None: + """Validate the arguments of the ``run`` method. + + Args: + circuits: The list of quantum circuits to compute the QGTs. + parameter_values: The list of parameter values to be bound to the circuits. + parameters: The sequence of parameters with respect to which the QGTs should be + computed. + + Raises: + ValueError: Invalid arguments are given. + """ + if len(circuits) != len(parameter_values): + raise ValueError( + f"The number of circuits ({len(circuits)}) does not match " + f"the number of parameter values ({len(parameter_values)})." + ) + + if len(circuits) != len(parameters): + raise ValueError( + f"The number of circuits ({len(circuits)}) does not match " + f"the number of the specified parameter sets ({len(parameters)})." + ) + + for i, (circuit, parameter_value) in enumerate(zip(circuits, parameter_values)): + if not circuit.num_parameters: + raise ValueError(f"The {i}-th circuit is not parameterised.") + if len(parameter_value) != circuit.num_parameters: + raise ValueError( + f"The number of values ({len(parameter_value)}) does not match " + f"the number of parameters ({circuit.num_parameters}) for the {i}-th circuit." + ) + + if len(circuits) != len(parameters): + raise ValueError( + f"The number of circuits ({len(circuits)}) does not match " + f"the number of the list of specified parameters ({len(parameters)})." + ) + + for i, (circuit, parameters_) in enumerate(zip(circuits, parameters)): + if not set(parameters_).issubset(circuit.parameters): + raise ValueError( + f"The {i}-th parameters contains parameters not present in the " + f"{i}-th circuit." + ) + + @property + def precision(self) -> float | None: + """Return the precision used by the `run` method of the Estimator primitive. If None, + the default precision of the primitive is used. + + Returns: + The default precision. + """ + return self._precision + + @precision.setter + def precision(self, precision: float | None): + """Update the gradient's default precision setting. + + Args: + precision: The new default precision. + """ + + self._precision = precision diff --git a/qiskit_machine_learning/gradients/base/qgt_result.py b/qiskit_machine_learning/gradients/base/qgt_result.py new file mode 100644 index 000000000..543ec3780 --- /dev/null +++ b/qiskit_machine_learning/gradients/base/qgt_result.py @@ -0,0 +1,37 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2022, 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +QGT result class +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Sequence + +import numpy as np + +from ..utils import DerivativeType + + +@dataclass(frozen=True) +class QGTResult: + """Result of QGT.""" + + qgts: list[np.ndarray] + """The QGT.""" + derivative_type: DerivativeType + """The type of derivative.""" + metadata: list[dict[str, Any]] | list[list[dict[str, Any]]] + """Additional information about the job.""" + precision: float | Sequence[float] + """Precision for the execution of the job.""" diff --git a/qiskit_machine_learning/gradients/lin_comb/lin_comb_qgt.py b/qiskit_machine_learning/gradients/lin_comb/lin_comb_qgt.py new file mode 100644 index 000000000..b5fd67990 --- /dev/null +++ b/qiskit_machine_learning/gradients/lin_comb/lin_comb_qgt.py @@ -0,0 +1,317 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2022, 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +A class for the Linear Combination Quantum Gradient Tensor. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np + +from qiskit.circuit import Parameter, QuantumCircuit +from qiskit.primitives import BaseEstimatorV2 +from qiskit.quantum_info import SparsePauliOp +from qiskit.primitives.utils import _circuit_key +from qiskit.passmanager import BasePassManager + +from ..base.base_qgt import BaseQGT +from .lin_comb_estimator_gradient import LinCombEstimatorGradient +from ..base.qgt_result import QGTResult +from ..utils import DerivativeType, _make_lin_comb_qgt_circuit, _make_lin_comb_observables + +from ...exceptions import AlgorithmError + + +class LinCombQGT(BaseQGT): + """Computes the Quantum Geometric Tensor (QGT) given a pure, parameterized quantum state. + + This method employs a linear combination of unitaries [1]. + + **Reference:** + + [1]: Schuld et al., "Evaluating analytic gradients on quantum hardware" (2018). + `arXiv:1811.11184 `_ + """ + + SUPPORTED_GATES = [ + "rx", + "ry", + "rz", + "rzx", + "rzz", + "ryy", + "rxx", + "cx", + "cy", + "cz", + "ccx", + "swap", + "iswap", + "h", + "t", + "s", + "sdg", + "x", + "y", + "z", + ] + + def __init__( + self, + estimator: BaseEstimatorV2, + phase_fix: bool = True, + derivative_type: DerivativeType = DerivativeType.COMPLEX, + *, + pass_manager: BasePassManager | None = None, + ): + r""" + Args: + estimator: The estimator used to compute the QGT. + phase_fix: Whether to calculate the second term (phase fix) of the QGT, which is + :math:`\langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle`. + Default to ``True``. + derivative_type: The type of derivative. Can be either ``DerivativeType.REAL`` + ``DerivativeType.IMAG``, or ``DerivativeType.COMPLEX``. Defaults to + ``DerivativeType.REAL``. + + - ``DerivativeType.REAL`` computes + + .. math:: + + \mathrm{Re(QGT)}_{ij}= \mathrm{Re}[\langle \partial_i \psi | \partial_j \psi \rangle + - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle]. + + - ``DerivativeType.IMAG`` computes + + .. math:: + + \mathrm{Re(QGT)}_{ij}= \mathrm{Im}[\langle \partial_i \psi | \partial_j \psi \rangle + - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle]. + + - ``DerivativeType.COMPLEX`` computes + + .. math:: + + \mathrm{QGT}_{ij}= [\langle \partial_i \psi | \partial_j \psi \rangle + - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle]. + transpiler: An optional object with a `run` method allowing to transpile the circuits + that are produced by the internal gradient of this algorithm. If set to `None`, + these won't be transpiled. + """ + super().__init__( + estimator, + phase_fix, + derivative_type, + pass_manager=pass_manager, + ) + self._gradient = LinCombEstimatorGradient( + estimator, + derivative_type=DerivativeType.COMPLEX, + pass_manager=pass_manager, + ) + self._lin_comb_qgt_circuit_cache: dict[ + tuple, dict[tuple[Parameter, Parameter], QuantumCircuit] + ] = {} + + def _run( + self, + circuits: Sequence[QuantumCircuit], + parameter_values: Sequence[Sequence[float]], + parameters: Sequence[Sequence[Parameter]], + *, + precision: float | Sequence[float] | None, + ) -> QGTResult: + """Compute the QGT on the given circuits.""" + g_circuits, g_parameter_values, g_parameters = self._preprocess( + circuits, parameter_values, parameters, self.SUPPORTED_GATES + ) + results = self._run_unique( + g_circuits, g_parameter_values, g_parameters, precision=precision + ) + return self._postprocess(results, circuits, parameter_values, parameters) + + def _run_unique( + self, + circuits: Sequence[QuantumCircuit], + parameter_values: Sequence[Sequence[float]], + parameters: Sequence[Sequence[Parameter]], + *, + precision: float | Sequence[float] | None, + ) -> QGTResult: + """Compute the QGTs on the given circuits.""" + metadata = [] + all_n, all_m = [], [] + phase_fixes: list[int | np.ndarray] = [] + + has_transformed_precision = False + + if isinstance(precision, float) or precision is None: + precision = [precision] * len(circuits) + has_transformed_precision = True + + pubs = [] + + if not (len(circuits) == len(parameters) == len(parameter_values) == len(precision)): + raise ValueError( + f"circuits, parameters, parameter_values and precision must have the same length, but " + f"have respective lengths {len(circuits)}, {len(parameters)}, {len(parameter_values)} " + f"and {len(precision)}." + ) + + for circuit, parameter_values_, parameters_, precision_ in zip( + circuits, parameter_values, parameters, precision + ): + # Prepare circuits for the gradient of the specified parameters. + parameters_ = [p for p in circuit.parameters if p in parameters_] + meta = {"parameters": parameters_} + metadata.append(meta) + + # Compute the first term in the QGT + circuit_key = _circuit_key(circuit) + if circuit_key not in self._lin_comb_qgt_circuit_cache: + # generate the all of the circuits for the first term in the QGT and cache them. + # Only the circuit related to specified parameters will be executed. + # In the future, we can generate the specified circuits on demand. + self._lin_comb_qgt_circuit_cache[circuit_key] = _make_lin_comb_qgt_circuit(circuit) + lin_comb_qgt_circuits = self._lin_comb_qgt_circuit_cache[circuit_key] + + qgt_circuits = [] + rows, cols = np.triu_indices(len(parameters_)) + for row, col in zip(rows, cols): + param_i = parameters_[row] + param_j = parameters_[col] + qgt_circuits.append(lin_comb_qgt_circuits[(param_i, param_j)]) + + observable = SparsePauliOp.from_list([("I" * circuit.num_qubits, 1)]) + observable_1, observable_2 = _make_lin_comb_observables( + observable, self._derivative_type + ) + + n = len(qgt_circuits) + if self._derivative_type == DerivativeType.COMPLEX: + all_m.append(len(parameters_)) + all_n.append(2 * n) + pubs.extend( + [ + (qgt_circuit, observable_1, parameter_values_, precision_) + for qgt_circuit in qgt_circuits + ] + ) + pubs.extend( + [ + (qgt_circuit, observable_2, parameter_values_, precision_) + for qgt_circuit in qgt_circuits + ] + ) + else: + all_m.append(len(parameters_)) + all_n.append(n) + pubs.extend( + [ + (qgt_circuit, observable_1, parameter_values_, precision_) + for qgt_circuit in qgt_circuits + ] + ) + + if self._pass_manager is not None: + for index, pub in enumerate(pubs): + new_circuit = self._pass_manager.run(pub[0], **self._pass_manager_options) + new_observable = pub[1].apply_layout(new_circuit.layout) + pubs[index] = (new_circuit, new_observable) + pub[2:] + + # Run the single job with all circuits. + job = self._estimator.run(pubs) + + if self._phase_fix: + # Compute the second term in the QGT if phase fix is enabled. + phase_fix_obs = [ + SparsePauliOp.from_list([("I" * circuit.num_qubits, 1)]) for circuit in circuits + ] + phase_fix_job = self._gradient.run( + circuits=circuits, + observables=phase_fix_obs, + parameter_values=parameter_values, + parameters=parameters, + precision=precision, + ) + + try: + results = job.result() + if self._phase_fix: + gradient_results = phase_fix_job.result() + except AlgorithmError as exc: + raise AlgorithmError("Estimator job or gradient job failed.") from exc + + # Compute the phase fix + if self._phase_fix: + for gradient in gradient_results.gradients: + phase_fix = np.outer(np.conjugate(gradient), gradient) + # Select the real or imaginary part of the phase fix if needed + if self.derivative_type == DerivativeType.REAL: + phase_fix = np.real(phase_fix) + elif self.derivative_type == DerivativeType.IMAG: + phase_fix = np.imag(phase_fix) + phase_fixes.append(phase_fix) + else: + phase_fixes = [0 for _ in range(len(circuits))] + # Compute the QGT + qgts = [] + partial_sum_n = 0 + for phase_fix, n, m in zip(phase_fixes, all_n, all_m): # type: ignore + qgt = np.zeros((m, m), dtype="complex") + # Compute the first term in the QGT + if self.derivative_type == DerivativeType.COMPLEX: + qgt[np.triu_indices(m)] = np.array( + [result.data.evs for result in results[partial_sum_n : partial_sum_n + n // 2]] + ) + qgt[np.triu_indices(m)] += 1j * np.array( + [ + result.data.evs + for result in results[partial_sum_n + n // 2 : partial_sum_n + n] + ] + ) + elif self.derivative_type == DerivativeType.REAL: + qgt[np.triu_indices(m)] = np.real( + [result.data.evs for result in results[partial_sum_n : partial_sum_n + n]] + ) + elif self.derivative_type == DerivativeType.IMAG: + qgt[np.triu_indices(m)] = 1j * np.real( + [result.data.evs for result in results[partial_sum_n : partial_sum_n + n]] + ) + + # Add the conjugate of the upper triangle to the lower triangle + qgt += np.triu(qgt, k=1).conjugate().T + if self.derivative_type == DerivativeType.REAL: + qgt = np.real(qgt) + elif self.derivative_type == DerivativeType.IMAG: + qgt = np.imag(qgt) + + # Subtract the phase fix from the QGT + qgt -= phase_fix + partial_sum_n += n + qgts.append(qgt / 4) + + if has_transformed_precision: + precision = precision[0] + + if precision is None: + precision = results[0].metadata["target_precision"] + else: + for i, (precision_, result) in enumerate(zip(precision, results)): + if precision_ is None: + precision[i] = results[i].metadata["target_precision"] # type: ignore + + return QGTResult( + qgts=qgts, derivative_type=self.derivative_type, metadata=metadata, precision=precision + ) diff --git a/qiskit_machine_learning/gradients/qfi.py b/qiskit_machine_learning/gradients/qfi.py new file mode 100644 index 000000000..a699a6137 --- /dev/null +++ b/qiskit_machine_learning/gradients/qfi.py @@ -0,0 +1,153 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2022, 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +A class for the Quantum Fisher Information. +""" + +from __future__ import annotations + +from abc import ABC +from collections.abc import Sequence + +from qiskit.circuit import Parameter, QuantumCircuit + +from .base.base_qgt import BaseQGT +from .lin_comb.lin_comb_estimator_gradient import DerivativeType +from .qfi_result import QFIResult +from ..algorithm_job import AlgorithmJob +from ..exceptions import AlgorithmError + + +class QFI(ABC): + r"""Computes the Quantum Fisher Information (QFI) given a pure, + parameterized quantum state. QFI is defined as: + + .. math:: + + \mathrm{QFI}_{ij}= 4 \mathrm{Re}[\langle \partial_i \psi | \partial_j \psi \rangle + - \langle\partial_i \psi | \psi \rangle \langle\psi | \partial_j \psi \rangle]. + """ + + def __init__( + self, + qgt: BaseQGT, + precision: float | None = None, + ): + r""" + Args: + qgt: The quantum geometric tensor used to compute the QFI. + precision: Precision to override the BaseQGT's. If None, the BaseQGT's precision will + be used. + """ + self._qgt: BaseQGT = qgt + self._precision = precision + + def run( + self, + circuits: Sequence[QuantumCircuit], + parameter_values: Sequence[Sequence[float]], + parameters: Sequence[Sequence[Parameter] | None] | None = None, + *, + precision: float | Sequence[float] | None = None, + ) -> AlgorithmJob: + """Run the job of the QFIs on the given circuits. + + Args: + circuits: The list of quantum circuits to compute the QFIs. + parameter_values: The list of parameter values to be bound to the circuit. + parameters: The sequence of parameters to calculate only the QFIs of + the specified parameters. Each sequence of parameters corresponds to a circuit in + ``circuits``. Defaults to None, which means that the QFIs of all parameters in + each circuit are calculated. + precision: Precision to be used by the underlying Estimator. If a single float is + provided, this number will be used for all circuits. If a sequence of floats is + provided, they will be used on a per-circuit basis. If not set, the gradient's default + precision will be used for all circuits, and if that is None (not set) then the + underlying primitive's (default) precision will be used for all circuits. + + Returns: + The job object of the QFIs of the expectation values. The i-th result corresponds to + ``circuits[i]`` evaluated with parameters bound as ``parameter_values[i]``. + """ + + if isinstance(circuits, QuantumCircuit): + # Allow a single circuit to be passed in. + circuits = (circuits,) + + if parameters is None: + # If parameters is None, we calculate the gradients of all parameters in each circuit. + parameters = [circuit.parameters for circuit in circuits] + else: + # If parameters is not None, we calculate the gradients of the specified parameters. + # None in parameters means that the gradients of all parameters in the corresponding + # circuit are calculated. + parameters = [ + params if params is not None else circuits[i].parameters + for i, params in enumerate(parameters) + ] + + if precision is None: + precision = self.precision # May still be None + + job = AlgorithmJob(self._run, circuits, parameter_values, parameters, precision=precision) + job._submit() + return job + + def _run( + self, + circuits: Sequence[QuantumCircuit], + parameter_values: Sequence[Sequence[float]], + parameters: Sequence[Sequence[Parameter]], + *, + precision: float | Sequence[float] | None, + ) -> QFIResult: + """Compute the QFI on the given circuits.""" + # Set the derivative type to real + temp_derivative_type, self._qgt.derivative_type = ( + self._qgt.derivative_type, + DerivativeType.REAL, + ) + + job = self._qgt.run(circuits, parameter_values, parameters, precision=precision) + + try: + result = job.result() + except AlgorithmError as exc: + raise AlgorithmError("Estimator job or gradient job failed.") from exc + + self._qgt.derivative_type = temp_derivative_type + + return QFIResult( + qfis=[4 * qgt.real for qgt in result.qgts], + metadata=result.metadata, + precision=result.precision, + ) + + @property + def precision(self) -> float | None: + """Return the precision used by the `run` method of the BaseQGT's Estimator primitive. If + None, the default precision of the primitive is used. + + Returns: + The default precision. + """ + return self._precision + + @precision.setter + def precision(self, precision: float | None): + """Update the QFI's default precision setting. + + Args: + precision: The new default precision. + """ + + self._precision = precision diff --git a/qiskit_machine_learning/gradients/qfi_result.py b/qiskit_machine_learning/gradients/qfi_result.py new file mode 100644 index 000000000..77a39ad2f --- /dev/null +++ b/qiskit_machine_learning/gradients/qfi_result.py @@ -0,0 +1,33 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2022, 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +QFI result class +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Sequence + +import numpy as np + + +@dataclass(frozen=True) +class QFIResult: + """Result of QFI.""" + + qfis: list[np.ndarray] + """The QFI.""" + metadata: dict[str, Any] + """Additional information about the job.""" + precision: float | Sequence[float] + """Precision for the execution of the job.""" diff --git a/qiskit_machine_learning/gradients/utils.py b/qiskit_machine_learning/gradients/utils.py index 572815a54..bae9d174f 100644 --- a/qiskit_machine_learning/gradients/utils.py +++ b/qiskit_machine_learning/gradients/utils.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2022, 2024. +# (C) Copyright IBM 2022, 2025. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -32,6 +32,7 @@ QuantumCircuit, QuantumRegister, ) + from qiskit.circuit.library.standard_gates import ( CXGate, CYGate, @@ -43,6 +44,7 @@ RZGate, RZXGate, RZZGate, + XGate, ) from qiskit.quantum_info import SparsePauliOp @@ -300,3 +302,75 @@ def _make_gradient_parameters( ] # make g_parameters unique and return it. return list(dict.fromkeys(g_parameters)) + + +def _make_lin_comb_qgt_circuit( + circuit: QuantumCircuit, add_measurement: bool = False +) -> dict[tuple[Parameter, Parameter], QuantumCircuit]: + """Makes a circuit that computes the linear combination of the QGT circuits.""" + circuit_temp = circuit.copy() + qr_aux = QuantumRegister(1, "aux") + circuit_temp.add_register(qr_aux) + if add_measurement: + cr_aux = ClassicalRegister(1, "aux") + circuit_temp.add_bits(cr_aux) + circuit_temp.h(qr_aux) + circuit_temp.data.insert(0, circuit_temp.data.pop()) + + lin_comb_qgt_circuits = {} + for i, instruction_i in enumerate(circuit_temp.data): + if not instruction_i.operation.is_parameterized(): + continue + for j, instruction_j in enumerate(circuit_temp.data): + if not instruction_j.operation.is_parameterized(): + continue + # Calculate the QGT of the i-th gate with respect to the j-th gate. + param_i = instruction_i.operation.params[0] + param_j = instruction_j.operation.params[0] + + for p_i in param_i.parameters: + for p_j in param_j.parameters: + if circuit_temp.parameters.data.index(p_i) > circuit_temp.parameters.data.index( + p_j + ): + continue + gate_i = _gate_gradient(instruction_i.operation) + gate_j = _gate_gradient(instruction_j.operation) + lin_comb_qgt_circuit = circuit_temp.copy() + if i < j: + # insert gate_j to j-th position + lin_comb_qgt_circuit.append( + gate_j, [qr_aux[0]] + list(instruction_j.qubits), [] + ) + lin_comb_qgt_circuit.data.insert(j, lin_comb_qgt_circuit.data.pop()) + # insert gate_i to i-th position with two X gates at its sides + lin_comb_qgt_circuit.append(XGate(), [qr_aux[0]], []) + lin_comb_qgt_circuit.data.insert(i, lin_comb_qgt_circuit.data.pop()) + lin_comb_qgt_circuit.append( + gate_i, [qr_aux[0]] + list(instruction_i.qubits), [] + ) + lin_comb_qgt_circuit.data.insert(i, lin_comb_qgt_circuit.data.pop()) + lin_comb_qgt_circuit.append(XGate(), [qr_aux[0]], []) + lin_comb_qgt_circuit.data.insert(i, lin_comb_qgt_circuit.data.pop()) + else: + # insert gate_i to i-th position + lin_comb_qgt_circuit.append( + gate_i, [qr_aux[0]] + list(instruction_i.qubits), [] + ) + lin_comb_qgt_circuit.data.insert(i, lin_comb_qgt_circuit.data.pop()) + # insert gate_j to j-th position with two X gates at its sides + lin_comb_qgt_circuit.append(XGate(), [qr_aux[0]], []) + lin_comb_qgt_circuit.data.insert(j, lin_comb_qgt_circuit.data.pop()) + lin_comb_qgt_circuit.append( + gate_j, [qr_aux[0]] + list(instruction_j.qubits), [] + ) + lin_comb_qgt_circuit.data.insert(j, lin_comb_qgt_circuit.data.pop()) + lin_comb_qgt_circuit.append(XGate(), [qr_aux[0]], []) + lin_comb_qgt_circuit.data.insert(j, lin_comb_qgt_circuit.data.pop()) + + lin_comb_qgt_circuit.h(qr_aux) + if add_measurement: + lin_comb_qgt_circuit.measure(qr_aux, cr_aux) + lin_comb_qgt_circuits[(p_i, p_j)] = lin_comb_qgt_circuit + + return lin_comb_qgt_circuits diff --git a/test/gradients/test_qfi.py b/test/gradients/test_qfi.py new file mode 100644 index 000000000..aa7cafbe1 --- /dev/null +++ b/test/gradients/test_qfi.py @@ -0,0 +1,156 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2022, 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +# ============================================================================= + +"""Test QFI.""" + +import unittest +from test import QiskitAlgorithmsTestCase + +from ddt import ddt, data +import numpy as np + +from qiskit import QuantumCircuit +from qiskit.circuit import Parameter +from qiskit.circuit.parametervector import ParameterVector +from qiskit.primitives import StatevectorEstimator + +from qiskit_machine_learning.gradients import LinCombQGT, QFI, DerivativeType + + +@ddt +class TestQFI(QiskitAlgorithmsTestCase): + """Test QFI""" + + def setUp(self): + super().setUp() + self.estimator = StatevectorEstimator() + self.lcu_qgt = LinCombQGT(self.estimator, derivative_type=DerivativeType.REAL) + + def test_qfi(self): + """Test if the quantum fisher information calculation is correct for a simple test case. + QFI = [[1, 0], [0, 1]] - [[0, 0], [0, cos^2(a)]] + """ + # create the circuit + a, b = Parameter("a"), Parameter("b") + qc = QuantumCircuit(1) + qc.h(0) + qc.rz(a, 0) + qc.rx(b, 0) + + param_list = [[np.pi / 4, 0.1], [np.pi, 0.1], [np.pi / 2, 0.1]] + correct_values = [[[1, 0], [0, 0.5]], [[1, 0], [0, 0]], [[1, 0], [0, 1]]] + + qfi = QFI(self.lcu_qgt) + for i, param in enumerate(param_list): + qfis = qfi.run([qc], [param]).result().qfis + np.testing.assert_allclose(qfis[0], correct_values[i], atol=1e-3) + + def test_qfi_phase_fix(self): + """Test the phase-fix argument in the QFI calculation""" + # create the circuit + a, b = Parameter("a"), Parameter("b") + qc = QuantumCircuit(1) + qc.h(0) + qc.rz(a, 0) + qc.rx(b, 0) + + param = [np.pi / 4, 0.1] + # test for different values + correct_values = [[1, 0], [0, 1]] + qgt = LinCombQGT(self.estimator, phase_fix=False) + qfi = QFI(qgt) + qfis = qfi.run([qc], [param]).result().qfis + np.testing.assert_allclose(qfis[0], correct_values, atol=1e-3) + + @data("lcu") + def test_qfi_maxcut(self, qgt_kind): + """Test the QFI for a simple MaxCut problem. + + This is interesting because it contains the same parameters in different gates. + """ + # create maxcut circuit for the hamiltonian + # H = (I ^ I ^ Z ^ Z) + (I ^ Z ^ I ^ Z) + (Z ^ I ^ I ^ Z) + (I ^ Z ^ Z ^ I) + + x = ParameterVector("x", 2) + ansatz = QuantumCircuit(4) + + # initial hadamard layer + ansatz.h(ansatz.qubits) + + # e^{iZZ} layers + def expiz(qubit0, qubit1): + ansatz.cx(qubit0, qubit1) + ansatz.rz(2 * x[0], qubit1) + ansatz.cx(qubit0, qubit1) + + expiz(2, 1) + expiz(3, 0) + expiz(2, 0) + expiz(1, 0) + + # mixer layer with RX gates + for i in range(ansatz.num_qubits): + ansatz.rx(2 * x[1], i) + + reference = np.array([[16.0, -5.551], [-5.551, 18.497]]) + param = [0.4, 0.69] + + if qgt_kind == "lcu": + qgt = self.lcu_qgt + else: + raise NotImplementedError + + qfi = QFI(qgt) + qfi_result = qfi.run([ansatz], [param]).result().qfis + np.testing.assert_array_almost_equal(qfi_result[0], reference, decimal=3) + + def test_precision(self): + """Test QFI's precision option""" + a = Parameter("a") + qc = QuantumCircuit(1) + qc.rx(a, 0) + estimator = StatevectorEstimator(default_precision=0.1) + qgt = LinCombQGT(estimator=estimator) + + with self.subTest("QGT"): + qfi = QFI(qgt=qgt) + precision = qfi.precision + result = qfi.run([qc], [[1]]).result() + self.assertEqual(result.precision, 0.1) + self.assertEqual(precision, None) + + with self.subTest("QFI init"): + qfi = QFI(qgt=qgt, precision=0.2) + result = qfi.run([qc], [[1]]).result() + precision = qfi.precision + self.assertEqual(result.precision, 0.2) + self.assertEqual(precision, 0.2) + + with self.subTest("QFI update"): + qfi = QFI(qgt, precision=0.2) + qfi.precision = 0.1 + precision = qfi.precision + result = qfi.run([qc], [[1]]).result() + self.assertEqual(result.precision, 0.1) + self.assertEqual(precision, 0.1) + + with self.subTest("QFI run"): + qfi = QFI(qgt=qgt, precision=0.2) + result = qfi.run([qc], [[0]], precision=0.3).result() + precision = qfi.precision + self.assertEqual(result.precision, 0.3) + self.assertEqual(precision, 0.2) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/gradients/test_qgt.py b/test/gradients/test_qgt.py new file mode 100644 index 000000000..51247d887 --- /dev/null +++ b/test/gradients/test_qgt.py @@ -0,0 +1,337 @@ +# This code is part of a Qiskit project. +# +# (C) Copyright IBM 2022, 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +# ============================================================================= + +"""Test QGT.""" + +import unittest +from test import QiskitAlgorithmsTestCase + +from ddt import ddt, data +import numpy as np + +from qiskit import QuantumCircuit, generate_preset_pass_manager +from qiskit.circuit import Parameter +from qiskit.circuit.library import real_amplitudes +from qiskit.primitives import StatevectorEstimator + +from qiskit_machine_learning.gradients import DerivativeType, LinCombQGT + +from .logging_primitives import LoggingEstimator + + +@ddt +class TestQGT(QiskitAlgorithmsTestCase): + """Test QGT""" + + def setUp(self): + super().setUp() + self.estimator = StatevectorEstimator(default_precision=0) + + @data(LinCombQGT) + def test_qgt_derivative_type(self, qgt_type): + """Test QGT derivative_type""" + args = (self.estimator,) + qgt = qgt_type(*args, derivative_type=DerivativeType.REAL) + + a, b = Parameter("a"), Parameter("b") + qc = QuantumCircuit(1) + qc.h(0) + qc.rz(a, 0) + qc.rx(b, 0) + + param_list = [[np.pi / 4, 0], [np.pi / 2, np.pi / 4]] + correct_values = [ + np.array([[1, 0.707106781j], [-0.707106781j, 0.5]]) / 4, + np.array([[1, 1j], [-1j, 1]]) / 4, + ] + + # test real derivative + with self.subTest("Test with DerivativeType.REAL"): + qgt.derivative_type = DerivativeType.REAL + for i, param in enumerate(param_list): + qgt_result = qgt.run([qc], [param]).result().qgts + np.testing.assert_allclose(qgt_result[0], correct_values[i].real, atol=1e-3) + + # test imaginary derivative + with self.subTest("Test with DerivativeType.IMAG"): + qgt.derivative_type = DerivativeType.IMAG + for i, param in enumerate(param_list): + qgt_result = qgt.run([qc], [param]).result().qgts + np.testing.assert_allclose(qgt_result[0], correct_values[i].imag, atol=1e-3) + + # test real + imaginary derivative + with self.subTest("Test with DerivativeType.COMPLEX"): + qgt.derivative_type = DerivativeType.COMPLEX + for i, param in enumerate(param_list): + qgt_result = qgt.run([qc], [param]).result().qgts + np.testing.assert_allclose(qgt_result[0], correct_values[i], atol=1e-3) + + @data(LinCombQGT) + def test_qgt_phase_fix(self, qgt_type): + """Test the phase-fix argument in a QGT calculation""" + args = (self.estimator,) + qgt = qgt_type(*args, phase_fix=False) + + # create the circuit + a, b = Parameter("a"), Parameter("b") + qc = QuantumCircuit(1) + qc.h(0) + qc.rz(a, 0) + qc.rx(b, 0) + + param_list = [[np.pi / 4, 0], [np.pi / 2, np.pi / 4]] + correct_values = [ + np.array([[1, 0.707106781j], [-0.707106781j, 1]]) / 4, + np.array([[1, 1j], [-1j, 1]]) / 4, + ] + + # test real derivative + with self.subTest("Test phase fix with DerivativeType.REAL"): + qgt.derivative_type = DerivativeType.REAL + for i, param in enumerate(param_list): + qgt_result = qgt.run([qc], [param]).result().qgts + np.testing.assert_allclose(qgt_result[0], correct_values[i].real, atol=1e-3) + + # test imaginary derivative + with self.subTest("Test phase fix with DerivativeType.IMAG"): + qgt.derivative_type = DerivativeType.IMAG + for i, param in enumerate(param_list): + qgt_result = qgt.run([qc], [param]).result().qgts + np.testing.assert_allclose(qgt_result[0], correct_values[i].imag, atol=1e-3) + + # test real + imaginary derivative + with self.subTest("Test phase fix with DerivativeType.COMPLEX"): + qgt.derivative_type = DerivativeType.COMPLEX + for i, param in enumerate(param_list): + qgt_result = qgt.run([qc], [param]).result().qgts + np.testing.assert_allclose(qgt_result[0], correct_values[i], atol=1e-3) + + @data(LinCombQGT) + def test_qgt_coefficients(self, qgt_type): + """Test the derivative option of QGT""" + args = (self.estimator,) + qgt = qgt_type(*args, derivative_type=DerivativeType.REAL) + + qc = real_amplitudes(num_qubits=2, reps=1) + qc.rz(qc.parameters[0].exp() + 2 * qc.parameters[1], 0) + qc.rx(3.0 * qc.parameters[2] + qc.parameters[3].sin(), 1) + + # test imaginary derivative + param_list = [ + [np.pi / 4 for param in qc.parameters], + [np.pi / 2 for param in qc.parameters], + ] + correct_values = ( + np.array( + [ + [ + [5.707309, 4.2924833, 1.5295868, 0.1938604], + [4.2924833, 4.9142136, 0.75, 0.8838835], + [1.5295868, 0.75, 3.4430195, 0.0758252], + [0.1938604, 0.8838835, 0.0758252, 1.1357233], + ], + [ + [1.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [1.0, 0.0, 10.0, -0.0], + [0.0, 0.0, -0.0, 1.0], + ], + ] + ) + / 4 + ) + for i, param in enumerate(param_list): + qgt_result = qgt.run([qc], [param]).result().qgts + np.testing.assert_allclose(qgt_result[0], correct_values[i], atol=1e-3) + + @data(LinCombQGT) + def test_qgt_parameters(self, qgt_type): + """Test the QGT with specified parameters""" + args = (self.estimator,) + qgt = qgt_type(*args, derivative_type=DerivativeType.REAL) + + a = Parameter("a") + b = Parameter("b") + qc = QuantumCircuit(1) + qc.rx(a, 0) + qc.ry(b, 0) + param_values = [np.pi / 4, np.pi / 4] + qgt_result = qgt.run([qc], [param_values], [[a]]).result().qgts + np.testing.assert_allclose(qgt_result[0], [[1 / 4]], atol=1e-3) + + with self.subTest("Test with different parameter orders"): + c = Parameter("c") + qc2 = QuantumCircuit(1) + qc2.rx(a, 0) + qc2.rz(b, 0) + qc2.rx(c, 0) + param_values = [np.pi / 4, np.pi / 4, np.pi / 4] + params = [[a, b, c], [c, b, a], [a, c], [b, a]] + expected = [ + np.array( + [ + [0.25, 0.0, 0.1767767], + [0.0, 0.125, -0.08838835], + [0.1767767, -0.08838835, 0.1875], + ] + ), + np.array( + [ + [0.1875, -0.08838835, 0.1767767], + [-0.08838835, 0.125, 0.0], + [0.1767767, 0.0, 0.25], + ] + ), + np.array([[0.25, 0.1767767], [0.1767767, 0.1875]]), + np.array([[0.125, 0.0], [0.0, 0.25]]), + ] + for i, param in enumerate(params): + qgt_result = qgt.run([qc2], [param_values], [param]).result().qgts + np.testing.assert_allclose(qgt_result[0], expected[i], atol=1e-3) + + @data(LinCombQGT) + def test_qgt_multi_arguments(self, qgt_type): + """Test the QGT for multiple arguments""" + args = (self.estimator,) + qgt = qgt_type(*args, derivative_type=DerivativeType.REAL) + + a = Parameter("a") + b = Parameter("b") + qc = QuantumCircuit(1) + qc.rx(a, 0) + qc.ry(b, 0) + qc2 = QuantumCircuit(1) + qc2.rx(a, 0) + qc2.ry(b, 0) + + param_list = [[np.pi / 4], [np.pi / 2]] + correct_values = [[[1 / 4]], [[1 / 4, 0], [0, 0]]] + param_list = [[np.pi / 4, np.pi / 4], [np.pi / 2, np.pi / 2]] + qgt_results = qgt.run([qc, qc2], param_list, [[a], None]).result().qgts + for i, _ in enumerate(param_list): + np.testing.assert_allclose(qgt_results[i], correct_values[i], atol=1e-3) + + @data(LinCombQGT) + def test_qgt_validation(self, qgt_type): + """Test estimator QGT's validation""" + args = (self.estimator,) + qgt = qgt_type(*args) + + a = Parameter("a") + qc = QuantumCircuit(1) + qc.rx(a, 0) + parameter_values = [[np.pi / 4]] + with self.subTest("assert number of circuits does not match"): + with self.assertRaises(ValueError): + qgt.run([qc, qc], parameter_values) + with self.subTest("assert number of parameter values does not match"): + with self.assertRaises(ValueError): + qgt.run([qc], [[np.pi / 4], [np.pi / 2]]) + with self.subTest("assert number of parameters does not match"): + with self.assertRaises(ValueError): + qgt.run([qc], parameter_values, parameters=[[a], [a]]) + + @unittest.skip("Estimator precision is handled by the primitive itself") + def test_precision(self): + """Test QGT's precision option""" + a = Parameter("a") + qc = QuantumCircuit(1) + qc.rx(a, 0) + estimator = StatevectorEstimator(default_precision=0.1) + + with self.subTest("estimator"): + qgt = LinCombQGT(estimator) + precision = qgt.precision + result = qgt.run([qc], [[1]]).result() + self.assertEqual(result.precision, 0.1) + self.assertEqual(precision, None) + + with self.subTest("QGT init"): + qgt = LinCombQGT(estimator, precision=0.2) + result = qgt.run([qc], [[1]]).result() + precision = qgt.precision + self.assertEqual(result.precision, 0.2) + self.assertEqual(precision, 0.2) + + with self.subTest("QGT update"): + qgt = LinCombQGT(estimator, precision=0.2) + qgt.precision = 0.1 + precision = qgt.precision + result = qgt.run([qc], [[1]]).result() + self.assertEqual(result.precision, 0.1) + self.assertEqual(precision, 0.1) + + with self.subTest("QGT run"): + qgt = LinCombQGT(estimator, precision=0.2) + result = qgt.run([qc], [[0]], precision=0.3).result() + precision = qgt.precision + self.assertEqual(result.precision, 0.3) + self.assertEqual(precision, 0.2) + + @unittest.skip("LoggingEstimator must be updated to work with PUBs.") + def test_operations_preserved(self): + """Test non-parameterized instructions are preserved and not unrolled.""" + x, y = Parameter("x"), Parameter("y") + circuit = QuantumCircuit(2) + circuit.initialize([0.5, 0.5, 0.5, 0.5]) # this should remain as initialize + circuit.crx(x, 0, 1) # this should get unrolled + circuit.ry(y, 0) + + values = [np.pi / 2, np.pi] + expect = np.diag([0.25, 0.5]) / 4 + + ops = [] + + def operations_callback(op): + ops.append(op) + + estimator = LoggingEstimator(operations_callback=operations_callback) + qgt = LinCombQGT(estimator, derivative_type=DerivativeType.REAL) + + job = qgt.run([circuit], [values]) + result = job.result() + + with self.subTest(msg="assert initialize is preserved"): + self.assertTrue(all("initialize" in ops_i[0].keys() for ops_i in ops)) + + with self.subTest(msg="assert result is correct"): + np.testing.assert_allclose(result.qgts[0], expect, atol=1e-5) + + @unittest.skip("No need to test this.") + def test_transpiler(self): + """Test that the transpiler is called for the LinCombQGT""" + pass_manager = generate_preset_pass_manager(optimization_level=1, seed_transpiler=42) + counts = [0] + + def callback(**kwargs): + counts[0] = kwargs["count"] + + a = Parameter("a") + qc = QuantumCircuit(1) + qc.rx(a, 0) + estimator = StatevectorEstimator(default_precision=0.1) + # Test transpiler without options + qgt = LinCombQGT(estimator, pass_manager=pass_manager) + qgt.run([qc], [[1]]).result() + + # Test transpiler is called using callback function + qgt = LinCombQGT( + estimator, pass_manager=pass_manager, transpiler_options={"callback": callback} + ) + qgt.run([qc], [[1]]).result() + + self.assertGreater(counts[0], 0) + + +if __name__ == "__main__": + unittest.main()