From 0e428a9d96d6e7297efcfd69796a39d17925092f Mon Sep 17 00:00:00 2001 From: ikkoham Date: Mon, 6 Nov 2023 17:21:57 +0900 Subject: [PATCH 01/55] Add EstimatorV2 --- qiskit/primitives/__init__.py | 21 +- qiskit/primitives/base/__init__.py | 2 +- qiskit/primitives/base/base_estimator.py | 153 +++++++- qiskit/primitives/base/base_primitive.py | 39 +- qiskit/primitives/containers/__init__.py | 23 ++ qiskit/primitives/containers/base_task.py | 34 ++ .../primitives/containers/bindings_array.py | 369 ++++++++++++++++++ qiskit/primitives/containers/data_bin.py | 90 +++++ qiskit/primitives/containers/dataclasses.py | 27 ++ .../primitives/containers/estimator_task.py | 94 +++++ qiskit/primitives/containers/object_array.py | 93 +++++ .../containers/observables_array.py | 257 ++++++++++++ qiskit/primitives/containers/options.py | 35 ++ .../primitives/containers/primitive_result.py | 49 +++ qiskit/primitives/containers/shape.py | 129 ++++++ qiskit/primitives/containers/task_result.py | 32 ++ qiskit/primitives/statevector_estimator.py | 146 +++++++ .../notes/estimatorv2-9b09b66ecc12af1b.yaml | 7 + requirements.txt | 1 + test/python/primitives/primitive_result.py | 44 +++ test/python/primitives/test_bindings_array.py | 302 ++++++++++++++ test/python/primitives/test_data_bin.py | 64 +++ test/python/primitives/test_estimatorv2.py | 310 +++++++++++++++ .../primitives/test_observables_array.py | 306 +++++++++++++++ test/python/primitives/test_shape.py | 105 +++++ 25 files changed, 2715 insertions(+), 17 deletions(-) create mode 100644 qiskit/primitives/containers/__init__.py create mode 100644 qiskit/primitives/containers/base_task.py create mode 100644 qiskit/primitives/containers/bindings_array.py create mode 100644 qiskit/primitives/containers/data_bin.py create mode 100644 qiskit/primitives/containers/dataclasses.py create mode 100644 qiskit/primitives/containers/estimator_task.py create mode 100644 qiskit/primitives/containers/object_array.py create mode 100644 qiskit/primitives/containers/observables_array.py create mode 100644 qiskit/primitives/containers/options.py create mode 100644 qiskit/primitives/containers/primitive_result.py create mode 100644 qiskit/primitives/containers/shape.py create mode 100644 qiskit/primitives/containers/task_result.py create mode 100644 qiskit/primitives/statevector_estimator.py create mode 100644 releasenotes/notes/estimatorv2-9b09b66ecc12af1b.yaml create mode 100644 test/python/primitives/primitive_result.py create mode 100644 test/python/primitives/test_bindings_array.py create mode 100644 test/python/primitives/test_data_bin.py create mode 100644 test/python/primitives/test_estimatorv2.py create mode 100644 test/python/primitives/test_observables_array.py create mode 100644 test/python/primitives/test_shape.py diff --git a/qiskit/primitives/__init__.py b/qiskit/primitives/__init__.py index cc12c9fbf083..5c0d7f68bdc1 100644 --- a/qiskit/primitives/__init__.py +++ b/qiskit/primitives/__init__.py @@ -32,6 +32,14 @@ Estimator BackendEstimator +EstimatorV2 +=========== + +.. autosummary:: + :toctree: ../stubs/ + + StatevectorEstimator + Sampler ======= @@ -50,13 +58,16 @@ EstimatorResult SamplerResult + PrimitiveResult + TaskResult """ -from .base import BaseEstimator -from .base import BaseSampler from .backend_estimator import BackendEstimator -from .estimator import Estimator -from .base.estimator_result import EstimatorResult from .backend_sampler import BackendSampler -from .sampler import Sampler +from .base import BaseEstimator, BaseSampler +from .base.estimator_result import EstimatorResult from .base.sampler_result import SamplerResult +from .containers import BindingsArray, EstimatorTask, ObservablesArray, PrimitiveResult, TaskResult +from .estimator import Estimator +from .sampler import Sampler +from .statevector_estimator import Estimator as StatevectorEstimator diff --git a/qiskit/primitives/base/__init__.py b/qiskit/primitives/base/__init__.py index d7695fbf4259..2384cb181f18 100644 --- a/qiskit/primitives/base/__init__.py +++ b/qiskit/primitives/base/__init__.py @@ -14,7 +14,7 @@ Abstract base classes for primitives module. """ -from .base_estimator import BaseEstimator +from .base_estimator import BaseEstimator, BaseEstimatorV2 from .base_sampler import BaseSampler from .estimator_result import EstimatorResult from .sampler_result import SamplerResult diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index b789863a812d..331e041f367d 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2022. +# (C) Copyright IBM 2022, 2023. # # 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 @@ -14,9 +14,99 @@ .. estimator-desc: -===================== -Overview of Estimator -===================== +======================== +Overview of EstimatorV2 +======================== + +EstimatorV2 class estimates expectation values of quantum circuits and observables. + +An estimator is initialized with an empty parameter set. The estimator is used to +create a :class:`~qiskit.providers.JobV1`, via the +:meth:`~.BaseEstimatorV2.run()` method. This method is called +with the list of task. +Task is composed of tuple of following parameters ``[(circuit, observables, parameter_values)]``. + +* quantum circuit (:math:`\psi(\theta)`): (parameterized) quantum circuits + :class:`~qiskit.circuit.QuantumCircuit`. + +* observables (:math:`H_j`): a list of :class:`~.ObservablesArrayLike` classes + (including :class:`~.Pauli`, :class:`~.SparsePauliOp`, str). + +* parameter values (:math:`\theta_k`): list of sets of values + to be bound to the parameters of the quantum circuits + (list of list of float or list of dict). + +The method returns a :class:`~qiskit.providers.JobV1` object, calling +:meth:`qiskit.providers.JobV1.result()` yields the +a list of expectation values plus optional metadata like confidence intervals for +the estimation. + +.. math:: + + \langle\psi(\theta_k)|H_j|\psi(\theta_k)\rangle + +The broadcast rule applies for observables and parameters. For more information, please check +`here `_. + +Here is an example of how the estimator is used. + +.. code-block:: python + + from qiskit.primitives.statevector_estimator import Estimator + from qiskit.circuit.library import RealAmplitudes + from qiskit.quantum_info import SparsePauliOp + + psi1 = RealAmplitudes(num_qubits=2, reps=2) + psi2 = RealAmplitudes(num_qubits=2, reps=3) + + H1 = SparsePauliOp.from_list([("II", 1), ("IZ", 2), ("XI", 3)]) + H2 = SparsePauliOp.from_list([("IZ", 1)]) + H3 = SparsePauliOp.from_list([("ZI", 1), ("ZZ", 1)]) + + theta1 = [0, 1, 1, 2, 3, 5] + theta2 = [0, 1, 1, 2, 3, 5, 8, 13] + theta3 = [1, 2, 3, 4, 5, 6] + + estimator = Estimator() + + # calculate [ ] + job = estimator.run([(psi1, hamiltonian1, [theta1])]) + job_result = job.result() # It will block until the job finishes. + print(f"The primitive-job finished with result {job_result}")) + + # calculate [ [, + # ], + # [] ] + job2 = estimator.run( + [(psi1, [hamiltonian1, hamiltonian3], [theta1, theta3]), (psi2, hamiltonian2, theta2)] + ) + job_result = job2.result() + print(f"The primitive-job finished with result {job_result}") + +============================== +Migration guide from V1 to V2 +============================== + + +The original three arguments are now a single argument task. +To accommodate this change, the zip function can be used for easy migration. +For example, suppose the code originally is: + +.. code-block:: python + + estimator.run([psi1], [hamiltonian1], [theta1]) # for EstimatorV1 + +Just add zip function: + +.. code-block:: python + + estimator.run(zip([psi1], [hamiltonian1], [theta1])) # for EstimatorV2 + + +======================== +Overview of EstimatorV1 +======================== Estimator class estimates expectation values of quantum circuits and observables. @@ -80,22 +170,24 @@ from __future__ import annotations +import typing import warnings from abc import abstractmethod -from collections.abc import Sequence +from collections.abc import Iterable, Sequence from copy import copy -from typing import Generic, TypeVar -import typing +from typing import Generic, Optional, TypeVar -from qiskit.utils.deprecation import deprecate_func from qiskit.circuit import QuantumCircuit from qiskit.circuit.parametertable import ParameterView from qiskit.providers import JobV1 as Job from qiskit.quantum_info.operators import SparsePauliOp from qiskit.quantum_info.operators.base_operator import BaseOperator +from qiskit.utils.deprecation import deprecate_func -from .base_primitive import BasePrimitive +from ..containers.estimator_task import EstimatorTask, EstimatorTaskLike +from ..containers.options import BasePrimitiveOptionsLike from . import validation +from .base_primitive import BasePrimitive, BasePrimitiveV2 if typing.TYPE_CHECKING: from qiskit.opflow import PauliSumOp @@ -103,7 +195,7 @@ T = TypeVar("T", bound=Job) -class BaseEstimator(BasePrimitive, Generic[T]): +class BaseEstimatorV1(BasePrimitive, Generic[T]): """Estimator base class. Base class for Estimator that estimates expectation values of quantum circuits and observables. @@ -258,3 +350,44 @@ def parameters(self) -> tuple[ParameterView, ...]: Parameters, where ``parameters[i][j]`` is the j-th parameter of the i-th circuit. """ return tuple(self._parameters) + + +BaseEstimator = BaseEstimatorV1 + + +class BaseEstimatorV2(BasePrimitiveV2, Generic[T]): + """Estimator base class version 2. + + Estimator estimates expectation values of quantum circuits and observables. + """ + + def __init__(self, options: Optional[BasePrimitiveOptionsLike]): + super().__init__(options=options) + + def run(self, tasks: EstimatorTaskLike | Iterable[EstimatorTaskLike]) -> T: + """Run the tasks of the estimation of expectation value(s). + + Args: + tasks: a tasklike object. Typically, list of tuple + ``(QuantumCircuit, observables, parameter_values)`` + + Returns: + The job object of Estimator's Result. + """ + if isinstance(tasks, EstimatorTask): + tasks = [tasks] + elif isinstance(tasks, tuple) and isinstance(tasks[0], QuantumCircuit): + tasks = [EstimatorTask.coerce(tasks)] + elif isinstance(tasks, Iterable): + tasks = [EstimatorTask.coerce(task) for task in tasks] + else: + raise TypeError(f"Unsupported type {type(tasks)} is given.") + + for task in tasks: + task.validate() + + return self._run(tasks) + + @abstractmethod + def _run(self, tasks: list[EstimatorTask]) -> T: + pass diff --git a/qiskit/primitives/base/base_primitive.py b/qiskit/primitives/base/base_primitive.py index c161ca8094fa..2437e4fea9ef 100644 --- a/qiskit/primitives/base/base_primitive.py +++ b/qiskit/primitives/base/base_primitive.py @@ -16,15 +16,17 @@ from abc import ABC from collections.abc import Sequence +from typing import Optional from qiskit.circuit import QuantumCircuit +from qiskit.primitives.containers import BasePrimitiveOptions, BasePrimitiveOptionsLike from qiskit.providers import Options from qiskit.utils.deprecation import deprecate_func from . import validation -class BasePrimitive(ABC): +class BasePrimitiveV1(ABC): """Primitive abstract base class.""" def __init__(self, options: dict | None = None): @@ -72,3 +74,38 @@ def _cross_validate_circuits_parameter_values( return validation._cross_validate_circuits_parameter_values( circuits, parameter_values=parameter_values ) + + +BasePrimitive = BasePrimitiveV1 + + +class BasePrimitiveV2(ABC): + """Primitive abstract base class version 2.""" + + version = 2 + _options_class: type[BasePrimitiveOptions] = BasePrimitiveOptions + + def __init__(self, options: Optional[BasePrimitiveOptionsLike] = None): + self._options: type(self)._options_class + self._set_options(options) + + @property + def options(self) -> BasePrimitiveOptions: + """Options for BaseEstimator""" + return self._options + + @options.setter + def options(self, options: BasePrimitiveOptionsLike): + self._set_options(options) + + def _set_options(self, options): + if options is None: + self._options = self._options_class() + elif isinstance(options, dict): + self._options = self._options_class(**options) + elif isinstance(options, self._options_class): + self._options = options + else: + raise TypeError( + f"Invalid 'options' type. It can only be a dictionary of {self._options_class}" + ) diff --git a/qiskit/primitives/containers/__init__.py b/qiskit/primitives/containers/__init__.py new file mode 100644 index 000000000000..aaaca1af40fd --- /dev/null +++ b/qiskit/primitives/containers/__init__.py @@ -0,0 +1,23 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Data containers for primitives. +""" + +from .bindings_array import BindingsArray +from .data_bin import make_databin +from .estimator_task import EstimatorTask +from .observables_array import ObservablesArray +from .options import BasePrimitiveOptions, BasePrimitiveOptionsLike +from .primitive_result import PrimitiveResult +from .task_result import TaskResult diff --git a/qiskit/primitives/containers/base_task.py b/qiskit/primitives/containers/base_task.py new file mode 100644 index 000000000000..012ca3e98f16 --- /dev/null +++ b/qiskit/primitives/containers/base_task.py @@ -0,0 +1,34 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Base Task class +""" + +from __future__ import annotations + +from qiskit import QuantumCircuit + +from .dataclasses import frozen_dataclass + + +@frozen_dataclass +class BaseTask: + """Base class for Task""" + + circuit: QuantumCircuit + """Quantum circuit object for the task.""" + + def validate(self): + """Validate the data""" + if not isinstance(self.circuit, QuantumCircuit): + raise TypeError("circuit must be QuantumCircuit.") diff --git a/qiskit/primitives/containers/bindings_array.py b/qiskit/primitives/containers/bindings_array.py new file mode 100644 index 000000000000..753fcdb246d1 --- /dev/null +++ b/qiskit/primitives/containers/bindings_array.py @@ -0,0 +1,369 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Bindings array class +""" +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from itertools import chain, product +from typing import Dict, List, Optional, Tuple, Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from qiskit.circuit import Parameter, QuantumCircuit +from qiskit.circuit.parameterexpression import ParameterValueType + +from .shape import ShapedMixin, ShapeInput, shape_tuple + +ParameterLike = Union[Parameter, str] + + +class BindingsArray(ShapedMixin): + r"""Stores many possible parameter binding values for a :class:`qiskit.QuantumCircuit`. + + Similar to a ``inspect.BoundArguments`` instance, which stores arguments that can be bound to a + compatible Python function, this class stores both values without names, so that their ordering + is important, as well as values attached to ``qiskit.circuit.Parameters``. However, a dense + rectangular array of possible values is stored for each parameter, so that this class is akin to + an object-array of ``inspect.BoundArguments``. + + The storage format is a list of arrays, ``[vals0, vals1, ...]``, as well as a dictionary of + arrays attached to parameters, ``{params0: kwvals0, ...}``. Crucially, the last dimension of + each array indexes one or more parameters. For example, if the last dimension of ``vals1`` is + 25, then it represents an array of possible binding values for 25 distinct parameters, where its + leading shape is the array :attr:`~.shape` of its binding array. This implies a degeneracy of the + storage format: ``[vals, vals1[..., :10], vals1[..., 10:], ...]`` is exactly equivalent to + ``[vals0, vals1, ...]`` in the bindings it specifies. This complication has been included to + satisfy two competing constraints: + + * Arrays with different dtypes cannot be concatenated into a single array, so that multiple + arrays are required for generality. + * It is extremely convenient to put everything into a small number of big arrays, when + possible. + + .. code-block:: python + + # 0-d array (i.e. only one binding) + BindingsArray([1, 2, 3], {"a": 4, ("b", "c"): [5, 6]}) + + # single array, last index is parameters + BindingsArray(np.empty((10, 10, 100))) + + # multiple arrays, where each last index is parameters. notice that it's smart enough to + # figure out that a missing last dimension corresponds to a single parameter. + BindingsArray( + [np.empty((10, 10, 100)), np.empty((10, 10)), np.empty((10, 10, 20), dtype=complex)], + {("c", "a"): np.empty((10, 10, 2)), "b": np.empty((10, 10))} + ) + """ + __slots__ = ("_vals", "_kwvals") + + def __init__( + self, + vals: Union[None, ArrayLike, Iterable[ArrayLike]] = None, + kwvals: Union[None, Mapping[ParameterLike, Iterable[ParameterValueType]], ArrayLike] = None, + shape: Optional[ShapeInput] = None, + ): + """ + The ``shape`` argument does not need to be provided whenever it can unambiguously + be inferred from the provided arrays. Ambiguity arises because an array provided to the + constructor might represent values for either a single parameter, with an implicit missing + last dimension of size ``1``, or for many parameters, where the size of the last dimension + is the number of parameters it is providing values to. This ambiguity can be broken in the + following common ways: + + * Only a single array is provided to ``vals``, and no arrays to ``kwvals``, in which case + it is assumed that the last dimension is over many parameters. + * Multiple arrays are given whose shapes differ only in the last dimension size. + * Some array is given in ``kwvals`` where the key contains multiple + :class:`~.Parameter` s, whose length the last dimension of the array must therefore match. + + Args: + vals: One or more arrays, where the last index of each corresponds to + distinct parameters. If their dtypes allow it, concatenating these + arrays over the last axis is equivalent to providing them separately. + kwvals: A mapping from one or more parameters to arrays of values to bind + them to, where the last axis is over parameters. + shape: The leading shape of every array in these bindings. + + Raises: + ValueError: If all inputs are ``None``. + ValueError: If the shape cannot be automatically inferred from the arrays, or if there + is some inconsistency in the shape of the given arrays. + """ + super().__init__() + + if vals is None and kwvals is None and shape is None: + raise ValueError("Must specify a shape if no values are present") + if vals is None: + vals = [] + if kwvals is None: + kwvals = {} + + vals = [vals] if isinstance(vals, np.ndarray) else [np.array(v, copy=False) for v in vals] + # TODO str will be used for internal data (_kwvals) instead of Parameter. + # This requires https://github.com/Qiskit/qiskit/issues/7107 + kwvals = { + (p,) if isinstance(p, Parameter) else tuple(p): np.array(val, copy=False) + for p, val in kwvals.items() + } + + if shape is None: + # jump through hoops to find out user's intended shape + shape = _infer_shape(vals, kwvals) + + # shape checking, and normalization so that each last index must be over parameters + self._shape = shape_tuple(shape) + for idx, val in enumerate(vals): + vals[idx] = _standardize_shape(val, self._shape) + + self._vals = vals + self._kwvals = kwvals + + self.validate() + + def __getitem__(self, args) -> BindingsArray: + # because the parameters live on the last axis, we don't need to do anything special to + # accomodate them because there will always be an implicit slice(None, None, None) + # on all unspecified trailing dimensions + # separately, we choose to not disallow args which touch the last dimension, even though it + # would not be a particularly friendly way to chop parameters + vals = [val[args] for val in self._vals] + kwvals = {params: val[args] for params, val in self._kwvals.items()} + try: + shape = next(chain(vals, kwvals.values())).shape[:-1] + except StopIteration: + shape = () + return BindingsArray(vals, kwvals, shape) + + @property + def kwvals(self) -> Dict[Tuple[str, ...], np.ndarray]: + """The keyword values of this array.""" + return {_format_key(k): v for k, v in self._kwvals.items()} + + @property + def num_parameters(self) -> int: + """The total number of parameters.""" + return sum(val.shape[-1] for val in chain(self.vals, self._kwvals.values())) + + @property + def vals(self) -> List[np.ndarray]: + """The non-keyword values of this array.""" + return self._vals + + def bind_at_idx(self, circuit: QuantumCircuit, idx: Tuple[int, ...]) -> QuantumCircuit: + """Return the circuit bound to the values at the provided index. + + Args: + circuit: The circuit to bind. + idx: A tuple of indices, on for each dimension of this array. + + Returns: + The bound circuit. + + Raises: + ValueError: If the index doesn't have the right number of values. + """ + if len(idx) != self.ndim: + raise ValueError(f"Expected {idx} to index all dimensions of {self.shape}") + + flat_vals = (val for vals in self.vals for val in vals[idx]) + + if not self._kwvals: + # special case to avoid constructing a dictionary input + return circuit.assign_parameters(list(flat_vals)) + + parameters = dict(zip(circuit.parameters, flat_vals)) + parameters.update( + (param, val) + for params, vals in self._kwvals.items() + for param, val in zip(params, vals[idx]) + ) + return circuit.assign_parameters(parameters) + + def bind_flat(self, circuit: QuantumCircuit) -> Iterable[QuantumCircuit]: + """Yield a bound circuit for every array index in flattened order. + + Args: + circuit: The circuit to bind. + + Yields: + Bound circuits, in flattened array order. + """ + for idx in product(*map(range, self.shape)): + yield self.bind_at_idx(circuit, idx) + + def bind_all(self, circuit: QuantumCircuit) -> np.ndarray: + """Return an object array of bound circuits with the same shape. + + Args: + circuit: The circuit to bind. + + Returns: + An object array of the same shape containing all bound circuits. + """ + arr = np.empty(self.shape, dtype=object) + for idx in np.ndindex(self.shape): + arr[idx] = self.bind_at_idx(circuit, idx) + return arr + + def ravel(self) -> BindingsArray: + """Return a new :class:`~BindingsArray` with one dimension. + + The returned bindings array has a :attr:`shape` given by ``(size, )``, where the size is the + :attr:`~size` of this bindings array. + + Returns: + A new bindings array. + """ + return self.reshape(self.size) + + def reshape(self, shape: Union[int, Iterable[int]]) -> BindingsArray: + """Return a new :class:`~BindingsArray` with a different shape. + + This results in a new view of the same arrays. + + Args: + shape: The shape of the returned bindings array. + + Returns: + A new bindings array. + + Raises: + ValueError: If the provided shape has a different product than the current size. + """ + shape = (shape, -1) if isinstance(shape, int) else (*shape, -1) + if np.prod(shape[:-1]).astype(int) != self.size: + raise ValueError("Reshaping cannot change the total number of elements.") + vals = [val.reshape(shape) for val in self._vals] + kwvals = {params: val.reshape(shape) for params, val in self._kwvals.items()} + return BindingsArray(vals, kwvals, shape[:-1]) + + @classmethod + def coerce(cls, bindings_array: BindingsArrayLike) -> BindingsArray: + """Coerce BindingsArrayLike into BindingsArray + + Args: + bindings_array: an object to be bindings array. + + Returns: + A coerced bindings array. + """ + if isinstance(bindings_array, Sequence): + bindings_array = np.array(bindings_array) + if bindings_array is None: + bindings_array = cls([], shape=(1,)) + elif isinstance(bindings_array, np.ndarray): + if bindings_array.ndim == 1: + bindings_array = bindings_array.reshape((1, -1)) + bindings_array = cls(bindings_array) + elif isinstance(bindings_array, Mapping): + bindings_array = cls(kwvals=bindings_array) + else: + raise TypeError(f"Unsupported type {type(bindings_array)} is given.") + return bindings_array + + def validate(self): + """Validate the consistency in bindings_array.""" + for parameters, val in self._kwvals.items(): + val = self._kwvals[parameters] = _standardize_shape(val, self._shape) + if len(parameters) != val.shape[-1]: + raise ValueError( + f"Length of {parameters} inconsistent with last dimension of {val}" + ) + + +def _standardize_shape(val: np.ndarray, shape: Tuple[int, ...]) -> np.ndarray: + """Return ``val`` or ``val[..., None]``. + + Args: + val: The array whose shape to standardize. + shape: The shape to standardize to. + + Returns: + An array with one more dimension than ``len(shape)``, and whose leading dimensions match + ``shape``. + + Raises: + ValueError: If the leading shape of ``val`` does not match the ``shape``. + """ + if val.shape == shape: + val = val[..., None] + elif val.ndim - 1 != len(shape) or val.shape[:-1] != shape: + raise ValueError(f"Array with shape {val.shape} inconsistent with {shape}") + return val + + +def _infer_shape( + vals: List[np.ndarray], kwvals: Dict[Tuple[Parameter, ...], np.ndarray] +) -> Tuple[int, ...]: + """Return a shape tuple that consistently defines the leading dimensions of all arrays. + + Args: + vals: A list of arrays. + kwvals: A mapping from tuples to arrays, where the length of each tuple should match the + last dimension of the corresponding array. + + Returns: + A shape tuple that matches the leading dimension of every array. + + Raises: + ValueError: If this cannot be done unambiguously. + """ + only_possible_shapes = None + + def examine_array(*possible_shapes): + nonlocal only_possible_shapes + if only_possible_shapes is None: + only_possible_shapes = set(possible_shapes) + else: + only_possible_shapes.intersection_update(possible_shapes) + + for parameters, val in kwvals.items(): + if len(parameters) > 1: + # here, the last dimension _has_ to be over parameters + examine_array(val.shape[:-1]) + elif val.shape[-1] != 1: + # here, if the last dimension is not 1 then the shape is the shape + examine_array(val.shape) + else: + # here, the last dimension could be over parameters or not + examine_array(val.shape, val.shape[:-1]) + + if len(vals) == 1 and len(kwvals) == 0: + examine_array(vals[0].shape[:-1]) + else: + for val in vals: + # here, the last dimension could be over parameters or not + examine_array(val.shape, val.shape[:-1]) + + if len(only_possible_shapes) == 1: + return next(iter(only_possible_shapes)) + elif len(only_possible_shapes) == 0: + raise ValueError("Could not find any consistent shape.") + raise ValueError("Could not unambiguously determine the intended shape; specify shape manually") + + +def _format_key(key: tuple[Parameter | str, ...]): + return tuple(k.name if isinstance(k, Parameter) else k for k in key) + + +BindingsArrayLike = Union[ + BindingsArray, + NDArray, + "Mapping[Parameter, NDArray]", + "Sequence[NDArray]", + None, +] diff --git a/qiskit/primitives/containers/data_bin.py b/qiskit/primitives/containers/data_bin.py new file mode 100644 index 000000000000..40a75bf72eeb --- /dev/null +++ b/qiskit/primitives/containers/data_bin.py @@ -0,0 +1,90 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Dataclass tools for data namespaces (bins) +""" + +from dataclasses import make_dataclass +from typing import Iterable, Optional, Tuple + + +class DataBinMeta(type): + """Metaclass for :class:`DataBin` that adds the shape to the type name. + + This is so that the class has a custom repr with DataBin<*shape> notation. + """ + + def __repr__(cls): + name = cls.__name__ + if cls._SHAPE is None: + return name + shape = ",".join(map(str, cls._SHAPE)) + return f"{name}<{shape}>" + + +class DataBin(metaclass=DataBinMeta): + """Base class for data bin containers. + + Subclasses are typically made via :class:`~make_databin`, which is a specialization of + :class:`make_dataclass`. + """ + + _RESTRICTED_NAMES = ("_RESTRICTED_NAMES", "_SHAPE", "_FIELDS", "_FIELD_TYPES") + _SHAPE: Optional[Tuple[int, ...]] = None + _FIELDS: Tuple[str, ...] = () + """The fields allowed in this data bin.""" + _FIELD_TYPES: Tuple[type, ...] = () + """The types of each field.""" + + def __repr__(self): + vals = (f"{name}={getattr(self, name)}" for name in self._FIELDS if hasattr(self, name)) + return f"{type(self)}({', '.join(vals)})" + + +def make_databin( + fields: Iterable[Tuple[str, type]], shape: Optional[Tuple[int, ...]] = None +) -> DataBinMeta: + """Return a new subclass of :class:`~DataBin` with the provided fields and shape. + + .. code-block:: python + + my_bin = make_databin([("alpha", np.NDArray[np.float])], shape=(20, 30)) + + # behaves like a dataclass + my_bin(alpha=np.empty((20, 30))) + + Args: + fields: Tuples ``(name, type)`` specifying the attributes of the returned class. + shape: The intended shape of every attribute of this class. + + Returns: + A new class. + """ + fields = list(fields) + field_names = tuple(name for name, _ in fields) + field_types = tuple(field_type for _, field_type in fields) + for name in field_names: + if name in DataBin._RESTRICTED_NAMES: + raise ValueError(f"'{name}' is a restricted name for a DataBin.") + cls = make_dataclass( + "DataBin", + dict(zip(field_names, field_types)), + bases=(DataBin,), + frozen=True, + unsafe_hash=True, + repr=False, + ) + cls._SHAPE = shape + cls._FIELDS = field_names + cls._FIELD_TYPES = field_types + return cls diff --git a/qiskit/primitives/containers/dataclasses.py b/qiskit/primitives/containers/dataclasses.py new file mode 100644 index 000000000000..20a8f1e38913 --- /dev/null +++ b/qiskit/primitives/containers/dataclasses.py @@ -0,0 +1,27 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. +""" +Dataclass +""" + +from pydantic import ConfigDict +from pydantic.dataclasses import dataclass + +mutable_dataclass = dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) + +frozen_dataclass = dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid"), + frozen=True, + slots=True, +) diff --git a/qiskit/primitives/containers/estimator_task.py b/qiskit/primitives/containers/estimator_task.py new file mode 100644 index 000000000000..dbe87d8c35d3 --- /dev/null +++ b/qiskit/primitives/containers/estimator_task.py @@ -0,0 +1,94 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + + +""" +Estimator Task class +""" + +from __future__ import annotations + +from typing import Tuple, Union + +import numpy as np + +from qiskit import QuantumCircuit + +from .base_task import BaseTask +from .bindings_array import BindingsArray, BindingsArrayLike +from .dataclasses import frozen_dataclass +from .observables_array import ObservablesArray, ObservablesArrayLike +from .shape import ShapedMixin + + +@frozen_dataclass +class EstimatorTask(BaseTask, ShapedMixin): + """Task for Estimator. + Task is composed of triple (circuit, observables, parameter_values). + """ + + observables: ObservablesArray + parameter_values: BindingsArray = BindingsArray(shape=()) + _shape: Tuple[int, ...] = () + + def __post_init__(self): + shape = np.broadcast_shapes(self.observables.shape, self.parameter_values.shape) + self._shape = shape + + @classmethod + def coerce(cls, task: EstimatorTaskLike) -> EstimatorTask: + """Coerce EstimatorTaskLike into EstimatorTask. + + Args: + task: an object to be estimator task. + + Returns: + A coerced estimator task. + """ + if isinstance(task, EstimatorTask): + return task + if len(task) != 2 and len(task) != 3: + raise ValueError(f"The length of task must be 2 or 3, but length {len(task)} is given.") + circuit = task[0] + observables = ObservablesArray.coerce(task[1]) + if len(task) == 2: + return cls(circuit=circuit, observables=observables) + parameter_values = BindingsArray.coerce(task[2]) + return cls(circuit=circuit, observables=observables, parameter_values=parameter_values) + + def validate(self): + """Validate the task.""" + super(EstimatorTask, self).validate() # pylint: disable=super-with-arguments + # I'm not sure why these arguments for super are needed. But if no args, tests are failed + # for Python >=3.10. Seems to be some bug, but I can't fix. + self.observables.validate() + self.parameter_values.validate() + # Cross validate circuits and observables + for i, observable in enumerate(self.observables): + num_qubits = len(next(iter(observable))) + if self.circuit.num_qubits != num_qubits: + raise ValueError( + f"The number of qubits of the circuit ({self.circuit.num_qubits}) does " + f"not match the number of qubits of the {i}-th observable ({num_qubits})." + ) + # Cross validate circuits and paramter_values + num_parameters = self.parameter_values.num_parameters + if num_parameters != self.circuit.num_parameters: + raise ValueError( + f"The number of values ({num_parameters}) does not match " + f"the number of parameters ({self.circuit.num_parameters}) for the circuit." + ) + + +EstimatorTaskLike = Union[ + EstimatorTask, Tuple[QuantumCircuit, ObservablesArrayLike, BindingsArrayLike] +] diff --git a/qiskit/primitives/containers/object_array.py b/qiskit/primitives/containers/object_array.py new file mode 100644 index 000000000000..efd31234ddf3 --- /dev/null +++ b/qiskit/primitives/containers/object_array.py @@ -0,0 +1,93 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Object ND-array initialization function. +""" + +from typing import Optional, Sequence, Tuple + +import numpy as np +from numpy.typing import ArrayLike + + +def object_array( + arr: ArrayLike, + order: Optional[str] = None, + copy: bool = True, + list_types: Optional[Sequence[type]] = (), +) -> np.ndarray: + """Convert an array-like of objects into an object array. + + .. note:: + + If the objects in the array like input define ``__array__`` methods + this avoids calling them and will instead set the returned array values + to the Python objects themselves. + + Args: + arr: An array-like input. + order: Optional, the order of the returned array (C, F, A, K). If None + the default NumPy ordering of C is used. + copy: If True make a copy of the input if it is already an array. + list_types: Optional, a sequence of types to treat as lists of array + element objects when inferring the array shape from the input. + + Returns: + A NumPy ND-array with ``dtype=object``. + + Raises: + ValueError: If the input cannot be coerced into an object array. + """ + if isinstance(arr, np.ndarray): + if arr.dtype != object or order is not None or copy is True: + arr = arr.astype(object, order=order, copy=copy) + return arr + + shape = _infer_shape(arr, list_types=tuple(list_types)) + obj_arr = np.empty(shape, dtype=object, order=order) + if not shape: + # We call fill here instead of [()] to avoid invoking the + # objects `__array__` method if it has one (eg for Pauli's). + obj_arr.fill(arr) + else: + # For other arrays we need to do some tricks to avoid invoking the + # objects __array__ method by flattening the input and initializing + # using `np.fromiter` which does not invoke `__array__` for object + # dtypes. + def _flatten(nested, k): + if k == 1: + return nested + else: + return [item for sublist in nested for item in _flatten(sublist, k - 1)] + + flattened = _flatten(arr, len(shape)) + if len(flattened) != obj_arr.size: + raise ValueError( + "Input object size does not match the inferred array shape." + " This most likely occurs when the input is a ragged array." + ) + obj_arr.flat = np.fromiter(flattened, dtype=object, count=len(flattened)) + + return obj_arr + + +def _infer_shape(obj: ArrayLike, list_types: Tuple[type, ...] = ()) -> Tuple[int, ...]: + """Infer the shape of an array-like object without casting""" + if isinstance(obj, np.ndarray): + return obj.shape + if not isinstance(obj, (list, *list_types)): + return () + size = len(obj) + if size == 0: + return (size,) + return (size, *_infer_shape(obj[0], list_types=list_types)) diff --git a/qiskit/primitives/containers/observables_array.py b/qiskit/primitives/containers/observables_array.py new file mode 100644 index 000000000000..098bb68ec13c --- /dev/null +++ b/qiskit/primitives/containers/observables_array.py @@ -0,0 +1,257 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + + +""" +ND-Array container class for Estimator observables. +""" +from __future__ import annotations + +import re +from collections import defaultdict +from collections.abc import Mapping as MappingType +from functools import lru_cache +from typing import Iterable, Mapping, Union + +import numpy as np +from numpy.typing import ArrayLike + +from qiskit.quantum_info import Pauli, PauliList, SparsePauliOp + +from .object_array import object_array +from .shape import ShapedMixin + +BasisObservable = Mapping[str, complex] +"""Representation type of a single observable.""" + +BasisObservableLike = Union[ + str, + Pauli, + SparsePauliOp, + Mapping[Union[str, Pauli], complex], + Iterable[Union[str, Pauli, SparsePauliOp]], +] +"""Types that can be natively used to construct a :const:`BasisObservable`.""" + + +class ObservablesArray(ShapedMixin): + """An ND-array of :const:`.BasisObservable` for an :class:`.Estimator` primitive.""" + + __slots__ = ("_array", "_shape") + ALLOWED_BASIS: str = "IXYZ01+-lr" + """The allowed characters in :const:`BasisObservable` strings.""" + + def __init__( + self, + observables: Union[BasisObservableLike, ArrayLike], + copy: bool = True, + validate: bool = True, + ): + """Initialize an observables array. + + Args: + observables: An array-like of basis observable compatible objects. + copy: Specify the ``copy`` kwarg of the :func:`.object_array` function + when initializing observables. + validate: If True, convert :const:`.BasisObservableLike` input objects + to :const:`.BasisObservable` objects and validate. If False the + input should already be an array-like of valid + :const:`.BasisObservble` objects. + + Raises: + ValueError: If ``validate=True`` and the input observables is not valid. + """ + super().__init__() + if isinstance(observables, ObservablesArray): + observables = observables._array + self._array = object_array(observables, copy=copy, list_types=(PauliList,)) + self._shape = self._array.shape + if validate: + num_qubits = None + for ndi, obs in np.ndenumerate(self._array): + basis_obs = self.format_observable(obs) + basis_num_qubits = len(next(iter(basis_obs))) + if num_qubits is None: + num_qubits = basis_num_qubits + elif basis_num_qubits != num_qubits: + raise ValueError( + "The number of qubits must be the same for all observables in the " + "observables array." + ) + self._array[ndi] = basis_obs + + def __repr__(self): + prefix = f"{type(self).__name__}(" + suffix = f", shape={self.shape})" + array = np.array2string(self._array, prefix=prefix, suffix=suffix, threshold=50) + return prefix + array + suffix + + def tolist(self) -> list: + """Convert to a nested list""" + return self._array.tolist() + + def __array__(self, dtype=None): + """Convert to an Numpy.ndarray""" + if dtype is None or dtype == object: + return self._array + raise ValueError("Type must be 'None' or 'object'") + + def __getitem__(self, args) -> Union[ObservablesArray, BasisObservable]: + item = self._array[args] + if not isinstance(item, np.ndarray): + return item + return ObservablesArray(item, copy=False, validate=False) + + def reshape(self, shape: Union[int, Iterable[int]]) -> "ObservablesArray": + """Return a new array with a different shape. + + This results in a new view of the same arrays. + + Args: + shape: The shape of the returned array. + + Returns: + A new array. + """ + return ObservablesArray(self._array.reshape(shape), copy=False, validate=False) + + def ravel(self) -> ObservablesArray: + """Return a new array with one dimension. + + The returned array has a :attr:`shape` given by ``(size, )``, where + the size is the :attr:`~size` of this array. + + Returns: + A new flattened array. + """ + return self.reshape(self.size) + + @classmethod + def format_observable(cls, observable: BasisObservableLike) -> BasisObservable: + """Format an observable-like object into a :const:`BasisObservable`. + + Args: + observable: The observable-like to format. + + Returns: + The given observable as a :const:`~BasisObservable`. + + Raises: + TypeError: If the input cannot be formatted because its type is not valid. + ValueError: If the input observable is invalid. + """ + + # Pauli-type conversions + if isinstance(observable, SparsePauliOp): + # Call simplify to combine duplicate keys before converting to a mapping + return cls.format_observable(dict(observable.simplify(atol=0).to_list())) + + if isinstance(observable, Pauli): + label, phase = observable[:].to_label(), observable.phase + return {label: 1} if phase == 0 else {label: (-1j) ** phase} + + # String conversion + if isinstance(observable, str): + cls._validate_basis(observable) + return {observable: 1} + + # Mapping conversion (with possible Pauli keys) + if isinstance(observable, MappingType): + num_qubits = len(next(iter(observable))) + unique = defaultdict(complex) + for basis, coeff in observable.items(): + if isinstance(basis, Pauli): + basis, phase = basis[:].to_label(), basis.phase + if phase != 0: + coeff = coeff * (-1j) ** phase + # Validate basis + cls._validate_basis(basis) + if len(basis) != num_qubits: + raise ValueError( + "Number of qubits must be the same for all observable basis elements." + ) + unique[basis] += coeff + return dict(unique) + + raise TypeError(f"Invalid observable type: {type(observable)}") + + @classmethod + def coerce(cls, observables: ObservablesArrayLike) -> ObservablesArray: + """Coerce ObservablesArrayLike into ObservableArray. + + Args: + observables: an object to be observables array. + + Returns: + A coerced observables array. + """ + if isinstance(observables, ObservablesArray): + return observables + if isinstance(observables, (str, SparsePauliOp, Pauli, Mapping)): + observables = [observables] + return cls(observables) + + def validate(self): + """Validate the consistency in observables array.""" + num_qubits = None + for obs in self._array: + basis_num_qubits = len(next(iter(obs))) + if num_qubits is None: + num_qubits = basis_num_qubits + elif basis_num_qubits != num_qubits: + raise ValueError( + "The number of qubits must be the same for all observables in the " + "observables array." + ) + + @classmethod + def _validate_basis(cls, basis: str) -> None: + """Validate a basis string. + + Args: + basis: a basis string to validate. + + Raises: + ValueError: If basis string contains invalid characters + """ + # NOTE: the allowed basis characters can be overridden by modifying the class + # attribute ALLOWED_BASIS + allowed_pattern = _regex_match(cls.ALLOWED_BASIS) + if not allowed_pattern.match(basis): + invalid_pattern = _regex_invalid(cls.ALLOWED_BASIS) + invalid_chars = list(set(invalid_pattern.findall(basis))) + raise ValueError( + f"Observable basis string '{basis}' contains invalid characters {invalid_chars}," + f" allowed characters are {list(cls.ALLOWED_BASIS)}.", + ) + + +ObservablesArrayLike = Union[ObservablesArray, ArrayLike, BasisObservableLike] +"""Types that can be natively converted to an ObservablesArray""" + + +class PauliArray(ObservablesArray): + """An ND-array of Pauli-basis observables for an :class:`.Estimator` primitive.""" + + ALLOWED_BASIS = "IXYZ" + + +@lru_cache(1) +def _regex_match(allowed_chars: str) -> re.Pattern: + """Return pattern for matching if a string contains only the allowed characters.""" + return re.compile(f"^[{re.escape(allowed_chars)}]*$") + + +@lru_cache(1) +def _regex_invalid(allowed_chars: str) -> re.Pattern: + """Return pattern for selecting invalid strings""" + return re.compile(f"[^{re.escape(allowed_chars)}]") diff --git a/qiskit/primitives/containers/options.py b/qiskit/primitives/containers/options.py new file mode 100644 index 000000000000..a96e5a4633fe --- /dev/null +++ b/qiskit/primitives/containers/options.py @@ -0,0 +1,35 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Options class +""" + +from __future__ import annotations + +from abc import ABC +from typing import Union + +from .dataclasses import mutable_dataclass + + +@mutable_dataclass +class BasePrimitiveOptions(ABC): + """Base calss of options for primitives.""" + + def update(self, **kwargs): + """Update the options.""" + for key, val in kwargs.items(): + setattr(self, key, val) + + +BasePrimitiveOptionsLike = Union[BasePrimitiveOptions, dict] diff --git a/qiskit/primitives/containers/primitive_result.py b/qiskit/primitives/containers/primitive_result.py new file mode 100644 index 000000000000..35c97b02033c --- /dev/null +++ b/qiskit/primitives/containers/primitive_result.py @@ -0,0 +1,49 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +"""PrimitiveResult""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any, Optional, Sequence, TypeVar + +from .task_result import TaskResult + +T = TypeVar("T", bound=TaskResult) + + +class PrimitiveResult(Sequence[T]): + """A container for multiple task results and global metadata.""" + + def __init__(self, task_results: Iterable[T], metadata: Optional[dict[str, Any]] = None): + """ + Args: + task_results: Task results. + metadata: Any metadata that doesn't make sense to put inside of task results. + """ + self._task_results = list(task_results) + self._metadata = metadata if metadata is not None else {} + + @property + def metadata(self) -> dict[str, Any]: + """The metadata of this primitive result.""" + return self._metadata + + def __getitem__(self, index) -> T: + return self._task_results[index] + + def __len__(self) -> int: + return len(self._task_results) + + def __repr__(self) -> str: + return f"PrimitiveResult({self._task_results}, metadata={self.metadata})" diff --git a/qiskit/primitives/containers/shape.py b/qiskit/primitives/containers/shape.py new file mode 100644 index 000000000000..1a000da684c3 --- /dev/null +++ b/qiskit/primitives/containers/shape.py @@ -0,0 +1,129 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Array shape related classes and functions +""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Protocol, Tuple, Union, runtime_checkable + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +ShapeInput = Union[int, "Iterable[ShapeInput]"] +"""An input that is coercible into a shape tuple.""" + + +@runtime_checkable +class Shaped(Protocol): + """Protocol that defines what it means to be a shaped object. + + Note that static type checkers will classify ``numpy.ndarray`` as being :class:`Shaped`. + Moreover, since this protocol is runtime-checkable, we will even have + ``isinstance(, Shaped) == True``. + """ + + @property + def shape(self) -> Tuple[int, ...]: + """The array shape of this object.""" + raise NotImplementedError("A `Shaped` protocol must implement the `shape` property") + + @property + def ndim(self) -> int: + """The number of array dimensions of this object.""" + raise NotImplementedError("A `Shaped` protocol must implement the `ndim` property") + + @property + def size(self) -> int: + """The total dimension of this object, i.e. the product of the entries of :attr:`~shape`.""" + raise NotImplementedError("A `Shaped` protocol must implement the `size` property") + + +class ShapedMixin(Shaped): + """Mixin class to create :class:`~Shaped` types by only providing :attr:`_shape` attribute.""" + + _shape: Tuple[int, ...] + + def __repr__(self): + return f"{type(self).__name__}(<{self.shape}>)" + + @property + def shape(self): + return self._shape + + @property + def ndim(self): + return len(self._shape) + + @property + def size(self): + return int(np.prod(self._shape, dtype=int)) + + +def array_coerce(arr: Union[ArrayLike, Shaped]) -> Union[NDArray, Shaped]: + """Coerce the input into an object with a shape attribute. + + Copies are avoided. + + Args: + arr: The object to coerce. + + Returns: + Something that is :class:`~Shaped`, and always ``numpy.ndarray`` if the input is not + already :class:`~Shaped`. + """ + if isinstance(arr, Shaped): + return arr + return np.array(arr, copy=False) + + +def _flatten_to_ints(arg: ShapeInput) -> Iterable[int]: + """ + Yield one integer at a time. + + Args: + arg: Integers or iterables of integers, possibly nested, to be yielded. + + Yields: + The provided integers in depth-first recursive order. + + Raises: + ValueError: If an input is not an iterable or an integer. + """ + for item in arg: + try: + if isinstance(item, Iterable): + yield from _flatten_to_ints(item) + elif int(item) == item: + yield int(item) + else: + raise ValueError(f"Expected {item} to be iterable or an integer.") + except (TypeError, RecursionError) as ex: + raise ValueError(f"Expected {item} to be iterable or an integer.") from ex + + +def shape_tuple(*shapes: ShapeInput) -> Tuple[int, ...]: + """ + Flatten the input into a single tuple of integers, preserving order. + + Args: + shapes: Integers or iterables of integers, possibly nested. + + Returns: + A tuple of integers. + + Raises: + ValueError: If some member of ``shapes`` is not an integer or iterable. + """ + return tuple(_flatten_to_ints(shapes)) diff --git a/qiskit/primitives/containers/task_result.py b/qiskit/primitives/containers/task_result.py new file mode 100644 index 000000000000..d773084cb5c9 --- /dev/null +++ b/qiskit/primitives/containers/task_result.py @@ -0,0 +1,32 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Base Task class +""" + +from __future__ import annotations + +from pydantic import Field + +from .data_bin import DataBin +from .dataclasses import frozen_dataclass + + +@frozen_dataclass +class TaskResult: + """Result of task.""" + + data: DataBin + """Result data for the task""" + metadata: dict = Field(default_factory=dict) + """Metadata for the task""" diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py new file mode 100644 index 000000000000..ed46425dc2da --- /dev/null +++ b/qiskit/primitives/statevector_estimator.py @@ -0,0 +1,146 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. +""" +Estimator class +""" + +from __future__ import annotations + +from typing import List, Optional, Union + +import numpy as np +from numpy.typing import NDArray +from pydantic import Field + +from qiskit.quantum_info import SparsePauliOp, Statevector + +from .base import BaseEstimatorV2 +from .containers import ( + BasePrimitiveOptions, + BasePrimitiveOptionsLike, + EstimatorTask, + PrimitiveResult, + TaskResult, + make_databin, +) +from .containers.dataclasses import mutable_dataclass +from .primitive_job import PrimitiveJob +from .utils import bound_circuit_to_instruction + + +@mutable_dataclass +class ExecutionOptions(BasePrimitiveOptions): + """Options for execution.""" + + shots: Optional[int] = None + seed: Optional[Union[int, np.random.Generator]] = None + + +@mutable_dataclass +class Options(BasePrimitiveOptions): + """Options for the primitives. + + Args: + execution: Execution time options. See :class:`ExecutionOptions` for all available options. + """ + + execution: ExecutionOptions = Field(default_factory=ExecutionOptions) + + +class Estimator(BaseEstimatorV2[PrimitiveJob[List[TaskResult]]]): + """ + Simple implementation of :class:`BaseEstimatorV2` with Statevector. + + :Run Options: + + - **shots** (None or int) -- + The number of shots. If None, it calculates the exact expectation + values. Otherwise, it samples from normal distributions with standard errors as standard + deviations using normal distribution approximation. + + - **seed** (np.random.Generator or int) -- + Set a fixed seed or generator for the normal distribution. If shots is None, + this option is ignored. + """ + + _options_class = Options + + def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): + """ + Args: + options: Options including shots, seed. + """ + if options is None: + options = Options() + elif not isinstance(options, Options): + options = Options(**options) + super().__init__(options=options) + + def _run(self, tasks: list[EstimatorTask]) -> PrimitiveJob[list[TaskResult]]: + job = PrimitiveJob(self._run_task, tasks) + job.submit() + return job + + def _run_task(self, tasks: list[EstimatorTask]) -> list[TaskResult]: + shots = self.options.execution.shots + + rng = _get_rng(self.options.execution.seed) + + results = [] + for task in tasks: + circuit = task.circuit + observables = task.observables + parameter_values = task.parameter_values + bound_circuits = parameter_values.bind_all(circuit) + + bound_circuits, observables = np.broadcast_arrays(bound_circuits, observables) + evs = np.zeros_like(bound_circuits, dtype=np.complex128) + stds = np.zeros_like(bound_circuits, dtype=np.complex128) + for index in np.ndindex(*bound_circuits.shape): + bound_circuit = bound_circuits[index] + observable = observables[index] + + final_state = Statevector(bound_circuit_to_instruction(bound_circuit)) + paulis = list(observable.keys()) + coeffs = list(observable.values()) + obs = SparsePauliOp(paulis, coeffs) + # TODO: support non Pauli operators + expectation_value = final_state.expectation_value(obs) + if shots is None: + standard_error = 0 + else: + expectation_value = np.real_if_close(expectation_value) + sq_obs = (obs @ obs).simplify(atol=0) + sq_exp_val = np.real_if_close(final_state.expectation_value(sq_obs)) + variance = sq_exp_val - expectation_value**2 + variance = max(variance, 0) + standard_error = np.sqrt(variance / shots) + expectation_value = rng.normal(expectation_value, standard_error) + evs[index] = expectation_value + stds[index] = standard_error + data_bin_cls = make_databin( + [("evs", NDArray[np.complex128]), ("stds", NDArray[np.complex128])], + shape=bound_circuits.shape, + ) + data_bin = data_bin_cls(evs=evs, stds=stds) + results.append(TaskResult(data_bin, metadata={"shots": shots})) + return PrimitiveResult(results) + + +def _get_rng(seed): + if seed is None: + rng = np.random.default_rng() + elif isinstance(seed, np.random.Generator): + rng = seed + else: + rng = np.random.default_rng(seed) + return rng diff --git a/releasenotes/notes/estimatorv2-9b09b66ecc12af1b.yaml b/releasenotes/notes/estimatorv2-9b09b66ecc12af1b.yaml new file mode 100644 index 000000000000..f70dccf84e13 --- /dev/null +++ b/releasenotes/notes/estimatorv2-9b09b66ecc12af1b.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Add :class:`~.BaseEstimatorV2` and its reference implementation + :class:`~.statevector_estimator.Estimator`. + EstimatorV2 is based on + `the RFC `_. diff --git a/requirements.txt b/requirements.txt index d41eee50ce95..5f14ec5d6458 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ typing-extensions; python_version<'3.11' # multiplication in version 0.10 wich breaks parameter assignment test # (can be removed once issue is fix) symengine>=0.9, <0.10; platform_machine == 'x86_64' or platform_machine == 'aarch64' or platform_machine == 'ppc64le' or platform_machine == 'amd64' or platform_machine == 'arm64' +pydantic diff --git a/test/python/primitives/primitive_result.py b/test/python/primitives/primitive_result.py new file mode 100644 index 000000000000..2fc0b8b9e089 --- /dev/null +++ b/test/python/primitives/primitive_result.py @@ -0,0 +1,44 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + + +"""Unit tests for PrimitiveResult.""" + +import numpy as np +import numpy.typing as npt + +from qiskit.primitives.containers import PrimitiveResult, TaskResult, make_databin +from qiskit.test import QiskitTestCase + + +class PrimitiveResultCase(QiskitTestCase): + """Test the PrimitiveResult class.""" + + def test_primitive_result(self): + """Test the PrimitiveResult class.""" + data_bin_cls = make_databin( + [("alpha", npt.NDArray[np.uint16]), ("beta", np.ndarray)], shape=(10, 20) + ) + + alpha = np.empty((10, 20), dtype=np.uint16) + beta = np.empty((10, 20), dtype=int) + + task_results = [ + TaskResult(data_bin_cls(alpha, beta)), + TaskResult(data_bin_cls(alpha, beta)), + ] + result = PrimitiveResult(task_results, {1: 2}) + + self.assertTrue(result[0] is task_results[0]) + self.assertTrue(result[1] is task_results[1]) + self.assertTrue(list(result)[0] is task_results[0]) + self.assertEqual(len(result), 2) diff --git a/test/python/primitives/test_bindings_array.py b/test/python/primitives/test_bindings_array.py new file mode 100644 index 000000000000..a37589b15792 --- /dev/null +++ b/test/python/primitives/test_bindings_array.py @@ -0,0 +1,302 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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 BindingsArray""" + +from types import GeneratorType + +import numpy as np + +from qiskit.circuit import Parameter, ParameterVector, QuantumCircuit +from qiskit.primitives import BindingsArray +from qiskit.test import QiskitTestCase + + +class BindingsArrayTestCase(QiskitTestCase): + """Test the BindingsArray class""" + + def setUp(self): + self.circuit = QuantumCircuit(5) + self.params = ParameterVector("a", 50) + param_iter = iter(self.params) + for _ in range(10): + for qubit in range(5): + self.circuit.sx(qubit) + self.circuit.rz(next(param_iter), qubit) + self.circuit.cx(0, 1) + self.circuit.cx(2, 3) + return super().setUp() + + def test_construction_failures(self): + """Test all the possible construction failures""" + with self.assertRaisesRegex(ValueError, "specify a shape if no values"): + BindingsArray() + + with self.assertRaisesRegex(ValueError, "inconsistent with last dimension of"): + BindingsArray(kwvals={Parameter("a"): [0, 1]}, shape=()) + + with self.assertRaisesRegex(ValueError, r"\(3, 5\) inconsistent with \(2,\)"): + BindingsArray(np.empty((3, 5)), shape=2) + + with self.assertRaisesRegex(ValueError, "ambiguous"): + # could have shape (2,) or () + BindingsArray([np.empty(2), np.empty(2)]) + + with self.assertRaisesRegex(ValueError, "Could not find any consistent shape"): + BindingsArray([np.empty((5, 8, 3)), np.empty((4, 7, 2))]) + + with self.assertRaisesRegex(ValueError, "inconsistent with last dimension of"): + BindingsArray( + vals=np.empty((5, 10)), + kwvals={(Parameter("a"), Parameter("b")): np.empty((5, 10, 3))}, + ) + + def test_bind_at_idx(self): + """Test binding at a specified index""" + vals = np.linspace(0, 1, 1000).reshape((5, 4, 50)) + expected_circuit = self.circuit.assign_parameters(vals[2, 3]) + + ba = BindingsArray(vals) + self.assertEqual(ba.bind_at_idx(self.circuit, (2, 3)), expected_circuit) + + ba = BindingsArray([vals[:, :, :20], vals[:, :, 20:27], vals[:, :, 27:]]) + self.assertEqual(ba.bind_at_idx(self.circuit, (2, 3)), expected_circuit) + + ba = BindingsArray(vals[:, :, :20], {tuple(self.params[20:]): vals[:, :, 20:]}) + self.assertEqual(ba.bind_at_idx(self.circuit, (2, 3)), expected_circuit) + + order = np.arange(30, 50, dtype=int) + np.random.default_rng().shuffle(order) + ba = BindingsArray( + [vals[:, :, :20], vals[:, :, 20:25]], + { + tuple(self.params[25:30]): vals[:, :, 25:30], + tuple(self.params[i] for i in order): vals[:, :, order], + }, + ) + self.assertEqual(ba.bind_at_idx(self.circuit, (2, 3)), expected_circuit) + + def test_bind_flat(self): + """Test flat binding all possible values""" + # this test assumes bind_flat() is implemented via bind_at_idx(), which we have already + # tested. so here, we just test that it gets the order right + vals = np.linspace(0, 1, 300).reshape((2, 3, 50)) + bound_iter = BindingsArray(vals).bind_flat(self.circuit) + self.assertIsInstance(bound_iter, GeneratorType) + bound_circuits = list(bound_iter) + self.assertEqual(len(bound_circuits), 6) + self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(vals[0, 0])) + self.assertEqual(bound_circuits[1], self.circuit.assign_parameters(vals[0, 1])) + self.assertEqual(bound_circuits[2], self.circuit.assign_parameters(vals[0, 2])) + self.assertEqual(bound_circuits[3], self.circuit.assign_parameters(vals[1, 0])) + self.assertEqual(bound_circuits[4], self.circuit.assign_parameters(vals[1, 1])) + self.assertEqual(bound_circuits[5], self.circuit.assign_parameters(vals[1, 2])) + + def test_bind_all(self): + """Test binding all possible values""" + # this test assumes bind_all() is implemented via bind_at_idx(), which we have already + # tested. so here, we just test that it gets the order right + vals = np.linspace(0, 1, 300).reshape((2, 3, 50)) + bound_circuits = BindingsArray(vals).bind_all(self.circuit) + self.assertIsInstance(bound_circuits, np.ndarray) + self.assertEqual(bound_circuits.shape, (2, 3)) + for idx in np.ndindex((2, 3)): + self.assertEqual(bound_circuits[idx], self.circuit.assign_parameters(vals[idx])) + + def test_properties(self): + """Test properties""" + with self.subTest("binding a list"): + vals = np.linspace(0, 1, 50).tolist() + ba = BindingsArray(vals) + self.assertEqual(ba.num_parameters, 50) + self.assertEqual(ba.ndim, 0) + self.assertEqual(ba.shape, ()) + self.assertEqual(ba.size, 1) + self.assertEqual(ba.kwvals, {}) + np.testing.assert_allclose(ba.vals, np.array(vals)[:, np.newaxis]) + + with self.subTest("binding a single array"): + vals = np.linspace(0, 1, 300).reshape((2, 3, 50)) + ba = BindingsArray(vals) + self.assertEqual(ba.num_parameters, 50) + self.assertEqual(ba.ndim, 2) + self.assertEqual(ba.shape, (2, 3)) + self.assertEqual(ba.size, 6) + self.assertEqual(ba.kwvals, {}) + np.testing.assert_allclose(ba.vals, vals.reshape((1, 2, 3, 50))) + + with self.subTest("binding multiple arrays"): + vals = np.linspace(0, 1, 300).reshape((2, 3, 50)) + ba = BindingsArray([vals[:, :, :20], vals[:, :, 20:]]) + self.assertEqual(ba.num_parameters, 50) + self.assertEqual(ba.ndim, 2) + self.assertEqual(ba.shape, (2, 3)) + self.assertEqual(ba.size, 6) + self.assertEqual(ba.kwvals, {}) + self.assertEqual(len(ba.vals), 2) + np.testing.assert_allclose(ba.vals[0], vals[:, :, :20]) + np.testing.assert_allclose(ba.vals[1], vals[:, :, 20:]) + + def test_ravel(self): + """Test ravel""" + vals = np.linspace(0, 1, 300).reshape((2, 3, 50)) + + ba = BindingsArray(vals) + flat = ba.ravel() + self.assertEqual(flat.num_parameters, 50) + self.assertEqual(flat.ndim, 1) + self.assertEqual(flat.shape, (6,)) + self.assertEqual(flat.size, 6) + self.assertEqual(flat.kwvals, {}) + flat_vals = vals.reshape(-1, 50) + np.testing.assert_allclose(flat.vals, flat_vals.reshape((1, 6, 50))) + + bound_circuits = list(flat.bind_flat(self.circuit)) + self.assertEqual(len(bound_circuits), 6) + for i in range(6): + self.assertEqual(bound_circuits[i], self.circuit.assign_parameters(flat_vals[i])) + + def test_reshape(self): + """Test reshape""" + vals = np.linspace(0, 1, 300).reshape((2, 3, 50)) + + with self.subTest("reshape"): + ba = BindingsArray(vals) + reshape_ba = ba.reshape((3, 2)) + self.assertEqual(reshape_ba.num_parameters, 50) + self.assertEqual(reshape_ba.ndim, 2) + self.assertEqual(reshape_ba.shape, (3, 2)) + self.assertEqual(reshape_ba.size, 6) + self.assertEqual(reshape_ba.kwvals, {}) + reshape_vals = vals.reshape((3, 2, 50)) + np.testing.assert_allclose(reshape_ba.vals, reshape_vals.reshape((1, 3, 2, 50))) + + bound_circuits = list(reshape_ba.bind_flat(self.circuit)) + self.assertEqual(len(bound_circuits), 6) + self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(reshape_vals[0, 0])) + self.assertEqual(bound_circuits[1], self.circuit.assign_parameters(reshape_vals[0, 1])) + self.assertEqual(bound_circuits[2], self.circuit.assign_parameters(reshape_vals[1, 0])) + self.assertEqual(bound_circuits[3], self.circuit.assign_parameters(reshape_vals[1, 1])) + self.assertEqual(bound_circuits[4], self.circuit.assign_parameters(reshape_vals[2, 0])) + self.assertEqual(bound_circuits[5], self.circuit.assign_parameters(reshape_vals[2, 1])) + + with self.subTest("flatten"): + ba = BindingsArray(vals) + reshape_ba = ba.reshape(6) + self.assertEqual(reshape_ba.num_parameters, 50) + self.assertEqual(reshape_ba.ndim, 1) + self.assertEqual(reshape_ba.shape, (6,)) + self.assertEqual(reshape_ba.size, 6) + self.assertEqual(reshape_ba.kwvals, {}) + reshape_vals = vals.reshape(-1, 50) + np.testing.assert_allclose(reshape_ba.vals, reshape_vals.reshape((1, 6, 50))) + + bound_circuits = list(reshape_ba.bind_flat(self.circuit)) + self.assertEqual(len(bound_circuits), 6) + for i in range(6): + self.assertEqual(bound_circuits[i], self.circuit.assign_parameters(reshape_vals[i])) + + def test_kwvals(self): + """Test constructor with kwvals""" + with self.subTest("binding a single value"): + vals = np.linspace(0, 1, 50) + kwvals = {self.params: vals} + ba = BindingsArray(kwvals=kwvals) + self.assertEqual(ba.num_parameters, 50) + self.assertEqual(ba.ndim, 0) + self.assertEqual(ba.shape, ()) + self.assertEqual(ba.size, 1) + self.assertEqual(ba.vals, []) + self.assertEqual(ba.kwvals, {tuple(param.name for param in self.params): vals}) + + bound_circuits = list(ba.bind_flat(self.circuit)) + self.assertEqual(len(bound_circuits), 1) + self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(vals)) + + with self.subTest("binding an array"): + vals = np.linspace(0, 1, 300).reshape((2, 3, 50)) + kwvals = {self.params: vals} + ba = BindingsArray(kwvals=kwvals) + self.assertEqual(ba.num_parameters, 50) + self.assertEqual(ba.ndim, 2) + self.assertEqual(ba.shape, (2, 3)) + self.assertEqual(ba.size, 6) + self.assertEqual(ba.vals, []) + self.assertEqual(ba.kwvals, {tuple(param.name for param in self.params): vals}) + + bound_circuits = list(ba.bind_flat(self.circuit)) + self.assertEqual(len(bound_circuits), 6) + self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(vals[0, 0])) + self.assertEqual(bound_circuits[1], self.circuit.assign_parameters(vals[0, 1])) + self.assertEqual(bound_circuits[2], self.circuit.assign_parameters(vals[0, 2])) + self.assertEqual(bound_circuits[3], self.circuit.assign_parameters(vals[1, 0])) + self.assertEqual(bound_circuits[4], self.circuit.assign_parameters(vals[1, 1])) + self.assertEqual(bound_circuits[5], self.circuit.assign_parameters(vals[1, 2])) + + with self.subTest("binding a single param"): + vals = np.linspace(0, 1, 50) + kwvals = {self.params[0]: vals} + ba = BindingsArray(kwvals=kwvals) + self.assertEqual(ba.num_parameters, 1) + self.assertEqual(ba.ndim, 1) + self.assertEqual(ba.shape, (50,)) + self.assertEqual(ba.size, 50) + self.assertEqual(ba.vals, []) + self.assertEqual(list(ba.kwvals.keys()), [(self.params[0].name,)]) + np.testing.assert_allclose(list(ba.kwvals.values()), [vals[..., np.newaxis]]) + + def test_vals_kwvals(self): + """Test constructor with vals and kwvals""" + with self.subTest("binding a single value"): + vals = np.linspace(0, 1, 50) + kwvals = {tuple(self.params[20:]): vals[20:]} + ba = BindingsArray(vals=vals[:20], kwvals=kwvals) + self.assertEqual(ba.num_parameters, 50) + self.assertEqual(ba.ndim, 0) + self.assertEqual(ba.shape, ()) + self.assertEqual(ba.size, 1) + np.testing.assert_allclose(ba.vals, vals[np.newaxis, :20]) + self.assertEqual(ba.kwvals, {tuple(p.name for p in k): v for k, v in kwvals.items()}) + + bound_circuits = list(ba.bind_flat(self.circuit)) + self.assertEqual(len(bound_circuits), 1) + self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(vals)) + + with self.subTest("binding an array"): + vals = np.linspace(0, 1, 300).reshape((2, 3, 50)) + kwvals = {tuple(self.params[20:]): vals[:, :, 20:]} + ba = BindingsArray(vals=vals[:, :, :20], kwvals=kwvals) + self.assertEqual(ba.num_parameters, 50) + self.assertEqual(ba.ndim, 2) + self.assertEqual(ba.shape, (2, 3)) + self.assertEqual(ba.size, 6) + np.testing.assert_allclose(ba.vals, vals[np.newaxis, :, :, :20]) + self.assertEqual(ba.kwvals, {tuple(p.name for p in k): v for k, v in kwvals.items()}) + + bound_circuits = list(ba.bind_flat(self.circuit)) + self.assertEqual(len(bound_circuits), 6) + self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(vals[0, 0])) + self.assertEqual(bound_circuits[1], self.circuit.assign_parameters(vals[0, 1])) + self.assertEqual(bound_circuits[2], self.circuit.assign_parameters(vals[0, 2])) + self.assertEqual(bound_circuits[3], self.circuit.assign_parameters(vals[1, 0])) + self.assertEqual(bound_circuits[4], self.circuit.assign_parameters(vals[1, 1])) + self.assertEqual(bound_circuits[5], self.circuit.assign_parameters(vals[1, 2])) + + with self.subTest("len(val) == 1 and len(kwvals) > 0"): + ba = BindingsArray( + vals=np.empty((5, 10)), + kwvals={(Parameter("a"), Parameter("b")): np.empty((5, 10, 2))}, + ) + self.assertEqual(ba.num_parameters, 3) + self.assertEqual(ba.ndim, 2) + self.assertEqual(ba.shape, (5, 10)) + self.assertEqual(ba.size, 50) diff --git a/test/python/primitives/test_data_bin.py b/test/python/primitives/test_data_bin.py new file mode 100644 index 000000000000..a1dd0f28a587 --- /dev/null +++ b/test/python/primitives/test_data_bin.py @@ -0,0 +1,64 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + + +"""Unit tests for DataBin.""" + + +import numpy as np +import numpy.typing as npt + +from qiskit.primitives.containers import make_databin +from qiskit.primitives.containers.data_bin import DataBin, DataBinMeta +from qiskit.test import QiskitTestCase + + +class DataBinTestCase(QiskitTestCase): + """Test the DataBin class.""" + + def test_make_databin(self): + """Test the make_databin() function.""" + data_bin_cls = make_databin( + [("alpha", npt.NDArray[np.uint16]), ("beta", np.ndarray)], shape=(10, 20) + ) + + self.assertTrue(issubclass(type(data_bin_cls), DataBinMeta)) + self.assertTrue(issubclass(data_bin_cls, DataBin)) + self.assertEqual(data_bin_cls._FIELDS, ("alpha", "beta")) + self.assertEqual(data_bin_cls._FIELD_TYPES, (npt.NDArray[np.uint16], np.ndarray)) + + alpha = np.empty((10, 20), dtype=np.uint16) + beta = np.empty((10, 20), dtype=int) + my_bin = data_bin_cls(alpha, beta) + self.assertTrue(np.all(my_bin.alpha == alpha)) + self.assertTrue(np.all(my_bin.beta == beta)) + self.assertTrue("alpha=" in str(my_bin)) + self.assertTrue(str(my_bin).startswith("DataBin<10,20>")) + + my_bin = data_bin_cls(beta=beta, alpha=alpha) + self.assertTrue(np.all(my_bin.alpha == alpha)) + self.assertTrue(np.all(my_bin.beta == beta)) + + def test_make_databin_no_shape(self): + """Test the make_databin() function with no shape.""" + data_bin_cls = make_databin([("alpha", dict), ("beta", int)]) + + self.assertTrue(issubclass(type(data_bin_cls), DataBinMeta)) + self.assertTrue(issubclass(data_bin_cls, DataBin)) + self.assertEqual(data_bin_cls._FIELDS, ("alpha", "beta")) + self.assertEqual(data_bin_cls._FIELD_TYPES, (dict, int)) + + my_bin = data_bin_cls({1: 2}, 5) + self.assertEqual(my_bin.alpha, {1: 2}) + self.assertEqual(my_bin.beta, 5) + self.assertTrue("alpha=" in str(my_bin)) + self.assertTrue(">" not in str(my_bin)) diff --git a/test/python/primitives/test_estimatorv2.py b/test/python/primitives/test_estimatorv2.py new file mode 100644 index 000000000000..12cd9b2626af --- /dev/null +++ b/test/python/primitives/test_estimatorv2.py @@ -0,0 +1,310 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. + +"""Tests for Estimator.""" + +import unittest + +import numpy as np + +from qiskit.circuit import Parameter, QuantumCircuit +from qiskit.circuit.library import RealAmplitudes +from qiskit.primitives import BindingsArray, EstimatorTask, ObservablesArray +from qiskit.primitives.statevector_estimator import Estimator, Options +from qiskit.providers import JobV1 +from qiskit.quantum_info import SparsePauliOp +from qiskit.test import QiskitTestCase + + +class TestEstimatorV2(QiskitTestCase): + """Test Estimator""" + + def setUp(self): + super().setUp() + self.ansatz = RealAmplitudes(num_qubits=2, reps=2) + self.observable = SparsePauliOp.from_list( + [ + ("II", -1.052373245772859), + ("IZ", 0.39793742484318045), + ("ZI", -0.39793742484318045), + ("ZZ", -0.01128010425623538), + ("XX", 0.18093119978423156), + ] + ) + self.expvals = -1.0284380963435145, -1.284366511861733 + + self.psi = (RealAmplitudes(num_qubits=2, reps=2), RealAmplitudes(num_qubits=2, reps=3)) + self.params = tuple(psi.parameters for psi in self.psi) + self.hamiltonian = ( + SparsePauliOp.from_list([("II", 1), ("IZ", 2), ("XI", 3)]), + SparsePauliOp.from_list([("IZ", 1)]), + SparsePauliOp.from_list([("ZI", 1), ("ZZ", 1)]), + ) + self.theta = ( + [0, 1, 1, 2, 3, 5], + [0, 1, 1, 2, 3, 5, 8, 13], + [1, 2, 3, 4, 5, 6], + ) + + def test_estimator_run(self): + """Test Estimator.run()""" + psi1, psi2 = self.psi + hamiltonian1, hamiltonian2, hamiltonian3 = self.hamiltonian + theta1, theta2, theta3 = self.theta + estimator = Estimator() + + # Specify the circuit and observable by indices. + # calculate [ ] + job = estimator.run([(psi1, hamiltonian1, [theta1])]) + self.assertIsInstance(job, JobV1) + result = job.result() + np.testing.assert_allclose(result[0].data.evs, [1.5555572817900956]) + + # Objects can be passed instead of indices. + # Note that passing objects has an overhead + # since the corresponding indices need to be searched. + # User can append a circuit and observable. + # calculate [ ] + result2 = estimator.run([(psi2, hamiltonian1, theta2)]).result() + np.testing.assert_allclose(result2[0].data.evs, [2.97797666]) + + # calculate [ , ] + result3 = estimator.run([(psi1, [hamiltonian2, hamiltonian3], theta1)]).result() + np.testing.assert_allclose(result3[0].data.evs, [-0.551653, 0.07535239]) + + # calculate [ [, + # ], + # [] ] + result4 = estimator.run( + [(psi1, [hamiltonian1, hamiltonian3], [theta1, theta3]), (psi2, hamiltonian2, theta2)] + ).result() + np.testing.assert_allclose(result4[0].data.evs, [1.55555728, -1.08766318]) + np.testing.assert_allclose(result4[1].data.evs, [0.17849238]) + + def test_estimator_with_task(self): + """Test estimator with explicit EstimatorTask.""" + psi1, psi2 = self.psi + hamiltonian1, hamiltonian2, hamiltonian3 = self.hamiltonian + theta1, theta2, theta3 = self.theta + + obs1 = ObservablesArray.coerce([hamiltonian1, hamiltonian3]) + bind1 = BindingsArray.coerce([theta1, theta3]) + task1 = EstimatorTask(psi1, obs1, bind1) + obs2 = ObservablesArray.coerce(hamiltonian2) + bind2 = BindingsArray.coerce(theta2) + task2 = EstimatorTask(psi2, obs2, bind2) + + estimator = Estimator() + result4 = estimator.run([task1, task2]).result() + np.testing.assert_allclose(result4[0].data.evs, [1.55555728, -1.08766318]) + np.testing.assert_allclose(result4[1].data.evs, [0.17849238]) + + def test_estimator_run_no_params(self): + """test for estimator without parameters""" + circuit = self.ansatz.assign_parameters([0, 1, 1, 2, 3, 5]) + est = Estimator() + result = est.run((circuit, self.observable)).result() + np.testing.assert_allclose(result[0].data.evs, [-1.284366511861733]) + + def test_run_single_circuit_observable(self): + """Test for single circuit and single observable case.""" + est = Estimator() + + with self.subTest("No parameter"): + qc = QuantumCircuit(1) + qc.x(0) + op = SparsePauliOp("Z") + param_vals = [None, [], [[]], np.array([]), np.array([[]]), [np.array([])]] + target = [-1] + for val in param_vals: + self.subTest(f"{val}") + result = est.run((qc, op, val)).result() + np.testing.assert_allclose(result[0].data.evs, target) + self.assertIsNone(result[0].metadata["shots"]) + + with self.subTest("One parameter"): + param = Parameter("x") + qc = QuantumCircuit(1) + qc.ry(param, 0) + op = SparsePauliOp("Z") + param_vals = [ + [np.pi], + [[np.pi]], + np.array([np.pi]), + np.array([[np.pi]]), + [np.array([np.pi])], + ] + target = [-1] + for val in param_vals: + self.subTest(f"{val}") + result = est.run((qc, op, val)).result() + np.testing.assert_allclose(result[0].data.evs, target) + self.assertIsNone(result[0].metadata["shots"]) + + with self.subTest("More than one parameter"): + qc = self.psi[0] + op = self.hamiltonian[0] + param_vals = [ + self.theta[0], + [self.theta[0]], + np.array(self.theta[0]), + np.array([self.theta[0]]), + [np.array(self.theta[0])], + ] + target = [1.5555572817900956] + for val in param_vals: + self.subTest(f"{val}") + result = est.run((qc, op, val)).result() + np.testing.assert_allclose(result[0].data.evs, target) + self.assertIsNone(result[0].metadata["shots"]) + + def test_run_1qubit(self): + """Test for 1-qubit cases""" + qc = QuantumCircuit(1) + qc2 = QuantumCircuit(1) + qc2.x(0) + + op = SparsePauliOp.from_list([("I", 1)]) + op2 = SparsePauliOp.from_list([("Z", 1)]) + + est = Estimator() + result = est.run((qc, op)).result() + np.testing.assert_allclose(result[0].data.evs, [1]) + + result = est.run((qc, op2)).result() + np.testing.assert_allclose(result[0].data.evs, [1]) + + result = est.run((qc2, op)).result() + np.testing.assert_allclose(result[0].data.evs, [1]) + + result = est.run((qc2, op2)).result() + np.testing.assert_allclose(result[0].data.evs, [-1]) + + def test_run_2qubits(self): + """Test for 2-qubit cases (to check endian)""" + qc = QuantumCircuit(2) + qc2 = QuantumCircuit(2) + qc2.x(0) + + op = SparsePauliOp.from_list([("II", 1)]) + op2 = SparsePauliOp.from_list([("ZI", 1)]) + op3 = SparsePauliOp.from_list([("IZ", 1)]) + + est = Estimator() + result = est.run((qc, op)).result() + np.testing.assert_allclose(result[0].data.evs, [1]) + + result = est.run((qc2, op)).result() + np.testing.assert_allclose(result[0].data.evs, [1]) + + result = est.run((qc, op2)).result() + np.testing.assert_allclose(result[0].data.evs, [1]) + + result = est.run((qc2, op2)).result() + np.testing.assert_allclose(result[0].data.evs, [1]) + + result = est.run((qc, op3)).result() + np.testing.assert_allclose(result[0].data.evs, [1]) + + result = est.run((qc2, op3)).result() + np.testing.assert_allclose(result[0].data.evs, [-1]) + + def test_run_errors(self): + """Test for errors""" + qc = QuantumCircuit(1) + qc2 = QuantumCircuit(2) + + op = SparsePauliOp.from_list([("I", 1)]) + op2 = SparsePauliOp.from_list([("II", 1)]) + + est = Estimator() + # TODO: add validation + with self.assertRaises(ValueError): + est.run((qc, op2)).result() + with self.assertRaises(ValueError): + est.run((qc, op, [[1e4]])).result() + with self.assertRaises(ValueError): + est.run((qc2, op2, [[1, 2]])).result() + with self.assertRaises(ValueError): + est.run((qc, [op, op2], [[1]])).result() + + def test_run_numpy_params(self): + """Test for numpy array as parameter values""" + qc = RealAmplitudes(num_qubits=2, reps=2) + op = SparsePauliOp.from_list([("IZ", 1), ("XI", 2), ("ZY", -1)]) + k = 5 + params_array = np.random.rand(k, qc.num_parameters) + params_list = params_array.tolist() + params_list_array = list(params_array) + estimator = Estimator() + target = estimator.run((qc, op, params_list)).result() + + with self.subTest("ndarrary"): + result = estimator.run((qc, op, params_array)).result() + self.assertEqual(len(result[0].data.evs), k) + np.testing.assert_allclose(result[0].data.evs, target[0].data.evs) + + with self.subTest("list of ndarray"): + result = estimator.run((qc, op, params_list_array)).result() + self.assertEqual(len(result[0].data.evs), k) + np.testing.assert_allclose(result[0].data.evs, target[0].data.evs) + + def test_run_with_shots_option(self): + """test with shots option.""" + est = Estimator(options={"execution": {"shots": 1024, "seed": 15}}) + result = est.run((self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])).result() + np.testing.assert_allclose(result[0].data.evs, [-1.307397243478641]) + self.assertEqual(result[0].metadata["shots"], 1024) + + def test_run_with_shots_option_none(self): + """test with shots=None option. Seed is ignored then.""" + est = Estimator(options={"execution": {"shots": None, "seed": 42}}) + result_42 = est.run((self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])).result() + est.options.execution.seed = 15 + result_15 = est.run((self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])).result() + np.testing.assert_allclose(result_42[0].data.evs, result_15[0].data.evs) + + def test_options(self): + """Test for options""" + with self.subTest("init"): + estimator = Estimator(options={"execution": {"shots": 3000}}) + self.assertEqual(estimator.options.execution.shots, 3000) + with self.subTest("set_options"): + estimator.options.execution.shots = 1024 + estimator.options.execution.seed = 15 + self.assertEqual(estimator.options.execution.shots, 1024) + self.assertEqual(estimator.options.execution.seed, 15) + with self.subTest("run"): + result = estimator.run((self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])).result() + np.testing.assert_allclose(result[0].data.evs, [-1.307397243478641]) + self.assertEqual(result[0].metadata["shots"], 1024) + with self.subTest("Options class"): + options = Options() + options.execution.shots = 1024 # pylint: disable=assigning-non-slot # pylint's bug? + options.execution.seed = 15 # pylint: disable=assigning-non-slot + estimator = Estimator(options=options) + result = estimator.run((self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])).result() + np.testing.assert_allclose(result[0].data.evs, [-1.307397243478641]) + self.assertEqual(result[0].metadata["shots"], 1024) + + def test_negative_variance(self): + """Test for negative variance caused by numerical error.""" + qc = QuantumCircuit(1) + + estimator = Estimator(options={"execution": {"shots": 1024}}) + result = estimator.run((qc, 1e-4 * SparsePauliOp("I"))).result() + self.assertEqual(result[0].data.evs, 1e-4) + self.assertEqual(result[0].data.stds, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/primitives/test_observables_array.py b/test/python/primitives/test_observables_array.py new file mode 100644 index 000000000000..b315266c6c5c --- /dev/null +++ b/test/python/primitives/test_observables_array.py @@ -0,0 +1,306 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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 ObservablesArray""" + +import itertools as it + +import ddt +import numpy as np + +import qiskit.quantum_info as qi +from qiskit.primitives import ObservablesArray +from qiskit.test import QiskitTestCase + + +@ddt.ddt +class ObservablesArrayTestCase(QiskitTestCase): + """Test the ObservablesArray class""" + + @ddt.data(0, 1, 2) + def test_format_observable_str(self, num_qubits): + """Test format_observable for allowed basis str input""" + for chars in it.permutations(ObservablesArray.ALLOWED_BASIS, num_qubits): + label = "".join(chars) + obs = ObservablesArray.format_observable(label) + self.assertEqual(obs, {label: 1}) + + def test_format_observable_custom_basis(self): + """Test format_observable for custom allowed basis""" + + class PauliArray(ObservablesArray): + """Custom array allowing only Paulis, not projectors""" + + ALLOWED_BASIS = "IXYZ" + + with self.assertRaises(ValueError): + PauliArray.format_observable("0101") + for p in qi.pauli_basis(1): + obs = PauliArray.format_observable(p) + self.assertEqual(obs, {p.to_label(): 1}) + + @ddt.data("iXX", "012", "+/-") + def test_format_observable_invalid_str(self, basis): + """Test format_observable for Pauli input""" + with self.assertRaises(ValueError): + ObservablesArray.format_observable(basis) + + @ddt.data(1, 2, 3) + def test_format_observable_pauli(self, num_qubits): + """Test format_observable for Pauli input""" + for p in qi.pauli_basis(num_qubits): + obs = ObservablesArray.format_observable(p) + self.assertEqual(obs, {p.to_label(): 1}) + + @ddt.data(0, 1, 2, 3) + def test_format_observable_phased_pauli(self, phase): + """Test format_observable for phased Pauli input""" + pauli = qi.Pauli("IXYZ") + pauli.phase = phase + coeff = (-1j) ** phase + obs = ObservablesArray.format_observable(pauli) + self.assertIsInstance(obs, dict) + self.assertEqual(list(obs.keys()), ["IXYZ"]) + np.testing.assert_allclose( + list(obs.values()), [coeff], err_msg=f"Wrong value for Pauli {pauli}" + ) + + @ddt.data("+IXYZ", "-IXYZ", "iIXYZ", "+iIXYZ", "-IXYZ") + def test_format_observable_phased_pauli_str(self, pauli): + """Test format_observable for phased Pauli input""" + pauli = qi.Pauli(pauli) + coeff = (-1j) ** pauli.phase + obs = ObservablesArray.format_observable(pauli) + self.assertIsInstance(obs, dict) + self.assertEqual(list(obs.keys()), ["IXYZ"]) + np.testing.assert_allclose( + list(obs.values()), [coeff], err_msg=f"Wrong value for Pauli {pauli}" + ) + + def test_format_observable_phased_sparse_pauli_op(self): + """Test format_observable for SparsePauliOp input with phase paulis""" + op = qi.SparsePauliOp(["+I", "-X", "iY", "-iZ"], [1, 2, 3, 4]) + obs = ObservablesArray.format_observable(op) + self.assertIsInstance(obs, dict) + self.assertEqual(len(obs), 4) + self.assertEqual(sorted(obs.keys()), sorted(["I", "X", "Y", "Z"])) + np.testing.assert_allclose([obs[i] for i in ["I", "X", "Y", "Z"]], [1, -2, 3j, -4j]) + + def test_format_observable_zero_sparse_pauli_op(self): + """Test format_observable for SparsePauliOp input with zero val coeffs""" + op = qi.SparsePauliOp(["I", "X", "Y", "Z"], [0, 0, 0, 1]) + obs = ObservablesArray.format_observable(op) + self.assertIsInstance(obs, dict) + self.assertEqual(len(obs), 1) + self.assertEqual(sorted(obs.keys()), ["Z"]) + self.assertEqual(obs["Z"], 1) + + def test_format_observable_duplicate_sparse_pauli_op(self): + """Test format_observable for SparsePauliOp wiht duplicate paulis""" + op = qi.SparsePauliOp(["XX", "-XX", "iXX", "-iXX"], [2, 1, 3, 2]) + obs = ObservablesArray.format_observable(op) + self.assertIsInstance(obs, dict) + self.assertEqual(len(obs), 1) + self.assertEqual(list(obs.keys()), ["XX"]) + self.assertEqual(obs["XX"], 1 + 1j) + + def test_format_observable_pauli_mapping(self): + """Test format_observable for pauli-keyed Mapping input""" + mapping = dict(zip(qi.pauli_basis(1), range(1, 5))) + obs = ObservablesArray.format_observable(mapping) + target = {key.to_label(): val for key, val in mapping.items()} + self.assertEqual(obs, target) + + def test_format_invalid_mapping_qubits(self): + """Test an error is raised when different qubits in mapping keys""" + mapping = {"IX": 1, "XXX": 2} + with self.assertRaises(ValueError): + ObservablesArray.format_observable(mapping) + + def test_format_invalid_mapping_basis(self): + """Test an error is raised when keys contain invalid characters""" + mapping = {"XX": 1, "0Z": 2, "02": 3} + with self.assertRaises(ValueError): + ObservablesArray.format_observable(mapping) + + def test_init_nested_list_str(self): + """Test init with nested lists of str""" + obj = [["X", "Y", "Z"], ["0", "1", "+"]] + obs = ObservablesArray(obj) + self.assertEqual(obs.size, 6) + self.assertEqual(obs.shape, (2, 3)) + + def test_init_nested_list_sparse_pauli_op(self): + """Test init with nested lists of SparsePauliOp""" + obj = [[qi.SparsePauliOp(qi.random_pauli_list(2, 3)) for _ in range(3)] for _ in range(5)] + obs = ObservablesArray(obj) + self.assertEqual(obs.size, 15) + self.assertEqual(obs.shape, (5, 3)) + + def test_init_single_sparse_pauli_op(self): + """Test init with single SparsePauliOps""" + obj = qi.SparsePauliOp(qi.random_pauli_list(2, 3)) + obs = ObservablesArray(obj) + self.assertEqual(obs.size, 1) + self.assertEqual(obs.shape, ()) + + def test_init_pauli_list(self): + """Test init with PauliList""" + obs = ObservablesArray(qi.pauli_basis(2)) + self.assertEqual(obs.size, 16) + self.assertEqual(obs.shape, (16,)) + + def test_init_nested_pauli_list(self): + """Test init with nested PauliList""" + obj = [qi.random_pauli_list(2, 3) for _ in range(5)] + obs = ObservablesArray(obj) + self.assertEqual(obs.size, 15) + self.assertEqual(obs.shape, (5, 3)) + + def test_init_ragged_array(self): + """Test init with ragged input""" + obj = [["X", "Y"], ["X", "Y", "Z"]] + with self.assertRaises(ValueError): + ObservablesArray(obj) + + def test_init_validate_false(self): + """Test init validate kwarg""" + obj = [["A", "B", "C"], ["D", "E", "F"]] + obs = ObservablesArray(obj, validate=False) + self.assertEqual(obs.shape, (2, 3)) + self.assertEqual(obs.size, 6) + for i in range(2): + for j in range(3): + self.assertEqual(obs[i, j], obj[i][j]) + + def test_init_validate_true(self): + """Test init validate kwarg""" + obj = [["A", "B", "C"], ["D", "E", "F"]] + with self.assertRaises(ValueError): + ObservablesArray(obj, validate=True) + + @ddt.data(0, 1, 2, 3) + def test_size_and_shape_single(self, ndim): + """Test size and shape method for size=1 array""" + obs = {"XX": 1} + for _ in range(ndim): + obs = [obs] + arr = ObservablesArray(obs, validate=False) + self.assertEqual(arr.size, 1, msg="Incorrect ObservablesArray.size") + self.assertEqual(arr.shape, (1,) * ndim, msg="Incorrect ObservablesArray.shape") + + @ddt.data(0, 1, 2, 3) + def test_tolist_single(self, ndim): + """Test tolist method for size=1 array""" + obs = {"XX": 1} + for _ in range(ndim): + obs = [obs] + arr = ObservablesArray(obs, validate=False) + ls = arr.tolist() + self.assertEqual(ls, obs) + + @ddt.data(0, 1, 2, 3) + def test_array_single(self, ndim): + """Test __array__ method for size=1 array""" + obs = {"XX": 1} + for _ in range(ndim): + obs = [obs] + arr = ObservablesArray(obs, validate=False) + nparr = np.array(arr) + self.assertEqual(nparr.dtype, object) + self.assertEqual(nparr.shape, arr.shape) + self.assertEqual(nparr.size, arr.size) + self.assertTrue(np.all(nparr == np.array(obs))) + + @ddt.data(0, 1, 2, 3) + def test_getitem_single(self, ndim): + """Test __getitem__ method for size=1 array""" + base_obs = {"XX": 1} + obs = base_obs + for _ in range(ndim): + obs = [obs] + arr = ObservablesArray(obs, validate=False) + idx = ndim * (0,) + item = arr[idx] + self.assertEqual(item, base_obs) + + def test_tolist_1d(self): + """Test tolist method""" + obj = ["A", "B", "C", "D"] + obs = ObservablesArray(obj, validate=False) + self.assertEqual(obs.tolist(), obj) + + def test_tolist_2d(self): + """Test tolist method""" + obj = [["A", "B", "C"], ["D", "E", "F"]] + obs = ObservablesArray(obj, validate=False) + self.assertEqual(obs.tolist(), obj) + + def test_array_1d(self): + """Test __array__ dunder method""" + obj = np.array(["A", "B", "C", "D"], dtype=object) + obs = ObservablesArray(obj, validate=False) + self.assertTrue(np.all(np.array(obs) == obj)) + + def test_array_2d(self): + """Test __array__ dunder method""" + obj = np.array([["A", "B", "C"], ["D", "E", "F"]], dtype=object) + obs = ObservablesArray(obj, validate=False) + self.assertTrue(np.all(np.array(obs) == obj)) + + def test_getitem_1d(self): + """Test __getitem__ for 1D array""" + obj = np.array(["A", "B", "C", "D"], dtype=object) + obs = ObservablesArray(obj, validate=False) + for i in range(obj.size): + self.assertEqual(obs[i], obj[i]) + + def test_getitem_2d(self): + """Test __getitem__ for 2D array""" + obj = np.array([["A", "B", "C"], ["D", "E", "F"]], dtype=object) + obs = ObservablesArray(obj, validate=False) + for i in range(obj.shape[0]): + row = obs[i] + self.assertIsInstance(row, ObservablesArray) + self.assertEqual(row.shape, (3,)) + self.assertTrue(np.all(np.array(row) == obj[i])) + + def test_ravel(self): + """Test ravel method""" + bases_flat = qi.pauli_basis(2).to_labels() + bases = [bases_flat[4 * i : 4 * (i + 1)] for i in range(4)] + obs = ObservablesArray(bases) + flat = obs.ravel() + self.assertEqual(flat.ndim, 1) + self.assertEqual(flat.shape, (16,)) + self.assertEqual(flat.size, 16) + for ( + i, + label, + ) in enumerate(bases_flat): + self.assertEqual(flat[i], {label: 1}) + + def test_reshape(self): + """Test reshape method""" + bases = qi.pauli_basis(2) + labels = np.array(bases.to_labels(), dtype=object) + obs = ObservablesArray(qi.pauli_basis(2)) + + for shape in [(16,), (4, 4), (2, 4, 2), (2, 2, 2, 2), (1, 8, 1, 2)]: + with self.subTest(shape): + obs_rs = obs.reshape(shape) + self.assertEqual(obs_rs.shape, shape) + labels_rs = labels.reshape(shape) + for idx in np.ndindex(shape): + self.assertEqual( + obs_rs[idx], {labels_rs[idx]: 1}, msg=f"failed for shape {shape}" + ) diff --git a/test/python/primitives/test_shape.py b/test/python/primitives/test_shape.py new file mode 100644 index 000000000000..c3dd307b9719 --- /dev/null +++ b/test/python/primitives/test_shape.py @@ -0,0 +1,105 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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 shape.py module""" + + +import numpy as np + +from qiskit.primitives.containers.shape import Shaped, ShapedMixin, array_coerce, shape_tuple +from qiskit.test import QiskitTestCase + + +class DummyShaped(ShapedMixin): + """Dummy ShapedMixin child for testing.""" + + def __init__(self, arr): + super().__init__() + self._shape = arr.shape + self._arr = arr + + def __getitem__(self, arg): + return self._arr[arg] + + +class ShapedTestCase(QiskitTestCase): + """Test the Shaped protocol class""" + + def test_ndarray_is_shaped(self): + """Test that ndarrays are shaped""" + self.assertTrue(isinstance(np.empty((1, 2, 3)), Shaped)) + + def test_mixin_is_shaped(self): + """Test that ShapedMixin is shaped""" + self.assertTrue(isinstance(DummyShaped(np.empty((1, 2, 3))), Shaped)) + + +class ShapedMixinTestCase(QiskitTestCase): + """Test the ShapedMixin class""" + + def test_shape(self): + """Test the shape attribute.""" + self.assertEqual(DummyShaped(np.empty((1, 2, 3))).shape, (1, 2, 3)) + self.assertEqual(DummyShaped(np.empty(())).shape, ()) + + def test_ndim(self): + """Test the ndim attribute.""" + self.assertEqual(DummyShaped(np.empty(())).ndim, 0) + self.assertEqual(DummyShaped(np.empty((1, 2, 3))).ndim, 3) + + def test_size(self): + """Test the size attribute.""" + self.assertEqual(DummyShaped(np.empty(())).size, 1) + self.assertEqual(DummyShaped(np.empty((0, 1))).size, 0) + self.assertEqual(DummyShaped(np.empty((1, 2, 3))).size, 6) + + def test_getitem(self): + """Missing docstring.""" + arr = np.arange(100).reshape(2, 5, 10) + np.testing.assert_allclose(DummyShaped(arr)[:, 0, :2], arr[:, 0, :2]) + + +class ArrayCoerceTestCase(QiskitTestCase): + """Test array_coerce() function.""" + + def test_shaped(self): + """Test that array_coerce() works with ShapedMixin objects.""" + sh = DummyShaped(np.empty((1, 2, 3))) + self.assertIs(sh, array_coerce(sh)) + + def test_ndarray(self): + """Test that array_coerce() works with ndarray objects.""" + sh = np.arange(100).reshape(5, 2, 2, 5) + np.testing.assert_allclose(sh, array_coerce(sh)) + + +class ShapeTupleTestCase(QiskitTestCase): + """Test shape_tuple() function.""" + + def test_int(self): + """Test shape_tuple() with int inputs.""" + self.assertEqual(shape_tuple(), ()) + self.assertEqual(shape_tuple(5), (5,)) + self.assertEqual(shape_tuple(5, 10), (5, 10)) + self.assertEqual(shape_tuple(1e2), (100,)) + + def test_nested(self): + """Test shape_tuple() with nested inputs.""" + self.assertEqual(shape_tuple(0, (), (1, (2, (3,)), (4, 5))), (0, 1, 2, 3, 4, 5)) + + def test_exceptions(self): + """Test shape_tuple() raises correctly.""" + with self.assertRaisesRegex(ValueError, "iterable or an integer"): + shape_tuple(None) + + with self.assertRaisesRegex(ValueError, "iterable or an integer"): + shape_tuple(1.5) From 4698d74e7f5bd9f5340f6e4ebba13889e5d1045d Mon Sep 17 00:00:00 2001 From: Ikko Hamamura Date: Mon, 27 Nov 2023 23:24:34 +0900 Subject: [PATCH 02/55] Update qiskit/primitives/base/base_estimator.py --- qiskit/primitives/base/base_estimator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 0eb1947fc1df..d4c73e2f585a 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -170,7 +170,6 @@ from __future__ import annotations -import typing import warnings from abc import abstractmethod from collections.abc import Iterable, Sequence From a3d76435becbb2870eb1e4b80a7d8aa5a7bbe798 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Tue, 28 Nov 2023 16:48:06 +0900 Subject: [PATCH 03/55] Use PositiveInt for shots --- qiskit/primitives/statevector_estimator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py index ed46425dc2da..cbaebaf1387e 100644 --- a/qiskit/primitives/statevector_estimator.py +++ b/qiskit/primitives/statevector_estimator.py @@ -20,6 +20,7 @@ import numpy as np from numpy.typing import NDArray from pydantic import Field +from pydantic.types import PositiveInt from qiskit.quantum_info import SparsePauliOp, Statevector @@ -41,7 +42,7 @@ class ExecutionOptions(BasePrimitiveOptions): """Options for execution.""" - shots: Optional[int] = None + shots: Optional[PositiveInt] = None seed: Optional[Union[int, np.random.Generator]] = None From c18b20958f44b28c1d6020aceda5262611ac4e0d Mon Sep 17 00:00:00 2001 From: ikkoham Date: Tue, 28 Nov 2023 23:52:44 +0900 Subject: [PATCH 04/55] improve type hint --- qiskit/primitives/base/base_primitive.py | 2 +- .../primitives/containers/bindings_array.py | 2 +- .../containers/observables_array.py | 10 +++++- .../primitives/containers/primitive_result.py | 6 ++-- qiskit/primitives/primitive_job.py | 5 +-- qiskit/primitives/statevector_estimator.py | 33 +++++++++---------- 6 files changed, 33 insertions(+), 25 deletions(-) diff --git a/qiskit/primitives/base/base_primitive.py b/qiskit/primitives/base/base_primitive.py index 2437e4fea9ef..b67798a1e338 100644 --- a/qiskit/primitives/base/base_primitive.py +++ b/qiskit/primitives/base/base_primitive.py @@ -86,7 +86,7 @@ class BasePrimitiveV2(ABC): _options_class: type[BasePrimitiveOptions] = BasePrimitiveOptions def __init__(self, options: Optional[BasePrimitiveOptionsLike] = None): - self._options: type(self)._options_class + self._options: type(self._options_class) self._set_options(options) @property diff --git a/qiskit/primitives/containers/bindings_array.py b/qiskit/primitives/containers/bindings_array.py index 753fcdb246d1..fa81b6369437 100644 --- a/qiskit/primitives/containers/bindings_array.py +++ b/qiskit/primitives/containers/bindings_array.py @@ -129,7 +129,7 @@ def __init__( for idx, val in enumerate(vals): vals[idx] = _standardize_shape(val, self._shape) - self._vals = vals + self._vals: list[np.ndarray] = vals self._kwvals = kwvals self.validate() diff --git a/qiskit/primitives/containers/observables_array.py b/qiskit/primitives/containers/observables_array.py index 098bb68ec13c..4f62958a53e7 100644 --- a/qiskit/primitives/containers/observables_array.py +++ b/qiskit/primitives/containers/observables_array.py @@ -20,7 +20,7 @@ from collections import defaultdict from collections.abc import Mapping as MappingType from functools import lru_cache -from typing import Iterable, Mapping, Union +from typing import Iterable, Mapping, Union, overload import numpy as np from numpy.typing import ArrayLike @@ -105,6 +105,14 @@ def __array__(self, dtype=None): return self._array raise ValueError("Type must be 'None' or 'object'") + @overload + def __getitem__(self, args: int | tuple[int, ...]) -> BasisObservable: + ... + + @overload + def __getitem__(self, args: slice) -> ObservablesArray: + ... + def __getitem__(self, args) -> Union[ObservablesArray, BasisObservable]: item = self._array[args] if not isinstance(item, np.ndarray): diff --git a/qiskit/primitives/containers/primitive_result.py b/qiskit/primitives/containers/primitive_result.py index 35c97b02033c..bbca14d3005c 100644 --- a/qiskit/primitives/containers/primitive_result.py +++ b/qiskit/primitives/containers/primitive_result.py @@ -15,14 +15,14 @@ from __future__ import annotations from collections.abc import Iterable -from typing import Any, Optional, Sequence, TypeVar +from typing import Any, Generic, Optional, Sequence, TypeVar from .task_result import TaskResult T = TypeVar("T", bound=TaskResult) -class PrimitiveResult(Sequence[T]): +class PrimitiveResult(Generic[T], Sequence[T]): """A container for multiple task results and global metadata.""" def __init__(self, task_results: Iterable[T], metadata: Optional[dict[str, Any]] = None): @@ -32,7 +32,7 @@ def __init__(self, task_results: Iterable[T], metadata: Optional[dict[str, Any]] metadata: Any metadata that doesn't make sense to put inside of task results. """ self._task_results = list(task_results) - self._metadata = metadata if metadata is not None else {} + self._metadata = metadata or {} @property def metadata(self) -> dict[str, Any]: diff --git a/qiskit/primitives/primitive_job.py b/qiskit/primitives/primitive_job.py index 7c87fd34994d..93924b2a4c23 100644 --- a/qiskit/primitives/primitive_job.py +++ b/qiskit/primitives/primitive_job.py @@ -15,13 +15,14 @@ import uuid from concurrent.futures import ThreadPoolExecutor -from typing import Generic, TypeVar +from typing import Generic, TypeVar, Union from qiskit.providers import JobError, JobStatus, JobV1 from .base.base_result import BasePrimitiveResult +from .containers import PrimitiveResult -T = TypeVar("T", bound=BasePrimitiveResult) +T = TypeVar("T", bound=Union[BasePrimitiveResult, PrimitiveResult]) class PrimitiveJob(JobV1, Generic[T]): diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py index cbaebaf1387e..2432465b6c4b 100644 --- a/qiskit/primitives/statevector_estimator.py +++ b/qiskit/primitives/statevector_estimator.py @@ -15,7 +15,7 @@ from __future__ import annotations -from typing import List, Optional, Union +from typing import Optional, Union import numpy as np from numpy.typing import NDArray @@ -57,7 +57,7 @@ class Options(BasePrimitiveOptions): execution: ExecutionOptions = Field(default_factory=ExecutionOptions) -class Estimator(BaseEstimatorV2[PrimitiveJob[List[TaskResult]]]): +class Estimator(BaseEstimatorV2[PrimitiveJob[PrimitiveResult[TaskResult]]]): """ Simple implementation of :class:`BaseEstimatorV2` with Statevector. @@ -74,6 +74,7 @@ class Estimator(BaseEstimatorV2[PrimitiveJob[List[TaskResult]]]): """ _options_class = Options + options: Options def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): """ @@ -82,16 +83,16 @@ def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): """ if options is None: options = Options() - elif not isinstance(options, Options): + elif not isinstance(options, BasePrimitiveOptions): options = Options(**options) super().__init__(options=options) - def _run(self, tasks: list[EstimatorTask]) -> PrimitiveJob[list[TaskResult]]: - job = PrimitiveJob(self._run_task, tasks) + def _run(self, tasks: list[EstimatorTask]) -> PrimitiveJob[PrimitiveResult[TaskResult]]: + job: PrimitiveJob[PrimitiveResult[TaskResult]] = PrimitiveJob(self._run_task, tasks) job.submit() return job - def _run_task(self, tasks: list[EstimatorTask]) -> list[TaskResult]: + def _run_task(self, tasks: list[EstimatorTask]) -> PrimitiveResult[TaskResult]: shots = self.options.execution.shots rng = _get_rng(self.options.execution.seed) @@ -103,23 +104,21 @@ def _run_task(self, tasks: list[EstimatorTask]) -> list[TaskResult]: parameter_values = task.parameter_values bound_circuits = parameter_values.bind_all(circuit) - bound_circuits, observables = np.broadcast_arrays(bound_circuits, observables) - evs = np.zeros_like(bound_circuits, dtype=np.complex128) - stds = np.zeros_like(bound_circuits, dtype=np.complex128) - for index in np.ndindex(*bound_circuits.shape): - bound_circuit = bound_circuits[index] - observable = observables[index] + bc_circuits, bc_obs = np.broadcast_arrays(bound_circuits, observables) + evs = np.zeros_like(bc_circuits, dtype=np.complex128) + stds = np.zeros_like(bc_circuits, dtype=np.complex128) + for index in np.ndindex(*bc_circuits.shape): + bound_circuit = bc_circuits[index] + observable = bc_obs[index] final_state = Statevector(bound_circuit_to_instruction(bound_circuit)) - paulis = list(observable.keys()) - coeffs = list(observable.values()) + paulis, coeffs = zip(*observable.items()) obs = SparsePauliOp(paulis, coeffs) # TODO: support non Pauli operators - expectation_value = final_state.expectation_value(obs) + expectation_value = np.real_if_close(final_state.expectation_value(obs)) if shots is None: standard_error = 0 else: - expectation_value = np.real_if_close(expectation_value) sq_obs = (obs @ obs).simplify(atol=0) sq_exp_val = np.real_if_close(final_state.expectation_value(sq_obs)) variance = sq_exp_val - expectation_value**2 @@ -130,7 +129,7 @@ def _run_task(self, tasks: list[EstimatorTask]) -> list[TaskResult]: stds[index] = standard_error data_bin_cls = make_databin( [("evs", NDArray[np.complex128]), ("stds", NDArray[np.complex128])], - shape=bound_circuits.shape, + shape=bc_circuits.shape, ) data_bin = data_bin_cls(evs=evs, stds=stds) results.append(TaskResult(data_bin, metadata={"shots": shots})) From cc27a642ebc6e8fd6d9fdcb8d959f7e791ef2faa Mon Sep 17 00:00:00 2001 From: Ikko Hamamura Date: Tue, 28 Nov 2023 23:58:12 +0900 Subject: [PATCH 05/55] Update qiskit/primitives/containers/data_bin.py Co-authored-by: Takashi Imamichi <31178928+t-imamichi@users.noreply.github.com> --- qiskit/primitives/containers/data_bin.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/qiskit/primitives/containers/data_bin.py b/qiskit/primitives/containers/data_bin.py index 40a75bf72eeb..82fc46a9dc0c 100644 --- a/qiskit/primitives/containers/data_bin.py +++ b/qiskit/primitives/containers/data_bin.py @@ -70,9 +70,7 @@ def make_databin( Returns: A new class. """ - fields = list(fields) - field_names = tuple(name for name, _ in fields) - field_types = tuple(field_type for _, field_type in fields) + field_names, field_types = zip(*fields) for name in field_names: if name in DataBin._RESTRICTED_NAMES: raise ValueError(f"'{name}' is a restricted name for a DataBin.") From 9d593188fdfbd36d6f9917ba14481eb922f61ea8 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Wed, 29 Nov 2023 11:44:58 +0900 Subject: [PATCH 06/55] remove _run and make run abstractmethod --- qiskit/primitives/base/base_estimator.py | 23 ++------- qiskit/primitives/base/base_primitive.py | 21 ++------ qiskit/primitives/containers/__init__.py | 2 +- qiskit/primitives/containers/options.py | 11 ++++- qiskit/primitives/statevector_estimator.py | 16 +++++-- test/python/primitives/test_estimatorv2.py | 56 +++++++++++----------- 6 files changed, 58 insertions(+), 71 deletions(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index d4c73e2f585a..1182796dcbe5 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -183,7 +183,7 @@ from qiskit.quantum_info.operators.base_operator import BaseOperator from qiskit.utils.deprecation import deprecate_func -from ..containers.estimator_task import EstimatorTask, EstimatorTaskLike +from ..containers.estimator_task import EstimatorTaskLike from ..containers.options import BasePrimitiveOptionsLike from . import validation from .base_primitive import BasePrimitive, BasePrimitiveV2 @@ -360,30 +360,15 @@ class BaseEstimatorV2(BasePrimitiveV2, Generic[T]): def __init__(self, options: Optional[BasePrimitiveOptionsLike]): super().__init__(options=options) - def run(self, tasks: EstimatorTaskLike | Iterable[EstimatorTaskLike]) -> T: + @abstractmethod + def run(self, tasks: Iterable[EstimatorTaskLike]) -> T: """Run the tasks of the estimation of expectation value(s). Args: - tasks: a tasklike object. Typically, list of tuple + tasks: a iterable of tasklike object. Typically, list of tuple ``(QuantumCircuit, observables, parameter_values)`` Returns: The job object of Estimator's Result. """ - if isinstance(tasks, EstimatorTask): - tasks = [tasks] - elif isinstance(tasks, tuple) and isinstance(tasks[0], QuantumCircuit): - tasks = [EstimatorTask.coerce(tasks)] - elif isinstance(tasks, Iterable): - tasks = [EstimatorTask.coerce(task) for task in tasks] - else: - raise TypeError(f"Unsupported type {type(tasks)} is given.") - - for task in tasks: - task.validate() - - return self._run(tasks) - - @abstractmethod - def _run(self, tasks: list[EstimatorTask]) -> T: pass diff --git a/qiskit/primitives/base/base_primitive.py b/qiskit/primitives/base/base_primitive.py index b67798a1e338..b54240feb7a4 100644 --- a/qiskit/primitives/base/base_primitive.py +++ b/qiskit/primitives/base/base_primitive.py @@ -86,26 +86,11 @@ class BasePrimitiveV2(ABC): _options_class: type[BasePrimitiveOptions] = BasePrimitiveOptions def __init__(self, options: Optional[BasePrimitiveOptionsLike] = None): - self._options: type(self._options_class) - self._set_options(options) + self._options = self._options_class() + if options: + self._options.update(options) @property def options(self) -> BasePrimitiveOptions: """Options for BaseEstimator""" return self._options - - @options.setter - def options(self, options: BasePrimitiveOptionsLike): - self._set_options(options) - - def _set_options(self, options): - if options is None: - self._options = self._options_class() - elif isinstance(options, dict): - self._options = self._options_class(**options) - elif isinstance(options, self._options_class): - self._options = options - else: - raise TypeError( - f"Invalid 'options' type. It can only be a dictionary of {self._options_class}" - ) diff --git a/qiskit/primitives/containers/__init__.py b/qiskit/primitives/containers/__init__.py index aaaca1af40fd..fcf86d52c23c 100644 --- a/qiskit/primitives/containers/__init__.py +++ b/qiskit/primitives/containers/__init__.py @@ -16,7 +16,7 @@ from .bindings_array import BindingsArray from .data_bin import make_databin -from .estimator_task import EstimatorTask +from .estimator_task import EstimatorTask, EstimatorTaskLike from .observables_array import ObservablesArray from .options import BasePrimitiveOptions, BasePrimitiveOptionsLike from .primitive_result import PrimitiveResult diff --git a/qiskit/primitives/containers/options.py b/qiskit/primitives/containers/options.py index a96e5a4633fe..9811710444da 100644 --- a/qiskit/primitives/containers/options.py +++ b/qiskit/primitives/containers/options.py @@ -17,7 +17,7 @@ from __future__ import annotations from abc import ABC -from typing import Union +from typing import Optional, Union from .dataclasses import mutable_dataclass @@ -26,8 +26,15 @@ class BasePrimitiveOptions(ABC): """Base calss of options for primitives.""" - def update(self, **kwargs): + def update(self, options: Optional[BasePrimitiveOptions] = None, **kwargs): """Update the options.""" + if options is not None: + if not isinstance(options, BasePrimitiveOptions): + raise TypeError(f"Type {type(options)} is not options class") + for key, val in options.__dict__.items(): + print(key, val) + setattr(self, key, val) + for key, val in kwargs.items(): setattr(self, key, val) diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py index 2432465b6c4b..bef7149c4f3a 100644 --- a/qiskit/primitives/statevector_estimator.py +++ b/qiskit/primitives/statevector_estimator.py @@ -15,6 +15,7 @@ from __future__ import annotations +from collections.abc import Iterable from typing import Optional, Union import numpy as np @@ -29,6 +30,7 @@ BasePrimitiveOptions, BasePrimitiveOptionsLike, EstimatorTask, + EstimatorTaskLike, PrimitiveResult, TaskResult, make_databin, @@ -61,7 +63,7 @@ class Estimator(BaseEstimatorV2[PrimitiveJob[PrimitiveResult[TaskResult]]]): """ Simple implementation of :class:`BaseEstimatorV2` with Statevector. - :Run Options: + :Execution Options: - **shots** (None or int) -- The number of shots. If None, it calculates the exact expectation @@ -87,8 +89,16 @@ def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): options = Options(**options) super().__init__(options=options) - def _run(self, tasks: list[EstimatorTask]) -> PrimitiveJob[PrimitiveResult[TaskResult]]: - job: PrimitiveJob[PrimitiveResult[TaskResult]] = PrimitiveJob(self._run_task, tasks) + def run(self, tasks: Iterable[EstimatorTaskLike]) -> PrimitiveJob[PrimitiveResult[TaskResult]]: + coerced_tasks = [ + task if isinstance(task, EstimatorTask) else EstimatorTask.coerce(task) + for task in tasks + ] + + for task in coerced_tasks: + task.validate() + + job: PrimitiveJob[PrimitiveResult[TaskResult]] = PrimitiveJob(self._run_task, coerced_tasks) job.submit() return job diff --git a/test/python/primitives/test_estimatorv2.py b/test/python/primitives/test_estimatorv2.py index 12cd9b2626af..242c811562e0 100644 --- a/test/python/primitives/test_estimatorv2.py +++ b/test/python/primitives/test_estimatorv2.py @@ -112,7 +112,7 @@ def test_estimator_run_no_params(self): """test for estimator without parameters""" circuit = self.ansatz.assign_parameters([0, 1, 1, 2, 3, 5]) est = Estimator() - result = est.run((circuit, self.observable)).result() + result = est.run([(circuit, self.observable)]).result() np.testing.assert_allclose(result[0].data.evs, [-1.284366511861733]) def test_run_single_circuit_observable(self): @@ -127,7 +127,7 @@ def test_run_single_circuit_observable(self): target = [-1] for val in param_vals: self.subTest(f"{val}") - result = est.run((qc, op, val)).result() + result = est.run([(qc, op, val)]).result() np.testing.assert_allclose(result[0].data.evs, target) self.assertIsNone(result[0].metadata["shots"]) @@ -146,7 +146,7 @@ def test_run_single_circuit_observable(self): target = [-1] for val in param_vals: self.subTest(f"{val}") - result = est.run((qc, op, val)).result() + result = est.run([(qc, op, val)]).result() np.testing.assert_allclose(result[0].data.evs, target) self.assertIsNone(result[0].metadata["shots"]) @@ -163,7 +163,7 @@ def test_run_single_circuit_observable(self): target = [1.5555572817900956] for val in param_vals: self.subTest(f"{val}") - result = est.run((qc, op, val)).result() + result = est.run([(qc, op, val)]).result() np.testing.assert_allclose(result[0].data.evs, target) self.assertIsNone(result[0].metadata["shots"]) @@ -177,16 +177,16 @@ def test_run_1qubit(self): op2 = SparsePauliOp.from_list([("Z", 1)]) est = Estimator() - result = est.run((qc, op)).result() + result = est.run([(qc, op)]).result() np.testing.assert_allclose(result[0].data.evs, [1]) - result = est.run((qc, op2)).result() + result = est.run([(qc, op2)]).result() np.testing.assert_allclose(result[0].data.evs, [1]) - result = est.run((qc2, op)).result() + result = est.run([(qc2, op)]).result() np.testing.assert_allclose(result[0].data.evs, [1]) - result = est.run((qc2, op2)).result() + result = est.run([(qc2, op2)]).result() np.testing.assert_allclose(result[0].data.evs, [-1]) def test_run_2qubits(self): @@ -200,22 +200,22 @@ def test_run_2qubits(self): op3 = SparsePauliOp.from_list([("IZ", 1)]) est = Estimator() - result = est.run((qc, op)).result() + result = est.run([(qc, op)]).result() np.testing.assert_allclose(result[0].data.evs, [1]) - result = est.run((qc2, op)).result() + result = est.run([(qc2, op)]).result() np.testing.assert_allclose(result[0].data.evs, [1]) - result = est.run((qc, op2)).result() + result = est.run([(qc, op2)]).result() np.testing.assert_allclose(result[0].data.evs, [1]) - result = est.run((qc2, op2)).result() + result = est.run([(qc2, op2)]).result() np.testing.assert_allclose(result[0].data.evs, [1]) - result = est.run((qc, op3)).result() + result = est.run([(qc, op3)]).result() np.testing.assert_allclose(result[0].data.evs, [1]) - result = est.run((qc2, op3)).result() + result = est.run([(qc2, op3)]).result() np.testing.assert_allclose(result[0].data.evs, [-1]) def test_run_errors(self): @@ -229,13 +229,13 @@ def test_run_errors(self): est = Estimator() # TODO: add validation with self.assertRaises(ValueError): - est.run((qc, op2)).result() + est.run([(qc, op2)]).result() with self.assertRaises(ValueError): - est.run((qc, op, [[1e4]])).result() + est.run([(qc, op, [[1e4]])]).result() with self.assertRaises(ValueError): - est.run((qc2, op2, [[1, 2]])).result() + est.run([(qc2, op2, [[1, 2]])]).result() with self.assertRaises(ValueError): - est.run((qc, [op, op2], [[1]])).result() + est.run([(qc, [op, op2], [[1]])]).result() def test_run_numpy_params(self): """Test for numpy array as parameter values""" @@ -246,31 +246,31 @@ def test_run_numpy_params(self): params_list = params_array.tolist() params_list_array = list(params_array) estimator = Estimator() - target = estimator.run((qc, op, params_list)).result() + target = estimator.run([(qc, op, params_list)]).result() with self.subTest("ndarrary"): - result = estimator.run((qc, op, params_array)).result() + result = estimator.run([(qc, op, params_array)]).result() self.assertEqual(len(result[0].data.evs), k) np.testing.assert_allclose(result[0].data.evs, target[0].data.evs) with self.subTest("list of ndarray"): - result = estimator.run((qc, op, params_list_array)).result() + result = estimator.run([(qc, op, params_list_array)]).result() self.assertEqual(len(result[0].data.evs), k) np.testing.assert_allclose(result[0].data.evs, target[0].data.evs) def test_run_with_shots_option(self): """test with shots option.""" est = Estimator(options={"execution": {"shots": 1024, "seed": 15}}) - result = est.run((self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])).result() + result = est.run([(self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])]).result() np.testing.assert_allclose(result[0].data.evs, [-1.307397243478641]) self.assertEqual(result[0].metadata["shots"], 1024) def test_run_with_shots_option_none(self): """test with shots=None option. Seed is ignored then.""" est = Estimator(options={"execution": {"shots": None, "seed": 42}}) - result_42 = est.run((self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])).result() - est.options.execution.seed = 15 - result_15 = est.run((self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])).result() + result_42 = est.run([(self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])]).result() + est.options.execution.seed = 15 # pylint: disable=assigning-non-slot + result_15 = est.run([(self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])]).result() np.testing.assert_allclose(result_42[0].data.evs, result_15[0].data.evs) def test_options(self): @@ -284,7 +284,7 @@ def test_options(self): self.assertEqual(estimator.options.execution.shots, 1024) self.assertEqual(estimator.options.execution.seed, 15) with self.subTest("run"): - result = estimator.run((self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])).result() + result = estimator.run([(self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])]).result() np.testing.assert_allclose(result[0].data.evs, [-1.307397243478641]) self.assertEqual(result[0].metadata["shots"], 1024) with self.subTest("Options class"): @@ -292,7 +292,7 @@ def test_options(self): options.execution.shots = 1024 # pylint: disable=assigning-non-slot # pylint's bug? options.execution.seed = 15 # pylint: disable=assigning-non-slot estimator = Estimator(options=options) - result = estimator.run((self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])).result() + result = estimator.run([(self.ansatz, self.observable, [[0, 1, 1, 2, 3, 5]])]).result() np.testing.assert_allclose(result[0].data.evs, [-1.307397243478641]) self.assertEqual(result[0].metadata["shots"], 1024) @@ -301,7 +301,7 @@ def test_negative_variance(self): qc = QuantumCircuit(1) estimator = Estimator(options={"execution": {"shots": 1024}}) - result = estimator.run((qc, 1e-4 * SparsePauliOp("I"))).result() + result = estimator.run([(qc, 1e-4 * SparsePauliOp("I"))]).result() self.assertEqual(result[0].data.evs, 1e-4) self.assertEqual(result[0].data.stds, 0.0) From 556aec2eecc633d97415c9e53362368c105456e2 Mon Sep 17 00:00:00 2001 From: Ikko Hamamura Date: Wed, 29 Nov 2023 11:47:28 +0900 Subject: [PATCH 07/55] Apply suggestions from code review Co-authored-by: Ian Hincks Co-authored-by: Christopher J. Wood --- qiskit/primitives/base/base_estimator.py | 4 ++-- qiskit/primitives/base/base_primitive.py | 2 +- qiskit/primitives/containers/bindings_array.py | 4 ++-- qiskit/primitives/containers/options.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 1182796dcbe5..5cef6c4f2b03 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -18,7 +18,7 @@ Overview of EstimatorV2 ======================== -EstimatorV2 class estimates expectation values of quantum circuits and observables. +:class:`~EstimatorV2`estimates expectation values of quantum circuits for provided observables. An estimator is initialized with an empty parameter set. The estimator is used to create a :class:`~qiskit.providers.JobV1`, via the @@ -354,7 +354,7 @@ def parameters(self) -> tuple[ParameterView, ...]: class BaseEstimatorV2(BasePrimitiveV2, Generic[T]): """Estimator base class version 2. - Estimator estimates expectation values of quantum circuits and observables. + An Estimator estimates expectation values of quantum circuits and observables. """ def __init__(self, options: Optional[BasePrimitiveOptionsLike]): diff --git a/qiskit/primitives/base/base_primitive.py b/qiskit/primitives/base/base_primitive.py index b54240feb7a4..531b3cedb8d5 100644 --- a/qiskit/primitives/base/base_primitive.py +++ b/qiskit/primitives/base/base_primitive.py @@ -92,5 +92,5 @@ def __init__(self, options: Optional[BasePrimitiveOptionsLike] = None): @property def options(self) -> BasePrimitiveOptions: - """Options for BaseEstimator""" + """Options for the primitive""" return self._options diff --git a/qiskit/primitives/containers/bindings_array.py b/qiskit/primitives/containers/bindings_array.py index fa81b6369437..aa672d32f631 100644 --- a/qiskit/primitives/containers/bindings_array.py +++ b/qiskit/primitives/containers/bindings_array.py @@ -253,13 +253,13 @@ def reshape(self, shape: Union[int, Iterable[int]]) -> BindingsArray: @classmethod def coerce(cls, bindings_array: BindingsArrayLike) -> BindingsArray: - """Coerce BindingsArrayLike into BindingsArray + """Coerce an input that is :class:`~BindingsArrayLike` into a new :class:`~BindingsArray`. Args: bindings_array: an object to be bindings array. Returns: - A coerced bindings array. + A new bindings array. """ if isinstance(bindings_array, Sequence): bindings_array = np.array(bindings_array) diff --git a/qiskit/primitives/containers/options.py b/qiskit/primitives/containers/options.py index 9811710444da..8ff77f1c8c77 100644 --- a/qiskit/primitives/containers/options.py +++ b/qiskit/primitives/containers/options.py @@ -24,7 +24,7 @@ @mutable_dataclass class BasePrimitiveOptions(ABC): - """Base calss of options for primitives.""" + """Base class of options for primitives.""" def update(self, options: Optional[BasePrimitiveOptions] = None, **kwargs): """Update the options.""" From 232522475aa50cd339beef3ef505da25a8c8cba2 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Wed, 29 Nov 2023 16:11:52 +0900 Subject: [PATCH 08/55] fix from review comments --- qiskit/primitives/base/base_estimator.py | 4 +- qiskit/primitives/containers/__init__.py | 2 +- .../primitives/containers/bindings_array.py | 28 ++---- qiskit/primitives/containers/data_bin.py | 6 +- .../primitives/containers/primitive_result.py | 7 +- qiskit/primitives/statevector_estimator.py | 6 +- test/python/primitives/primitive_result.py | 4 +- test/python/primitives/test_bindings_array.py | 88 ++++++++----------- test/python/primitives/test_data_bin.py | 6 +- test/python/primitives/test_estimatorv2.py | 4 +- 10 files changed, 64 insertions(+), 91 deletions(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 5cef6c4f2b03..b5e6f37a8b48 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -351,7 +351,7 @@ def parameters(self) -> tuple[ParameterView, ...]: BaseEstimator = BaseEstimatorV1 -class BaseEstimatorV2(BasePrimitiveV2, Generic[T]): +class BaseEstimatorV2(BasePrimitiveV2): """Estimator base class version 2. An Estimator estimates expectation values of quantum circuits and observables. @@ -361,7 +361,7 @@ def __init__(self, options: Optional[BasePrimitiveOptionsLike]): super().__init__(options=options) @abstractmethod - def run(self, tasks: Iterable[EstimatorTaskLike]) -> T: + def run(self, tasks: Iterable[EstimatorTaskLike]) -> Job: """Run the tasks of the estimation of expectation value(s). Args: diff --git a/qiskit/primitives/containers/__init__.py b/qiskit/primitives/containers/__init__.py index fcf86d52c23c..181d446f4392 100644 --- a/qiskit/primitives/containers/__init__.py +++ b/qiskit/primitives/containers/__init__.py @@ -15,7 +15,7 @@ """ from .bindings_array import BindingsArray -from .data_bin import make_databin +from .data_bin import make_data_bin from .estimator_task import EstimatorTask, EstimatorTaskLike from .observables_array import ObservablesArray from .options import BasePrimitiveOptions, BasePrimitiveOptionsLike diff --git a/qiskit/primitives/containers/bindings_array.py b/qiskit/primitives/containers/bindings_array.py index aa672d32f631..a8a9b15207c1 100644 --- a/qiskit/primitives/containers/bindings_array.py +++ b/qiskit/primitives/containers/bindings_array.py @@ -16,7 +16,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping, Sequence -from itertools import chain, product +from itertools import chain from typing import Dict, List, Optional, Tuple, Union import numpy as np @@ -163,12 +163,12 @@ def vals(self) -> List[np.ndarray]: """The non-keyword values of this array.""" return self._vals - def bind_at_idx(self, circuit: QuantumCircuit, idx: Tuple[int, ...]) -> QuantumCircuit: + def bind(self, circuit: QuantumCircuit, loc: Tuple[int, ...]) -> QuantumCircuit: """Return the circuit bound to the values at the provided index. Args: circuit: The circuit to bind. - idx: A tuple of indices, on for each dimension of this array. + loc: A tuple of indices, on for each dimension of this array. Returns: The bound circuit. @@ -176,10 +176,10 @@ def bind_at_idx(self, circuit: QuantumCircuit, idx: Tuple[int, ...]) -> QuantumC Raises: ValueError: If the index doesn't have the right number of values. """ - if len(idx) != self.ndim: - raise ValueError(f"Expected {idx} to index all dimensions of {self.shape}") + if len(loc) != self.ndim: + raise ValueError(f"Expected {loc} to index all dimensions of {self.shape}") - flat_vals = (val for vals in self.vals for val in vals[idx]) + flat_vals = (val for vals in self.vals for val in vals[loc]) if not self._kwvals: # special case to avoid constructing a dictionary input @@ -189,22 +189,10 @@ def bind_at_idx(self, circuit: QuantumCircuit, idx: Tuple[int, ...]) -> QuantumC parameters.update( (param, val) for params, vals in self._kwvals.items() - for param, val in zip(params, vals[idx]) + for param, val in zip(params, vals[loc]) ) return circuit.assign_parameters(parameters) - def bind_flat(self, circuit: QuantumCircuit) -> Iterable[QuantumCircuit]: - """Yield a bound circuit for every array index in flattened order. - - Args: - circuit: The circuit to bind. - - Yields: - Bound circuits, in flattened array order. - """ - for idx in product(*map(range, self.shape)): - yield self.bind_at_idx(circuit, idx) - def bind_all(self, circuit: QuantumCircuit) -> np.ndarray: """Return an object array of bound circuits with the same shape. @@ -216,7 +204,7 @@ def bind_all(self, circuit: QuantumCircuit) -> np.ndarray: """ arr = np.empty(self.shape, dtype=object) for idx in np.ndindex(self.shape): - arr[idx] = self.bind_at_idx(circuit, idx) + arr[idx] = self.bind(circuit, idx) return arr def ravel(self) -> BindingsArray: diff --git a/qiskit/primitives/containers/data_bin.py b/qiskit/primitives/containers/data_bin.py index 82fc46a9dc0c..6f8b4668d59d 100644 --- a/qiskit/primitives/containers/data_bin.py +++ b/qiskit/primitives/containers/data_bin.py @@ -35,7 +35,7 @@ def __repr__(cls): class DataBin(metaclass=DataBinMeta): """Base class for data bin containers. - Subclasses are typically made via :class:`~make_databin`, which is a specialization of + Subclasses are typically made via :class:`~make_data_bin`, which is a specialization of :class:`make_dataclass`. """ @@ -51,14 +51,14 @@ def __repr__(self): return f"{type(self)}({', '.join(vals)})" -def make_databin( +def make_data_bin( fields: Iterable[Tuple[str, type]], shape: Optional[Tuple[int, ...]] = None ) -> DataBinMeta: """Return a new subclass of :class:`~DataBin` with the provided fields and shape. .. code-block:: python - my_bin = make_databin([("alpha", np.NDArray[np.float])], shape=(20, 30)) + my_bin = make_data_bin([("alpha", np.NDArray[np.float])], shape=(20, 30)) # behaves like a dataclass my_bin(alpha=np.empty((20, 30))) diff --git a/qiskit/primitives/containers/primitive_result.py b/qiskit/primitives/containers/primitive_result.py index bbca14d3005c..bbb2fb7e3d6f 100644 --- a/qiskit/primitives/containers/primitive_result.py +++ b/qiskit/primitives/containers/primitive_result.py @@ -15,14 +15,14 @@ from __future__ import annotations from collections.abc import Iterable -from typing import Any, Generic, Optional, Sequence, TypeVar +from typing import Any, Generic, Optional, TypeVar from .task_result import TaskResult T = TypeVar("T", bound=TaskResult) -class PrimitiveResult(Generic[T], Sequence[T]): +class PrimitiveResult(Generic[T]): """A container for multiple task results and global metadata.""" def __init__(self, task_results: Iterable[T], metadata: Optional[dict[str, Any]] = None): @@ -47,3 +47,6 @@ def __len__(self) -> int: def __repr__(self) -> str: return f"PrimitiveResult({self._task_results}, metadata={self.metadata})" + + def __iter__(self) -> Iterable[T]: + return iter(self._task_results) diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py index bef7149c4f3a..3db32b1eddb1 100644 --- a/qiskit/primitives/statevector_estimator.py +++ b/qiskit/primitives/statevector_estimator.py @@ -33,7 +33,7 @@ EstimatorTaskLike, PrimitiveResult, TaskResult, - make_databin, + make_data_bin, ) from .containers.dataclasses import mutable_dataclass from .primitive_job import PrimitiveJob @@ -59,7 +59,7 @@ class Options(BasePrimitiveOptions): execution: ExecutionOptions = Field(default_factory=ExecutionOptions) -class Estimator(BaseEstimatorV2[PrimitiveJob[PrimitiveResult[TaskResult]]]): +class Estimator(BaseEstimatorV2): """ Simple implementation of :class:`BaseEstimatorV2` with Statevector. @@ -137,7 +137,7 @@ def _run_task(self, tasks: list[EstimatorTask]) -> PrimitiveResult[TaskResult]: expectation_value = rng.normal(expectation_value, standard_error) evs[index] = expectation_value stds[index] = standard_error - data_bin_cls = make_databin( + data_bin_cls = make_data_bin( [("evs", NDArray[np.complex128]), ("stds", NDArray[np.complex128])], shape=bc_circuits.shape, ) diff --git a/test/python/primitives/primitive_result.py b/test/python/primitives/primitive_result.py index 2fc0b8b9e089..5e9c1a1fce9d 100644 --- a/test/python/primitives/primitive_result.py +++ b/test/python/primitives/primitive_result.py @@ -16,7 +16,7 @@ import numpy as np import numpy.typing as npt -from qiskit.primitives.containers import PrimitiveResult, TaskResult, make_databin +from qiskit.primitives.containers import PrimitiveResult, TaskResult, make_data_bin from qiskit.test import QiskitTestCase @@ -25,7 +25,7 @@ class PrimitiveResultCase(QiskitTestCase): def test_primitive_result(self): """Test the PrimitiveResult class.""" - data_bin_cls = make_databin( + data_bin_cls = make_data_bin( [("alpha", npt.NDArray[np.uint16]), ("beta", np.ndarray)], shape=(10, 20) ) diff --git a/test/python/primitives/test_bindings_array.py b/test/python/primitives/test_bindings_array.py index a37589b15792..de9723136b1d 100644 --- a/test/python/primitives/test_bindings_array.py +++ b/test/python/primitives/test_bindings_array.py @@ -12,7 +12,6 @@ """Test BindingsArray""" -from types import GeneratorType import numpy as np @@ -66,13 +65,13 @@ def test_bind_at_idx(self): expected_circuit = self.circuit.assign_parameters(vals[2, 3]) ba = BindingsArray(vals) - self.assertEqual(ba.bind_at_idx(self.circuit, (2, 3)), expected_circuit) + self.assertEqual(ba.bind(self.circuit, (2, 3)), expected_circuit) ba = BindingsArray([vals[:, :, :20], vals[:, :, 20:27], vals[:, :, 27:]]) - self.assertEqual(ba.bind_at_idx(self.circuit, (2, 3)), expected_circuit) + self.assertEqual(ba.bind(self.circuit, (2, 3)), expected_circuit) ba = BindingsArray(vals[:, :, :20], {tuple(self.params[20:]): vals[:, :, 20:]}) - self.assertEqual(ba.bind_at_idx(self.circuit, (2, 3)), expected_circuit) + self.assertEqual(ba.bind(self.circuit, (2, 3)), expected_circuit) order = np.arange(30, 50, dtype=int) np.random.default_rng().shuffle(order) @@ -83,23 +82,7 @@ def test_bind_at_idx(self): tuple(self.params[i] for i in order): vals[:, :, order], }, ) - self.assertEqual(ba.bind_at_idx(self.circuit, (2, 3)), expected_circuit) - - def test_bind_flat(self): - """Test flat binding all possible values""" - # this test assumes bind_flat() is implemented via bind_at_idx(), which we have already - # tested. so here, we just test that it gets the order right - vals = np.linspace(0, 1, 300).reshape((2, 3, 50)) - bound_iter = BindingsArray(vals).bind_flat(self.circuit) - self.assertIsInstance(bound_iter, GeneratorType) - bound_circuits = list(bound_iter) - self.assertEqual(len(bound_circuits), 6) - self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(vals[0, 0])) - self.assertEqual(bound_circuits[1], self.circuit.assign_parameters(vals[0, 1])) - self.assertEqual(bound_circuits[2], self.circuit.assign_parameters(vals[0, 2])) - self.assertEqual(bound_circuits[3], self.circuit.assign_parameters(vals[1, 0])) - self.assertEqual(bound_circuits[4], self.circuit.assign_parameters(vals[1, 1])) - self.assertEqual(bound_circuits[5], self.circuit.assign_parameters(vals[1, 2])) + self.assertEqual(ba.bind(self.circuit, (2, 3)), expected_circuit) def test_bind_all(self): """Test binding all possible values""" @@ -160,7 +143,7 @@ def test_ravel(self): flat_vals = vals.reshape(-1, 50) np.testing.assert_allclose(flat.vals, flat_vals.reshape((1, 6, 50))) - bound_circuits = list(flat.bind_flat(self.circuit)) + bound_circuits = list(flat.bind_all(self.circuit).reshape(6)) self.assertEqual(len(bound_circuits), 6) for i in range(6): self.assertEqual(bound_circuits[i], self.circuit.assign_parameters(flat_vals[i])) @@ -180,14 +163,15 @@ def test_reshape(self): reshape_vals = vals.reshape((3, 2, 50)) np.testing.assert_allclose(reshape_ba.vals, reshape_vals.reshape((1, 3, 2, 50))) - bound_circuits = list(reshape_ba.bind_flat(self.circuit)) - self.assertEqual(len(bound_circuits), 6) - self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(reshape_vals[0, 0])) - self.assertEqual(bound_circuits[1], self.circuit.assign_parameters(reshape_vals[0, 1])) - self.assertEqual(bound_circuits[2], self.circuit.assign_parameters(reshape_vals[1, 0])) - self.assertEqual(bound_circuits[3], self.circuit.assign_parameters(reshape_vals[1, 1])) - self.assertEqual(bound_circuits[4], self.circuit.assign_parameters(reshape_vals[2, 0])) - self.assertEqual(bound_circuits[5], self.circuit.assign_parameters(reshape_vals[2, 1])) + circuit = self.circuit + bound_circuits = reshape_ba.bind_all(circuit) + self.assertEqual(bound_circuits.shape, (3, 2)) + self.assertEqual(bound_circuits[0, 0], circuit.assign_parameters(reshape_vals[0, 0])) + self.assertEqual(bound_circuits[0, 1], circuit.assign_parameters(reshape_vals[0, 1])) + self.assertEqual(bound_circuits[1, 0], circuit.assign_parameters(reshape_vals[1, 0])) + self.assertEqual(bound_circuits[1, 1], circuit.assign_parameters(reshape_vals[1, 1])) + self.assertEqual(bound_circuits[2, 0], circuit.assign_parameters(reshape_vals[2, 0])) + self.assertEqual(bound_circuits[2, 1], circuit.assign_parameters(reshape_vals[2, 1])) with self.subTest("flatten"): ba = BindingsArray(vals) @@ -200,7 +184,7 @@ def test_reshape(self): reshape_vals = vals.reshape(-1, 50) np.testing.assert_allclose(reshape_ba.vals, reshape_vals.reshape((1, 6, 50))) - bound_circuits = list(reshape_ba.bind_flat(self.circuit)) + bound_circuits = list(reshape_ba.bind_all(self.circuit)) self.assertEqual(len(bound_circuits), 6) for i in range(6): self.assertEqual(bound_circuits[i], self.circuit.assign_parameters(reshape_vals[i])) @@ -218,9 +202,8 @@ def test_kwvals(self): self.assertEqual(ba.vals, []) self.assertEqual(ba.kwvals, {tuple(param.name for param in self.params): vals}) - bound_circuits = list(ba.bind_flat(self.circuit)) - self.assertEqual(len(bound_circuits), 1) - self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(vals)) + bound_circuit = ba.bind(self.circuit, ()) + self.assertEqual(bound_circuit, self.circuit.assign_parameters(vals)) with self.subTest("binding an array"): vals = np.linspace(0, 1, 300).reshape((2, 3, 50)) @@ -233,14 +216,14 @@ def test_kwvals(self): self.assertEqual(ba.vals, []) self.assertEqual(ba.kwvals, {tuple(param.name for param in self.params): vals}) - bound_circuits = list(ba.bind_flat(self.circuit)) - self.assertEqual(len(bound_circuits), 6) - self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(vals[0, 0])) - self.assertEqual(bound_circuits[1], self.circuit.assign_parameters(vals[0, 1])) - self.assertEqual(bound_circuits[2], self.circuit.assign_parameters(vals[0, 2])) - self.assertEqual(bound_circuits[3], self.circuit.assign_parameters(vals[1, 0])) - self.assertEqual(bound_circuits[4], self.circuit.assign_parameters(vals[1, 1])) - self.assertEqual(bound_circuits[5], self.circuit.assign_parameters(vals[1, 2])) + bound_circuits = ba.bind_all(self.circuit) + self.assertEqual(bound_circuits.shape, (2, 3)) + self.assertEqual(bound_circuits[0, 0], self.circuit.assign_parameters(vals[0, 0])) + self.assertEqual(bound_circuits[0, 1], self.circuit.assign_parameters(vals[0, 1])) + self.assertEqual(bound_circuits[0, 2], self.circuit.assign_parameters(vals[0, 2])) + self.assertEqual(bound_circuits[1, 0], self.circuit.assign_parameters(vals[1, 0])) + self.assertEqual(bound_circuits[1, 1], self.circuit.assign_parameters(vals[1, 1])) + self.assertEqual(bound_circuits[1, 2], self.circuit.assign_parameters(vals[1, 2])) with self.subTest("binding a single param"): vals = np.linspace(0, 1, 50) @@ -267,9 +250,8 @@ def test_vals_kwvals(self): np.testing.assert_allclose(ba.vals, vals[np.newaxis, :20]) self.assertEqual(ba.kwvals, {tuple(p.name for p in k): v for k, v in kwvals.items()}) - bound_circuits = list(ba.bind_flat(self.circuit)) - self.assertEqual(len(bound_circuits), 1) - self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(vals)) + bound_circuit = ba.bind(self.circuit, ()) + self.assertEqual(bound_circuit, self.circuit.assign_parameters(vals)) with self.subTest("binding an array"): vals = np.linspace(0, 1, 300).reshape((2, 3, 50)) @@ -282,14 +264,14 @@ def test_vals_kwvals(self): np.testing.assert_allclose(ba.vals, vals[np.newaxis, :, :, :20]) self.assertEqual(ba.kwvals, {tuple(p.name for p in k): v for k, v in kwvals.items()}) - bound_circuits = list(ba.bind_flat(self.circuit)) - self.assertEqual(len(bound_circuits), 6) - self.assertEqual(bound_circuits[0], self.circuit.assign_parameters(vals[0, 0])) - self.assertEqual(bound_circuits[1], self.circuit.assign_parameters(vals[0, 1])) - self.assertEqual(bound_circuits[2], self.circuit.assign_parameters(vals[0, 2])) - self.assertEqual(bound_circuits[3], self.circuit.assign_parameters(vals[1, 0])) - self.assertEqual(bound_circuits[4], self.circuit.assign_parameters(vals[1, 1])) - self.assertEqual(bound_circuits[5], self.circuit.assign_parameters(vals[1, 2])) + bound_circuits = ba.bind_all(self.circuit) + self.assertEqual(bound_circuits.shape, (2, 3)) + self.assertEqual(bound_circuits[0, 0], self.circuit.assign_parameters(vals[0, 0])) + self.assertEqual(bound_circuits[0, 1], self.circuit.assign_parameters(vals[0, 1])) + self.assertEqual(bound_circuits[0, 2], self.circuit.assign_parameters(vals[0, 2])) + self.assertEqual(bound_circuits[1, 0], self.circuit.assign_parameters(vals[1, 0])) + self.assertEqual(bound_circuits[1, 1], self.circuit.assign_parameters(vals[1, 1])) + self.assertEqual(bound_circuits[1, 2], self.circuit.assign_parameters(vals[1, 2])) with self.subTest("len(val) == 1 and len(kwvals) > 0"): ba = BindingsArray( diff --git a/test/python/primitives/test_data_bin.py b/test/python/primitives/test_data_bin.py index a1dd0f28a587..1c17dc4203c6 100644 --- a/test/python/primitives/test_data_bin.py +++ b/test/python/primitives/test_data_bin.py @@ -17,7 +17,7 @@ import numpy as np import numpy.typing as npt -from qiskit.primitives.containers import make_databin +from qiskit.primitives.containers import make_data_bin from qiskit.primitives.containers.data_bin import DataBin, DataBinMeta from qiskit.test import QiskitTestCase @@ -27,7 +27,7 @@ class DataBinTestCase(QiskitTestCase): def test_make_databin(self): """Test the make_databin() function.""" - data_bin_cls = make_databin( + data_bin_cls = make_data_bin( [("alpha", npt.NDArray[np.uint16]), ("beta", np.ndarray)], shape=(10, 20) ) @@ -50,7 +50,7 @@ def test_make_databin(self): def test_make_databin_no_shape(self): """Test the make_databin() function with no shape.""" - data_bin_cls = make_databin([("alpha", dict), ("beta", int)]) + data_bin_cls = make_data_bin([("alpha", dict), ("beta", int)]) self.assertTrue(issubclass(type(data_bin_cls), DataBinMeta)) self.assertTrue(issubclass(data_bin_cls, DataBin)) diff --git a/test/python/primitives/test_estimatorv2.py b/test/python/primitives/test_estimatorv2.py index 242c811562e0..2c23862a6906 100644 --- a/test/python/primitives/test_estimatorv2.py +++ b/test/python/primitives/test_estimatorv2.py @@ -279,8 +279,8 @@ def test_options(self): estimator = Estimator(options={"execution": {"shots": 3000}}) self.assertEqual(estimator.options.execution.shots, 3000) with self.subTest("set_options"): - estimator.options.execution.shots = 1024 - estimator.options.execution.seed = 15 + estimator.options.execution.shots = 1024 # pylint: disable=assigning-non-slot + estimator.options.execution.seed = 15 # pylint: disable=assigning-non-slot self.assertEqual(estimator.options.execution.shots, 1024) self.assertEqual(estimator.options.execution.seed, 15) with self.subTest("run"): From 200ef36d92d6d9317f69486a9917a6a919de7157 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Wed, 29 Nov 2023 16:17:38 +0900 Subject: [PATCH 09/55] move test/python/primitives/containers --- test/python/primitives/containers/__init__.py | 13 +++++++++++++ .../{ => containers}/test_bindings_array.py | 0 .../primitives/{ => containers}/test_data_bin.py | 0 .../{ => containers}/test_observables_array.py | 0 .../test_primitive_result.py} | 0 .../primitives/{ => containers}/test_shape.py | 0 6 files changed, 13 insertions(+) create mode 100644 test/python/primitives/containers/__init__.py rename test/python/primitives/{ => containers}/test_bindings_array.py (100%) rename test/python/primitives/{ => containers}/test_data_bin.py (100%) rename test/python/primitives/{ => containers}/test_observables_array.py (100%) rename test/python/primitives/{primitive_result.py => containers/test_primitive_result.py} (100%) rename test/python/primitives/{ => containers}/test_shape.py (100%) diff --git a/test/python/primitives/containers/__init__.py b/test/python/primitives/containers/__init__.py new file mode 100644 index 000000000000..5e1f320c97e3 --- /dev/null +++ b/test/python/primitives/containers/__init__.py @@ -0,0 +1,13 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +"""Tests for the data containers of primitives.""" diff --git a/test/python/primitives/test_bindings_array.py b/test/python/primitives/containers/test_bindings_array.py similarity index 100% rename from test/python/primitives/test_bindings_array.py rename to test/python/primitives/containers/test_bindings_array.py diff --git a/test/python/primitives/test_data_bin.py b/test/python/primitives/containers/test_data_bin.py similarity index 100% rename from test/python/primitives/test_data_bin.py rename to test/python/primitives/containers/test_data_bin.py diff --git a/test/python/primitives/test_observables_array.py b/test/python/primitives/containers/test_observables_array.py similarity index 100% rename from test/python/primitives/test_observables_array.py rename to test/python/primitives/containers/test_observables_array.py diff --git a/test/python/primitives/primitive_result.py b/test/python/primitives/containers/test_primitive_result.py similarity index 100% rename from test/python/primitives/primitive_result.py rename to test/python/primitives/containers/test_primitive_result.py diff --git a/test/python/primitives/test_shape.py b/test/python/primitives/containers/test_shape.py similarity index 100% rename from test/python/primitives/test_shape.py rename to test/python/primitives/containers/test_shape.py From 55122f42b0e5cdc8a0476822a29d23bb5b740441 Mon Sep 17 00:00:00 2001 From: Ikko Hamamura Date: Wed, 29 Nov 2023 23:55:31 +0900 Subject: [PATCH 10/55] Apply suggestions from code review Co-authored-by: Takashi Imamichi <31178928+t-imamichi@users.noreply.github.com> --- qiskit/primitives/containers/options.py | 1 - qiskit/primitives/statevector_estimator.py | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/qiskit/primitives/containers/options.py b/qiskit/primitives/containers/options.py index 8ff77f1c8c77..af6e0fa55bd1 100644 --- a/qiskit/primitives/containers/options.py +++ b/qiskit/primitives/containers/options.py @@ -32,7 +32,6 @@ def update(self, options: Optional[BasePrimitiveOptions] = None, **kwargs): if not isinstance(options, BasePrimitiveOptions): raise TypeError(f"Type {type(options)} is not options class") for key, val in options.__dict__.items(): - print(key, val) setattr(self, key, val) for key, val in kwargs.items(): diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py index 3db32b1eddb1..d4eebf192044 100644 --- a/qiskit/primitives/statevector_estimator.py +++ b/qiskit/primitives/statevector_estimator.py @@ -90,10 +90,7 @@ def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): super().__init__(options=options) def run(self, tasks: Iterable[EstimatorTaskLike]) -> PrimitiveJob[PrimitiveResult[TaskResult]]: - coerced_tasks = [ - task if isinstance(task, EstimatorTask) else EstimatorTask.coerce(task) - for task in tasks - ] + coerced_tasks = [EstimatorTask.coerce(task) for task in tasks] for task in coerced_tasks: task.validate() From 9ffda4e02c80e9aaece9c1a014dfa975bcb4d7bc Mon Sep 17 00:00:00 2001 From: ikkoham Date: Thu, 30 Nov 2023 00:01:44 +0900 Subject: [PATCH 11/55] update docs --- qiskit/primitives/base/base_estimator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index b5e6f37a8b48..2c0cfb300e1b 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -18,7 +18,7 @@ Overview of EstimatorV2 ======================== -:class:`~EstimatorV2`estimates expectation values of quantum circuits for provided observables. +:class:`~BaseEstimatorV2` estimates expectation values of quantum circuits for provided observables. An estimator is initialized with an empty parameter set. The estimator is used to create a :class:`~qiskit.providers.JobV1`, via the From 8cfc8fffe5b72d4741a937d3fb3b565de73b10e5 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Thu, 30 Nov 2023 12:28:00 +0900 Subject: [PATCH 12/55] rename task to pubs --- qiskit/primitives/__init__.py | 4 +- qiskit/primitives/base/base_estimator.py | 14 +++---- qiskit/primitives/containers/__init__.py | 4 +- .../containers/{base_task.py => base_pubs.py} | 8 ++-- .../{estimator_task.py => estimator_pubs.py} | 42 +++++++++---------- .../primitives/containers/primitive_result.py | 22 +++++----- .../{task_result.py => pubs_result.py} | 10 ++--- qiskit/primitives/statevector_estimator.py | 28 ++++++------- .../containers/test_primitive_result.py | 16 +++---- test/python/primitives/test_estimatorv2.py | 12 +++--- 10 files changed, 80 insertions(+), 80 deletions(-) rename qiskit/primitives/containers/{base_task.py => base_pubs.py} (88%) rename qiskit/primitives/containers/{estimator_task.py => estimator_pubs.py} (71%) rename qiskit/primitives/containers/{task_result.py => pubs_result.py} (82%) diff --git a/qiskit/primitives/__init__.py b/qiskit/primitives/__init__.py index 5c0d7f68bdc1..1e5b3fc7a551 100644 --- a/qiskit/primitives/__init__.py +++ b/qiskit/primitives/__init__.py @@ -59,7 +59,7 @@ EstimatorResult SamplerResult PrimitiveResult - TaskResult + PubsResult """ from .backend_estimator import BackendEstimator @@ -67,7 +67,7 @@ from .base import BaseEstimator, BaseSampler from .base.estimator_result import EstimatorResult from .base.sampler_result import SamplerResult -from .containers import BindingsArray, EstimatorTask, ObservablesArray, PrimitiveResult, TaskResult +from .containers import BindingsArray, EstimatorPubs, ObservablesArray, PrimitiveResult, PubsResult from .estimator import Estimator from .sampler import Sampler from .statevector_estimator import Estimator as StatevectorEstimator diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 2c0cfb300e1b..2252b576e975 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -23,8 +23,8 @@ An estimator is initialized with an empty parameter set. The estimator is used to create a :class:`~qiskit.providers.JobV1`, via the :meth:`~.BaseEstimatorV2.run()` method. This method is called -with the list of task. -Task is composed of tuple of following parameters ``[(circuit, observables, parameter_values)]``. +with the list of pubs (Primitive Unified Blocs). +Pubs are composed of tuple of following parameters ``[(circuit, observables, parameter_values)]``. * quantum circuit (:math:`\psi(\theta)`): (parameterized) quantum circuits :class:`~qiskit.circuit.QuantumCircuit`. @@ -89,7 +89,7 @@ ============================== -The original three arguments are now a single argument task. +The original three arguments are now a single argument pubs. To accommodate this change, the zip function can be used for easy migration. For example, suppose the code originally is: @@ -183,7 +183,7 @@ from qiskit.quantum_info.operators.base_operator import BaseOperator from qiskit.utils.deprecation import deprecate_func -from ..containers.estimator_task import EstimatorTaskLike +from ..containers.estimator_pubs import EstimatorPubsLike from ..containers.options import BasePrimitiveOptionsLike from . import validation from .base_primitive import BasePrimitive, BasePrimitiveV2 @@ -361,11 +361,11 @@ def __init__(self, options: Optional[BasePrimitiveOptionsLike]): super().__init__(options=options) @abstractmethod - def run(self, tasks: Iterable[EstimatorTaskLike]) -> Job: - """Run the tasks of the estimation of expectation value(s). + def run(self, pubs: Iterable[EstimatorPubsLike]) -> Job: + """Run the pubs of the estimation of expectation value(s). Args: - tasks: a iterable of tasklike object. Typically, list of tuple + pubs: a iterable of pubslike object. Typically, list of tuple ``(QuantumCircuit, observables, parameter_values)`` Returns: diff --git a/qiskit/primitives/containers/__init__.py b/qiskit/primitives/containers/__init__.py index 181d446f4392..4ee1663b576b 100644 --- a/qiskit/primitives/containers/__init__.py +++ b/qiskit/primitives/containers/__init__.py @@ -16,8 +16,8 @@ from .bindings_array import BindingsArray from .data_bin import make_data_bin -from .estimator_task import EstimatorTask, EstimatorTaskLike +from .estimator_pubs import EstimatorPubs, EstimatorPubsLike from .observables_array import ObservablesArray from .options import BasePrimitiveOptions, BasePrimitiveOptionsLike from .primitive_result import PrimitiveResult -from .task_result import TaskResult +from .pubs_result import PubsResult diff --git a/qiskit/primitives/containers/base_task.py b/qiskit/primitives/containers/base_pubs.py similarity index 88% rename from qiskit/primitives/containers/base_task.py rename to qiskit/primitives/containers/base_pubs.py index 012ca3e98f16..ac095897cf59 100644 --- a/qiskit/primitives/containers/base_task.py +++ b/qiskit/primitives/containers/base_pubs.py @@ -11,7 +11,7 @@ # that they have been altered from the originals. """ -Base Task class +Base Pubs class """ from __future__ import annotations @@ -22,11 +22,11 @@ @frozen_dataclass -class BaseTask: - """Base class for Task""" +class BasePubs: + """Base class for Pubs""" circuit: QuantumCircuit - """Quantum circuit object for the task.""" + """Quantum circuit object for the pubs.""" def validate(self): """Validate the data""" diff --git a/qiskit/primitives/containers/estimator_task.py b/qiskit/primitives/containers/estimator_pubs.py similarity index 71% rename from qiskit/primitives/containers/estimator_task.py rename to qiskit/primitives/containers/estimator_pubs.py index dbe87d8c35d3..ea1fa86a11f0 100644 --- a/qiskit/primitives/containers/estimator_task.py +++ b/qiskit/primitives/containers/estimator_pubs.py @@ -12,7 +12,7 @@ """ -Estimator Task class +Estimator Pubs class """ from __future__ import annotations @@ -23,7 +23,7 @@ from qiskit import QuantumCircuit -from .base_task import BaseTask +from .base_pubs import BasePubs from .bindings_array import BindingsArray, BindingsArrayLike from .dataclasses import frozen_dataclass from .observables_array import ObservablesArray, ObservablesArrayLike @@ -31,9 +31,9 @@ @frozen_dataclass -class EstimatorTask(BaseTask, ShapedMixin): - """Task for Estimator. - Task is composed of triple (circuit, observables, parameter_values). +class EstimatorPubs(BasePubs, ShapedMixin): + """Pubs (Primitive Unified Blocs) for Estimator. + Pubs are composed of triple (circuit, observables, parameter_values). """ observables: ObservablesArray @@ -45,29 +45,29 @@ def __post_init__(self): self._shape = shape @classmethod - def coerce(cls, task: EstimatorTaskLike) -> EstimatorTask: - """Coerce EstimatorTaskLike into EstimatorTask. + def coerce(cls, pubs: EstimatorPubsLike) -> EstimatorPubs: + """Coerce EstimatorPubsLike into EstimatorPubs. Args: - task: an object to be estimator task. + pubs: an object to be estimator pubs. Returns: - A coerced estimator task. + A coerced estimator pubs. """ - if isinstance(task, EstimatorTask): - return task - if len(task) != 2 and len(task) != 3: - raise ValueError(f"The length of task must be 2 or 3, but length {len(task)} is given.") - circuit = task[0] - observables = ObservablesArray.coerce(task[1]) - if len(task) == 2: + if isinstance(pubs, EstimatorPubs): + return pubs + if len(pubs) != 2 and len(pubs) != 3: + raise ValueError(f"The length of pubs must be 2 or 3, but length {len(pubs)} is given.") + circuit = pubs[0] + observables = ObservablesArray.coerce(pubs[1]) + if len(pubs) == 2: return cls(circuit=circuit, observables=observables) - parameter_values = BindingsArray.coerce(task[2]) + parameter_values = BindingsArray.coerce(pubs[2]) return cls(circuit=circuit, observables=observables, parameter_values=parameter_values) def validate(self): - """Validate the task.""" - super(EstimatorTask, self).validate() # pylint: disable=super-with-arguments + """Validate the pubs.""" + super(EstimatorPubs, self).validate() # pylint: disable=super-with-arguments # I'm not sure why these arguments for super are needed. But if no args, tests are failed # for Python >=3.10. Seems to be some bug, but I can't fix. self.observables.validate() @@ -89,6 +89,6 @@ def validate(self): ) -EstimatorTaskLike = Union[ - EstimatorTask, Tuple[QuantumCircuit, ObservablesArrayLike, BindingsArrayLike] +EstimatorPubsLike = Union[ + EstimatorPubs, Tuple[QuantumCircuit, ObservablesArrayLike, BindingsArrayLike] ] diff --git a/qiskit/primitives/containers/primitive_result.py b/qiskit/primitives/containers/primitive_result.py index bbb2fb7e3d6f..fb7097409788 100644 --- a/qiskit/primitives/containers/primitive_result.py +++ b/qiskit/primitives/containers/primitive_result.py @@ -17,21 +17,21 @@ from collections.abc import Iterable from typing import Any, Generic, Optional, TypeVar -from .task_result import TaskResult +from .pubs_result import PubsResult -T = TypeVar("T", bound=TaskResult) +T = TypeVar("T", bound=PubsResult) class PrimitiveResult(Generic[T]): - """A container for multiple task results and global metadata.""" + """A container for multiple pubs results and global metadata.""" - def __init__(self, task_results: Iterable[T], metadata: Optional[dict[str, Any]] = None): + def __init__(self, pubs_results: Iterable[T], metadata: Optional[dict[str, Any]] = None): """ Args: - task_results: Task results. - metadata: Any metadata that doesn't make sense to put inside of task results. + pubs_results: Pubs results. + metadata: Any metadata that doesn't make sense to put inside of pubs results. """ - self._task_results = list(task_results) + self._pubs_results = list(pubs_results) self._metadata = metadata or {} @property @@ -40,13 +40,13 @@ def metadata(self) -> dict[str, Any]: return self._metadata def __getitem__(self, index) -> T: - return self._task_results[index] + return self._pubs_results[index] def __len__(self) -> int: - return len(self._task_results) + return len(self._pubs_results) def __repr__(self) -> str: - return f"PrimitiveResult({self._task_results}, metadata={self.metadata})" + return f"PrimitiveResult({self._pubs_results}, metadata={self.metadata})" def __iter__(self) -> Iterable[T]: - return iter(self._task_results) + return iter(self._pubs_results) diff --git a/qiskit/primitives/containers/task_result.py b/qiskit/primitives/containers/pubs_result.py similarity index 82% rename from qiskit/primitives/containers/task_result.py rename to qiskit/primitives/containers/pubs_result.py index d773084cb5c9..aca1780c95f2 100644 --- a/qiskit/primitives/containers/task_result.py +++ b/qiskit/primitives/containers/pubs_result.py @@ -11,7 +11,7 @@ # that they have been altered from the originals. """ -Base Task class +Base Pubs class """ from __future__ import annotations @@ -23,10 +23,10 @@ @frozen_dataclass -class TaskResult: - """Result of task.""" +class PubsResult: + """Result of pub (Primitive Unified Bloc).""" data: DataBin - """Result data for the task""" + """Result data for the pub""" metadata: dict = Field(default_factory=dict) - """Metadata for the task""" + """Metadata for the pub""" diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py index d4eebf192044..8ab859f8a6aa 100644 --- a/qiskit/primitives/statevector_estimator.py +++ b/qiskit/primitives/statevector_estimator.py @@ -29,10 +29,10 @@ from .containers import ( BasePrimitiveOptions, BasePrimitiveOptionsLike, - EstimatorTask, - EstimatorTaskLike, + EstimatorPubs, + EstimatorPubsLike, PrimitiveResult, - TaskResult, + PubsResult, make_data_bin, ) from .containers.dataclasses import mutable_dataclass @@ -89,26 +89,26 @@ def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): options = Options(**options) super().__init__(options=options) - def run(self, tasks: Iterable[EstimatorTaskLike]) -> PrimitiveJob[PrimitiveResult[TaskResult]]: - coerced_tasks = [EstimatorTask.coerce(task) for task in tasks] + def run(self, pubs: Iterable[EstimatorPubsLike]) -> PrimitiveJob[PrimitiveResult[PubsResult]]: + coerced_pubs = [EstimatorPubs.coerce(pub) for pub in pubs] - for task in coerced_tasks: - task.validate() + for pub in coerced_pubs: + pub.validate() - job: PrimitiveJob[PrimitiveResult[TaskResult]] = PrimitiveJob(self._run_task, coerced_tasks) + job: PrimitiveJob[PrimitiveResult[PubsResult]] = PrimitiveJob(self._run_pubs, coerced_pubs) job.submit() return job - def _run_task(self, tasks: list[EstimatorTask]) -> PrimitiveResult[TaskResult]: + def _run_pubs(self, pubs: list[EstimatorPubs]) -> PrimitiveResult[PubsResult]: shots = self.options.execution.shots rng = _get_rng(self.options.execution.seed) results = [] - for task in tasks: - circuit = task.circuit - observables = task.observables - parameter_values = task.parameter_values + for pub in pubs: + circuit = pub.circuit + observables = pub.observables + parameter_values = pub.parameter_values bound_circuits = parameter_values.bind_all(circuit) bc_circuits, bc_obs = np.broadcast_arrays(bound_circuits, observables) @@ -139,7 +139,7 @@ def _run_task(self, tasks: list[EstimatorTask]) -> PrimitiveResult[TaskResult]: shape=bc_circuits.shape, ) data_bin = data_bin_cls(evs=evs, stds=stds) - results.append(TaskResult(data_bin, metadata={"shots": shots})) + results.append(PubsResult(data_bin, metadata={"shots": shots})) return PrimitiveResult(results) diff --git a/test/python/primitives/containers/test_primitive_result.py b/test/python/primitives/containers/test_primitive_result.py index 5e9c1a1fce9d..2b49d8a2a792 100644 --- a/test/python/primitives/containers/test_primitive_result.py +++ b/test/python/primitives/containers/test_primitive_result.py @@ -16,7 +16,7 @@ import numpy as np import numpy.typing as npt -from qiskit.primitives.containers import PrimitiveResult, TaskResult, make_data_bin +from qiskit.primitives.containers import PrimitiveResult, PubsResult, make_data_bin from qiskit.test import QiskitTestCase @@ -32,13 +32,13 @@ def test_primitive_result(self): alpha = np.empty((10, 20), dtype=np.uint16) beta = np.empty((10, 20), dtype=int) - task_results = [ - TaskResult(data_bin_cls(alpha, beta)), - TaskResult(data_bin_cls(alpha, beta)), + pubs_results = [ + PubsResult(data_bin_cls(alpha, beta)), + PubsResult(data_bin_cls(alpha, beta)), ] - result = PrimitiveResult(task_results, {1: 2}) + result = PrimitiveResult(pubs_results, {1: 2}) - self.assertTrue(result[0] is task_results[0]) - self.assertTrue(result[1] is task_results[1]) - self.assertTrue(list(result)[0] is task_results[0]) + self.assertTrue(result[0] is pubs_results[0]) + self.assertTrue(result[1] is pubs_results[1]) + self.assertTrue(list(result)[0] is pubs_results[0]) self.assertEqual(len(result), 2) diff --git a/test/python/primitives/test_estimatorv2.py b/test/python/primitives/test_estimatorv2.py index 2c23862a6906..c03c806538ba 100644 --- a/test/python/primitives/test_estimatorv2.py +++ b/test/python/primitives/test_estimatorv2.py @@ -18,7 +18,7 @@ from qiskit.circuit import Parameter, QuantumCircuit from qiskit.circuit.library import RealAmplitudes -from qiskit.primitives import BindingsArray, EstimatorTask, ObservablesArray +from qiskit.primitives import BindingsArray, EstimatorPubs, ObservablesArray from qiskit.primitives.statevector_estimator import Estimator, Options from qiskit.providers import JobV1 from qiskit.quantum_info import SparsePauliOp @@ -90,21 +90,21 @@ def test_estimator_run(self): np.testing.assert_allclose(result4[0].data.evs, [1.55555728, -1.08766318]) np.testing.assert_allclose(result4[1].data.evs, [0.17849238]) - def test_estimator_with_task(self): - """Test estimator with explicit EstimatorTask.""" + def test_estimator_with_pubs(self): + """Test estimator with explicit EstimatorPubs.""" psi1, psi2 = self.psi hamiltonian1, hamiltonian2, hamiltonian3 = self.hamiltonian theta1, theta2, theta3 = self.theta obs1 = ObservablesArray.coerce([hamiltonian1, hamiltonian3]) bind1 = BindingsArray.coerce([theta1, theta3]) - task1 = EstimatorTask(psi1, obs1, bind1) + pubs1 = EstimatorPubs(psi1, obs1, bind1) obs2 = ObservablesArray.coerce(hamiltonian2) bind2 = BindingsArray.coerce(theta2) - task2 = EstimatorTask(psi2, obs2, bind2) + pubs2 = EstimatorPubs(psi2, obs2, bind2) estimator = Estimator() - result4 = estimator.run([task1, task2]).result() + result4 = estimator.run([pubs1, pubs2]).result() np.testing.assert_allclose(result4[0].data.evs, [1.55555728, -1.08766318]) np.testing.assert_allclose(result4[1].data.evs, [0.17849238]) From 18e739515e1ebf02caaf768ad43b09bafbe1b3d8 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Thu, 30 Nov 2023 23:02:30 +0900 Subject: [PATCH 13/55] Pubs -> Pub --- qiskit/primitives/__init__.py | 4 +- qiskit/primitives/base/base_estimator.py | 6 +-- qiskit/primitives/containers/__init__.py | 4 +- .../containers/{base_pubs.py => base_pub.py} | 4 +- .../{estimator_pubs.py => estimator_pub.py} | 42 +++++++++---------- .../primitives/containers/primitive_result.py | 22 +++++----- .../{pubs_result.py => pub_result.py} | 4 +- qiskit/primitives/statevector_estimator.py | 24 +++++------ .../containers/test_primitive_result.py | 16 +++---- test/python/primitives/test_estimatorv2.py | 10 ++--- 10 files changed, 68 insertions(+), 68 deletions(-) rename qiskit/primitives/containers/{base_pubs.py => base_pub.py} (92%) rename qiskit/primitives/containers/{estimator_pubs.py => estimator_pub.py} (70%) rename qiskit/primitives/containers/{pubs_result.py => pub_result.py} (96%) diff --git a/qiskit/primitives/__init__.py b/qiskit/primitives/__init__.py index 1e5b3fc7a551..ccf2ebc920d5 100644 --- a/qiskit/primitives/__init__.py +++ b/qiskit/primitives/__init__.py @@ -59,7 +59,7 @@ EstimatorResult SamplerResult PrimitiveResult - PubsResult + PubResult """ from .backend_estimator import BackendEstimator @@ -67,7 +67,7 @@ from .base import BaseEstimator, BaseSampler from .base.estimator_result import EstimatorResult from .base.sampler_result import SamplerResult -from .containers import BindingsArray, EstimatorPubs, ObservablesArray, PrimitiveResult, PubsResult +from .containers import BindingsArray, EstimatorPub, ObservablesArray, PrimitiveResult, PubResult from .estimator import Estimator from .sampler import Sampler from .statevector_estimator import Estimator as StatevectorEstimator diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 2252b576e975..0aafe74b56c5 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -24,7 +24,7 @@ create a :class:`~qiskit.providers.JobV1`, via the :meth:`~.BaseEstimatorV2.run()` method. This method is called with the list of pubs (Primitive Unified Blocs). -Pubs are composed of tuple of following parameters ``[(circuit, observables, parameter_values)]``. +Pub is composed of tuple of following parameters ``[(circuit, observables, parameter_values)]``. * quantum circuit (:math:`\psi(\theta)`): (parameterized) quantum circuits :class:`~qiskit.circuit.QuantumCircuit`. @@ -183,7 +183,7 @@ from qiskit.quantum_info.operators.base_operator import BaseOperator from qiskit.utils.deprecation import deprecate_func -from ..containers.estimator_pubs import EstimatorPubsLike +from ..containers.estimator_pub import EstimatorPubLike from ..containers.options import BasePrimitiveOptionsLike from . import validation from .base_primitive import BasePrimitive, BasePrimitiveV2 @@ -361,7 +361,7 @@ def __init__(self, options: Optional[BasePrimitiveOptionsLike]): super().__init__(options=options) @abstractmethod - def run(self, pubs: Iterable[EstimatorPubsLike]) -> Job: + def run(self, pubs: Iterable[EstimatorPubLike]) -> Job: """Run the pubs of the estimation of expectation value(s). Args: diff --git a/qiskit/primitives/containers/__init__.py b/qiskit/primitives/containers/__init__.py index 4ee1663b576b..54fc8615a1dd 100644 --- a/qiskit/primitives/containers/__init__.py +++ b/qiskit/primitives/containers/__init__.py @@ -16,8 +16,8 @@ from .bindings_array import BindingsArray from .data_bin import make_data_bin -from .estimator_pubs import EstimatorPubs, EstimatorPubsLike +from .estimator_pub import EstimatorPub, EstimatorPubLike from .observables_array import ObservablesArray from .options import BasePrimitiveOptions, BasePrimitiveOptionsLike from .primitive_result import PrimitiveResult -from .pubs_result import PubsResult +from .pub_result import PubResult diff --git a/qiskit/primitives/containers/base_pubs.py b/qiskit/primitives/containers/base_pub.py similarity index 92% rename from qiskit/primitives/containers/base_pubs.py rename to qiskit/primitives/containers/base_pub.py index ac095897cf59..46495e29ad4b 100644 --- a/qiskit/primitives/containers/base_pubs.py +++ b/qiskit/primitives/containers/base_pub.py @@ -22,8 +22,8 @@ @frozen_dataclass -class BasePubs: - """Base class for Pubs""" +class BasePub: + """Base class for PUB (Primitive Unified Bloc)""" circuit: QuantumCircuit """Quantum circuit object for the pubs.""" diff --git a/qiskit/primitives/containers/estimator_pubs.py b/qiskit/primitives/containers/estimator_pub.py similarity index 70% rename from qiskit/primitives/containers/estimator_pubs.py rename to qiskit/primitives/containers/estimator_pub.py index ea1fa86a11f0..ae61e02533af 100644 --- a/qiskit/primitives/containers/estimator_pubs.py +++ b/qiskit/primitives/containers/estimator_pub.py @@ -12,7 +12,7 @@ """ -Estimator Pubs class +Estimator Pub class """ from __future__ import annotations @@ -23,7 +23,7 @@ from qiskit import QuantumCircuit -from .base_pubs import BasePubs +from .base_pub import BasePub from .bindings_array import BindingsArray, BindingsArrayLike from .dataclasses import frozen_dataclass from .observables_array import ObservablesArray, ObservablesArrayLike @@ -31,9 +31,9 @@ @frozen_dataclass -class EstimatorPubs(BasePubs, ShapedMixin): - """Pubs (Primitive Unified Blocs) for Estimator. - Pubs are composed of triple (circuit, observables, parameter_values). +class EstimatorPub(BasePub, ShapedMixin): + """Pub (Primitive Unified Bloc) for Estimator. + Pub is composed of triple (circuit, observables, parameter_values). """ observables: ObservablesArray @@ -45,29 +45,29 @@ def __post_init__(self): self._shape = shape @classmethod - def coerce(cls, pubs: EstimatorPubsLike) -> EstimatorPubs: - """Coerce EstimatorPubsLike into EstimatorPubs. + def coerce(cls, pub: EstimatorPubLike) -> EstimatorPub: + """Coerce EstimatorPubLike into EstimatorPub. Args: - pubs: an object to be estimator pubs. + pub: an object to be estimator pub. Returns: - A coerced estimator pubs. + A coerced estimator pub. """ - if isinstance(pubs, EstimatorPubs): - return pubs - if len(pubs) != 2 and len(pubs) != 3: - raise ValueError(f"The length of pubs must be 2 or 3, but length {len(pubs)} is given.") - circuit = pubs[0] - observables = ObservablesArray.coerce(pubs[1]) - if len(pubs) == 2: + if isinstance(pub, EstimatorPub): + return pub + if len(pub) != 2 and len(pub) != 3: + raise ValueError(f"The length of pub must be 2 or 3, but length {len(pub)} is given.") + circuit = pub[0] + observables = ObservablesArray.coerce(pub[1]) + if len(pub) == 2: return cls(circuit=circuit, observables=observables) - parameter_values = BindingsArray.coerce(pubs[2]) + parameter_values = BindingsArray.coerce(pub[2]) return cls(circuit=circuit, observables=observables, parameter_values=parameter_values) def validate(self): - """Validate the pubs.""" - super(EstimatorPubs, self).validate() # pylint: disable=super-with-arguments + """Validate the pub.""" + super(EstimatorPub, self).validate() # pylint: disable=super-with-arguments # I'm not sure why these arguments for super are needed. But if no args, tests are failed # for Python >=3.10. Seems to be some bug, but I can't fix. self.observables.validate() @@ -89,6 +89,6 @@ def validate(self): ) -EstimatorPubsLike = Union[ - EstimatorPubs, Tuple[QuantumCircuit, ObservablesArrayLike, BindingsArrayLike] +EstimatorPubLike = Union[ + EstimatorPub, Tuple[QuantumCircuit, ObservablesArrayLike, BindingsArrayLike] ] diff --git a/qiskit/primitives/containers/primitive_result.py b/qiskit/primitives/containers/primitive_result.py index fb7097409788..7f2f27ec9596 100644 --- a/qiskit/primitives/containers/primitive_result.py +++ b/qiskit/primitives/containers/primitive_result.py @@ -17,21 +17,21 @@ from collections.abc import Iterable from typing import Any, Generic, Optional, TypeVar -from .pubs_result import PubsResult +from .pub_result import PubResult -T = TypeVar("T", bound=PubsResult) +T = TypeVar("T", bound=PubResult) class PrimitiveResult(Generic[T]): - """A container for multiple pubs results and global metadata.""" + """A container for multiple pub results and global metadata.""" - def __init__(self, pubs_results: Iterable[T], metadata: Optional[dict[str, Any]] = None): + def __init__(self, pub_results: Iterable[T], metadata: Optional[dict[str, Any]] = None): """ Args: - pubs_results: Pubs results. - metadata: Any metadata that doesn't make sense to put inside of pubs results. + pub_results: Pub results. + metadata: Any metadata that doesn't make sense to put inside of pub results. """ - self._pubs_results = list(pubs_results) + self._pub_results = list(pub_results) self._metadata = metadata or {} @property @@ -40,13 +40,13 @@ def metadata(self) -> dict[str, Any]: return self._metadata def __getitem__(self, index) -> T: - return self._pubs_results[index] + return self._pub_results[index] def __len__(self) -> int: - return len(self._pubs_results) + return len(self._pub_results) def __repr__(self) -> str: - return f"PrimitiveResult({self._pubs_results}, metadata={self.metadata})" + return f"PrimitiveResult({self._pub_results}, metadata={self.metadata})" def __iter__(self) -> Iterable[T]: - return iter(self._pubs_results) + return iter(self._pub_results) diff --git a/qiskit/primitives/containers/pubs_result.py b/qiskit/primitives/containers/pub_result.py similarity index 96% rename from qiskit/primitives/containers/pubs_result.py rename to qiskit/primitives/containers/pub_result.py index aca1780c95f2..28cac8e7e933 100644 --- a/qiskit/primitives/containers/pubs_result.py +++ b/qiskit/primitives/containers/pub_result.py @@ -11,7 +11,7 @@ # that they have been altered from the originals. """ -Base Pubs class +Base Pub class """ from __future__ import annotations @@ -23,7 +23,7 @@ @frozen_dataclass -class PubsResult: +class PubResult: """Result of pub (Primitive Unified Bloc).""" data: DataBin diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py index 8ab859f8a6aa..e7298c99a096 100644 --- a/qiskit/primitives/statevector_estimator.py +++ b/qiskit/primitives/statevector_estimator.py @@ -29,10 +29,10 @@ from .containers import ( BasePrimitiveOptions, BasePrimitiveOptionsLike, - EstimatorPubs, - EstimatorPubsLike, + EstimatorPub, + EstimatorPubLike, PrimitiveResult, - PubsResult, + PubResult, make_data_bin, ) from .containers.dataclasses import mutable_dataclass @@ -89,23 +89,23 @@ def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): options = Options(**options) super().__init__(options=options) - def run(self, pubs: Iterable[EstimatorPubsLike]) -> PrimitiveJob[PrimitiveResult[PubsResult]]: - coerced_pubs = [EstimatorPubs.coerce(pub) for pub in pubs] + def run(self, pubs: Iterable[EstimatorPubLike]) -> PrimitiveJob[PrimitiveResult[PubResult]]: + job: PrimitiveJob[PrimitiveResult[PubResult]] = PrimitiveJob(self._run, pubs) + job.submit() + return job + + def _run(self, pubs: Iterable[EstimatorPub]) -> PrimitiveResult[PubResult]: + coerced_pubs = [EstimatorPub.coerce(pub) for pub in pubs] for pub in coerced_pubs: pub.validate() - job: PrimitiveJob[PrimitiveResult[PubsResult]] = PrimitiveJob(self._run_pubs, coerced_pubs) - job.submit() - return job - - def _run_pubs(self, pubs: list[EstimatorPubs]) -> PrimitiveResult[PubsResult]: shots = self.options.execution.shots rng = _get_rng(self.options.execution.seed) results = [] - for pub in pubs: + for pub in coerced_pubs: circuit = pub.circuit observables = pub.observables parameter_values = pub.parameter_values @@ -139,7 +139,7 @@ def _run_pubs(self, pubs: list[EstimatorPubs]) -> PrimitiveResult[PubsResult]: shape=bc_circuits.shape, ) data_bin = data_bin_cls(evs=evs, stds=stds) - results.append(PubsResult(data_bin, metadata={"shots": shots})) + results.append(PubResult(data_bin, metadata={"shots": shots})) return PrimitiveResult(results) diff --git a/test/python/primitives/containers/test_primitive_result.py b/test/python/primitives/containers/test_primitive_result.py index 2b49d8a2a792..524faaffa4c8 100644 --- a/test/python/primitives/containers/test_primitive_result.py +++ b/test/python/primitives/containers/test_primitive_result.py @@ -16,7 +16,7 @@ import numpy as np import numpy.typing as npt -from qiskit.primitives.containers import PrimitiveResult, PubsResult, make_data_bin +from qiskit.primitives.containers import PrimitiveResult, PubResult, make_data_bin from qiskit.test import QiskitTestCase @@ -32,13 +32,13 @@ def test_primitive_result(self): alpha = np.empty((10, 20), dtype=np.uint16) beta = np.empty((10, 20), dtype=int) - pubs_results = [ - PubsResult(data_bin_cls(alpha, beta)), - PubsResult(data_bin_cls(alpha, beta)), + pub_results = [ + PubResult(data_bin_cls(alpha, beta)), + PubResult(data_bin_cls(alpha, beta)), ] - result = PrimitiveResult(pubs_results, {1: 2}) + result = PrimitiveResult(pub_results, {1: 2}) - self.assertTrue(result[0] is pubs_results[0]) - self.assertTrue(result[1] is pubs_results[1]) - self.assertTrue(list(result)[0] is pubs_results[0]) + self.assertTrue(result[0] is pub_results[0]) + self.assertTrue(result[1] is pub_results[1]) + self.assertTrue(list(result)[0] is pub_results[0]) self.assertEqual(len(result), 2) diff --git a/test/python/primitives/test_estimatorv2.py b/test/python/primitives/test_estimatorv2.py index c03c806538ba..21f74c56d14a 100644 --- a/test/python/primitives/test_estimatorv2.py +++ b/test/python/primitives/test_estimatorv2.py @@ -18,7 +18,7 @@ from qiskit.circuit import Parameter, QuantumCircuit from qiskit.circuit.library import RealAmplitudes -from qiskit.primitives import BindingsArray, EstimatorPubs, ObservablesArray +from qiskit.primitives import BindingsArray, EstimatorPub, ObservablesArray from qiskit.primitives.statevector_estimator import Estimator, Options from qiskit.providers import JobV1 from qiskit.quantum_info import SparsePauliOp @@ -90,7 +90,7 @@ def test_estimator_run(self): np.testing.assert_allclose(result4[0].data.evs, [1.55555728, -1.08766318]) np.testing.assert_allclose(result4[1].data.evs, [0.17849238]) - def test_estimator_with_pubs(self): + def test_estimator_with_pub(self): """Test estimator with explicit EstimatorPubs.""" psi1, psi2 = self.psi hamiltonian1, hamiltonian2, hamiltonian3 = self.hamiltonian @@ -98,13 +98,13 @@ def test_estimator_with_pubs(self): obs1 = ObservablesArray.coerce([hamiltonian1, hamiltonian3]) bind1 = BindingsArray.coerce([theta1, theta3]) - pubs1 = EstimatorPubs(psi1, obs1, bind1) + pub1 = EstimatorPub(psi1, obs1, bind1) obs2 = ObservablesArray.coerce(hamiltonian2) bind2 = BindingsArray.coerce(theta2) - pubs2 = EstimatorPubs(psi2, obs2, bind2) + pub2 = EstimatorPub(psi2, obs2, bind2) estimator = Estimator() - result4 = estimator.run([pubs1, pubs2]).result() + result4 = estimator.run([pub1, pub2]).result() np.testing.assert_allclose(result4[0].data.evs, [1.55555728, -1.08766318]) np.testing.assert_allclose(result4[1].data.evs, [0.17849238]) From cd5b70f8f5bc8f50562c3646eec7f8a30521f57c Mon Sep 17 00:00:00 2001 From: ikkoham Date: Fri, 1 Dec 2023 14:02:45 +0900 Subject: [PATCH 14/55] make pydantic optional --- qiskit/primitives/base/base_estimator.py | 2 ++ qiskit/primitives/containers/base_pub.py | 2 ++ qiskit/primitives/containers/dataclasses.py | 28 +++++++++++++-------- qiskit/primitives/containers/pub_result.py | 7 +++++- qiskit/primitives/statevector_estimator.py | 11 ++++++-- qiskit/utils/optionals.py | 5 ++++ requirements-optional.txt | 1 + requirements.txt | 1 - test/python/primitives/test_estimatorv2.py | 2 ++ 9 files changed, 45 insertions(+), 14 deletions(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 0aafe74b56c5..953510b89cac 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -182,6 +182,7 @@ from qiskit.quantum_info.operators import SparsePauliOp from qiskit.quantum_info.operators.base_operator import BaseOperator from qiskit.utils.deprecation import deprecate_func +from qiskit.utils.optionals import HAS_PYDANTIC from ..containers.estimator_pub import EstimatorPubLike from ..containers.options import BasePrimitiveOptionsLike @@ -351,6 +352,7 @@ def parameters(self) -> tuple[ParameterView, ...]: BaseEstimator = BaseEstimatorV1 +@HAS_PYDANTIC.require_in_instance class BaseEstimatorV2(BasePrimitiveV2): """Estimator base class version 2. diff --git a/qiskit/primitives/containers/base_pub.py b/qiskit/primitives/containers/base_pub.py index 46495e29ad4b..fdb55f4c1ade 100644 --- a/qiskit/primitives/containers/base_pub.py +++ b/qiskit/primitives/containers/base_pub.py @@ -17,10 +17,12 @@ from __future__ import annotations from qiskit import QuantumCircuit +from qiskit.utils.optionals import HAS_PYDANTIC from .dataclasses import frozen_dataclass +@HAS_PYDANTIC.require_in_instance @frozen_dataclass class BasePub: """Base class for PUB (Primitive Unified Bloc)""" diff --git a/qiskit/primitives/containers/dataclasses.py b/qiskit/primitives/containers/dataclasses.py index 20a8f1e38913..e31cb69642e7 100644 --- a/qiskit/primitives/containers/dataclasses.py +++ b/qiskit/primitives/containers/dataclasses.py @@ -13,15 +13,23 @@ Dataclass """ -from pydantic import ConfigDict -from pydantic.dataclasses import dataclass +from qiskit.utils.optionals import HAS_PYDANTIC -mutable_dataclass = dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) +if HAS_PYDANTIC: + from pydantic import ConfigDict + from pydantic.dataclasses import dataclass -frozen_dataclass = dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid"), - frozen=True, - slots=True, -) + mutable_dataclass = dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") + ) + + frozen_dataclass = dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid"), + frozen=True, + slots=True, + ) +else: + from dataclasses import dataclass + + mutable_dataclass = dataclass(frozen=False) + frozen_dataclass = dataclass(frozen=True, slots=True) diff --git a/qiskit/primitives/containers/pub_result.py b/qiskit/primitives/containers/pub_result.py index 28cac8e7e933..feb61fb6283a 100644 --- a/qiskit/primitives/containers/pub_result.py +++ b/qiskit/primitives/containers/pub_result.py @@ -16,11 +16,16 @@ from __future__ import annotations -from pydantic import Field +from qiskit.utils.optionals import HAS_PYDANTIC from .data_bin import DataBin from .dataclasses import frozen_dataclass +if HAS_PYDANTIC: + from pydantic import Field +else: + from dataclasses import field as Field + @frozen_dataclass class PubResult: diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py index e7298c99a096..3243213ff9eb 100644 --- a/qiskit/primitives/statevector_estimator.py +++ b/qiskit/primitives/statevector_estimator.py @@ -20,10 +20,9 @@ import numpy as np from numpy.typing import NDArray -from pydantic import Field -from pydantic.types import PositiveInt from qiskit.quantum_info import SparsePauliOp, Statevector +from qiskit.utils.optionals import HAS_PYDANTIC from .base import BaseEstimatorV2 from .containers import ( @@ -39,7 +38,14 @@ from .primitive_job import PrimitiveJob from .utils import bound_circuit_to_instruction +if HAS_PYDANTIC: + from pydantic import Field + from pydantic.types import PositiveInt +else: + from dataclasses import field as Field + +@HAS_PYDANTIC.require_in_instance @mutable_dataclass class ExecutionOptions(BasePrimitiveOptions): """Options for execution.""" @@ -48,6 +54,7 @@ class ExecutionOptions(BasePrimitiveOptions): seed: Optional[Union[int, np.random.Generator]] = None +@HAS_PYDANTIC.require_in_instance @mutable_dataclass class Options(BasePrimitiveOptions): """Options for the primitives. diff --git a/qiskit/utils/optionals.py b/qiskit/utils/optionals.py index 07dc6e4376e5..a08f4c058c7c 100644 --- a/qiskit/utils/optionals.py +++ b/qiskit/utils/optionals.py @@ -172,6 +172,10 @@ - `Z3 `__ is a theorem prover, used in the :class:`.CrosstalkAdaptiveSchedule` and :class:`.HoareOptimizer` transpiler passes. + * - .. py:data:: HAS_PYDANTIC + - `Pydantic `__ is a data validation libarary, used in the + :class:`.BasePrimitiveV2` for options. + External Command-Line Tools --------------------------- @@ -304,6 +308,7 @@ HAS_TESTTOOLS = _LazyImportTester("testtools", install="pip install testtools") HAS_TWEEDLEDUM = _LazyImportTester("tweedledum", install="pip install tweedledum") HAS_Z3 = _LazyImportTester("z3", install="pip install z3-solver") +HAS_PYDANTIC = _LazyImportTester("pydantic", install="pip install pydantic") HAS_GRAPHVIZ = _LazySubprocessTester( ("dot", "-V"), diff --git a/requirements-optional.txt b/requirements-optional.txt index 6afa25271ab9..3698891a0a85 100644 --- a/requirements-optional.txt +++ b/requirements-optional.txt @@ -38,3 +38,4 @@ z3-solver>=4.7 # manage to get a working install on a Mac the functionality should still work, # but as a convenience this file won't attempt the install itself. tweedledum; python_version<'3.11' and platform_system!="Darwin" +pydantic>=2 diff --git a/requirements.txt b/requirements.txt index 61d7bad257c9..31206e951128 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,3 @@ python-dateutil>=2.8.0 stevedore>=3.0.0 typing-extensions; python_version<'3.11' symengine>=0.9,!=0.10.0 -pydantic diff --git a/test/python/primitives/test_estimatorv2.py b/test/python/primitives/test_estimatorv2.py index 21f74c56d14a..ecc42996117e 100644 --- a/test/python/primitives/test_estimatorv2.py +++ b/test/python/primitives/test_estimatorv2.py @@ -23,8 +23,10 @@ from qiskit.providers import JobV1 from qiskit.quantum_info import SparsePauliOp from qiskit.test import QiskitTestCase +from qiskit.utils.optionals import HAS_PYDANTIC +@unittest.skipUnless(HAS_PYDANTIC, "pydantic not installed.") class TestEstimatorV2(QiskitTestCase): """Test Estimator""" From f075b4e480bd3c09aabf9fa9f748279b85aeddef Mon Sep 17 00:00:00 2001 From: Ikko Hamamura Date: Fri, 1 Dec 2023 15:22:27 +0900 Subject: [PATCH 15/55] Apply suggestions from code review Co-authored-by: Ian Hincks --- qiskit/primitives/base/base_estimator.py | 32 +++++++++--------------- qiskit/primitives/base/base_primitive.py | 2 +- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 953510b89cac..17d18ba8e9f9 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -20,32 +20,24 @@ :class:`~BaseEstimatorV2` estimates expectation values of quantum circuits for provided observables. -An estimator is initialized with an empty parameter set. The estimator is used to -create a :class:`~qiskit.providers.JobV1`, via the -:meth:`~.BaseEstimatorV2.run()` method. This method is called -with the list of pubs (Primitive Unified Blocs). -Pub is composed of tuple of following parameters ``[(circuit, observables, parameter_values)]``. +Following construction, and estimator is used by calling its :meth:`~.BaseEstimatorV2.run` method with a list of pubs (Primitive Unified Blocs). +Each pub contains three values that, together, define a computation unit of work for the estimator to complete: -* quantum circuit (:math:`\psi(\theta)`): (parameterized) quantum circuits - :class:`~qiskit.circuit.QuantumCircuit`. +* a single :class:`~qiskit.circuit.QuantumCircuit`, possibly parametrized, whose final state we define as :math:`\psi(\theta)`, -* observables (:math:`H_j`): a list of :class:`~.ObservablesArrayLike` classes - (including :class:`~.Pauli`, :class:`~.SparsePauliOp`, str). +* one or more observables (specified as any :class:`~.ObservablesArrayLike`, including :class:`~.Pauli`, :class:`~.SparsePauliOp`, ``str``) that specify which expectation values to estimate, denoted :math:`H_j`, and -* parameter values (:math:`\theta_k`): list of sets of values - to be bound to the parameters of the quantum circuits - (list of list of float or list of dict). +* a collection parameter value sets to bind the circuit against, :math:`\theta_k`. -The method returns a :class:`~qiskit.providers.JobV1` object, calling -:meth:`qiskit.providers.JobV1.result()` yields the -a list of expectation values plus optional metadata like confidence intervals for -the estimation. +Running an estimator returns a :class:`~qiskit.providers.JobV1` object, where calling +the method :meth:`qiskit.providers.JobV1.result` results in expectation value estimates and metadata for each pub: .. math:: \langle\psi(\theta_k)|H_j|\psi(\theta_k)\rangle -The broadcast rule applies for observables and parameters. For more information, please check +The observables and parameter values portion of a pub can be array-valued with arbitrary dimensions, +where standard broadcasting rules are applied, so that, in turn, the estimated result for each pub is in general array-valued as well. For more information, please check `here `_. @@ -356,7 +348,7 @@ def parameters(self) -> tuple[ParameterView, ...]: class BaseEstimatorV2(BasePrimitiveV2): """Estimator base class version 2. - An Estimator estimates expectation values of quantum circuits and observables. + An estimator estimates expectation values for provided quantum circuit and observable combinations. """ def __init__(self, options: Optional[BasePrimitiveOptionsLike]): @@ -364,13 +356,13 @@ def __init__(self, options: Optional[BasePrimitiveOptionsLike]): @abstractmethod def run(self, pubs: Iterable[EstimatorPubLike]) -> Job: - """Run the pubs of the estimation of expectation value(s). + """Estimate expectation values for each provided pub (Primitive Unified Bloc). Args: pubs: a iterable of pubslike object. Typically, list of tuple ``(QuantumCircuit, observables, parameter_values)`` Returns: - The job object of Estimator's Result. + A job object that contains results. """ pass diff --git a/qiskit/primitives/base/base_primitive.py b/qiskit/primitives/base/base_primitive.py index 531b3cedb8d5..919d035e597b 100644 --- a/qiskit/primitives/base/base_primitive.py +++ b/qiskit/primitives/base/base_primitive.py @@ -85,7 +85,7 @@ class BasePrimitiveV2(ABC): version = 2 _options_class: type[BasePrimitiveOptions] = BasePrimitiveOptions - def __init__(self, options: Optional[BasePrimitiveOptionsLike] = None): + def __init__(self, options: BasePrimitiveOptionsLike | None = None): self._options = self._options_class() if options: self._options.update(options) From 90447be8569fb251555934a9c8bc858ba3dce15a Mon Sep 17 00:00:00 2001 From: Ikko Hamamura Date: Fri, 1 Dec 2023 15:22:48 +0900 Subject: [PATCH 16/55] Apply suggestions from code review Co-authored-by: Ian Hincks --- qiskit/primitives/base/base_estimator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 17d18ba8e9f9..9e9213d78e3a 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -18,7 +18,7 @@ Overview of EstimatorV2 ======================== -:class:`~BaseEstimatorV2` estimates expectation values of quantum circuits for provided observables. +:class:`~BaseEstimatorV2` is a primitive that estimates expectation values for provided quantum circuit and observable combinations. Following construction, and estimator is used by calling its :meth:`~.BaseEstimatorV2.run` method with a list of pubs (Primitive Unified Blocs). Each pub contains three values that, together, define a computation unit of work for the estimator to complete: From 18053bd571dcd887e998680b965a35c8dca234d7 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Fri, 1 Dec 2023 15:33:24 +0900 Subject: [PATCH 17/55] fix lint --- qiskit/primitives/base/base_estimator.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 9e9213d78e3a..558ad23027a1 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -18,26 +18,33 @@ Overview of EstimatorV2 ======================== -:class:`~BaseEstimatorV2` is a primitive that estimates expectation values for provided quantum circuit and observable combinations. +:class:`~BaseEstimatorV2` is a primitive that estimates expectation values for provided quantum +circuit and observable combinations. -Following construction, and estimator is used by calling its :meth:`~.BaseEstimatorV2.run` method with a list of pubs (Primitive Unified Blocs). -Each pub contains three values that, together, define a computation unit of work for the estimator to complete: +Following construction, and estimator is used by calling its :meth:`~.BaseEstimatorV2.run` method +with a list of pubs (Primitive Unified Blocs). Each pub contains three values that, together, +define a computation unit of work for the estimator to complete: -* a single :class:`~qiskit.circuit.QuantumCircuit`, possibly parametrized, whose final state we define as :math:`\psi(\theta)`, +* a single :class:`~qiskit.circuit.QuantumCircuit`, possibly parametrized, whose final state we +define as :math:`\psi(\theta)`, -* one or more observables (specified as any :class:`~.ObservablesArrayLike`, including :class:`~.Pauli`, :class:`~.SparsePauliOp`, ``str``) that specify which expectation values to estimate, denoted :math:`H_j`, and +* one or more observables (specified as any :class:`~.ObservablesArrayLike`, including +:class:`~.Pauli`, :class:`~.SparsePauliOp`, ``str``) that specify which expectation values to +estimate, denoted :math:`H_j`, and * a collection parameter value sets to bind the circuit against, :math:`\theta_k`. Running an estimator returns a :class:`~qiskit.providers.JobV1` object, where calling -the method :meth:`qiskit.providers.JobV1.result` results in expectation value estimates and metadata for each pub: +the method :meth:`qiskit.providers.JobV1.result` results in expectation value estimates and metadata +for each pub: .. math:: \langle\psi(\theta_k)|H_j|\psi(\theta_k)\rangle -The observables and parameter values portion of a pub can be array-valued with arbitrary dimensions, -where standard broadcasting rules are applied, so that, in turn, the estimated result for each pub is in general array-valued as well. For more information, please check +The observables and parameter values portion of a pub can be array-valued with arbitrary dimensions, +where standard broadcasting rules are applied, so that, in turn, the estimated result for each pub +is in general array-valued as well. For more information, please check `here `_. From 998547e3c0d583ee9324314857b5910c9439139f Mon Sep 17 00:00:00 2001 From: ikkoham Date: Fri, 1 Dec 2023 15:41:45 +0900 Subject: [PATCH 18/55] type hint --- qiskit/primitives/base/base_estimator.py | 4 ++-- qiskit/primitives/base/base_primitive.py | 1 - qiskit/primitives/containers/bindings_array.py | 10 +++++----- qiskit/primitives/containers/data_bin.py | 7 ++++--- qiskit/primitives/containers/object_array.py | 9 +++++---- qiskit/primitives/containers/observables_array.py | 6 +++--- qiskit/primitives/containers/options.py | 4 ++-- qiskit/primitives/containers/primitive_result.py | 4 ++-- qiskit/primitives/containers/shape.py | 2 +- qiskit/primitives/statevector_estimator.py | 8 ++++---- 10 files changed, 28 insertions(+), 27 deletions(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 558ad23027a1..f7c2f1d9f889 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -173,7 +173,7 @@ from abc import abstractmethod from collections.abc import Iterable, Sequence from copy import copy -from typing import Generic, Optional, TypeVar +from typing import Generic, TypeVar from qiskit.circuit import QuantumCircuit from qiskit.circuit.parametertable import ParameterView @@ -358,7 +358,7 @@ class BaseEstimatorV2(BasePrimitiveV2): An estimator estimates expectation values for provided quantum circuit and observable combinations. """ - def __init__(self, options: Optional[BasePrimitiveOptionsLike]): + def __init__(self, options: BasePrimitiveOptionsLike | None): super().__init__(options=options) @abstractmethod diff --git a/qiskit/primitives/base/base_primitive.py b/qiskit/primitives/base/base_primitive.py index 919d035e597b..e49a245f203c 100644 --- a/qiskit/primitives/base/base_primitive.py +++ b/qiskit/primitives/base/base_primitive.py @@ -16,7 +16,6 @@ from abc import ABC from collections.abc import Sequence -from typing import Optional from qiskit.circuit import QuantumCircuit from qiskit.primitives.containers import BasePrimitiveOptions, BasePrimitiveOptionsLike diff --git a/qiskit/primitives/containers/bindings_array.py b/qiskit/primitives/containers/bindings_array.py index a8a9b15207c1..a99203283d21 100644 --- a/qiskit/primitives/containers/bindings_array.py +++ b/qiskit/primitives/containers/bindings_array.py @@ -17,7 +17,7 @@ from collections.abc import Iterable, Mapping, Sequence from itertools import chain -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, List, Tuple, Union import numpy as np from numpy.typing import ArrayLike, NDArray @@ -72,9 +72,9 @@ class BindingsArray(ShapedMixin): def __init__( self, - vals: Union[None, ArrayLike, Iterable[ArrayLike]] = None, - kwvals: Union[None, Mapping[ParameterLike, Iterable[ParameterValueType]], ArrayLike] = None, - shape: Optional[ShapeInput] = None, + vals: ArrayLike | Iterable[ArrayLike] | None = None, + kwvals: Mapping[ParameterLike, Iterable[ParameterValueType]] | ArrayLike | None = None, + shape: ShapeInput | None = None, ): """ The ``shape`` argument does not need to be provided whenever it can unambiguously @@ -218,7 +218,7 @@ def ravel(self) -> BindingsArray: """ return self.reshape(self.size) - def reshape(self, shape: Union[int, Iterable[int]]) -> BindingsArray: + def reshape(self, shape: int | Iterable[int]) -> BindingsArray: """Return a new :class:`~BindingsArray` with a different shape. This results in a new view of the same arrays. diff --git a/qiskit/primitives/containers/data_bin.py b/qiskit/primitives/containers/data_bin.py index 6f8b4668d59d..61ea2ba07ad8 100644 --- a/qiskit/primitives/containers/data_bin.py +++ b/qiskit/primitives/containers/data_bin.py @@ -13,9 +13,10 @@ """ Dataclass tools for data namespaces (bins) """ +from __future__ import annotations from dataclasses import make_dataclass -from typing import Iterable, Optional, Tuple +from typing import Iterable, Tuple class DataBinMeta(type): @@ -40,7 +41,7 @@ class DataBin(metaclass=DataBinMeta): """ _RESTRICTED_NAMES = ("_RESTRICTED_NAMES", "_SHAPE", "_FIELDS", "_FIELD_TYPES") - _SHAPE: Optional[Tuple[int, ...]] = None + _SHAPE: Tuple[int, ...] | None = None _FIELDS: Tuple[str, ...] = () """The fields allowed in this data bin.""" _FIELD_TYPES: Tuple[type, ...] = () @@ -52,7 +53,7 @@ def __repr__(self): def make_data_bin( - fields: Iterable[Tuple[str, type]], shape: Optional[Tuple[int, ...]] = None + fields: Iterable[Tuple[str, type]], shape: Tuple[int, ...] | None = None ) -> DataBinMeta: """Return a new subclass of :class:`~DataBin` with the provided fields and shape. diff --git a/qiskit/primitives/containers/object_array.py b/qiskit/primitives/containers/object_array.py index efd31234ddf3..64d42be3be92 100644 --- a/qiskit/primitives/containers/object_array.py +++ b/qiskit/primitives/containers/object_array.py @@ -13,8 +13,9 @@ """ Object ND-array initialization function. """ +from __future__ import annotations -from typing import Optional, Sequence, Tuple +from collections.abc import Sequence import numpy as np from numpy.typing import ArrayLike @@ -22,9 +23,9 @@ def object_array( arr: ArrayLike, - order: Optional[str] = None, + order: str | None = None, copy: bool = True, - list_types: Optional[Sequence[type]] = (), + list_types: Sequence[type] | None = (), ) -> np.ndarray: """Convert an array-like of objects into an object array. @@ -81,7 +82,7 @@ def _flatten(nested, k): return obj_arr -def _infer_shape(obj: ArrayLike, list_types: Tuple[type, ...] = ()) -> Tuple[int, ...]: +def _infer_shape(obj: ArrayLike, list_types: tuple[type, ...] = ()) -> tuple[int, ...]: """Infer the shape of an array-like object without casting""" if isinstance(obj, np.ndarray): return obj.shape diff --git a/qiskit/primitives/containers/observables_array.py b/qiskit/primitives/containers/observables_array.py index 4f62958a53e7..9e111f458d83 100644 --- a/qiskit/primitives/containers/observables_array.py +++ b/qiskit/primitives/containers/observables_array.py @@ -52,7 +52,7 @@ class ObservablesArray(ShapedMixin): def __init__( self, - observables: Union[BasisObservableLike, ArrayLike], + observables: BasisObservableLike | ArrayLike, copy: bool = True, validate: bool = True, ): @@ -113,13 +113,13 @@ def __getitem__(self, args: int | tuple[int, ...]) -> BasisObservable: def __getitem__(self, args: slice) -> ObservablesArray: ... - def __getitem__(self, args) -> Union[ObservablesArray, BasisObservable]: + def __getitem__(self, args): item = self._array[args] if not isinstance(item, np.ndarray): return item return ObservablesArray(item, copy=False, validate=False) - def reshape(self, shape: Union[int, Iterable[int]]) -> "ObservablesArray": + def reshape(self, shape: int | Iterable[int]) -> ObservablesArray: """Return a new array with a different shape. This results in a new view of the same arrays. diff --git a/qiskit/primitives/containers/options.py b/qiskit/primitives/containers/options.py index af6e0fa55bd1..227b0afd8ae2 100644 --- a/qiskit/primitives/containers/options.py +++ b/qiskit/primitives/containers/options.py @@ -17,7 +17,7 @@ from __future__ import annotations from abc import ABC -from typing import Optional, Union +from typing import Union from .dataclasses import mutable_dataclass @@ -26,7 +26,7 @@ class BasePrimitiveOptions(ABC): """Base class of options for primitives.""" - def update(self, options: Optional[BasePrimitiveOptions] = None, **kwargs): + def update(self, options: BasePrimitiveOptions | None = None, **kwargs): """Update the options.""" if options is not None: if not isinstance(options, BasePrimitiveOptions): diff --git a/qiskit/primitives/containers/primitive_result.py b/qiskit/primitives/containers/primitive_result.py index 7f2f27ec9596..3a77c2325282 100644 --- a/qiskit/primitives/containers/primitive_result.py +++ b/qiskit/primitives/containers/primitive_result.py @@ -15,7 +15,7 @@ from __future__ import annotations from collections.abc import Iterable -from typing import Any, Generic, Optional, TypeVar +from typing import Any, Generic, TypeVar from .pub_result import PubResult @@ -25,7 +25,7 @@ class PrimitiveResult(Generic[T]): """A container for multiple pub results and global metadata.""" - def __init__(self, pub_results: Iterable[T], metadata: Optional[dict[str, Any]] = None): + def __init__(self, pub_results: Iterable[T], metadata: dict[str, Any] | None = None): """ Args: pub_results: Pub results. diff --git a/qiskit/primitives/containers/shape.py b/qiskit/primitives/containers/shape.py index 1a000da684c3..cb19cbc61068 100644 --- a/qiskit/primitives/containers/shape.py +++ b/qiskit/primitives/containers/shape.py @@ -71,7 +71,7 @@ def size(self): return int(np.prod(self._shape, dtype=int)) -def array_coerce(arr: Union[ArrayLike, Shaped]) -> Union[NDArray, Shaped]: +def array_coerce(arr: ArrayLike | Shaped) -> NDArray | Shaped: """Coerce the input into an object with a shape attribute. Copies are avoided. diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py index 3243213ff9eb..21720a55c797 100644 --- a/qiskit/primitives/statevector_estimator.py +++ b/qiskit/primitives/statevector_estimator.py @@ -16,7 +16,7 @@ from __future__ import annotations from collections.abc import Iterable -from typing import Optional, Union +from typing import Union import numpy as np from numpy.typing import NDArray @@ -50,8 +50,8 @@ class ExecutionOptions(BasePrimitiveOptions): """Options for execution.""" - shots: Optional[PositiveInt] = None - seed: Optional[Union[int, np.random.Generator]] = None + shots: PositiveInt | None = None + seed: Union[int, np.random.Generator] | None = None @HAS_PYDANTIC.require_in_instance @@ -85,7 +85,7 @@ class Estimator(BaseEstimatorV2): _options_class = Options options: Options - def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): + def __init__(self, *, options: BasePrimitiveOptionsLike | None = None): """ Args: options: Options including shots, seed. From c3fd89ac76d8bc994e8fd8912da65a38e54edee4 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Fri, 1 Dec 2023 15:49:16 +0900 Subject: [PATCH 19/55] fix type hint for python 3.8 --- qiskit/primitives/statevector_estimator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py index 21720a55c797..21a750777bc6 100644 --- a/qiskit/primitives/statevector_estimator.py +++ b/qiskit/primitives/statevector_estimator.py @@ -16,7 +16,7 @@ from __future__ import annotations from collections.abc import Iterable -from typing import Union +from typing import Optional, Union import numpy as np from numpy.typing import NDArray @@ -50,8 +50,8 @@ class ExecutionOptions(BasePrimitiveOptions): """Options for execution.""" - shots: PositiveInt | None = None - seed: Union[int, np.random.Generator] | None = None + shots: Optional[PositiveInt] = None + seed: Optional[Union[int, np.random.Generator]] = None @HAS_PYDANTIC.require_in_instance From 2bc26555a7ca56394da835e7b5948bb229ad9148 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Fri, 1 Dec 2023 15:59:02 +0900 Subject: [PATCH 20/55] improve BindingsArray --- .../primitives/containers/bindings_array.py | 12 +- .../containers/test_bindings_array.py | 115 +++++++++++++++++- 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/qiskit/primitives/containers/bindings_array.py b/qiskit/primitives/containers/bindings_array.py index a99203283d21..dae1c57d1e0e 100644 --- a/qiskit/primitives/containers/bindings_array.py +++ b/qiskit/primitives/containers/bindings_array.py @@ -105,8 +105,6 @@ def __init__( """ super().__init__() - if vals is None and kwvals is None and shape is None: - raise ValueError("Must specify a shape if no values are present") if vals is None: vals = [] if kwvals is None: @@ -252,10 +250,8 @@ def coerce(cls, bindings_array: BindingsArrayLike) -> BindingsArray: if isinstance(bindings_array, Sequence): bindings_array = np.array(bindings_array) if bindings_array is None: - bindings_array = cls([], shape=(1,)) + bindings_array = cls() elif isinstance(bindings_array, np.ndarray): - if bindings_array.ndim == 1: - bindings_array = bindings_array.reshape((1, -1)) bindings_array = cls(bindings_array) elif isinstance(bindings_array, Mapping): bindings_array = cls(kwvals=bindings_array) @@ -323,8 +319,8 @@ def examine_array(*possible_shapes): if len(parameters) > 1: # here, the last dimension _has_ to be over parameters examine_array(val.shape[:-1]) - elif val.shape[-1] != 1: - # here, if the last dimension is not 1 then the shape is the shape + elif val.shape == () or val.shape == (1,) or val.shape[-1] != 1: + # here, if the last dimension is not 1 or shape is () or (1,) then the shape is the shape examine_array(val.shape) else: # here, the last dimension could be over parameters or not @@ -332,6 +328,8 @@ def examine_array(*possible_shapes): if len(vals) == 1 and len(kwvals) == 0: examine_array(vals[0].shape[:-1]) + elif len(vals) == 0 and len(kwvals) == 0: + examine_array(()) else: for val in vals: # here, the last dimension could be over parameters or not diff --git a/test/python/primitives/containers/test_bindings_array.py b/test/python/primitives/containers/test_bindings_array.py index de9723136b1d..fec1e228a960 100644 --- a/test/python/primitives/containers/test_bindings_array.py +++ b/test/python/primitives/containers/test_bindings_array.py @@ -37,12 +37,16 @@ def setUp(self): def test_construction_failures(self): """Test all the possible construction failures""" - with self.assertRaisesRegex(ValueError, "specify a shape if no values"): - BindingsArray() - with self.assertRaisesRegex(ValueError, "inconsistent with last dimension of"): BindingsArray(kwvals={Parameter("a"): [0, 1]}, shape=()) + with self.assertRaisesRegex(ValueError, r"Array with shape \(\) inconsistent with \(1,\)"): + BindingsArray(kwvals={Parameter("a"): 0}, shape=(1,)) + + with self.assertRaisesRegex(ValueError, "ambiguous"): + # could have shape (1,) or (1, 1) + BindingsArray(kwvals={Parameter("a"): [[1]]}) + with self.assertRaisesRegex(ValueError, r"\(3, 5\) inconsistent with \(2,\)"): BindingsArray(np.empty((3, 5)), shape=2) @@ -282,3 +286,108 @@ def test_vals_kwvals(self): self.assertEqual(ba.ndim, 2) self.assertEqual(ba.shape, (5, 10)) self.assertEqual(ba.size, 50) + + def test_simple_kwvals(self): + """Test simple constructions of BindingsArrays using kwvals.""" + with self.subTest("Single number kwval 1"): + ba = BindingsArray(kwvals={Parameter("a"): 1.0}) + self.assertEqual(ba.shape, ()) + + with self.subTest("Single number kwval 1 with shape"): + ba = BindingsArray(kwvals={Parameter("a"): 1.0}, shape=()) + self.assertEqual(ba.shape, ()) + + with self.subTest("Single number kwval 1 ndarray"): + ba = BindingsArray(kwvals={Parameter("a"): np.array(1.0)}) + self.assertEqual(ba.shape, ()) + + with self.subTest("Single number kwval 2"): + ba = BindingsArray(kwvals={Parameter("a"): 1.0, Parameter("b"): 0.0}) + self.assertEqual(ba.shape, ()) + + with self.subTest("Empty kwval"): + ba = BindingsArray(kwvals={Parameter("a"): []}) + self.assertEqual(ba.shape, (0,)) + + with self.subTest("Single kwval"): + ba = BindingsArray(kwvals={Parameter("a"): [0.0]}) + self.assertEqual(ba.shape, (1,)) + + with self.subTest("Single kwval ndarray"): + ba = BindingsArray(kwvals={Parameter("a"): np.array([0.0])}) + self.assertEqual(ba.shape, (1,)) + + with self.subTest("Multi kwval"): + ba = BindingsArray(kwvals={Parameter("a"): [0.0, 1.0]}) + self.assertEqual(ba.shape, (2,)) + + with self.subTest("Multiple kwvals empty"): + ba = BindingsArray(kwvals={Parameter("a"): [], Parameter("b"): []}) + self.assertEqual(ba.shape, (0,)) + + with self.subTest("Multiple kwvals single"): + ba = BindingsArray(kwvals={Parameter("a"): [0.0], Parameter("b"): [1.0]}) + self.assertEqual(ba.shape, (1,)) + + with self.subTest("Multiple kwvals multi"): + ba = BindingsArray(kwvals={Parameter("a"): [0.0, 1.0], Parameter("b"): [1.0, 0.0]}) + self.assertEqual(ba.shape, (2,)) + + def test_empty(self): + """Test simple constructions of BindingsArrays with empty cases""" + with self.subTest("Empty 1"): + ba = BindingsArray() + self.assertEqual(ba.shape, ()) + + with self.subTest("Empty 2"): + ba = BindingsArray([], shape=()) + self.assertEqual(ba.shape, ()) + + with self.subTest("Empty 3"): + ba = BindingsArray([], {}, shape=()) + self.assertEqual(ba.shape, ()) + + with self.subTest("Empty 4"): + ba = BindingsArray(shape=()) + self.assertEqual(ba.shape, ()) + + with self.subTest("Empty 5"): + ba = BindingsArray(kwvals={}, shape=()) + self.assertEqual(ba.shape, ()) + + def test_simple_vals(self): + """Test simple constructions of BindingsArrays using vals.""" + with self.subTest("0-d vals"): + ba = BindingsArray([1, 2, 3]) + self.assertEqual(ba.shape, ()) + # ba.vals => [array([1]), array([2]), array([3])] + self.assertEqual(len(ba.vals), 3) + self.assertEqual(ba.vals[0], 1) + self.assertEqual(ba.vals[1], 2) + self.assertEqual(ba.vals[2], 3) + + with self.subTest("1-d vals"): + ba = BindingsArray([[1, 2, 3]]) + self.assertEqual(ba.shape, ()) + # ba.vals => [array([1, 2, 3])] + self.assertEqual(len(ba.vals), 1) + np.testing.assert_allclose(ba.vals[0], [1, 2, 3]) + + with self.subTest("1-d vals ndarray"): + ba = BindingsArray(np.array([1, 2, 3])) + self.assertEqual(ba.shape, ()) + # ba.vals => [array([1, 2, 3])] + self.assertEqual(len(ba.vals), 1) + np.testing.assert_allclose(ba.vals[0], [1, 2, 3]) + + with self.subTest("2-d vals"): + ba = BindingsArray([[[1, 2, 3]]]) + self.assertEqual(ba.shape, (1,)) + self.assertEqual(len(ba.vals), 1) + np.testing.assert_allclose(ba.vals[0], [[1, 2, 3]]) + + with self.subTest("2-d vals ndarray"): + ba = BindingsArray(np.array([[1, 2, 3]])) + self.assertEqual(ba.shape, (1,)) + self.assertEqual(len(ba.vals), 1) + np.testing.assert_allclose(ba.vals[0], [[1, 2, 3]]) From dfcc9960a47997ac23107b59875b8bfe84937c23 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Fri, 1 Dec 2023 18:27:38 +0900 Subject: [PATCH 21/55] fix docs warning --- qiskit/primitives/base/base_estimator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index f7c2f1d9f889..89d14ce05f82 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -26,11 +26,11 @@ define a computation unit of work for the estimator to complete: * a single :class:`~qiskit.circuit.QuantumCircuit`, possibly parametrized, whose final state we -define as :math:`\psi(\theta)`, + define as :math:`\psi(\theta)`, * one or more observables (specified as any :class:`~.ObservablesArrayLike`, including -:class:`~.Pauli`, :class:`~.SparsePauliOp`, ``str``) that specify which expectation values to -estimate, denoted :math:`H_j`, and + :class:`~.Pauli`, :class:`~.SparsePauliOp`, ``str``) that specify which expectation values to + estimate, denoted :math:`H_j`, and * a collection parameter value sets to bind the circuit against, :math:`\theta_k`. From ce01d8f0796b187d69970d38762f26cad2f12dcb Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Fri, 1 Dec 2023 12:37:20 -0500 Subject: [PATCH 22/55] Remove `slots=True` in dataclass usage. This argument was introduced in Python 3.10, so we cannot use it yet. We don't expect that this has any performance impacts, it is just being used here to make it more difficult to mutate. --- qiskit/primitives/containers/dataclasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit/primitives/containers/dataclasses.py b/qiskit/primitives/containers/dataclasses.py index e31cb69642e7..e36404c582a7 100644 --- a/qiskit/primitives/containers/dataclasses.py +++ b/qiskit/primitives/containers/dataclasses.py @@ -32,4 +32,4 @@ from dataclasses import dataclass mutable_dataclass = dataclass(frozen=False) - frozen_dataclass = dataclass(frozen=True, slots=True) + frozen_dataclass = dataclass(frozen=True) From 199aeaa30c7b3560431900068857af0819a78a91 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Sat, 2 Dec 2023 11:24:21 +0900 Subject: [PATCH 23/55] update from review comments --- qiskit/primitives/base/base_estimator.py | 21 --------- qiskit/primitives/containers/base_pub.py | 25 +++++++--- .../primitives/containers/bindings_array.py | 16 ++++--- qiskit/primitives/containers/data_bin.py | 10 ++-- qiskit/primitives/containers/estimator_pub.py | 46 ++++++++++++++----- qiskit/primitives/containers/options.py | 16 ++++--- qiskit/primitives/containers/pub_result.py | 36 +++++++++------ qiskit/primitives/containers/shape.py | 8 ++-- qiskit/primitives/statevector_estimator.py | 1 + 9 files changed, 102 insertions(+), 77 deletions(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 89d14ce05f82..97c249e2bdba 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -83,25 +83,6 @@ job_result = job2.result() print(f"The primitive-job finished with result {job_result}") -============================== -Migration guide from V1 to V2 -============================== - - -The original three arguments are now a single argument pubs. -To accommodate this change, the zip function can be used for easy migration. -For example, suppose the code originally is: - -.. code-block:: python - - estimator.run([psi1], [hamiltonian1], [theta1]) # for EstimatorV1 - -Just add zip function: - -.. code-block:: python - - estimator.run(zip([psi1], [hamiltonian1], [theta1])) # for EstimatorV2 - ======================== Overview of EstimatorV1 @@ -181,7 +162,6 @@ from qiskit.quantum_info.operators import SparsePauliOp from qiskit.quantum_info.operators.base_operator import BaseOperator from qiskit.utils.deprecation import deprecate_func -from qiskit.utils.optionals import HAS_PYDANTIC from ..containers.estimator_pub import EstimatorPubLike from ..containers.options import BasePrimitiveOptionsLike @@ -351,7 +331,6 @@ def parameters(self) -> tuple[ParameterView, ...]: BaseEstimator = BaseEstimatorV1 -@HAS_PYDANTIC.require_in_instance class BaseEstimatorV2(BasePrimitiveV2): """Estimator base class version 2. diff --git a/qiskit/primitives/containers/base_pub.py b/qiskit/primitives/containers/base_pub.py index fdb55f4c1ade..47aaefd1bcf1 100644 --- a/qiskit/primitives/containers/base_pub.py +++ b/qiskit/primitives/containers/base_pub.py @@ -17,18 +17,29 @@ from __future__ import annotations from qiskit import QuantumCircuit -from qiskit.utils.optionals import HAS_PYDANTIC -from .dataclasses import frozen_dataclass - -@HAS_PYDANTIC.require_in_instance -@frozen_dataclass class BasePub: """Base class for PUB (Primitive Unified Bloc)""" - circuit: QuantumCircuit - """Quantum circuit object for the pubs.""" + __slots__ = ("_circuit",) + + def __init__(self, circuit: QuantumCircuit, validate: bool = False): + """ + Initialize a BasePub. + + Args: + circuit: Quantum circuit object for the pubs. + validate: if True, the input data is validated during initizlization. + """ + self._circuit = circuit + if validate: + self.validate() + + @property + def circuit(self) -> QuantumCircuit: + """A quantum circuit for the pub""" + return self._circuit def validate(self): """Validate the data""" diff --git a/qiskit/primitives/containers/bindings_array.py b/qiskit/primitives/containers/bindings_array.py index dae1c57d1e0e..f5b24577a55b 100644 --- a/qiskit/primitives/containers/bindings_array.py +++ b/qiskit/primitives/containers/bindings_array.py @@ -17,7 +17,7 @@ from collections.abc import Iterable, Mapping, Sequence from itertools import chain -from typing import Dict, List, Tuple, Union +from typing import Union import numpy as np from numpy.typing import ArrayLike, NDArray @@ -77,6 +77,8 @@ def __init__( shape: ShapeInput | None = None, ): """ + Initialize a ``BindingsArray``. It can take parameter vectors and dictionatirs. + The ``shape`` argument does not need to be provided whenever it can unambiguously be inferred from the provided arrays. Ambiguity arises because an array provided to the constructor might represent values for either a single parameter, with an implicit missing @@ -147,7 +149,7 @@ def __getitem__(self, args) -> BindingsArray: return BindingsArray(vals, kwvals, shape) @property - def kwvals(self) -> Dict[Tuple[str, ...], np.ndarray]: + def kwvals(self) -> dict[tuple[str, ...], np.ndarray]: """The keyword values of this array.""" return {_format_key(k): v for k, v in self._kwvals.items()} @@ -157,11 +159,11 @@ def num_parameters(self) -> int: return sum(val.shape[-1] for val in chain(self.vals, self._kwvals.values())) @property - def vals(self) -> List[np.ndarray]: + def vals(self) -> list[np.ndarray]: """The non-keyword values of this array.""" return self._vals - def bind(self, circuit: QuantumCircuit, loc: Tuple[int, ...]) -> QuantumCircuit: + def bind(self, circuit: QuantumCircuit, loc: tuple[int, ...]) -> QuantumCircuit: """Return the circuit bound to the values at the provided index. Args: @@ -269,7 +271,7 @@ def validate(self): ) -def _standardize_shape(val: np.ndarray, shape: Tuple[int, ...]) -> np.ndarray: +def _standardize_shape(val: np.ndarray, shape: tuple[int, ...]) -> np.ndarray: """Return ``val`` or ``val[..., None]``. Args: @@ -291,8 +293,8 @@ def _standardize_shape(val: np.ndarray, shape: Tuple[int, ...]) -> np.ndarray: def _infer_shape( - vals: List[np.ndarray], kwvals: Dict[Tuple[Parameter, ...], np.ndarray] -) -> Tuple[int, ...]: + vals: list[np.ndarray], kwvals: dict[tuple[Parameter, ...], np.ndarray] +) -> tuple[int, ...]: """Return a shape tuple that consistently defines the leading dimensions of all arrays. Args: diff --git a/qiskit/primitives/containers/data_bin.py b/qiskit/primitives/containers/data_bin.py index 61ea2ba07ad8..c8886d4d75a0 100644 --- a/qiskit/primitives/containers/data_bin.py +++ b/qiskit/primitives/containers/data_bin.py @@ -15,8 +15,8 @@ """ from __future__ import annotations +from collections.abc import Iterable from dataclasses import make_dataclass -from typing import Iterable, Tuple class DataBinMeta(type): @@ -41,10 +41,10 @@ class DataBin(metaclass=DataBinMeta): """ _RESTRICTED_NAMES = ("_RESTRICTED_NAMES", "_SHAPE", "_FIELDS", "_FIELD_TYPES") - _SHAPE: Tuple[int, ...] | None = None - _FIELDS: Tuple[str, ...] = () + _SHAPE: tuple[int, ...] | None = None + _FIELDS: tuple[str, ...] = () """The fields allowed in this data bin.""" - _FIELD_TYPES: Tuple[type, ...] = () + _FIELD_TYPES: tuple[type, ...] = () """The types of each field.""" def __repr__(self): @@ -53,7 +53,7 @@ def __repr__(self): def make_data_bin( - fields: Iterable[Tuple[str, type]], shape: Tuple[int, ...] | None = None + fields: Iterable[tuple[str, type]], shape: tuple[int, ...] | None = None ) -> DataBinMeta: """Return a new subclass of :class:`~DataBin` with the provided fields and shape. diff --git a/qiskit/primitives/containers/estimator_pub.py b/qiskit/primitives/containers/estimator_pub.py index ae61e02533af..fe2398a38c40 100644 --- a/qiskit/primitives/containers/estimator_pub.py +++ b/qiskit/primitives/containers/estimator_pub.py @@ -25,24 +25,48 @@ from .base_pub import BasePub from .bindings_array import BindingsArray, BindingsArrayLike -from .dataclasses import frozen_dataclass from .observables_array import ObservablesArray, ObservablesArrayLike from .shape import ShapedMixin -@frozen_dataclass class EstimatorPub(BasePub, ShapedMixin): """Pub (Primitive Unified Bloc) for Estimator. Pub is composed of triple (circuit, observables, parameter_values). """ - observables: ObservablesArray - parameter_values: BindingsArray = BindingsArray(shape=()) - _shape: Tuple[int, ...] = () + __slots__ = ("_observables", "_parameter_values", "_shape") - def __post_init__(self): - shape = np.broadcast_shapes(self.observables.shape, self.parameter_values.shape) - self._shape = shape + def __init__( + self, + circuit: QuantumCircuit, + observables: ObservablesArray, + parameter_values: BindingsArray | None = None, + validate: bool = False, + ): + """Initialize an estimator pub. + + Args: + circuit: a quantum circuit. + observables: an observables array. + parameter_values: a bindings array. + validate: if True, the input data is validated during initizlization. + """ + super().__init__(circuit, validate) + self._observables = observables + self._parameter_values = parameter_values or BindingsArray() + + # For ShapedMixin + self._shape = np.broadcast_shapes(self.observables.shape, self.parameter_values.shape) + + @property + def observables(self) -> ObservablesArray: + """An observables array""" + return self._observables + + @property + def parameter_values(self) -> BindingsArray: + """A bindings array""" + return self._parameter_values @classmethod def coerce(cls, pub: EstimatorPubLike) -> EstimatorPub: @@ -67,9 +91,7 @@ def coerce(cls, pub: EstimatorPubLike) -> EstimatorPub: def validate(self): """Validate the pub.""" - super(EstimatorPub, self).validate() # pylint: disable=super-with-arguments - # I'm not sure why these arguments for super are needed. But if no args, tests are failed - # for Python >=3.10. Seems to be some bug, but I can't fix. + super().validate() self.observables.validate() self.parameter_values.validate() # Cross validate circuits and observables @@ -80,7 +102,7 @@ def validate(self): f"The number of qubits of the circuit ({self.circuit.num_qubits}) does " f"not match the number of qubits of the {i}-th observable ({num_qubits})." ) - # Cross validate circuits and paramter_values + # Cross validate circuits and parameter_values num_parameters = self.parameter_values.num_parameters if num_parameters != self.circuit.num_parameters: raise ValueError( diff --git a/qiskit/primitives/containers/options.py b/qiskit/primitives/containers/options.py index 227b0afd8ae2..b8b4f95f5546 100644 --- a/qiskit/primitives/containers/options.py +++ b/qiskit/primitives/containers/options.py @@ -17,21 +17,23 @@ from __future__ import annotations from abc import ABC +from collections.abc import Mapping from typing import Union -from .dataclasses import mutable_dataclass - -@mutable_dataclass class BasePrimitiveOptions(ABC): """Base class of options for primitives.""" - def update(self, options: BasePrimitiveOptions | None = None, **kwargs): + def update(self, options: BasePrimitiveOptions | Mapping | None = None, **kwargs): """Update the options.""" if options is not None: - if not isinstance(options, BasePrimitiveOptions): - raise TypeError(f"Type {type(options)} is not options class") - for key, val in options.__dict__.items(): + if isinstance(options, Mapping): + options_dict = options + elif isinstance(options, BasePrimitiveOptions): + options_dict = options.__dict__ + else: + raise TypeError(f"Type {type(options)} is not options nor Mapping class") + for key, val in options_dict.items(): setattr(self, key, val) for key, val in kwargs.items(): diff --git a/qiskit/primitives/containers/pub_result.py b/qiskit/primitives/containers/pub_result.py index feb61fb6283a..ca1f3447ef56 100644 --- a/qiskit/primitives/containers/pub_result.py +++ b/qiskit/primitives/containers/pub_result.py @@ -16,22 +16,30 @@ from __future__ import annotations -from qiskit.utils.optionals import HAS_PYDANTIC - from .data_bin import DataBin -from .dataclasses import frozen_dataclass - -if HAS_PYDANTIC: - from pydantic import Field -else: - from dataclasses import field as Field -@frozen_dataclass class PubResult: - """Result of pub (Primitive Unified Bloc).""" + """Result of Primitive Unified Bloc.""" + + __slots__ = ("_data", "_metadata") + + def __init__(self, data: DataBin, metadata: dict | None = None): + """Initialize a pub result. + + Args: + data: result data bin. + metadata: metadata dictionary. + """ + self._data = data + self._metadata = metadata or {} + + @property + def data(self) -> DataBin: + """Result data for the pub""" + return self._data - data: DataBin - """Result data for the pub""" - metadata: dict = Field(default_factory=dict) - """Metadata for the pub""" + @property + def metadata(self) -> {}: + """Metadata for the pub""" + return self._metadata diff --git a/qiskit/primitives/containers/shape.py b/qiskit/primitives/containers/shape.py index cb19cbc61068..4153905ef971 100644 --- a/qiskit/primitives/containers/shape.py +++ b/qiskit/primitives/containers/shape.py @@ -16,7 +16,7 @@ from __future__ import annotations from collections.abc import Iterable -from typing import Protocol, Tuple, Union, runtime_checkable +from typing import Protocol, Union, runtime_checkable import numpy as np from numpy.typing import ArrayLike, NDArray @@ -35,7 +35,7 @@ class Shaped(Protocol): """ @property - def shape(self) -> Tuple[int, ...]: + def shape(self) -> tuple[int, ...]: """The array shape of this object.""" raise NotImplementedError("A `Shaped` protocol must implement the `shape` property") @@ -53,7 +53,7 @@ def size(self) -> int: class ShapedMixin(Shaped): """Mixin class to create :class:`~Shaped` types by only providing :attr:`_shape` attribute.""" - _shape: Tuple[int, ...] + _shape: tuple[int, ...] def __repr__(self): return f"{type(self).__name__}(<{self.shape}>)" @@ -113,7 +113,7 @@ def _flatten_to_ints(arg: ShapeInput) -> Iterable[int]: raise ValueError(f"Expected {item} to be iterable or an integer.") from ex -def shape_tuple(*shapes: ShapeInput) -> Tuple[int, ...]: +def shape_tuple(*shapes: ShapeInput) -> tuple[int, ...]: """ Flatten the input into a single tuple of integers, preserving order. diff --git a/qiskit/primitives/statevector_estimator.py b/qiskit/primitives/statevector_estimator.py index 21a750777bc6..f56f48c1329d 100644 --- a/qiskit/primitives/statevector_estimator.py +++ b/qiskit/primitives/statevector_estimator.py @@ -66,6 +66,7 @@ class Options(BasePrimitiveOptions): execution: ExecutionOptions = Field(default_factory=ExecutionOptions) +@HAS_PYDANTIC.require_in_instance class Estimator(BaseEstimatorV2): """ Simple implementation of :class:`BaseEstimatorV2` with Statevector. From 388d2bf7281945d5b5c54c4d08731ac3268c2229 Mon Sep 17 00:00:00 2001 From: Ikko Hamamura Date: Sat, 2 Dec 2023 11:30:20 +0900 Subject: [PATCH 24/55] Update qiskit/primitives/containers/estimator_pub.py Co-authored-by: Christopher J. Wood --- qiskit/primitives/containers/estimator_pub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit/primitives/containers/estimator_pub.py b/qiskit/primitives/containers/estimator_pub.py index fe2398a38c40..deb7891a8f62 100644 --- a/qiskit/primitives/containers/estimator_pub.py +++ b/qiskit/primitives/containers/estimator_pub.py @@ -30,7 +30,7 @@ class EstimatorPub(BasePub, ShapedMixin): - """Pub (Primitive Unified Bloc) for Estimator. + """Primitive Unified Bloc for Estimator. Pub is composed of triple (circuit, observables, parameter_values). """ From 7777deaf0b0775d383ddd1338f16406d9554c05b Mon Sep 17 00:00:00 2001 From: Ikko Hamamura Date: Sat, 2 Dec 2023 11:30:38 +0900 Subject: [PATCH 25/55] Update qiskit/primitives/base/base_estimator.py Co-authored-by: Ian Hincks --- qiskit/primitives/base/base_estimator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit/primitives/base/base_estimator.py b/qiskit/primitives/base/base_estimator.py index 97c249e2bdba..6b25a7e9774c 100644 --- a/qiskit/primitives/base/base_estimator.py +++ b/qiskit/primitives/base/base_estimator.py @@ -21,7 +21,7 @@ :class:`~BaseEstimatorV2` is a primitive that estimates expectation values for provided quantum circuit and observable combinations. -Following construction, and estimator is used by calling its :meth:`~.BaseEstimatorV2.run` method +Following construction, an estimator is used by calling its :meth:`~.BaseEstimatorV2.run` method with a list of pubs (Primitive Unified Blocs). Each pub contains three values that, together, define a computation unit of work for the estimator to complete: From 53079224a21bf9cd171b620c04f2b5bbcc8b0990 Mon Sep 17 00:00:00 2001 From: Ikko Hamamura Date: Tue, 19 Dec 2023 23:29:22 +0900 Subject: [PATCH 26/55] Apply suggestions from code review Co-authored-by: Takashi Imamichi <31178928+t-imamichi@users.noreply.github.com> --- qiskit/primitives/containers/base_pub.py | 2 +- qiskit/primitives/containers/bindings_array.py | 6 +++--- qiskit/primitives/containers/pub_result.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/qiskit/primitives/containers/base_pub.py b/qiskit/primitives/containers/base_pub.py index 47aaefd1bcf1..238463adec26 100644 --- a/qiskit/primitives/containers/base_pub.py +++ b/qiskit/primitives/containers/base_pub.py @@ -30,7 +30,7 @@ def __init__(self, circuit: QuantumCircuit, validate: bool = False): Args: circuit: Quantum circuit object for the pubs. - validate: if True, the input data is validated during initizlization. + validate: if True, the input data is validated during initialization. """ self._circuit = circuit if validate: diff --git a/qiskit/primitives/containers/bindings_array.py b/qiskit/primitives/containers/bindings_array.py index f5b24577a55b..f9fc2cd95d0b 100644 --- a/qiskit/primitives/containers/bindings_array.py +++ b/qiskit/primitives/containers/bindings_array.py @@ -350,8 +350,8 @@ def _format_key(key: tuple[Parameter | str, ...]): BindingsArrayLike = Union[ BindingsArray, - NDArray, - "Mapping[Parameter, NDArray]", - "Sequence[NDArray]", + ArrayLike, + "Mapping[Parameter, ArrayLike]", + "Sequence[ArrayLike]", None, ] diff --git a/qiskit/primitives/containers/pub_result.py b/qiskit/primitives/containers/pub_result.py index ca1f3447ef56..421c78332a78 100644 --- a/qiskit/primitives/containers/pub_result.py +++ b/qiskit/primitives/containers/pub_result.py @@ -40,6 +40,6 @@ def data(self) -> DataBin: return self._data @property - def metadata(self) -> {}: + def metadata(self) -> dict: """Metadata for the pub""" return self._metadata From bcdf842092b0051f2d66f0f4076111c28e829532 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Wed, 20 Dec 2023 00:07:19 +0900 Subject: [PATCH 27/55] lint --- qiskit/primitives/containers/bindings_array.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit/primitives/containers/bindings_array.py b/qiskit/primitives/containers/bindings_array.py index f9fc2cd95d0b..da0cf6351dcf 100644 --- a/qiskit/primitives/containers/bindings_array.py +++ b/qiskit/primitives/containers/bindings_array.py @@ -20,7 +20,7 @@ from typing import Union import numpy as np -from numpy.typing import ArrayLike, NDArray +from numpy.typing import ArrayLike from qiskit.circuit import Parameter, QuantumCircuit from qiskit.circuit.parameterexpression import ParameterValueType From bbe64b21e615b4f467651cc06f6d90d280021449 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Mon, 6 Nov 2023 17:21:57 +0900 Subject: [PATCH 28/55] WIP --- qiskit/primitives/base/__init__.py | 5 + qiskit/primitives/base/base_task.py | 33 ++ qiskit/primitives/base/bindings_array.py | 356 ++++++++++++++++++++ qiskit/primitives/base/estimator_task.py | 92 +++++ qiskit/primitives/base/object_array.py | 93 +++++ qiskit/primitives/base/observables_array.py | 247 ++++++++++++++ qiskit/primitives/base/options.py | 34 ++ qiskit/primitives/base/shape.py | 129 +++++++ qiskit/primitives/base/task_result.py | 27 ++ 9 files changed, 1016 insertions(+) create mode 100644 qiskit/primitives/base/base_task.py create mode 100644 qiskit/primitives/base/bindings_array.py create mode 100644 qiskit/primitives/base/estimator_task.py create mode 100644 qiskit/primitives/base/object_array.py create mode 100644 qiskit/primitives/base/observables_array.py create mode 100644 qiskit/primitives/base/options.py create mode 100644 qiskit/primitives/base/shape.py create mode 100644 qiskit/primitives/base/task_result.py diff --git a/qiskit/primitives/base/__init__.py b/qiskit/primitives/base/__init__.py index 2384cb181f18..afb12412cff2 100644 --- a/qiskit/primitives/base/__init__.py +++ b/qiskit/primitives/base/__init__.py @@ -16,5 +16,10 @@ from .base_estimator import BaseEstimator, BaseEstimatorV2 from .base_sampler import BaseSampler +from .bindings_array import BindingsArray from .estimator_result import EstimatorResult +from .estimator_task import EstimatorTask +from .observables_array import ObservablesArray +from .options import BasePrimitiveOptions, BasePrimitiveOptionsLike from .sampler_result import SamplerResult +from .task_result import TaskResult diff --git a/qiskit/primitives/base/base_task.py b/qiskit/primitives/base/base_task.py new file mode 100644 index 000000000000..781f5f7c611d --- /dev/null +++ b/qiskit/primitives/base/base_task.py @@ -0,0 +1,33 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Base Task class +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from qiskit import QuantumCircuit + + +@dataclass(frozen=True) +class BaseTask: + """Base class for Task""" + + circuit: QuantumCircuit + + def validate(self): + """TODO: docstring""" + if not isinstance(self.circuit, QuantumCircuit): + raise TypeError("circuit must be QuantumCircuit.") diff --git a/qiskit/primitives/base/bindings_array.py b/qiskit/primitives/base/bindings_array.py new file mode 100644 index 000000000000..177610832cb4 --- /dev/null +++ b/qiskit/primitives/base/bindings_array.py @@ -0,0 +1,356 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Bindings array class +""" +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from itertools import chain, product +from typing import Dict, List, Optional, Tuple, Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from qiskit.circuit import Parameter, QuantumCircuit + +from .shape import ShapedMixin, ShapeInput, shape_tuple + + +class BindingsArray(ShapedMixin): + r"""Stores many possible parameter binding values for a :class:`qiskit.QuantumCircuit`. + + Similar to a ``inspect.BoundArguments`` instance, which stores arguments that can be bound to a + compatible Python function, this class stores both values without names, so that their ordering + is important, as well as values attached to ``qiskit.circuit.Parameters``. However, a dense + rectangular array of possible values is stored for each parameter, so that this class is akin to + an object-array of ``inspect.BoundArguments``. + + The storage format is a list of arrays, ``[vals0, vals1, ...]``, as well as a dictionary of + arrays attached to parameters, ``{params0: kwvals0, ...}``. Crucially, the last dimension of + each array indexes one or more parameters. For example, if the last dimension of ``vals1`` is + 25, then it represents an array of possible binding values for 25 distinct parameters, where its + leading shape is the array :attr:`~.shape` of its binding array. This implies a degeneracy of the + storage format: ``[vals, vals1[..., :10], vals1[..., 10:], ...]`` is exactly equivalent to + ``[vals0, vals1, ...]`` in the bindings it specifies. This complication has been included to + satisfy two competing constraints: + + * Arrays with different dtypes cannot be concatenated into a single array, so that multiple + arrays are required for generality. + * It is extremely convenient to put everything into a small number of big arrays, when + possible. + + .. code-block:: python + + # 0-d array (i.e. only one binding) + BindingsArray([1, 2, 3], {"a": 4, ("b", "c"): [5, 6]}) + + # single array, last index is parameters + BindingsArray(np.empty((10, 10, 100))) + + # multiple arrays, where each last index is parameters. notice that it's smart enough to + # figure out that a missing last dimension corresponds to a single parameter. + BindingsArray( + [np.empty((10, 10, 100)), np.empty((10, 10)), np.empty((10, 10, 20), dtype=complex)], + {("c", "a"): np.empty((10, 10, 2)), "b": np.empty((10, 10))} + ) + """ + + def __init__( + self, + vals: Union[None, ArrayLike, Iterable[ArrayLike]] = None, + kwvals: Union[None, Mapping[Parameter, Iterable[Parameter]], ArrayLike] = None, + shape: Optional[ShapeInput] = None, + ): + """ + The ``shape`` argument does not need to be provided whenever it can unambiguously + be inferred from the provided arrays. Ambiguity arises because an array provided to the + constructor might represent values for either a single parameter, with an implicit missing + last dimension of size ``1``, or for many parameters, where the size of the last dimension + is the number of parameters it is providing values to. This ambiguity can be broken in the + following common ways: + + * Only a single array is provided to ``vals``, and no arrays to ``kwvals``, in which case + it is assumed that the last dimension is over many parameters. + * Multiple arrays are given whose shapes differ only in the last dimension size. + * Some array is given in ``kwvals`` where the key contains multiple + :class:`~.Parameter` s, whose length the last dimension of the array must therefore match. + + Args: + vals: One or more arrays, where the last index of each corresponds to + distinct parameters. If their dtypes allow it, concatenating these + arrays over the last axis is equivalent to providing them separately. + kwvals: A mapping from one or more parameters to arrays of values to bind + them to, where the last axis is over parameters. + shape: The leading shape of every array in these bindings. + + Raises: + ValueError: If all inputs are ``None``. + ValueError: If the shape cannot be automatically inferred from the arrays, or if there + is some inconsistency in the shape of the given arrays. + """ + super().__init__() + + if vals is None and kwvals is None and shape is None: + raise ValueError("Must specify a shape if no values are present") + if vals is None: + vals = [] + if kwvals is None: + kwvals = {} + + vals = [vals] if isinstance(vals, np.ndarray) else [np.array(v, copy=False) for v in vals] + kwvals = { + (p,) if isinstance(p, Parameter) else tuple(p): np.array(val, copy=False) + for p, val in kwvals.items() + } + + if shape is None: + # jump through hoops to find out user's intended shape + shape = _infer_shape(vals, kwvals) + + # shape checking, and normalization so that each last index must be over parameters + self._shape = shape_tuple(shape) + for idx, val in enumerate(vals): + vals[idx] = _standardize_shape(val, self._shape) + for parameters, val in kwvals.items(): + val = kwvals[parameters] = _standardize_shape(val, self._shape) + if len(parameters) != val.shape[-1]: + raise ValueError( + f"Length of {parameters} inconsistent with last dimension of {val}" + ) + + self._vals = vals + self._kwvals = kwvals + + def __getitem__(self, args) -> BindingsArray: + # because the parameters live on the last axis, we don't need to do anything special to + # accomodate them because there will always be an implicit slice(None, None, None) + # on all unspecified trailing dimensions + # separately, we choose to not disallow args which touch the last dimension, even though it + # would not be a particularly friendly way to chop parameters + vals = [val[args] for val in self._vals] + kwvals = {params: val[args] for params, val in self._kwvals.items()} + try: + shape = next(chain(vals, kwvals.values())).shape[:-1] + except StopIteration: + shape = () + return BindingsArray(vals, kwvals, shape) + + @property + def kwvals(self) -> Dict[Tuple[Parameter, ...], np.ndarray]: + """The keyword values of this array.""" + return self._kwvals + + @property + def num_parameters(self) -> int: + """The total number of parameters.""" + return sum(val.shape[-1] for val in chain(self.vals, self.kwvals.values())) + + @property + def vals(self) -> List[np.ndarray]: + """The non-keyword values of this array.""" + return self._vals + + def bind_at_idx(self, circuit: QuantumCircuit, idx: Tuple[int, ...]) -> QuantumCircuit: + """Return the circuit bound to the values at the provided index. + + Args: + circuit: The circuit to bind. + idx: A tuple of indices, on for each dimension of this array. + + Returns: + The bound circuit. + + Raises: + ValueError: If the index doesn't have the right number of values. + """ + if len(idx) != self.ndim: + raise ValueError(f"Expected {idx} to index all dimensions of {self.shape}") + + flat_vals = (val for vals in self.vals for val in vals[idx]) + + if not self.kwvals: + # special case to avoid constructing a dictionary input + return circuit.assign_parameters(list(flat_vals)) + + parameters = dict(zip(circuit.parameters, flat_vals)) + parameters.update( + (param, val) + for params, vals in self.kwvals.items() + for param, val in zip(params, vals[idx]) + ) + return circuit.assign_parameters(parameters) + + def bind_flat(self, circuit: QuantumCircuit) -> Iterable[QuantumCircuit]: + """Yield a bound circuit for every array index in flattened order. + + Args: + circuit: The circuit to bind. + + Yields: + Bound circuits, in flattened array order. + """ + for idx in product(*map(range, self.shape)): + yield self.bind_at_idx(circuit, idx) + + def bind_all(self, circuit: QuantumCircuit) -> np.ndarray: + """Return an object array of bound circuits with the same shape. + + Args: + circuit: The circuit to bind. + + Returns: + An object array of the same shape containing all bound circuits. + """ + arr = np.empty(self.shape, dtype=object) + for idx in np.ndindex(self.shape): + arr[idx] = self.bind_at_idx(circuit, idx) + return arr + + def ravel(self) -> BindingsArray: + """Return a new :class:`~BindingsArray` with one dimension. + + The returned bindings array has a :attr:`shape` given by ``(size, )``, where the size is the + :attr:`~size` of this bindings array. + + Returns: + A new bindings array. + """ + return self.reshape(self.size) + + def reshape(self, shape: Union[int, Iterable[int]]) -> BindingsArray: + """Return a new :class:`~BindingsArray` with a different shape. + + This results in a new view of the same arrays. + + Args: + shape: The shape of the returned bindings array. + + Returns: + A new bindings array. + + Raises: + ValueError: If the provided shape has a different product than the current size. + """ + shape = (shape, -1) if isinstance(shape, int) else (*shape, -1) + if np.prod(shape[:-1]).astype(int) != self.size: + raise ValueError("Reshaping cannot change the total number of elements.") + vals = [val.reshape(shape) for val in self._vals] + kwvals = {params: val.reshape(shape) for params, val in self._kwvals.items()} + return BindingsArray(vals, kwvals, shape[:-1]) + + @classmethod + def coerce(cls, bindings_array: BindingsArrayLike) -> BindingsArray: + """Coerce BindingsArrayLike into BindingsArray + + Args: + bindings_array: an object to be bindings array. + + Returns: + A coerced bindings array. + """ + if isinstance(bindings_array, Sequence): + bindings_array = np.array(bindings_array) + if bindings_array is None: + bindings_array = cls([], shape=(1,)) + elif isinstance(bindings_array, np.ndarray): + if bindings_array.ndim == 1: + bindings_array = bindings_array.reshape((1, -1)) + bindings_array = cls(bindings_array) + elif isinstance(bindings_array, Mapping): + bindings_array = cls(kwargs=bindings_array) + return bindings_array + + def validate(self): + """Validate the consistency in bindings_array.""" + pass + + +def _standardize_shape(val: np.ndarray, shape: Tuple[int, ...]) -> np.ndarray: + """Return ``val`` or ``val[..., None]``. + + Args: + val: The array whose shape to standardize. + shape: The shape to standardize to. + + Returns: + An array with one more dimension than ``len(shape)``, and whose leading dimensions match + ``shape``. + + Raises: + ValueError: If the leading shape of ``val`` does not match the ``shape``. + """ + if val.shape == shape: + val = val[..., None] + elif val.ndim - 1 != len(shape) or val.shape[:-1] != shape: + raise ValueError(f"Array with shape {val.shape} inconsistent with {shape}") + return val + + +def _infer_shape( + vals: List[np.ndarray], kwvals: Dict[Tuple[Parameter, ...], np.ndarray] +) -> Tuple[int, ...]: + """Return a shape tuple that consistently defines the leading dimensions of all arrays. + + Args: + vals: A list of arrays. + kwvals: A mapping from tuples to arrays, where the length of each tuple should match the + last dimension of the corresponding array. + + Returns: + A shape tuple that matches the leading dimension of every array. + + Raises: + ValueError: If this cannot be done unambiguously. + """ + only_possible_shapes = None + + def examine_array(*possible_shapes): + nonlocal only_possible_shapes + if only_possible_shapes is None: + only_possible_shapes = set(possible_shapes) + else: + only_possible_shapes.intersection_update(possible_shapes) + + for parameters, val in kwvals.items(): + if len(parameters) > 1: + # here, the last dimension _has_ to be over parameters + examine_array(val.shape[:-1]) + elif val.shape[-1] != 1: + # here, if the last dimension is not 1 then the shape is the shape + examine_array(val.shape) + else: + # here, the last dimension could be over parameters or not + examine_array(val.shape, val.shape[:-1]) + + if len(vals) == 1 and len(kwvals) == 0: + examine_array(vals[0].shape[:-1]) + else: + for val in vals: + # here, the last dimension could be over parameters or not + examine_array(val.shape, val.shape[:-1]) + + if len(only_possible_shapes) == 1: + return next(iter(only_possible_shapes)) + elif len(only_possible_shapes) == 0: + raise ValueError("Could not find any consistent shape.") + raise ValueError("Could not unambiguously determine the intended shape; specify shape manually") + + +BindingsArrayLike = Union[ + BindingsArray, + NDArray, + Mapping[Parameter, NDArray], + Sequence[NDArray], + None, +] diff --git a/qiskit/primitives/base/estimator_task.py b/qiskit/primitives/base/estimator_task.py new file mode 100644 index 000000000000..e5a13a2fbdfc --- /dev/null +++ b/qiskit/primitives/base/estimator_task.py @@ -0,0 +1,92 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + + +""" +Estiamtor Task class +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional, Union + +import numpy as np + +from qiskit import QuantumCircuit + +from .base_task import BaseTask +from .bindings_array import BindingsArray, BindingsArrayLike +from .observables_array import ObservablesArray, ObservablesArrayLike +from .shape import ShapedMixin + + +@dataclass(frozen=True) +class EstimatorTask(BaseTask, ShapedMixin): + """Task for Estimator. + Task is composed of triple (circuit, observables, parameter_values). + """ + + observables: ObservablesArray + parameter_values: BindingsArray + _shape: tuple[int, ...] = field(init=False) + + def __post_init__(self): + shape = np.broadcast_shapes(self.observables.shape, self.parameter_values.shape) + super().__setattr__("_shape", shape) + + @classmethod + def coerce(cls, task: EstimatorTaskLike) -> EstimatorTask: + """Coerce EstimatorTaskLike into EstimatorTask. + + Args: + task: an object to be estimator task. + + Returns: + A coerced estiamtor task. + """ + if isinstance(task, EstimatorTask): + return task + if len(task) != 2 and len(task) != 3: + raise ValueError(f"The length of task must be 2 or 3, but length {len(task)} is given.") + circuit = task[0] + observables = ObservablesArray.coerce(task[1]) + parameter_values = ( + BindingsArray.coerce(task[2]) if len(task) == 3 else BindingsArray([], shape=(1,)) + ) + return cls(circuit=circuit, observables=observables, parameter_values=parameter_values) + + def validate(self) -> None: + """Validate the task.""" + super().validate() + self.observables.validate() + self.parameter_values.validate() + # Cross validate circuits and observables + for i, observable in enumerate(self.observables): + num_qubits = len(next(iter(observable))) + if self.circuit.num_qubits != num_qubits: + raise ValueError( + f"The number of qubits of the circuit ({self.circuit.num_qubits}) does " + f"not match the number of qubits of the {i}-th observable ({num_qubits})." + ) + # Cross validate circuits and paramter_values + num_parameters = self.parameter_values.num_parameters + if num_parameters != self.circuit.num_parameters: + raise ValueError( + f"The number of values ({num_parameters}) does not match " + f"the number of parameters ({self.circuit.num_parameters}) for the circuit." + ) + + +EstimatorTaskLike = Union[ + EstimatorTask, tuple[QuantumCircuit, ObservablesArrayLike, BindingsArrayLike] +] diff --git a/qiskit/primitives/base/object_array.py b/qiskit/primitives/base/object_array.py new file mode 100644 index 000000000000..efd31234ddf3 --- /dev/null +++ b/qiskit/primitives/base/object_array.py @@ -0,0 +1,93 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Object ND-array initialization function. +""" + +from typing import Optional, Sequence, Tuple + +import numpy as np +from numpy.typing import ArrayLike + + +def object_array( + arr: ArrayLike, + order: Optional[str] = None, + copy: bool = True, + list_types: Optional[Sequence[type]] = (), +) -> np.ndarray: + """Convert an array-like of objects into an object array. + + .. note:: + + If the objects in the array like input define ``__array__`` methods + this avoids calling them and will instead set the returned array values + to the Python objects themselves. + + Args: + arr: An array-like input. + order: Optional, the order of the returned array (C, F, A, K). If None + the default NumPy ordering of C is used. + copy: If True make a copy of the input if it is already an array. + list_types: Optional, a sequence of types to treat as lists of array + element objects when inferring the array shape from the input. + + Returns: + A NumPy ND-array with ``dtype=object``. + + Raises: + ValueError: If the input cannot be coerced into an object array. + """ + if isinstance(arr, np.ndarray): + if arr.dtype != object or order is not None or copy is True: + arr = arr.astype(object, order=order, copy=copy) + return arr + + shape = _infer_shape(arr, list_types=tuple(list_types)) + obj_arr = np.empty(shape, dtype=object, order=order) + if not shape: + # We call fill here instead of [()] to avoid invoking the + # objects `__array__` method if it has one (eg for Pauli's). + obj_arr.fill(arr) + else: + # For other arrays we need to do some tricks to avoid invoking the + # objects __array__ method by flattening the input and initializing + # using `np.fromiter` which does not invoke `__array__` for object + # dtypes. + def _flatten(nested, k): + if k == 1: + return nested + else: + return [item for sublist in nested for item in _flatten(sublist, k - 1)] + + flattened = _flatten(arr, len(shape)) + if len(flattened) != obj_arr.size: + raise ValueError( + "Input object size does not match the inferred array shape." + " This most likely occurs when the input is a ragged array." + ) + obj_arr.flat = np.fromiter(flattened, dtype=object, count=len(flattened)) + + return obj_arr + + +def _infer_shape(obj: ArrayLike, list_types: Tuple[type, ...] = ()) -> Tuple[int, ...]: + """Infer the shape of an array-like object without casting""" + if isinstance(obj, np.ndarray): + return obj.shape + if not isinstance(obj, (list, *list_types)): + return () + size = len(obj) + if size == 0: + return (size,) + return (size, *_infer_shape(obj[0], list_types=list_types)) diff --git a/qiskit/primitives/base/observables_array.py b/qiskit/primitives/base/observables_array.py new file mode 100644 index 000000000000..d7779e69af55 --- /dev/null +++ b/qiskit/primitives/base/observables_array.py @@ -0,0 +1,247 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + + +""" +ND-Array container class for Estimator observables. +""" +from __future__ import annotations + +import re +from collections import defaultdict +from collections.abc import Mapping as MappingType +from functools import lru_cache +from typing import Iterable, Mapping, Union + +import numpy as np +from numpy.typing import ArrayLike + +from qiskit.quantum_info import Pauli, PauliList, SparsePauliOp + +from .object_array import object_array +from .shape import ShapedMixin + +BasisObservable = Mapping[str, complex] +"""Representation type of a single observable.""" + +BasisObservableLike = Union[ + str, + Pauli, + SparsePauliOp, + Mapping[Union[str, Pauli], complex], + Iterable[Union[str, Pauli, SparsePauliOp]], +] +"""Types that can be natively used to construct a :const:`BasisObservable`.""" + + +class ObservablesArray(ShapedMixin): + """An ND-array of :const:`.BasisObservable` for an :class:`.Estimator` primitive.""" + + ALLOWED_BASIS: str = "IXYZ01+-lr" + """The allowed characters in :const:`BasisObservable` strings.""" + + def __init__( + self, + observables: Union[BasisObservableLike, ArrayLike], + copy: bool = True, + validate: bool = True, + ): + """Initialize an observables array. + + Args: + observables: An array-like of basis observable compatible objects. + copy: Specify the ``copy`` kwarg of the :func:`.object_array` function + when initializing observables. + validate: If True, convert :const:`.BasisObservableLike` input objects + to :const:`.BasisObservable` objects and validate. If False the + input should already be an array-like of valid + :const:`.BasisObservble` objects. + + Raises: + ValueError: If ``validate=True`` and the input observables is not valid. + """ + super().__init__() + if isinstance(observables, ObservablesArray): + observables = observables._array + self._array = object_array(observables, copy=copy, list_types=(PauliList,)) + self._shape = self._array.shape + if validate: + num_qubits = None + for ndi, obs in np.ndenumerate(self._array): + basis_obs = self.format_observable(obs) + basis_num_qubits = len(next(iter(basis_obs))) + if num_qubits is None: + num_qubits = basis_num_qubits + elif basis_num_qubits != num_qubits: + raise ValueError( + "The number of qubits must be the same for all observables in the " + "observables array." + ) + self._array[ndi] = basis_obs + + def __repr__(self): + prefix = f"{type(self).__name__}(" + suffix = f", shape={self.shape})" + array = np.array2string(self._array, prefix=prefix, suffix=suffix, threshold=50) + return prefix + array + suffix + + def tolist(self) -> list: + """Convert to a nested list""" + return self._array.tolist() + + def __array__(self, dtype=None): + """Convert to an Numpy.ndarray""" + if dtype is None or dtype == object: + return self._array + raise ValueError("Type must be 'None' or 'object'") + + def __getitem__(self, args) -> Union[ObservablesArray, BasisObservable]: + item = self._array[args] + if not isinstance(item, np.ndarray): + return item + return ObservablesArray(item, copy=False, validate=False) + + def reshape(self, shape: Union[int, Iterable[int]]) -> "ObservablesArray": + """Return a new array with a different shape. + + This results in a new view of the same arrays. + + Args: + shape: The shape of the returned array. + + Returns: + A new array. + """ + return ObservablesArray(self._array.reshape(shape), copy=False, validate=False) + + def ravel(self) -> ObservablesArray: + """Return a new array with one dimension. + + The returned array has a :attr:`shape` given by ``(size, )``, where + the size is the :attr:`~size` of this array. + + Returns: + A new flattened array. + """ + return self.reshape(self.size) + + @classmethod + def format_observable(cls, observable: BasisObservableLike) -> BasisObservable: + """Format an observable-like object into a :const:`BasisObservable`. + + Args: + observable: The observable-like to format. + + Returns: + The given observable as a :const:`~BasisObservable`. + + Raises: + TypeError: If the input cannot be formatted because its type is not valid. + ValueError: If the input observable is invalid. + """ + + # Pauli-type conversions + if isinstance(observable, SparsePauliOp): + # Call simplify to combine duplicate keys before converting to a mapping + return cls.format_observable(dict(observable.simplify(atol=0).to_list())) + + if isinstance(observable, Pauli): + label, phase = observable[:].to_label(), observable.phase + return {label: 1} if phase == 0 else {label: (-1j) ** phase} + + # String conversion + if isinstance(observable, str): + cls._validate_basis(observable) + return {observable: 1} + + # Mapping conversion (with possible Pauli keys) + if isinstance(observable, MappingType): + num_qubits = len(next(iter(observable))) + unique = defaultdict(complex) + for basis, coeff in observable.items(): + if isinstance(basis, Pauli): + basis, phase = basis[:].to_label(), basis.phase + if phase != 0: + coeff = coeff * (-1j) ** phase + # Validate basis + cls._validate_basis(basis) + if len(basis) != num_qubits: + raise ValueError( + "Number of qubits must be the same for all observable basis elements." + ) + unique[basis] += coeff + return dict(unique) + + raise TypeError(f"Invalid observable type: {type(observable)}") + + @classmethod + def coerce(cls, observables: ObservablesArrayLike) -> ObservablesArray: + """Coerce ObservablesArrayLike into ObservableArray. + + Args: + observables: an object to be observables array. + + Returns: + A coerced observables array. + """ + if isinstance(observables, ObservablesArray): + return observables + if isinstance(observables, (str, SparsePauliOp, Pauli, Mapping)): + observables = [observables] + return cls(observables) + + def validate(self): + """Validate the consistency in observables array.""" + pass + + @classmethod + def _validate_basis(cls, basis: str) -> None: + """Validate a basis string. + + Args: + basis: a basis string to validate. + + Raises: + ValueError: If basis string contains invalid characters + """ + # NOTE: the allowed basis characters can be overridden by modifying the class + # attribute ALLOWED_BASIS + allowed_pattern = _regex_match(cls.ALLOWED_BASIS) + if not allowed_pattern.match(basis): + invalid_pattern = _regex_invalid(cls.ALLOWED_BASIS) + invalid_chars = list(set(invalid_pattern.findall(basis))) + raise ValueError( + f"Observable basis string '{basis}' contains invalid characters {invalid_chars}," + f" allowed characters are {list(cls.ALLOWED_BASIS)}.", + ) + + +ObservablesArrayLike = Union[ObservablesArray, ArrayLike, BasisObservableLike] +"""Types that can be natively converted to an ObservablesArray""" + + +class PauliArray(ObservablesArray): + """An ND-array of Pauli-basis observables for an :class:`.Estimator` primitive.""" + + ALLOWED_BASIS = "IXYZ" + + +@lru_cache(1) +def _regex_match(allowed_chars: str) -> re.Pattern: + """Return pattern for matching if a string contains only the allowed characters.""" + return re.compile(f"^[{re.escape(allowed_chars)}]*$") + + +@lru_cache(1) +def _regex_invalid(allowed_chars: str) -> re.Pattern: + """Return pattern for selecting invalid strings""" + return re.compile(f"[^{re.escape(allowed_chars)}]") diff --git a/qiskit/primitives/base/options.py b/qiskit/primitives/base/options.py new file mode 100644 index 000000000000..2a9a5347d3d4 --- /dev/null +++ b/qiskit/primitives/base/options.py @@ -0,0 +1,34 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Options class +""" + +from __future__ import annotations + +from abc import ABC +from dataclasses import dataclass +from typing import Union + + +@dataclass +class BasePrimitiveOptions(ABC): + """Base calss of options for primitives.""" + + def update(self, **kwargs): + """Update the options.""" + for key, val in kwargs.items(): + setattr(self, key, val) + + +BasePrimitiveOptionsLike = Union[BasePrimitiveOptions, dict] diff --git a/qiskit/primitives/base/shape.py b/qiskit/primitives/base/shape.py new file mode 100644 index 000000000000..b7636f013f68 --- /dev/null +++ b/qiskit/primitives/base/shape.py @@ -0,0 +1,129 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Array shape related classes and functions +""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Protocol, Tuple, Union, runtime_checkable + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +ShapeInput = Union[int, Iterable["ShapeInput"]] +"""An input that is coercible into a shape tuple.""" + + +@runtime_checkable +class Shaped(Protocol): + """Protocol that defines what it means to be a shaped object. + + Note that static type checkers will classify ``numpy.ndarray`` as being :class:`Shaped`. + Moreover, since this protocol is runtime-checkable, we will even have + ``isinstance(, Shaped) == True``. + """ + + @property + def shape(self) -> Tuple[int, ...]: + """The array shape of this object.""" + raise NotImplementedError("A `Shaped` protocol must implement the `shape` property") + + @property + def ndim(self) -> int: + """The number of array dimensions of this object.""" + raise NotImplementedError("A `Shaped` protocol must implement the `ndim` property") + + @property + def size(self) -> int: + """The total dimension of this object, i.e. the product of the entries of :attr:`~shape`.""" + raise NotImplementedError("A `Shaped` protocol must implement the `size` property") + + +class ShapedMixin(Shaped): + """Mixin class to create :class:`~Shaped` types by only providing :attr:`_shape` attribute.""" + + _shape: Tuple[int, ...] + + def __repr__(self): + return f"{type(self).__name__}(<{self.shape}>)" + + @property + def shape(self): + return self._shape + + @property + def ndim(self): + return len(self._shape) + + @property + def size(self): + return int(np.prod(self._shape, dtype=int)) + + +def array_coerce(arr: Union[ArrayLike, Shaped]) -> Union[NDArray, Shaped]: + """Coerce the input into an object with a shape attribute. + + Copies are avoided. + + Args: + arr: The object to coerce. + + Returns: + Something that is :class:`~Shaped`, and always ``numpy.ndarray`` if the input is not + already :class:`~Shaped`. + """ + if isinstance(arr, Shaped): + return arr + return np.array(arr, copy=False) + + +def _flatten_to_ints(arg: ShapeInput) -> Iterable[int]: + """ + Yield one integer at a time. + + Args: + arg: Integers or iterables of integers, possibly nested, to be yielded. + + Yields: + The provided integers in depth-first recursive order. + + Raises: + ValueError: If an input is not an iterable or an integer. + """ + for item in arg: + try: + if isinstance(item, Iterable): + yield from _flatten_to_ints(item) + elif int(item) == item: + yield int(item) + else: + raise ValueError(f"Expected {item} to be iterable or an integer.") + except (TypeError, RecursionError) as ex: + raise ValueError(f"Expected {item} to be iterable or an integer.") from ex + + +def shape_tuple(*shapes: ShapeInput) -> Tuple[int, ...]: + """ + Flatten the input into a single tuple of integers, preserving order. + + Args: + shapes: Integers or iterables of integers, possibly nested. + + Returns: + A tuple of integers. + + Raises: + ValueError: If some member of ``shapes`` is not an integer or iterable. + """ + return tuple(_flatten_to_ints(shapes)) diff --git a/qiskit/primitives/base/task_result.py b/qiskit/primitives/base/task_result.py new file mode 100644 index 000000000000..a008c73840fa --- /dev/null +++ b/qiskit/primitives/base/task_result.py @@ -0,0 +1,27 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +Base Task class +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TaskResult: + """Result of task.""" + + result: dict + metadata: dict From 24f3b769f10f937f8203feb2e11f5adc150a7234 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Fri, 10 Nov 2023 15:51:58 +0900 Subject: [PATCH 29/55] fix --- qiskit/primitives/base/estimator_task.py | 2 +- qiskit/primitives/base/task_result.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qiskit/primitives/base/estimator_task.py b/qiskit/primitives/base/estimator_task.py index e5a13a2fbdfc..6224d7f1f94a 100644 --- a/qiskit/primitives/base/estimator_task.py +++ b/qiskit/primitives/base/estimator_task.py @@ -18,7 +18,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional, Union +from typing import Union import numpy as np diff --git a/qiskit/primitives/base/task_result.py b/qiskit/primitives/base/task_result.py index a008c73840fa..98ee15be72cd 100644 --- a/qiskit/primitives/base/task_result.py +++ b/qiskit/primitives/base/task_result.py @@ -23,5 +23,5 @@ class TaskResult: """Result of task.""" - result: dict + data: dict metadata: dict From caafc92cb074d4c840cbb33ed045ae47e551b5cd Mon Sep 17 00:00:00 2001 From: ikkoham Date: Fri, 10 Nov 2023 16:12:10 +0900 Subject: [PATCH 30/55] Iterable is not subscriptable < 3.9 --- qiskit/primitives/base/shape.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit/primitives/base/shape.py b/qiskit/primitives/base/shape.py index b7636f013f68..1a000da684c3 100644 --- a/qiskit/primitives/base/shape.py +++ b/qiskit/primitives/base/shape.py @@ -21,7 +21,7 @@ import numpy as np from numpy.typing import ArrayLike, NDArray -ShapeInput = Union[int, Iterable["ShapeInput"]] +ShapeInput = Union[int, "Iterable[ShapeInput]"] """An input that is coercible into a shape tuple.""" From 4cd3329e312ceb9c7d07963d942afc2c0d697cc9 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Fri, 10 Nov 2023 16:23:25 +0900 Subject: [PATCH 31/55] refactor dataclass --- qiskit/primitives/base/options.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/qiskit/primitives/base/options.py b/qiskit/primitives/base/options.py index 2a9a5347d3d4..a608b97012d8 100644 --- a/qiskit/primitives/base/options.py +++ b/qiskit/primitives/base/options.py @@ -17,11 +17,17 @@ from __future__ import annotations from abc import ABC -from dataclasses import dataclass from typing import Union +from pydantic import ConfigDict +from pydantic.dataclasses import dataclass -@dataclass +primitive_dataclass = dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) + + +@primitive_dataclass class BasePrimitiveOptions(ABC): """Base calss of options for primitives.""" From 61303d7568a3bc2cb632974a25930ad37b265c9e Mon Sep 17 00:00:00 2001 From: ikkoham Date: Mon, 13 Nov 2023 16:07:17 +0900 Subject: [PATCH 32/55] fix type hint --- qiskit/primitives/base/bindings_array.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit/primitives/base/bindings_array.py b/qiskit/primitives/base/bindings_array.py index 177610832cb4..cd9182d2258f 100644 --- a/qiskit/primitives/base/bindings_array.py +++ b/qiskit/primitives/base/bindings_array.py @@ -350,7 +350,7 @@ def examine_array(*possible_shapes): BindingsArrayLike = Union[ BindingsArray, NDArray, - Mapping[Parameter, NDArray], + "Mapping[Parameter, NDArray]", Sequence[NDArray], None, ] From 70ba4ab5142db68c847385399e8a01fadcd5c8ac Mon Sep 17 00:00:00 2001 From: ikkoham Date: Mon, 13 Nov 2023 22:08:22 +0900 Subject: [PATCH 33/55] fix lint --- qiskit/primitives/base/bindings_array.py | 2 +- qiskit/primitives/base/estimator_task.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/qiskit/primitives/base/bindings_array.py b/qiskit/primitives/base/bindings_array.py index cd9182d2258f..d096091eb069 100644 --- a/qiskit/primitives/base/bindings_array.py +++ b/qiskit/primitives/base/bindings_array.py @@ -351,6 +351,6 @@ def examine_array(*possible_shapes): BindingsArray, NDArray, "Mapping[Parameter, NDArray]", - Sequence[NDArray], + "Sequence[NDArray]", None, ] diff --git a/qiskit/primitives/base/estimator_task.py b/qiskit/primitives/base/estimator_task.py index 6224d7f1f94a..191e5f0e430f 100644 --- a/qiskit/primitives/base/estimator_task.py +++ b/qiskit/primitives/base/estimator_task.py @@ -18,7 +18,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Union +from typing import Tuple, Union import numpy as np @@ -88,5 +88,5 @@ def validate(self) -> None: EstimatorTaskLike = Union[ - EstimatorTask, tuple[QuantumCircuit, ObservablesArrayLike, BindingsArrayLike] + EstimatorTask, Tuple[QuantumCircuit, ObservablesArrayLike, BindingsArrayLike] ] From cf38b656c5ce6c53fa9b7ddfe9f0293dc3c28175 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Tue, 14 Nov 2023 11:49:55 +0900 Subject: [PATCH 34/55] EstimatorTask.parameter_values is optional --- qiskit/primitives/base/base_task.py | 2 +- qiskit/primitives/base/estimator_task.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/qiskit/primitives/base/base_task.py b/qiskit/primitives/base/base_task.py index 781f5f7c611d..a2171e933572 100644 --- a/qiskit/primitives/base/base_task.py +++ b/qiskit/primitives/base/base_task.py @@ -28,6 +28,6 @@ class BaseTask: circuit: QuantumCircuit def validate(self): - """TODO: docstring""" + """Validate the data""" if not isinstance(self.circuit, QuantumCircuit): raise TypeError("circuit must be QuantumCircuit.") diff --git a/qiskit/primitives/base/estimator_task.py b/qiskit/primitives/base/estimator_task.py index 191e5f0e430f..aef850ffd044 100644 --- a/qiskit/primitives/base/estimator_task.py +++ b/qiskit/primitives/base/estimator_task.py @@ -37,7 +37,7 @@ class EstimatorTask(BaseTask, ShapedMixin): """ observables: ObservablesArray - parameter_values: BindingsArray + parameter_values: BindingsArray = BindingsArray(shape=()) _shape: tuple[int, ...] = field(init=False) def __post_init__(self): @@ -60,9 +60,9 @@ def coerce(cls, task: EstimatorTaskLike) -> EstimatorTask: raise ValueError(f"The length of task must be 2 or 3, but length {len(task)} is given.") circuit = task[0] observables = ObservablesArray.coerce(task[1]) - parameter_values = ( - BindingsArray.coerce(task[2]) if len(task) == 3 else BindingsArray([], shape=(1,)) - ) + if len(task) == 2: + return cls(circuit=circuit, observables=observables) + parameter_values = BindingsArray.coerce(task[2]) return cls(circuit=circuit, observables=observables, parameter_values=parameter_values) def validate(self) -> None: From c45ff3e0b38298849d1e8e6dc066ee1b5ed48997 Mon Sep 17 00:00:00 2001 From: ikkoham Date: Thu, 16 Nov 2023 15:21:20 +0900 Subject: [PATCH 35/55] refactor bindings_array --- qiskit/primitives/base/bindings_array.py | 25 +++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/qiskit/primitives/base/bindings_array.py b/qiskit/primitives/base/bindings_array.py index d096091eb069..6806687d1c50 100644 --- a/qiskit/primitives/base/bindings_array.py +++ b/qiskit/primitives/base/bindings_array.py @@ -23,9 +23,12 @@ from numpy.typing import ArrayLike, NDArray from qiskit.circuit import Parameter, QuantumCircuit +from qiskit.circuit.parameterexpression import ParameterValueType from .shape import ShapedMixin, ShapeInput, shape_tuple +ParameterLike = Union[Parameter, str] + class BindingsArray(ShapedMixin): r"""Stores many possible parameter binding values for a :class:`qiskit.QuantumCircuit`. @@ -65,11 +68,12 @@ class BindingsArray(ShapedMixin): {("c", "a"): np.empty((10, 10, 2)), "b": np.empty((10, 10))} ) """ + __slots__ = ("_vals", "_kwvals") def __init__( self, vals: Union[None, ArrayLike, Iterable[ArrayLike]] = None, - kwvals: Union[None, Mapping[Parameter, Iterable[Parameter]], ArrayLike] = None, + kwvals: Union[None, Mapping[ParameterLike, Iterable[ParameterValueType]], ArrayLike] = None, shape: Optional[ShapeInput] = None, ): """ @@ -122,16 +126,12 @@ def __init__( self._shape = shape_tuple(shape) for idx, val in enumerate(vals): vals[idx] = _standardize_shape(val, self._shape) - for parameters, val in kwvals.items(): - val = kwvals[parameters] = _standardize_shape(val, self._shape) - if len(parameters) != val.shape[-1]: - raise ValueError( - f"Length of {parameters} inconsistent with last dimension of {val}" - ) self._vals = vals self._kwvals = kwvals + self.validate() + def __getitem__(self, args) -> BindingsArray: # because the parameters live on the last axis, we don't need to do anything special to # accomodate them because there will always be an implicit slice(None, None, None) @@ -268,12 +268,19 @@ def coerce(cls, bindings_array: BindingsArrayLike) -> BindingsArray: bindings_array = bindings_array.reshape((1, -1)) bindings_array = cls(bindings_array) elif isinstance(bindings_array, Mapping): - bindings_array = cls(kwargs=bindings_array) + bindings_array = cls(kwvals=bindings_array) + else: + raise TypeError(f"Unsupported type {type(bindings_array)} is given.") return bindings_array def validate(self): """Validate the consistency in bindings_array.""" - pass + for parameters, val in self.kwvals.items(): + val = self.kwvals[parameters] = _standardize_shape(val, self._shape) + if len(parameters) != val.shape[-1]: + raise ValueError( + f"Length of {parameters} inconsistent with last dimension of {val}" + ) def _standardize_shape(val: np.ndarray, shape: Tuple[int, ...]) -> np.ndarray: From f240ff73685d2a4af122dd317c75f369ee1b5a5f Mon Sep 17 00:00:00 2001 From: ikkoham Date: Thu, 16 Nov 2023 15:39:30 +0900 Subject: [PATCH 36/55] qiskit.primitives.containers --- qiskit/primitives/base/__init__.py | 5 - qiskit/primitives/base/base_task.py | 33 -- qiskit/primitives/base/bindings_array.py | 363 -------------------- qiskit/primitives/base/estimator_task.py | 92 ----- qiskit/primitives/base/object_array.py | 93 ----- qiskit/primitives/base/observables_array.py | 247 ------------- qiskit/primitives/base/options.py | 40 --- qiskit/primitives/base/shape.py | 129 ------- qiskit/primitives/base/task_result.py | 27 -- 9 files changed, 1029 deletions(-) delete mode 100644 qiskit/primitives/base/base_task.py delete mode 100644 qiskit/primitives/base/bindings_array.py delete mode 100644 qiskit/primitives/base/estimator_task.py delete mode 100644 qiskit/primitives/base/object_array.py delete mode 100644 qiskit/primitives/base/observables_array.py delete mode 100644 qiskit/primitives/base/options.py delete mode 100644 qiskit/primitives/base/shape.py delete mode 100644 qiskit/primitives/base/task_result.py diff --git a/qiskit/primitives/base/__init__.py b/qiskit/primitives/base/__init__.py index afb12412cff2..2384cb181f18 100644 --- a/qiskit/primitives/base/__init__.py +++ b/qiskit/primitives/base/__init__.py @@ -16,10 +16,5 @@ from .base_estimator import BaseEstimator, BaseEstimatorV2 from .base_sampler import BaseSampler -from .bindings_array import BindingsArray from .estimator_result import EstimatorResult -from .estimator_task import EstimatorTask -from .observables_array import ObservablesArray -from .options import BasePrimitiveOptions, BasePrimitiveOptionsLike from .sampler_result import SamplerResult -from .task_result import TaskResult diff --git a/qiskit/primitives/base/base_task.py b/qiskit/primitives/base/base_task.py deleted file mode 100644 index a2171e933572..000000000000 --- a/qiskit/primitives/base/base_task.py +++ /dev/null @@ -1,33 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2023. -# -# 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. - -""" -Base Task class -""" - -from __future__ import annotations - -from dataclasses import dataclass - -from qiskit import QuantumCircuit - - -@dataclass(frozen=True) -class BaseTask: - """Base class for Task""" - - circuit: QuantumCircuit - - def validate(self): - """Validate the data""" - if not isinstance(self.circuit, QuantumCircuit): - raise TypeError("circuit must be QuantumCircuit.") diff --git a/qiskit/primitives/base/bindings_array.py b/qiskit/primitives/base/bindings_array.py deleted file mode 100644 index 6806687d1c50..000000000000 --- a/qiskit/primitives/base/bindings_array.py +++ /dev/null @@ -1,363 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2023. -# -# 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. - -""" -Bindings array class -""" -from __future__ import annotations - -from collections.abc import Iterable, Mapping, Sequence -from itertools import chain, product -from typing import Dict, List, Optional, Tuple, Union - -import numpy as np -from numpy.typing import ArrayLike, NDArray - -from qiskit.circuit import Parameter, QuantumCircuit -from qiskit.circuit.parameterexpression import ParameterValueType - -from .shape import ShapedMixin, ShapeInput, shape_tuple - -ParameterLike = Union[Parameter, str] - - -class BindingsArray(ShapedMixin): - r"""Stores many possible parameter binding values for a :class:`qiskit.QuantumCircuit`. - - Similar to a ``inspect.BoundArguments`` instance, which stores arguments that can be bound to a - compatible Python function, this class stores both values without names, so that their ordering - is important, as well as values attached to ``qiskit.circuit.Parameters``. However, a dense - rectangular array of possible values is stored for each parameter, so that this class is akin to - an object-array of ``inspect.BoundArguments``. - - The storage format is a list of arrays, ``[vals0, vals1, ...]``, as well as a dictionary of - arrays attached to parameters, ``{params0: kwvals0, ...}``. Crucially, the last dimension of - each array indexes one or more parameters. For example, if the last dimension of ``vals1`` is - 25, then it represents an array of possible binding values for 25 distinct parameters, where its - leading shape is the array :attr:`~.shape` of its binding array. This implies a degeneracy of the - storage format: ``[vals, vals1[..., :10], vals1[..., 10:], ...]`` is exactly equivalent to - ``[vals0, vals1, ...]`` in the bindings it specifies. This complication has been included to - satisfy two competing constraints: - - * Arrays with different dtypes cannot be concatenated into a single array, so that multiple - arrays are required for generality. - * It is extremely convenient to put everything into a small number of big arrays, when - possible. - - .. code-block:: python - - # 0-d array (i.e. only one binding) - BindingsArray([1, 2, 3], {"a": 4, ("b", "c"): [5, 6]}) - - # single array, last index is parameters - BindingsArray(np.empty((10, 10, 100))) - - # multiple arrays, where each last index is parameters. notice that it's smart enough to - # figure out that a missing last dimension corresponds to a single parameter. - BindingsArray( - [np.empty((10, 10, 100)), np.empty((10, 10)), np.empty((10, 10, 20), dtype=complex)], - {("c", "a"): np.empty((10, 10, 2)), "b": np.empty((10, 10))} - ) - """ - __slots__ = ("_vals", "_kwvals") - - def __init__( - self, - vals: Union[None, ArrayLike, Iterable[ArrayLike]] = None, - kwvals: Union[None, Mapping[ParameterLike, Iterable[ParameterValueType]], ArrayLike] = None, - shape: Optional[ShapeInput] = None, - ): - """ - The ``shape`` argument does not need to be provided whenever it can unambiguously - be inferred from the provided arrays. Ambiguity arises because an array provided to the - constructor might represent values for either a single parameter, with an implicit missing - last dimension of size ``1``, or for many parameters, where the size of the last dimension - is the number of parameters it is providing values to. This ambiguity can be broken in the - following common ways: - - * Only a single array is provided to ``vals``, and no arrays to ``kwvals``, in which case - it is assumed that the last dimension is over many parameters. - * Multiple arrays are given whose shapes differ only in the last dimension size. - * Some array is given in ``kwvals`` where the key contains multiple - :class:`~.Parameter` s, whose length the last dimension of the array must therefore match. - - Args: - vals: One or more arrays, where the last index of each corresponds to - distinct parameters. If their dtypes allow it, concatenating these - arrays over the last axis is equivalent to providing them separately. - kwvals: A mapping from one or more parameters to arrays of values to bind - them to, where the last axis is over parameters. - shape: The leading shape of every array in these bindings. - - Raises: - ValueError: If all inputs are ``None``. - ValueError: If the shape cannot be automatically inferred from the arrays, or if there - is some inconsistency in the shape of the given arrays. - """ - super().__init__() - - if vals is None and kwvals is None and shape is None: - raise ValueError("Must specify a shape if no values are present") - if vals is None: - vals = [] - if kwvals is None: - kwvals = {} - - vals = [vals] if isinstance(vals, np.ndarray) else [np.array(v, copy=False) for v in vals] - kwvals = { - (p,) if isinstance(p, Parameter) else tuple(p): np.array(val, copy=False) - for p, val in kwvals.items() - } - - if shape is None: - # jump through hoops to find out user's intended shape - shape = _infer_shape(vals, kwvals) - - # shape checking, and normalization so that each last index must be over parameters - self._shape = shape_tuple(shape) - for idx, val in enumerate(vals): - vals[idx] = _standardize_shape(val, self._shape) - - self._vals = vals - self._kwvals = kwvals - - self.validate() - - def __getitem__(self, args) -> BindingsArray: - # because the parameters live on the last axis, we don't need to do anything special to - # accomodate them because there will always be an implicit slice(None, None, None) - # on all unspecified trailing dimensions - # separately, we choose to not disallow args which touch the last dimension, even though it - # would not be a particularly friendly way to chop parameters - vals = [val[args] for val in self._vals] - kwvals = {params: val[args] for params, val in self._kwvals.items()} - try: - shape = next(chain(vals, kwvals.values())).shape[:-1] - except StopIteration: - shape = () - return BindingsArray(vals, kwvals, shape) - - @property - def kwvals(self) -> Dict[Tuple[Parameter, ...], np.ndarray]: - """The keyword values of this array.""" - return self._kwvals - - @property - def num_parameters(self) -> int: - """The total number of parameters.""" - return sum(val.shape[-1] for val in chain(self.vals, self.kwvals.values())) - - @property - def vals(self) -> List[np.ndarray]: - """The non-keyword values of this array.""" - return self._vals - - def bind_at_idx(self, circuit: QuantumCircuit, idx: Tuple[int, ...]) -> QuantumCircuit: - """Return the circuit bound to the values at the provided index. - - Args: - circuit: The circuit to bind. - idx: A tuple of indices, on for each dimension of this array. - - Returns: - The bound circuit. - - Raises: - ValueError: If the index doesn't have the right number of values. - """ - if len(idx) != self.ndim: - raise ValueError(f"Expected {idx} to index all dimensions of {self.shape}") - - flat_vals = (val for vals in self.vals for val in vals[idx]) - - if not self.kwvals: - # special case to avoid constructing a dictionary input - return circuit.assign_parameters(list(flat_vals)) - - parameters = dict(zip(circuit.parameters, flat_vals)) - parameters.update( - (param, val) - for params, vals in self.kwvals.items() - for param, val in zip(params, vals[idx]) - ) - return circuit.assign_parameters(parameters) - - def bind_flat(self, circuit: QuantumCircuit) -> Iterable[QuantumCircuit]: - """Yield a bound circuit for every array index in flattened order. - - Args: - circuit: The circuit to bind. - - Yields: - Bound circuits, in flattened array order. - """ - for idx in product(*map(range, self.shape)): - yield self.bind_at_idx(circuit, idx) - - def bind_all(self, circuit: QuantumCircuit) -> np.ndarray: - """Return an object array of bound circuits with the same shape. - - Args: - circuit: The circuit to bind. - - Returns: - An object array of the same shape containing all bound circuits. - """ - arr = np.empty(self.shape, dtype=object) - for idx in np.ndindex(self.shape): - arr[idx] = self.bind_at_idx(circuit, idx) - return arr - - def ravel(self) -> BindingsArray: - """Return a new :class:`~BindingsArray` with one dimension. - - The returned bindings array has a :attr:`shape` given by ``(size, )``, where the size is the - :attr:`~size` of this bindings array. - - Returns: - A new bindings array. - """ - return self.reshape(self.size) - - def reshape(self, shape: Union[int, Iterable[int]]) -> BindingsArray: - """Return a new :class:`~BindingsArray` with a different shape. - - This results in a new view of the same arrays. - - Args: - shape: The shape of the returned bindings array. - - Returns: - A new bindings array. - - Raises: - ValueError: If the provided shape has a different product than the current size. - """ - shape = (shape, -1) if isinstance(shape, int) else (*shape, -1) - if np.prod(shape[:-1]).astype(int) != self.size: - raise ValueError("Reshaping cannot change the total number of elements.") - vals = [val.reshape(shape) for val in self._vals] - kwvals = {params: val.reshape(shape) for params, val in self._kwvals.items()} - return BindingsArray(vals, kwvals, shape[:-1]) - - @classmethod - def coerce(cls, bindings_array: BindingsArrayLike) -> BindingsArray: - """Coerce BindingsArrayLike into BindingsArray - - Args: - bindings_array: an object to be bindings array. - - Returns: - A coerced bindings array. - """ - if isinstance(bindings_array, Sequence): - bindings_array = np.array(bindings_array) - if bindings_array is None: - bindings_array = cls([], shape=(1,)) - elif isinstance(bindings_array, np.ndarray): - if bindings_array.ndim == 1: - bindings_array = bindings_array.reshape((1, -1)) - bindings_array = cls(bindings_array) - elif isinstance(bindings_array, Mapping): - bindings_array = cls(kwvals=bindings_array) - else: - raise TypeError(f"Unsupported type {type(bindings_array)} is given.") - return bindings_array - - def validate(self): - """Validate the consistency in bindings_array.""" - for parameters, val in self.kwvals.items(): - val = self.kwvals[parameters] = _standardize_shape(val, self._shape) - if len(parameters) != val.shape[-1]: - raise ValueError( - f"Length of {parameters} inconsistent with last dimension of {val}" - ) - - -def _standardize_shape(val: np.ndarray, shape: Tuple[int, ...]) -> np.ndarray: - """Return ``val`` or ``val[..., None]``. - - Args: - val: The array whose shape to standardize. - shape: The shape to standardize to. - - Returns: - An array with one more dimension than ``len(shape)``, and whose leading dimensions match - ``shape``. - - Raises: - ValueError: If the leading shape of ``val`` does not match the ``shape``. - """ - if val.shape == shape: - val = val[..., None] - elif val.ndim - 1 != len(shape) or val.shape[:-1] != shape: - raise ValueError(f"Array with shape {val.shape} inconsistent with {shape}") - return val - - -def _infer_shape( - vals: List[np.ndarray], kwvals: Dict[Tuple[Parameter, ...], np.ndarray] -) -> Tuple[int, ...]: - """Return a shape tuple that consistently defines the leading dimensions of all arrays. - - Args: - vals: A list of arrays. - kwvals: A mapping from tuples to arrays, where the length of each tuple should match the - last dimension of the corresponding array. - - Returns: - A shape tuple that matches the leading dimension of every array. - - Raises: - ValueError: If this cannot be done unambiguously. - """ - only_possible_shapes = None - - def examine_array(*possible_shapes): - nonlocal only_possible_shapes - if only_possible_shapes is None: - only_possible_shapes = set(possible_shapes) - else: - only_possible_shapes.intersection_update(possible_shapes) - - for parameters, val in kwvals.items(): - if len(parameters) > 1: - # here, the last dimension _has_ to be over parameters - examine_array(val.shape[:-1]) - elif val.shape[-1] != 1: - # here, if the last dimension is not 1 then the shape is the shape - examine_array(val.shape) - else: - # here, the last dimension could be over parameters or not - examine_array(val.shape, val.shape[:-1]) - - if len(vals) == 1 and len(kwvals) == 0: - examine_array(vals[0].shape[:-1]) - else: - for val in vals: - # here, the last dimension could be over parameters or not - examine_array(val.shape, val.shape[:-1]) - - if len(only_possible_shapes) == 1: - return next(iter(only_possible_shapes)) - elif len(only_possible_shapes) == 0: - raise ValueError("Could not find any consistent shape.") - raise ValueError("Could not unambiguously determine the intended shape; specify shape manually") - - -BindingsArrayLike = Union[ - BindingsArray, - NDArray, - "Mapping[Parameter, NDArray]", - "Sequence[NDArray]", - None, -] diff --git a/qiskit/primitives/base/estimator_task.py b/qiskit/primitives/base/estimator_task.py deleted file mode 100644 index aef850ffd044..000000000000 --- a/qiskit/primitives/base/estimator_task.py +++ /dev/null @@ -1,92 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2023. -# -# 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. - - -""" -Estiamtor Task class -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Tuple, Union - -import numpy as np - -from qiskit import QuantumCircuit - -from .base_task import BaseTask -from .bindings_array import BindingsArray, BindingsArrayLike -from .observables_array import ObservablesArray, ObservablesArrayLike -from .shape import ShapedMixin - - -@dataclass(frozen=True) -class EstimatorTask(BaseTask, ShapedMixin): - """Task for Estimator. - Task is composed of triple (circuit, observables, parameter_values). - """ - - observables: ObservablesArray - parameter_values: BindingsArray = BindingsArray(shape=()) - _shape: tuple[int, ...] = field(init=False) - - def __post_init__(self): - shape = np.broadcast_shapes(self.observables.shape, self.parameter_values.shape) - super().__setattr__("_shape", shape) - - @classmethod - def coerce(cls, task: EstimatorTaskLike) -> EstimatorTask: - """Coerce EstimatorTaskLike into EstimatorTask. - - Args: - task: an object to be estimator task. - - Returns: - A coerced estiamtor task. - """ - if isinstance(task, EstimatorTask): - return task - if len(task) != 2 and len(task) != 3: - raise ValueError(f"The length of task must be 2 or 3, but length {len(task)} is given.") - circuit = task[0] - observables = ObservablesArray.coerce(task[1]) - if len(task) == 2: - return cls(circuit=circuit, observables=observables) - parameter_values = BindingsArray.coerce(task[2]) - return cls(circuit=circuit, observables=observables, parameter_values=parameter_values) - - def validate(self) -> None: - """Validate the task.""" - super().validate() - self.observables.validate() - self.parameter_values.validate() - # Cross validate circuits and observables - for i, observable in enumerate(self.observables): - num_qubits = len(next(iter(observable))) - if self.circuit.num_qubits != num_qubits: - raise ValueError( - f"The number of qubits of the circuit ({self.circuit.num_qubits}) does " - f"not match the number of qubits of the {i}-th observable ({num_qubits})." - ) - # Cross validate circuits and paramter_values - num_parameters = self.parameter_values.num_parameters - if num_parameters != self.circuit.num_parameters: - raise ValueError( - f"The number of values ({num_parameters}) does not match " - f"the number of parameters ({self.circuit.num_parameters}) for the circuit." - ) - - -EstimatorTaskLike = Union[ - EstimatorTask, Tuple[QuantumCircuit, ObservablesArrayLike, BindingsArrayLike] -] diff --git a/qiskit/primitives/base/object_array.py b/qiskit/primitives/base/object_array.py deleted file mode 100644 index efd31234ddf3..000000000000 --- a/qiskit/primitives/base/object_array.py +++ /dev/null @@ -1,93 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2023. -# -# 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. - -""" -Object ND-array initialization function. -""" - -from typing import Optional, Sequence, Tuple - -import numpy as np -from numpy.typing import ArrayLike - - -def object_array( - arr: ArrayLike, - order: Optional[str] = None, - copy: bool = True, - list_types: Optional[Sequence[type]] = (), -) -> np.ndarray: - """Convert an array-like of objects into an object array. - - .. note:: - - If the objects in the array like input define ``__array__`` methods - this avoids calling them and will instead set the returned array values - to the Python objects themselves. - - Args: - arr: An array-like input. - order: Optional, the order of the returned array (C, F, A, K). If None - the default NumPy ordering of C is used. - copy: If True make a copy of the input if it is already an array. - list_types: Optional, a sequence of types to treat as lists of array - element objects when inferring the array shape from the input. - - Returns: - A NumPy ND-array with ``dtype=object``. - - Raises: - ValueError: If the input cannot be coerced into an object array. - """ - if isinstance(arr, np.ndarray): - if arr.dtype != object or order is not None or copy is True: - arr = arr.astype(object, order=order, copy=copy) - return arr - - shape = _infer_shape(arr, list_types=tuple(list_types)) - obj_arr = np.empty(shape, dtype=object, order=order) - if not shape: - # We call fill here instead of [()] to avoid invoking the - # objects `__array__` method if it has one (eg for Pauli's). - obj_arr.fill(arr) - else: - # For other arrays we need to do some tricks to avoid invoking the - # objects __array__ method by flattening the input and initializing - # using `np.fromiter` which does not invoke `__array__` for object - # dtypes. - def _flatten(nested, k): - if k == 1: - return nested - else: - return [item for sublist in nested for item in _flatten(sublist, k - 1)] - - flattened = _flatten(arr, len(shape)) - if len(flattened) != obj_arr.size: - raise ValueError( - "Input object size does not match the inferred array shape." - " This most likely occurs when the input is a ragged array." - ) - obj_arr.flat = np.fromiter(flattened, dtype=object, count=len(flattened)) - - return obj_arr - - -def _infer_shape(obj: ArrayLike, list_types: Tuple[type, ...] = ()) -> Tuple[int, ...]: - """Infer the shape of an array-like object without casting""" - if isinstance(obj, np.ndarray): - return obj.shape - if not isinstance(obj, (list, *list_types)): - return () - size = len(obj) - if size == 0: - return (size,) - return (size, *_infer_shape(obj[0], list_types=list_types)) diff --git a/qiskit/primitives/base/observables_array.py b/qiskit/primitives/base/observables_array.py deleted file mode 100644 index d7779e69af55..000000000000 --- a/qiskit/primitives/base/observables_array.py +++ /dev/null @@ -1,247 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2023. -# -# 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. - - -""" -ND-Array container class for Estimator observables. -""" -from __future__ import annotations - -import re -from collections import defaultdict -from collections.abc import Mapping as MappingType -from functools import lru_cache -from typing import Iterable, Mapping, Union - -import numpy as np -from numpy.typing import ArrayLike - -from qiskit.quantum_info import Pauli, PauliList, SparsePauliOp - -from .object_array import object_array -from .shape import ShapedMixin - -BasisObservable = Mapping[str, complex] -"""Representation type of a single observable.""" - -BasisObservableLike = Union[ - str, - Pauli, - SparsePauliOp, - Mapping[Union[str, Pauli], complex], - Iterable[Union[str, Pauli, SparsePauliOp]], -] -"""Types that can be natively used to construct a :const:`BasisObservable`.""" - - -class ObservablesArray(ShapedMixin): - """An ND-array of :const:`.BasisObservable` for an :class:`.Estimator` primitive.""" - - ALLOWED_BASIS: str = "IXYZ01+-lr" - """The allowed characters in :const:`BasisObservable` strings.""" - - def __init__( - self, - observables: Union[BasisObservableLike, ArrayLike], - copy: bool = True, - validate: bool = True, - ): - """Initialize an observables array. - - Args: - observables: An array-like of basis observable compatible objects. - copy: Specify the ``copy`` kwarg of the :func:`.object_array` function - when initializing observables. - validate: If True, convert :const:`.BasisObservableLike` input objects - to :const:`.BasisObservable` objects and validate. If False the - input should already be an array-like of valid - :const:`.BasisObservble` objects. - - Raises: - ValueError: If ``validate=True`` and the input observables is not valid. - """ - super().__init__() - if isinstance(observables, ObservablesArray): - observables = observables._array - self._array = object_array(observables, copy=copy, list_types=(PauliList,)) - self._shape = self._array.shape - if validate: - num_qubits = None - for ndi, obs in np.ndenumerate(self._array): - basis_obs = self.format_observable(obs) - basis_num_qubits = len(next(iter(basis_obs))) - if num_qubits is None: - num_qubits = basis_num_qubits - elif basis_num_qubits != num_qubits: - raise ValueError( - "The number of qubits must be the same for all observables in the " - "observables array." - ) - self._array[ndi] = basis_obs - - def __repr__(self): - prefix = f"{type(self).__name__}(" - suffix = f", shape={self.shape})" - array = np.array2string(self._array, prefix=prefix, suffix=suffix, threshold=50) - return prefix + array + suffix - - def tolist(self) -> list: - """Convert to a nested list""" - return self._array.tolist() - - def __array__(self, dtype=None): - """Convert to an Numpy.ndarray""" - if dtype is None or dtype == object: - return self._array - raise ValueError("Type must be 'None' or 'object'") - - def __getitem__(self, args) -> Union[ObservablesArray, BasisObservable]: - item = self._array[args] - if not isinstance(item, np.ndarray): - return item - return ObservablesArray(item, copy=False, validate=False) - - def reshape(self, shape: Union[int, Iterable[int]]) -> "ObservablesArray": - """Return a new array with a different shape. - - This results in a new view of the same arrays. - - Args: - shape: The shape of the returned array. - - Returns: - A new array. - """ - return ObservablesArray(self._array.reshape(shape), copy=False, validate=False) - - def ravel(self) -> ObservablesArray: - """Return a new array with one dimension. - - The returned array has a :attr:`shape` given by ``(size, )``, where - the size is the :attr:`~size` of this array. - - Returns: - A new flattened array. - """ - return self.reshape(self.size) - - @classmethod - def format_observable(cls, observable: BasisObservableLike) -> BasisObservable: - """Format an observable-like object into a :const:`BasisObservable`. - - Args: - observable: The observable-like to format. - - Returns: - The given observable as a :const:`~BasisObservable`. - - Raises: - TypeError: If the input cannot be formatted because its type is not valid. - ValueError: If the input observable is invalid. - """ - - # Pauli-type conversions - if isinstance(observable, SparsePauliOp): - # Call simplify to combine duplicate keys before converting to a mapping - return cls.format_observable(dict(observable.simplify(atol=0).to_list())) - - if isinstance(observable, Pauli): - label, phase = observable[:].to_label(), observable.phase - return {label: 1} if phase == 0 else {label: (-1j) ** phase} - - # String conversion - if isinstance(observable, str): - cls._validate_basis(observable) - return {observable: 1} - - # Mapping conversion (with possible Pauli keys) - if isinstance(observable, MappingType): - num_qubits = len(next(iter(observable))) - unique = defaultdict(complex) - for basis, coeff in observable.items(): - if isinstance(basis, Pauli): - basis, phase = basis[:].to_label(), basis.phase - if phase != 0: - coeff = coeff * (-1j) ** phase - # Validate basis - cls._validate_basis(basis) - if len(basis) != num_qubits: - raise ValueError( - "Number of qubits must be the same for all observable basis elements." - ) - unique[basis] += coeff - return dict(unique) - - raise TypeError(f"Invalid observable type: {type(observable)}") - - @classmethod - def coerce(cls, observables: ObservablesArrayLike) -> ObservablesArray: - """Coerce ObservablesArrayLike into ObservableArray. - - Args: - observables: an object to be observables array. - - Returns: - A coerced observables array. - """ - if isinstance(observables, ObservablesArray): - return observables - if isinstance(observables, (str, SparsePauliOp, Pauli, Mapping)): - observables = [observables] - return cls(observables) - - def validate(self): - """Validate the consistency in observables array.""" - pass - - @classmethod - def _validate_basis(cls, basis: str) -> None: - """Validate a basis string. - - Args: - basis: a basis string to validate. - - Raises: - ValueError: If basis string contains invalid characters - """ - # NOTE: the allowed basis characters can be overridden by modifying the class - # attribute ALLOWED_BASIS - allowed_pattern = _regex_match(cls.ALLOWED_BASIS) - if not allowed_pattern.match(basis): - invalid_pattern = _regex_invalid(cls.ALLOWED_BASIS) - invalid_chars = list(set(invalid_pattern.findall(basis))) - raise ValueError( - f"Observable basis string '{basis}' contains invalid characters {invalid_chars}," - f" allowed characters are {list(cls.ALLOWED_BASIS)}.", - ) - - -ObservablesArrayLike = Union[ObservablesArray, ArrayLike, BasisObservableLike] -"""Types that can be natively converted to an ObservablesArray""" - - -class PauliArray(ObservablesArray): - """An ND-array of Pauli-basis observables for an :class:`.Estimator` primitive.""" - - ALLOWED_BASIS = "IXYZ" - - -@lru_cache(1) -def _regex_match(allowed_chars: str) -> re.Pattern: - """Return pattern for matching if a string contains only the allowed characters.""" - return re.compile(f"^[{re.escape(allowed_chars)}]*$") - - -@lru_cache(1) -def _regex_invalid(allowed_chars: str) -> re.Pattern: - """Return pattern for selecting invalid strings""" - return re.compile(f"[^{re.escape(allowed_chars)}]") diff --git a/qiskit/primitives/base/options.py b/qiskit/primitives/base/options.py deleted file mode 100644 index a608b97012d8..000000000000 --- a/qiskit/primitives/base/options.py +++ /dev/null @@ -1,40 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2023. -# -# 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. - -""" -Options class -""" - -from __future__ import annotations - -from abc import ABC -from typing import Union - -from pydantic import ConfigDict -from pydantic.dataclasses import dataclass - -primitive_dataclass = dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) - - -@primitive_dataclass -class BasePrimitiveOptions(ABC): - """Base calss of options for primitives.""" - - def update(self, **kwargs): - """Update the options.""" - for key, val in kwargs.items(): - setattr(self, key, val) - - -BasePrimitiveOptionsLike = Union[BasePrimitiveOptions, dict] diff --git a/qiskit/primitives/base/shape.py b/qiskit/primitives/base/shape.py deleted file mode 100644 index 1a000da684c3..000000000000 --- a/qiskit/primitives/base/shape.py +++ /dev/null @@ -1,129 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2023. -# -# 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. - -""" -Array shape related classes and functions -""" -from __future__ import annotations - -from collections.abc import Iterable -from typing import Protocol, Tuple, Union, runtime_checkable - -import numpy as np -from numpy.typing import ArrayLike, NDArray - -ShapeInput = Union[int, "Iterable[ShapeInput]"] -"""An input that is coercible into a shape tuple.""" - - -@runtime_checkable -class Shaped(Protocol): - """Protocol that defines what it means to be a shaped object. - - Note that static type checkers will classify ``numpy.ndarray`` as being :class:`Shaped`. - Moreover, since this protocol is runtime-checkable, we will even have - ``isinstance(, Shaped) == True``. - """ - - @property - def shape(self) -> Tuple[int, ...]: - """The array shape of this object.""" - raise NotImplementedError("A `Shaped` protocol must implement the `shape` property") - - @property - def ndim(self) -> int: - """The number of array dimensions of this object.""" - raise NotImplementedError("A `Shaped` protocol must implement the `ndim` property") - - @property - def size(self) -> int: - """The total dimension of this object, i.e. the product of the entries of :attr:`~shape`.""" - raise NotImplementedError("A `Shaped` protocol must implement the `size` property") - - -class ShapedMixin(Shaped): - """Mixin class to create :class:`~Shaped` types by only providing :attr:`_shape` attribute.""" - - _shape: Tuple[int, ...] - - def __repr__(self): - return f"{type(self).__name__}(<{self.shape}>)" - - @property - def shape(self): - return self._shape - - @property - def ndim(self): - return len(self._shape) - - @property - def size(self): - return int(np.prod(self._shape, dtype=int)) - - -def array_coerce(arr: Union[ArrayLike, Shaped]) -> Union[NDArray, Shaped]: - """Coerce the input into an object with a shape attribute. - - Copies are avoided. - - Args: - arr: The object to coerce. - - Returns: - Something that is :class:`~Shaped`, and always ``numpy.ndarray`` if the input is not - already :class:`~Shaped`. - """ - if isinstance(arr, Shaped): - return arr - return np.array(arr, copy=False) - - -def _flatten_to_ints(arg: ShapeInput) -> Iterable[int]: - """ - Yield one integer at a time. - - Args: - arg: Integers or iterables of integers, possibly nested, to be yielded. - - Yields: - The provided integers in depth-first recursive order. - - Raises: - ValueError: If an input is not an iterable or an integer. - """ - for item in arg: - try: - if isinstance(item, Iterable): - yield from _flatten_to_ints(item) - elif int(item) == item: - yield int(item) - else: - raise ValueError(f"Expected {item} to be iterable or an integer.") - except (TypeError, RecursionError) as ex: - raise ValueError(f"Expected {item} to be iterable or an integer.") from ex - - -def shape_tuple(*shapes: ShapeInput) -> Tuple[int, ...]: - """ - Flatten the input into a single tuple of integers, preserving order. - - Args: - shapes: Integers or iterables of integers, possibly nested. - - Returns: - A tuple of integers. - - Raises: - ValueError: If some member of ``shapes`` is not an integer or iterable. - """ - return tuple(_flatten_to_ints(shapes)) diff --git a/qiskit/primitives/base/task_result.py b/qiskit/primitives/base/task_result.py deleted file mode 100644 index 98ee15be72cd..000000000000 --- a/qiskit/primitives/base/task_result.py +++ /dev/null @@ -1,27 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2023. -# -# 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. - -""" -Base Task class -""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class TaskResult: - """Result of task.""" - - data: dict - metadata: dict From b53c010babbbccf82d116b954c3cdaaa76c288a7 Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Thu, 16 Nov 2023 23:38:59 +0900 Subject: [PATCH 37/55] Add BaseSamplerV2 and StatevectorSampler --- qiskit/primitives/base/__init__.py | 2 +- qiskit/primitives/base/base_sampler.py | 53 +++++- qiskit/primitives/containers/__init__.py | 1 + qiskit/primitives/containers/bit_array.py | 142 +++++++++++++++ qiskit/primitives/containers/sampler_task.py | 78 ++++++++ qiskit/primitives/statevector_sampler.py | 178 +++++++++++++++++++ 6 files changed, 448 insertions(+), 6 deletions(-) create mode 100644 qiskit/primitives/containers/bit_array.py create mode 100644 qiskit/primitives/containers/sampler_task.py create mode 100644 qiskit/primitives/statevector_sampler.py diff --git a/qiskit/primitives/base/__init__.py b/qiskit/primitives/base/__init__.py index 2384cb181f18..f11568b4213f 100644 --- a/qiskit/primitives/base/__init__.py +++ b/qiskit/primitives/base/__init__.py @@ -15,6 +15,6 @@ """ from .base_estimator import BaseEstimator, BaseEstimatorV2 -from .base_sampler import BaseSampler +from .base_sampler import BaseSampler, BaseSamplerV2 from .estimator_result import EstimatorResult from .sampler_result import SamplerResult diff --git a/qiskit/primitives/base/base_sampler.py b/qiskit/primitives/base/base_sampler.py index d21487261091..98cd37277129 100644 --- a/qiskit/primitives/base/base_sampler.py +++ b/qiskit/primitives/base/base_sampler.py @@ -77,22 +77,24 @@ import warnings from abc import abstractmethod -from collections.abc import Sequence +from collections.abc import Iterable, Sequence from copy import copy -from typing import Generic, TypeVar +from typing import Generic, Optional, TypeVar -from qiskit.utils.deprecation import deprecate_func from qiskit.circuit import QuantumCircuit from qiskit.circuit.parametertable import ParameterView from qiskit.providers import JobV1 as Job +from qiskit.utils.deprecation import deprecate_func -from .base_primitive import BasePrimitive +from ..containers.options import BasePrimitiveOptionsLike +from ..containers.sampler_task import SamplerTask, SamplerTaskLike from . import validation +from .base_primitive import BasePrimitiveV1, BasePrimitiveV2 T = TypeVar("T", bound=Job) -class BaseSampler(BasePrimitive, Generic[T]): +class BaseSamplerV1(BasePrimitiveV1, Generic[T]): """Sampler base class Base class of Sampler that calculates quasi-probabilities of bitstrings from quantum circuits. @@ -200,3 +202,44 @@ def parameters(self) -> tuple[ParameterView, ...]: List of the parameters in each quantum circuit. """ return tuple(self._parameters) + + +BaseSampler = BaseSamplerV1 + + +class BaseSamplerV2(BasePrimitiveV2, Generic[T]): + """Sampler base class version 2. + + Sampler returns samples of bitstrings of quantum circuits. + """ + + def __init__(self, options: Optional[BasePrimitiveOptionsLike]): + super().__init__(options=options) + + def run(self, tasks: SamplerTaskLike | Iterable[SamplerTaskLike]) -> T: + """Run the tasks of samples. + + Args: + tasks: a task-like object. Typically, list of tuple + ``(QuantumCircuit, parameter_values)`` + + Returns: + The job object of Sampler's Result. + """ + if isinstance(tasks, SamplerTask): + tasks = [tasks] + elif isinstance(tasks, QuantumCircuit): + tasks = [SamplerTask.coerce(tasks)] + elif isinstance(tasks, tuple) and isinstance(tasks[0], QuantumCircuit): + tasks = [SamplerTask.coerce(tasks)] + elif tasks is not SamplerTask: + tasks = [SamplerTask.coerce(task) for task in tasks] + + for task in tasks: + task.validate() + + return self._run(tasks) + + @abstractmethod + def _run(self, tasks: list[SamplerTask]) -> T: + pass diff --git a/qiskit/primitives/containers/__init__.py b/qiskit/primitives/containers/__init__.py index 54fc8615a1dd..2e079b067dfb 100644 --- a/qiskit/primitives/containers/__init__.py +++ b/qiskit/primitives/containers/__init__.py @@ -21,3 +21,4 @@ from .options import BasePrimitiveOptions, BasePrimitiveOptionsLike from .primitive_result import PrimitiveResult from .pub_result import PubResult +from .sampler_task import SamplerTask diff --git a/qiskit/primitives/containers/bit_array.py b/qiskit/primitives/containers/bit_array.py new file mode 100644 index 000000000000..139d665fbbd2 --- /dev/null +++ b/qiskit/primitives/containers/bit_array.py @@ -0,0 +1,142 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +""" +BitArray +""" + +from collections import defaultdict +from functools import partial +from typing import Callable, Dict, Optional, Tuple + +import numpy as np +from numpy.typing import NDArray + +from .shape import ShapedMixin + + +class BitArray(ShapedMixin): + """Stores bit outcomes. + + This object is somewhat analagous to an object array of ``memory=True`` results. + However, unlike an object array, all of the data is contiguous, stored in one big array. + The last axis is over packed bits, the second last axis is over samples, and the preceding + axes correspond to the shape of the task that was executed. + We use the word "samples" in reference to the fact that this is a primary data type returned by + the Sampler whose job is to supply samples, and whose name we can't easily change. + This is certainly confusing because this library uses the word "samples" to also refer to random + circuit instances. + """ + + def __init__(self, array: NDArray[np.uint8], num_bits: int): + """ + Args: + array: The data, where the last axis is over packed bits, the second last axis is over + shots, and the preceding axes correspond to the shape of the experiment. The byte + order is big endian. + num_bits: How many bit are in each outcome. + + Raises: + ValueError: If the input array has fewer than two axes, or the size of the last axis + is not the smallest number of bytes that can contain ``num_bits``. + """ + super().__init__() + self._array = np.array(array, copy=False, dtype=np.uint8) + self._num_bits = num_bits + # second last dimension is shots/samples, last dimension is packed bits + self._shape = self._array.shape[:-2] + + if self._array.ndim < 2: + raise ValueError("The input array must have at least two axes.") + if self._array.shape[-1] != (expected := num_bits // 8 + (num_bits % 8 > 0)): + raise ValueError(f"The input array is expected to have {expected} bytes per sample.") + + def __repr__(self): + desc = f"" + return f"BitArray({desc})" + + @property + def array(self) -> NDArray[np.uint8]: + """The raw NumPy array of data.""" + return self._array + + @property + def num_bits(self) -> int: + """The number of bits in the register this array stores data for. + + For example, a ``ClassicalRegister(5, "meas")`` would have ``num_bits=5``. + """ + return self._num_bits + + @property + def num_samples(self) -> int: + """The number of samples sampled from the register in each configuration. + + More precisely, the length of the second last axis of :attr:`~.array`. + """ + return self._array.shape[-2] + + @staticmethod + def _bytes_to_bitstring(data: bytes, num_bits: int, mask: int) -> str: + val = int.from_bytes(data, "big") & mask + return bin(val)[2:].zfill(num_bits) + + @staticmethod + def _bytes_to_int(data: bytes, mask: int) -> int: + return int.from_bytes(data, "big") & mask + + def _get_counts(self, *, loc: Optional[Tuple[int, ...]], converter: Callable) -> Dict[str, int]: + if loc is None and self.size == 1: + loc = (0,) * self.ndim + + elif loc is None: + raise ValueError( + f"Your BitArray has shape {self.shape}, meaning that it actually represents " + f"{self.size} different count dictionaries. You need to use the `loc` argument of " + "this function to pick one of them." + ) + + counts = defaultdict(int) + for shot_row in self._array[loc]: + counts[converter(shot_row.tobytes())] += 1 + return dict(counts) + + def get_counts(self, loc: Optional[Tuple[int, ...]] = None) -> Dict[str, int]: + """Return a counts dictionary. + + Args: + loc: Which entry of this array to return a dictionary for. + + Returns: + A dictionary mapping bitstrings to the number of occurrences of that bitstring. + + Raises: + ValueError: If this array has a non-trivial size and no ``loc`` is provided. + """ + mask = 2**self.num_bits - 1 + converter = partial(self._bytes_to_bitstring, num_bits=self.num_bits, mask=mask) + return self._get_counts(loc=loc, converter=converter) + + def get_int_counts(self, loc: Optional[Tuple[int, ...]] = None) -> Dict[int, int]: + r"""Return a counts dictionary, where bitstrings are stored as ``int``\s. + + Args: + loc: Which entry of this array to return a dictionary for. + + Returns: + A dictionary mapping ``ints`` to the number of occurrences of that ``int``. + + Raises: + ValueError: If this array has a non-trivial size and no ``loc`` is provided. + """ + converter = partial(self._bytes_to_int, mask=2**self.num_bits - 1) + return self._get_counts(loc=loc, converter=converter) diff --git a/qiskit/primitives/containers/sampler_task.py b/qiskit/primitives/containers/sampler_task.py new file mode 100644 index 000000000000..aec024071c00 --- /dev/null +++ b/qiskit/primitives/containers/sampler_task.py @@ -0,0 +1,78 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + + +""" +Sampler Task class +""" + +from __future__ import annotations + +from typing import Tuple, Union + +from qiskit import QuantumCircuit + +from .base_task import BaseTask +from .bindings_array import BindingsArray, BindingsArrayLike +from .dataclasses import frozen_dataclass +from .shape import ShapedMixin + + +@frozen_dataclass +class SamplerTask(BaseTask, ShapedMixin): + """Task for Sampler. + + Task is composed of triple (circuit, parameter_values). + """ + + parameter_values: BindingsArray = BindingsArray(shape=()) + _shape: tuple[int, ...] = () + + def __post_init__(self): + self._shape = self.parameter_values.shape + + @classmethod + def coerce(cls, task: SamplerTaskLike) -> SamplerTask: + """Coerce SamplerTaskLike into SamplerTask. + + Args: + task: an object to be Sampler task. + + Returns: + A coerced sampler task. + """ + if isinstance(task, SamplerTask): + return task + if isinstance(task, QuantumCircuit): + return cls(circuit=task) + if len(task) not in [1, 2]: + raise ValueError(f"The length of task must be 1 or 2, but length {len(task)} is given.") + circuit = task[0] + if len(task) == 1: + return cls(circuit=task) + parameter_values = BindingsArray.coerce(task[1]) + return cls(circuit=circuit, parameter_values=parameter_values) + + def validate(self): + """Validate the task.""" + super(SamplerTask, self).validate() + self.parameter_values.validate() + # Cross validate circuits and parameter values + num_parameters = self.parameter_values.num_parameters + if num_parameters != self.circuit.num_parameters: + raise ValueError( + f"The number of values ({num_parameters}) does not match " + f"the number of parameters ({self.circuit.num_parameters}) for the circuit." + ) + + +SamplerTaskLike = Union[SamplerTask, QuantumCircuit, Tuple[QuantumCircuit, BindingsArrayLike]] diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py new file mode 100644 index 000000000000..e00a802d664a --- /dev/null +++ b/qiskit/primitives/statevector_sampler.py @@ -0,0 +1,178 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. +""" +Sampler class +""" + +from __future__ import annotations + +from typing import List, Optional, Union + +import numpy as np +from numpy.typing import NDArray +from pydantic import Field + +from qiskit import ClassicalRegister, QuantumCircuit +from qiskit.quantum_info import Statevector + +from .base import BaseSamplerV2 +from .containers import BasePrimitiveOptions, BasePrimitiveOptionsLike, SamplerTask, TaskResult +from .containers.bit_array import BitArray +from .containers.options import mutable_dataclass +from .primitive_job import PrimitiveJob +from .utils import bound_circuit_to_instruction + + +@mutable_dataclass +class ExecutionOptions(BasePrimitiveOptions): + """Options for execution.""" + + shots: Optional[int] = None + seed: Optional[Union[int, np.random.Generator]] = None + + +@mutable_dataclass +class Options(BasePrimitiveOptions): + """Options for the primitives. + + Args: + execution: Execution time options. See :class:`ExecutionOptions` for all available options. + """ + + execution: ExecutionOptions = Field(default_factory=ExecutionOptions) + + +class StatevectorSampler(BaseSamplerV2[PrimitiveJob[List[TaskResult]]]): + """ + Simple implementation of :class:`BaseSamplerV2` with Statevector. + + :Run Options: + + - **shots** (None or int) -- + The number of shots. If None, it calculates the exact expectation + values. Otherwise, it samples from normal distributions with standard errors as standard + deviations using normal distribution approximation. + + - **seed** (np.random.Generator or int) -- + Set a fixed seed or generator for the normal distribution. If shots is None, + this option is ignored. + """ + + _options_class = Options + + def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): + """ + Args: + options: Options including shots, seed. + """ + if options is None: + options = Options() + elif not isinstance(options, Options): + options = Options(**options) + super().__init__(options=options) + + def _run(self, tasks: list[SamplerTask]) -> PrimitiveJob[list[TaskResult]]: + job = PrimitiveJob(self._run_task, tasks) + job.submit() + return job + + def _run_task(self, tasks: list[SamplerTask]) -> list[TaskResult]: + shots = self.options.execution.shots or 1 + + results = [] + for task in tasks: + circuit, qargs, num_bits, q_indices, packed_sizes = self._preprocess_circuit( + task.circuit + ) + parameter_values = task.parameter_values + bound_circuits = parameter_values.bind_all(circuit) + arrays = { + name: np.zeros(bound_circuits.shape + (shots, packed_size), dtype=np.uint8) + for name, packed_size in packed_sizes.items() + } + for index in np.ndindex(*bound_circuits.shape): + bound_circuit = bound_circuits[index] + final_state = Statevector(bound_circuit_to_instruction(bound_circuit)) + samples = final_state.sample_memory(shots=shots, qargs=qargs) + for name in num_bits: + ary = self._samples_to_packed_array(samples, num_bits[name], q_indices[name]) + arrays[name][index] = ary + meas = {name: BitArray(arrays[name], num_bits[name]) for name in num_bits} + results.append(TaskResult(meas, metadata={"shots": shots})) + + return results + + @staticmethod + def _preprocess_circuit(circuit: QuantumCircuit): + mapping = _final_measurement_mapping(circuit) + qargs = list(mapping.values()) + circuit = circuit.remove_final_measurements(inplace=False) + num_qubits = circuit.num_qubits + num_bits = {key[0].name: key[0].size for key in mapping} + # num_qubits is used as sentinel to fill 0 + indices = {key: [num_qubits] * val for key, val in num_bits.items()} + for key, qreg in mapping.items(): + creg, ind = key + indices[creg.name][ind] = qreg + packed_sizes = { + name: num_bits // 8 + (num_bits % 8 > 0) for name, num_bits in num_bits.items() + } + return circuit, qargs, num_bits, indices, packed_sizes + + @staticmethod + def _samples_to_packed_array( + samples: NDArray[str], num_bits: int, indices: list[int] + ) -> NDArray[np.uint8]: + pad_size = (8 - num_bits % 8) % 8 + ary = np.array([np.fromiter(sample, dtype=np.uint8) for sample in samples]) + # pad 0 to be used for the sentinel introduced by _preprocess_circuit + ary = np.pad(ary, ((0, 0), (0, 1)), constant_values=0) + ary = ary[:, indices] + ary = np.pad(ary, ((0, 0), (pad_size, 0)), constant_values=0) + ary = np.packbits(ary, axis=-1) + return ary + + +def _final_measurement_mapping(circuit: QuantumCircuit) -> dict[tuple[ClassicalRegister, int], int]: + """Return the final measurement mapping for the circuit. + + Parameters: + circuit: Input quantum circuit. + + Returns: + Mapping of classical bits to qubits for final measurements. + """ + active_qubits = set(range(circuit.num_qubits)) + active_cbits = set(range(circuit.num_clbits)) + + # Find final measurements starting in back + mapping = {} + for item in circuit._data[::-1]: + if item.operation.name == "measure": + loc = circuit.find_bit(item.clbits[0]) + cbit = loc.index + creg = loc.registers[0] + qbit = circuit.find_bit(item.qubits[0]).index + if cbit in active_cbits and qbit in active_qubits: + mapping[creg] = qbit + active_cbits.remove(cbit) + active_qubits.remove(qbit) + elif item.operation.name not in ["barrier", "delay"]: + for qq in item.qubits: + _temp_qubit = circuit.find_bit(qq).index + if _temp_qubit in active_qubits: + active_qubits.remove(_temp_qubit) + + if not active_cbits or not active_qubits: + break + + return mapping From 15195227dd2c1611998f8b5492ec291e13ff660a Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Fri, 17 Nov 2023 00:18:10 +0900 Subject: [PATCH 38/55] refactor --- qiskit/primitives/statevector_sampler.py | 105 ++++++++++++++--------- 1 file changed, 64 insertions(+), 41 deletions(-) diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index e00a802d664a..93d56d8a5fed 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -16,12 +16,13 @@ from __future__ import annotations from typing import List, Optional, Union +from dataclasses import dataclass import numpy as np from numpy.typing import NDArray from pydantic import Field -from qiskit import ClassicalRegister, QuantumCircuit +from qiskit import ClassicalRegister, QuantumCircuit, QiskitError from qiskit.quantum_info import Statevector from .base import BaseSamplerV2 @@ -36,7 +37,7 @@ class ExecutionOptions(BasePrimitiveOptions): """Options for execution.""" - shots: Optional[int] = None + shots: int = 1 # TODO: discuss the default number of shots seed: Optional[Union[int, np.random.Generator]] = None @@ -51,6 +52,14 @@ class Options(BasePrimitiveOptions): execution: ExecutionOptions = Field(default_factory=ExecutionOptions) +@dataclass +class _MeasureInfo: + creg_name: str + num_bits: int + packed_size: int + qreg_indices: list[int] + + class StatevectorSampler(BaseSamplerV2[PrimitiveJob[List[TaskResult]]]): """ Simple implementation of :class:`BaseSamplerV2` with Statevector. @@ -78,6 +87,7 @@ def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): options = Options() elif not isinstance(options, Options): options = Options(**options) + print(options) super().__init__(options=options) def _run(self, tasks: list[SamplerTask]) -> PrimitiveJob[list[TaskResult]]: @@ -86,60 +96,73 @@ def _run(self, tasks: list[SamplerTask]) -> PrimitiveJob[list[TaskResult]]: return job def _run_task(self, tasks: list[SamplerTask]) -> list[TaskResult]: - shots = self.options.execution.shots or 1 + shots = self.options.execution.shots + if shots is None: + raise QiskitError("`shots` should be a positive integer") + seed = self.options.execution.seed results = [] for task in tasks: - circuit, qargs, num_bits, q_indices, packed_sizes = self._preprocess_circuit( - task.circuit - ) + circuit, qargs, meas_info = _preprocess_circuit(task.circuit) parameter_values = task.parameter_values bound_circuits = parameter_values.bind_all(circuit) arrays = { - name: np.zeros(bound_circuits.shape + (shots, packed_size), dtype=np.uint8) - for name, packed_size in packed_sizes.items() + item.creg_name: np.zeros( + bound_circuits.shape + (shots, item.packed_size), dtype=np.uint8 + ) + for item in meas_info } for index in np.ndindex(*bound_circuits.shape): bound_circuit = bound_circuits[index] final_state = Statevector(bound_circuit_to_instruction(bound_circuit)) + final_state.seed(seed) samples = final_state.sample_memory(shots=shots, qargs=qargs) - for name in num_bits: - ary = self._samples_to_packed_array(samples, num_bits[name], q_indices[name]) - arrays[name][index] = ary - meas = {name: BitArray(arrays[name], num_bits[name]) for name in num_bits} + for item in meas_info: + ary = _samples_to_packed_array(samples, item.num_bits, item.qreg_indices) + arrays[item.creg_name][index] = ary + meas = { + item.creg_name: BitArray(arrays[item.creg_name], item.num_bits) + for item in meas_info + } results.append(TaskResult(meas, metadata={"shots": shots})) return results - @staticmethod - def _preprocess_circuit(circuit: QuantumCircuit): - mapping = _final_measurement_mapping(circuit) - qargs = list(mapping.values()) - circuit = circuit.remove_final_measurements(inplace=False) - num_qubits = circuit.num_qubits - num_bits = {key[0].name: key[0].size for key in mapping} - # num_qubits is used as sentinel to fill 0 - indices = {key: [num_qubits] * val for key, val in num_bits.items()} - for key, qreg in mapping.items(): - creg, ind = key - indices[creg.name][ind] = qreg - packed_sizes = { - name: num_bits // 8 + (num_bits % 8 > 0) for name, num_bits in num_bits.items() - } - return circuit, qargs, num_bits, indices, packed_sizes - - @staticmethod - def _samples_to_packed_array( - samples: NDArray[str], num_bits: int, indices: list[int] - ) -> NDArray[np.uint8]: - pad_size = (8 - num_bits % 8) % 8 - ary = np.array([np.fromiter(sample, dtype=np.uint8) for sample in samples]) - # pad 0 to be used for the sentinel introduced by _preprocess_circuit - ary = np.pad(ary, ((0, 0), (0, 1)), constant_values=0) - ary = ary[:, indices] - ary = np.pad(ary, ((0, 0), (pad_size, 0)), constant_values=0) - ary = np.packbits(ary, axis=-1) - return ary + +def _preprocess_circuit(circuit: QuantumCircuit): + mapping = _final_measurement_mapping(circuit) + qargs = list(mapping.values()) + circuit = circuit.remove_final_measurements(inplace=False) + num_qubits = circuit.num_qubits + num_bits_dict = {key[0].name: key[0].size for key in mapping} + # num_qubits is used as sentinel to fill 0 + indices = {key: [num_qubits] * val for key, val in num_bits_dict.items()} + for key, qreg in mapping.items(): + creg, ind = key + indices[creg.name][ind] = qreg + meas_info = [ + _MeasureInfo( + creg_name=name, + num_bits=num_bits, + qreg_indices=indices[name], + packed_size=num_bits // 8 + (num_bits % 8 > 0), + ) + for name, num_bits in num_bits_dict.items() + ] + return circuit, qargs, meas_info + + +def _samples_to_packed_array( + samples: NDArray[str], num_bits: int, indices: list[int] +) -> NDArray[np.uint8]: + pad_size = (8 - num_bits % 8) % 8 + ary = np.array([np.fromiter(sample, dtype=np.uint8) for sample in samples]) + # pad 0 to be used for the sentinel introduced by _preprocess_circuit + ary = np.pad(ary, ((0, 0), (0, 1)), constant_values=0) + ary = ary[:, indices] + ary = np.pad(ary, ((0, 0), (pad_size, 0)), constant_values=0) + ary = np.packbits(ary, axis=-1) + return ary def _final_measurement_mapping(circuit: QuantumCircuit) -> dict[tuple[ClassicalRegister, int], int]: From 66bfc7b5ad27723935a68639a20e7c874bfca0ff Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Fri, 17 Nov 2023 14:44:37 +0900 Subject: [PATCH 39/55] fix bit order --- qiskit/primitives/statevector_sampler.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index 93d56d8a5fed..3f94eb1008bb 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -10,7 +10,7 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. """ -Sampler class +Statevector Sampler class """ from __future__ import annotations @@ -87,7 +87,6 @@ def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): options = Options() elif not isinstance(options, Options): options = Options(**options) - print(options) super().__init__(options=options) def _run(self, tasks: list[SamplerTask]) -> PrimitiveJob[list[TaskResult]]: @@ -135,7 +134,7 @@ def _preprocess_circuit(circuit: QuantumCircuit): circuit = circuit.remove_final_measurements(inplace=False) num_qubits = circuit.num_qubits num_bits_dict = {key[0].name: key[0].size for key in mapping} - # num_qubits is used as sentinel to fill 0 + # num_qubits is used as sentinel to fill 0 in _samples_to_packed_array indices = {key: [num_qubits] * val for key, val in num_bits_dict.items()} for key, qreg in mapping.items(): creg, ind = key @@ -155,12 +154,18 @@ def _preprocess_circuit(circuit: QuantumCircuit): def _samples_to_packed_array( samples: NDArray[str], num_bits: int, indices: list[int] ) -> NDArray[np.uint8]: - pad_size = (8 - num_bits % 8) % 8 + # samples of `Statevector.sample_memory` will be the order of + # qubit_0, qubit_1, ..., qubit_last ary = np.array([np.fromiter(sample, dtype=np.uint8) for sample in samples]) - # pad 0 to be used for the sentinel introduced by _preprocess_circuit + # pad 0 in the rightmost to be used for the sentinel introduced by _preprocess_circuit ary = np.pad(ary, ((0, 0), (0, 1)), constant_values=0) - ary = ary[:, indices] + # place samples in the order of clbit_last, ..., clbit_1, clbit_0 + ary = ary[:, indices[::-1]] + # pad 0 in the left to align the number to be mod 8 + # since np.packbits(bitorder='big') pads 0 to the right. + pad_size = (8 - num_bits % 8) % 8 ary = np.pad(ary, ((0, 0), (pad_size, 0)), constant_values=0) + # pack bits in big endian order ary = np.packbits(ary, axis=-1) return ary From 834e24c642d227d5a04c721b5920a64d6ca64414 Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Mon, 20 Nov 2023 19:11:15 +0900 Subject: [PATCH 40/55] rebase EstimatorV2, update BitArray, and update StatevectorSampler --- qiskit/primitives/containers/bit_array.py | 232 +++++++++++++++++-- qiskit/primitives/containers/sampler_task.py | 6 +- qiskit/primitives/statevector_sampler.py | 24 +- 3 files changed, 234 insertions(+), 28 deletions(-) diff --git a/qiskit/primitives/containers/bit_array.py b/qiskit/primitives/containers/bit_array.py index 139d665fbbd2..c2dcc042802c 100644 --- a/qiskit/primitives/containers/bit_array.py +++ b/qiskit/primitives/containers/bit_array.py @@ -12,37 +12,45 @@ """ BitArray + +Details +======= + + We use the word "samples" in reference to the fact that this is a primary data type returned by + the Sampler whose job is to supply samples, and whose name we can't easily change. + This is certainly confusing because this library uses the word "samples" to also refer to random + circuit instances. """ +from __future__ import annotations + +from typing import Callable, Dict, Iterable, Literal, Mapping, Tuple from collections import defaultdict from functools import partial -from typing import Callable, Dict, Optional, Tuple +from itertools import chain, repeat import numpy as np from numpy.typing import NDArray +from qiskit.result import Counts -from .shape import ShapedMixin +from .shape import ShapedMixin, ShapeInput, shape_tuple + +# this lookup table tells you how many bits are 1 in each uint8 value +_WEIGHT_LOOKUP = np.unpackbits(np.arange(256, dtype=np.uint8).reshape(-1, 1), axis=1).sum(axis=1) class BitArray(ShapedMixin): - """Stores bit outcomes. + """Stores an array of bit values. - This object is somewhat analagous to an object array of ``memory=True`` results. - However, unlike an object array, all of the data is contiguous, stored in one big array. - The last axis is over packed bits, the second last axis is over samples, and the preceding - axes correspond to the shape of the task that was executed. - We use the word "samples" in reference to the fact that this is a primary data type returned by - the Sampler whose job is to supply samples, and whose name we can't easily change. - This is certainly confusing because this library uses the word "samples" to also refer to random - circuit instances. + This object contains a single, contiguous block of data that represents an array of bitstrings. + The last axis is over packed bits, the second last axis is over samples (aka shots), and the + preceding axes correspond to the shape of the task that was executed. """ def __init__(self, array: NDArray[np.uint8], num_bits: int): """ Args: - array: The data, where the last axis is over packed bits, the second last axis is over - shots, and the preceding axes correspond to the shape of the experiment. The byte - order is big endian. + array: The ``uint8`` data array. num_bits: How many bit are in each outcome. Raises: @@ -60,6 +68,40 @@ def __init__(self, array: NDArray[np.uint8], num_bits: int): if self._array.shape[-1] != (expected := num_bits // 8 + (num_bits % 8 > 0)): raise ValueError(f"The input array is expected to have {expected} bytes per sample.") + def _prepare_broadcastable(self, other: "BitArray") -> Tuple[NDArray[np.uint8], ...]: + """Validation and broadcasting of two bit arrays before element-wise binary operation.""" + if self.num_bits != other.num_bits: + raise ValueError(f"'num_bits' must match in {self} and {other}.") + self_shape = self.shape + (self.num_samples,) + other_shape = other.shape + (other.num_samples,) + try: + shape = np.broadcast_shapes(self_shape, other_shape) + (self._array.shape[-1],) + except ValueError as ex: + raise ValueError(f"{self} and {other} are not compatible for this operation.") from ex + return np.broadcast_to(self.array, shape), np.broadcast_to(other.array, shape) + + def __and__(self, other: "BitArray") -> "BitArray": + return BitArray(np.bitwise_and(*self._prepare_broadcastable(other)), self.num_bits) + + def __eq__(self, other: "BitArray") -> bool: + if (n := self.num_bits) != other.num_bits: + return False + arrs = [self._array, other._array] + if n % 8 > 0: + # ignore straggling bits on the left + mask = np.array([255 >> ((-n) % 8)] + [255] * (n // 8), dtype=np.uint8) + arrs = [np.bitwise_and(arr, mask) for arr in arrs] + return np.array_equal(*arrs, equal_nan=False) + + def __invert__(self) -> "BitArray": + return BitArray(np.bitwise_not(self._array), self.num_bits) + + def __or__(self, other: "BitArray") -> "BitArray": + return BitArray(np.bitwise_or(*self._prepare_broadcastable(other)), self.num_bits) + + def __xor__(self, other: "BitArray") -> "BitArray": + return BitArray(np.bitwise_xor(*self._prepare_broadcastable(other)), self.num_bits) + def __repr__(self): desc = f"" return f"BitArray({desc})" @@ -71,7 +113,7 @@ def array(self) -> NDArray[np.uint8]: @property def num_bits(self) -> int: - """The number of bits in the register this array stores data for. + """The number of bits in the register that this array stores data for. For example, a ``ClassicalRegister(5, "meas")`` would have ``num_bits=5``. """ @@ -94,7 +136,7 @@ def _bytes_to_bitstring(data: bytes, num_bits: int, mask: int) -> str: def _bytes_to_int(data: bytes, mask: int) -> int: return int.from_bytes(data, "big") & mask - def _get_counts(self, *, loc: Optional[Tuple[int, ...]], converter: Callable) -> Dict[str, int]: + def _get_counts(self, *, loc: Tuple[int, ...] | None, converter: Callable) -> Dict[str, int]: if loc is None and self.size == 1: loc = (0,) * self.ndim @@ -110,7 +152,133 @@ def _get_counts(self, *, loc: Optional[Tuple[int, ...]], converter: Callable) -> counts[converter(shot_row.tobytes())] += 1 return dict(counts) - def get_counts(self, loc: Optional[Tuple[int, ...]] = None) -> Dict[str, int]: + def bitcount(self) -> NDArray[np.uint64]: + """Compute the number of ones appearing in the binary representation of each sample. + + Returns: + A ``numpy.uint64``-array with shape ``(*shape, num_samples)``. + """ + return _WEIGHT_LOOKUP[self._array].sum(axis=-1) + + @staticmethod + def from_bool_array( + array: NDArray[np.bool], order: Literal["big", "little"] = "big" + ) -> "BitArray": + """Construct a new bit array from an array of bools. + + Args: + array: The array to convert, with "bitstrings" along the last axis. + order: One of ``"big"`` or ``"little"``, indicating whether ``array[..., 0]`` + correspond to the most significant bits or the least significant bits of each + bitstring, respectively. + + Returns: + A new bit array. + """ + array = np.array(array, dtype=bool, copy=False) + + if array.ndim < 2: + raise ValueError("Expecting at least two dimensions.") + + if order == "little": + # np.unpackbits assumes "big" + array = array[..., ::-1] + + num_bits = array.shape[-1] + if remainder := (-num_bits) % 8: + # unpackbits has strange (incorrect?) behaviour when the number of bits doesn't align + # to a multiple of 8, so we pad with zeros + pad = np.zeros(shape_tuple(array.shape[:-1], remainder), dtype=bool) + array = np.concatenate([pad, array], axis=-1) + + return BitArray(np.packbits(array, axis=-1), num_bits=num_bits) + + @staticmethod + def from_counts( + counts: Mapping[str | int, int] | Iterable[Mapping[str | int, int]], + num_bits: int | None = None, + ) -> "BitArray": + """Construct a new bit array from one or more ``Counts``-like objects. + + The ``counts`` can have keys that are (uniformly) integers, hexstrings, or bitstrings. + Their values represent numbers of occurences of that value. + + Args: + counts: One or more counts-like mappings. + num_bits: The desired number of bits per sample. If unset, the biggest sample provided + is used to determine this value. + + Returns: + A new bit array. + + Raises: + ValueError: If different mappings have different numbers of samples. + ValueError: If no counts dictionaries are supplied. + """ + if singleton := isinstance(counts, Mapping): + counts = [counts] + else: + counts = list(counts) + if not counts: + raise ValueError("At least one counts mapping expected.") + + counts = [ + mapping.int_outcomes() if isinstance(mapping, Counts) else mapping for mapping in counts + ] + + data = (v for mapping in counts for vs, count in mapping.items() for v in repeat(vs, count)) + + bit_array = BitArray.from_samples(data, num_bits) + if not singleton: + if bit_array.num_samples % len(counts) > 0: + raise ValueError("All of your mappings need to have the same number of samples.") + bit_array = bit_array.reshape(len(counts), bit_array.num_samples // len(counts)) + return bit_array + + @staticmethod + def from_samples( + samples: Iterable[str] | Iterable[int], num_bits: int | None = None + ) -> "BitArray": + """Construct a new bit array from an iterable of bitstrings, hexstrings, or integers. + + All samples are assumed to be integers if the first one is. + Strings are all assumed to be bitstrings whenever the first string doesn't start with + ``"0x"``. + + Consider pairing this method with :meth:`~reshape` if your samples represent nested data. + + Args: + samples: A list of bitstrings, a list of integers, or a list of hexstrings. + num_bits: The desired number of bits per sample. If unset, the biggest sample provided + is used to determine this value. + + Returns: + A new bit array. + + Raises: + ValueError: If no strings are given. + """ + samples = iter(samples) + try: + first_sample = next(samples) + except StopIteration as ex: + raise ValueError("At least one sample is required.") from ex + + ints = chain([first_sample], samples) + if isinstance(first_sample, str): + base = 16 if first_sample.startswith("0x") else 2 + ints = (int(val, base=base) for val in ints) + + if num_bits is None: + ints = list(ints) + num_bits = max(map(int.bit_length, ints)) + + num_bytes = num_bits // 8 + (num_bits % 8 > 0) + data = b"".join(val.to_bytes(num_bytes, "big") for val in ints) + array = np.frombuffer(data, dtype=np.uint8, count=len(data)) + return BitArray(array.reshape(-1, num_bytes), num_bits) + + def get_counts(self, loc: Tuple[int, ...] | None = None) -> Dict[str, int]: """Return a counts dictionary. Args: @@ -126,7 +294,7 @@ def get_counts(self, loc: Optional[Tuple[int, ...]] = None) -> Dict[str, int]: converter = partial(self._bytes_to_bitstring, num_bits=self.num_bits, mask=mask) return self._get_counts(loc=loc, converter=converter) - def get_int_counts(self, loc: Optional[Tuple[int, ...]] = None) -> Dict[int, int]: + def get_int_counts(self, loc: Tuple[int, ...] | None = None) -> Dict[int, int]: r"""Return a counts dictionary, where bitstrings are stored as ``int``\s. Args: @@ -140,3 +308,31 @@ def get_int_counts(self, loc: Optional[Tuple[int, ...]] = None) -> Dict[int, int """ converter = partial(self._bytes_to_int, mask=2**self.num_bits - 1) return self._get_counts(loc=loc, converter=converter) + + def reshape(self, *shape: ShapeInput) -> "BitArray": + """Return a new reshaped bit array. + + The :attr:`~num_samples` axis is either included or excluded from the reshaping procedure + depending on which picture the new shape is compatible with. For example, if this bit array + has shape ``(20, 5)`` and ``64`` samples, then a reshape to ``(100,)`` would leave the + number of samples intact, whereas a reshape to ``(200, 32)`` would change the number of + samples to ``32``. + + Args: + *shape: The new desired shape. + + Returns: + A new bit array. + + Raises: + ValueError: If the size corresponding to your new shape is not equal to either + :attr:`~size`, or the product of :attr:`~size` and :attr:`~num_samples`. + """ + shape = shape_tuple(shape) + if (size := np.product(shape, dtype=int)) == self.size: + shape = shape_tuple(shape, self._array.shape[-2:]) + elif size == self.size * self.num_samples: + shape = shape_tuple(shape, self._array.shape[-1:]) + else: + raise ValueError("Cannot change the size of the array.") + return BitArray(self._array.reshape(shape), self.num_bits) diff --git a/qiskit/primitives/containers/sampler_task.py b/qiskit/primitives/containers/sampler_task.py index aec024071c00..262e51fef3f4 100644 --- a/qiskit/primitives/containers/sampler_task.py +++ b/qiskit/primitives/containers/sampler_task.py @@ -35,7 +35,7 @@ class SamplerTask(BaseTask, ShapedMixin): """ parameter_values: BindingsArray = BindingsArray(shape=()) - _shape: tuple[int, ...] = () + _shape: Tuple[int, ...] = () def __post_init__(self): self._shape = self.parameter_values.shape @@ -64,7 +64,9 @@ def coerce(cls, task: SamplerTaskLike) -> SamplerTask: def validate(self): """Validate the task.""" - super(SamplerTask, self).validate() + super(SamplerTask, self).validate() # pylint: disable=super-with-arguments + # I'm not sure why these arguments for super are needed. But if no args, tests are failed + # for Python >=3.10. Seems to be some bug, but I can't fix. self.parameter_values.validate() # Cross validate circuits and parameter values num_parameters = self.parameter_values.num_parameters diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index 3f94eb1008bb..72da2e2a5186 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -15,19 +15,20 @@ from __future__ import annotations -from typing import List, Optional, Union from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple, Union import numpy as np from numpy.typing import NDArray from pydantic import Field -from qiskit import ClassicalRegister, QuantumCircuit, QiskitError +from qiskit import ClassicalRegister, QiskitError, QuantumCircuit from qiskit.quantum_info import Statevector from .base import BaseSamplerV2 from .containers import BasePrimitiveOptions, BasePrimitiveOptionsLike, SamplerTask, TaskResult from .containers.bit_array import BitArray +from .containers.data_bin import make_databin from .containers.options import mutable_dataclass from .primitive_job import PrimitiveJob from .utils import bound_circuit_to_instruction @@ -57,7 +58,7 @@ class _MeasureInfo: creg_name: str num_bits: int packed_size: int - qreg_indices: list[int] + qreg_indices: List[int] class StatevectorSampler(BaseSamplerV2[PrimitiveJob[List[TaskResult]]]): @@ -89,12 +90,12 @@ def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): options = Options(**options) super().__init__(options=options) - def _run(self, tasks: list[SamplerTask]) -> PrimitiveJob[list[TaskResult]]: + def _run(self, tasks: List[SamplerTask]) -> PrimitiveJob[List[TaskResult]]: job = PrimitiveJob(self._run_task, tasks) job.submit() return job - def _run_task(self, tasks: list[SamplerTask]) -> list[TaskResult]: + def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: shots = self.options.execution.shots if shots is None: raise QiskitError("`shots` should be a positive integer") @@ -111,6 +112,7 @@ def _run_task(self, tasks: list[SamplerTask]) -> list[TaskResult]: ) for item in meas_info } + for index in np.ndindex(*bound_circuits.shape): bound_circuit = bound_circuits[index] final_state = Statevector(bound_circuit_to_instruction(bound_circuit)) @@ -119,11 +121,17 @@ def _run_task(self, tasks: list[SamplerTask]) -> list[TaskResult]: for item in meas_info: ary = _samples_to_packed_array(samples, item.num_bits, item.qreg_indices) arrays[item.creg_name][index] = ary + + data_bin_cls = make_databin( + [(item.creg_name, BitArray) for item in meas_info], + shape=bound_circuits.shape, + ) meas = { item.creg_name: BitArray(arrays[item.creg_name], item.num_bits) for item in meas_info } - results.append(TaskResult(meas, metadata={"shots": shots})) + data_bin = data_bin_cls(**meas) + results.append(TaskResult(data_bin, metadata={"shots": shots})) return results @@ -152,7 +160,7 @@ def _preprocess_circuit(circuit: QuantumCircuit): def _samples_to_packed_array( - samples: NDArray[str], num_bits: int, indices: list[int] + samples: NDArray[str], num_bits: int, indices: List[int] ) -> NDArray[np.uint8]: # samples of `Statevector.sample_memory` will be the order of # qubit_0, qubit_1, ..., qubit_last @@ -170,7 +178,7 @@ def _samples_to_packed_array( return ary -def _final_measurement_mapping(circuit: QuantumCircuit) -> dict[tuple[ClassicalRegister, int], int]: +def _final_measurement_mapping(circuit: QuantumCircuit) -> Dict[Tuple[ClassicalRegister, int], int]: """Return the final measurement mapping for the circuit. Parameters: From cc983c70c715337a11c5e0a85dde38b37c92fd9d Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Mon, 20 Nov 2023 22:37:40 +0900 Subject: [PATCH 41/55] allow consecutive final measurements on the same qubit --- qiskit/primitives/statevector_sampler.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index 72da2e2a5186..89139274a607 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -26,6 +26,7 @@ from qiskit.quantum_info import Statevector from .base import BaseSamplerV2 +from .base.validation import _has_measure from .containers import BasePrimitiveOptions, BasePrimitiveOptionsLike, SamplerTask, TaskResult from .containers.bit_array import BitArray from .containers.data_bin import make_databin @@ -138,8 +139,10 @@ def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: def _preprocess_circuit(circuit: QuantumCircuit): mapping = _final_measurement_mapping(circuit) - qargs = list(mapping.values()) + qargs = sorted(set(mapping.values())) circuit = circuit.remove_final_measurements(inplace=False) + if _has_measure(circuit): + raise QiskitError("StatevectorSampler cannot handle mid-circuit measurements") num_qubits = circuit.num_qubits num_bits_dict = {key[0].name: key[0].size for key in mapping} # num_qubits is used as sentinel to fill 0 in _samples_to_packed_array @@ -192,7 +195,7 @@ def _final_measurement_mapping(circuit: QuantumCircuit) -> Dict[Tuple[ClassicalR # Find final measurements starting in back mapping = {} - for item in circuit._data[::-1]: + for item in circuit[::-1]: if item.operation.name == "measure": loc = circuit.find_bit(item.clbits[0]) cbit = loc.index @@ -201,7 +204,6 @@ def _final_measurement_mapping(circuit: QuantumCircuit) -> Dict[Tuple[ClassicalR if cbit in active_cbits and qbit in active_qubits: mapping[creg] = qbit active_cbits.remove(cbit) - active_qubits.remove(qbit) elif item.operation.name not in ["barrier", "delay"]: for qq in item.qubits: _temp_qubit = circuit.find_bit(qq).index From 4333b89679653d57cdbb554581964929317f75a5 Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Tue, 21 Nov 2023 18:54:10 +0900 Subject: [PATCH 42/55] add BackendSamplerV2 tentatively --- qiskit/primitives/backend_sampler.py | 2 +- qiskit/primitives/backend_sampler_v2.py | 281 ++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 qiskit/primitives/backend_sampler_v2.py diff --git a/qiskit/primitives/backend_sampler.py b/qiskit/primitives/backend_sampler.py index 140a3091f34a..ab0c9d39fa76 100644 --- a/qiskit/primitives/backend_sampler.py +++ b/qiskit/primitives/backend_sampler.py @@ -10,7 +10,7 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Sampler implementation for an artibtrary Backend object.""" +"""Sampler implementation for an arbitrary Backend object.""" from __future__ import annotations diff --git a/qiskit/primitives/backend_sampler_v2.py b/qiskit/primitives/backend_sampler_v2.py new file mode 100644 index 000000000000..f6aa96eadae2 --- /dev/null +++ b/qiskit/primitives/backend_sampler_v2.py @@ -0,0 +1,281 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. + +"""Sampler implementation for an arbitrary Backend object.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Union + +import numpy as np +from numpy.typing import NDArray +from pydantic import Field + +from qiskit import QiskitError +from qiskit.circuit.quantumcircuit import QuantumCircuit +from qiskit.providers.backend import BackendV1, BackendV2 +from qiskit.result import QuasiDistribution, Result +from qiskit.transpiler.passmanager import PassManager + +from .backend_estimator import _prepare_counts, _run_circuits +from .base import BaseSamplerV2, SamplerResult +from .containers import BasePrimitiveOptions, BasePrimitiveOptionsLike, SamplerTask, TaskResult +from .containers.bit_array import BitArray +from .containers.data_bin import make_databin +from .containers.options import mutable_dataclass +from .primitive_job import PrimitiveJob + + +@mutable_dataclass +class ExecutionOptions(BasePrimitiveOptions): + """Options for execution.""" + + shots: int = 1 # TODO: discuss the default number of shots + seed: Optional[Union[int, np.random.Generator]] = None + + +@mutable_dataclass +class Options(BasePrimitiveOptions): + """Options for the primitives. + + Args: + execution: Execution time options. See :class:`ExecutionOptions` for all available options. + """ + + execution: ExecutionOptions = Field(default_factory=ExecutionOptions) + transpilation: Dict[str, Any] = Field(default_factory=dict) + + +@dataclass +class _MeasureInfo: + creg_name: str + num_bits: int + packed_size: int + start: int + + +class BackendSamplerV2(BaseSamplerV2[PrimitiveJob[List[TaskResult]]]): + """A :class:`~.BaseSampler` implementation that provides an interface for + leveraging the sampler interface from any backend. + + This class provides a sampler interface from any backend and doesn't do + any measurement mitigation, it just computes the probability distribution + from the counts. It facilitates using backends that do not provide a + native :class:`~.BaseSampler` implementation in places that work with + :class:`~.BaseSampler`, such as algorithms in :mod:`qiskit.algorithms` + including :class:`~.qiskit.algorithms.minimum_eigensolvers.SamplingVQE`. + However, if you're using a provider that has a native implementation of + :class:`~.BaseSampler`, it is a better choice to leverage that native + implementation as it will likely include additional optimizations and be + a more efficient implementation. The generic nature of this class + precludes doing any provider- or backend-specific optimizations. + """ + + _options_class = Options + + def __init__( + self, + *, + backend: BackendV1 | BackendV2, + options: Optional[BasePrimitiveOptionsLike] = None, + pass_manager: PassManager | None = None, + skip_transpilation: bool = False, + ): + """Initialize a new BackendSampler + + Args: + backend: Required: the backend to run the sampler primitive on + options: Default options. + pass_manager: An optional pass manager to use for the internal compilation + skip_transpilation: If this is set to True the internal compilation + of the input circuits is skipped and the circuit objects + will be directly executed when this objected is called. + Raises: + ValueError: If backend is not provided + """ + if options is None: + options = Options() + elif not isinstance(options, Options): + options = Options(**options) + super().__init__(options=options) + self._backend = backend + self._circuits = [] + self._parameters = [] + self._transpile_options = Options() + self._pass_manager = pass_manager + self._transpiled_circuits: list[QuantumCircuit] = [] + self._skip_transpilation = skip_transpilation + + @property + def transpiled_circuits(self) -> list[QuantumCircuit]: + """ + Transpiled quantum circuits. + Returns: + List of the transpiled quantum circuit + Raises: + QiskitError: if the instance has been closed. + """ + return self._transpiled_circuits + + @property + def backend(self) -> BackendV1 | BackendV2: + """ + Returns: + The backend which this sampler object based on + """ + return self._backend + + @property + def transpile_options(self) -> Dict[str, Any]: + """Return the transpiler options for transpiling the circuits.""" + return self.options.transpilation + + def set_transpile_options(self, **fields): + """Set the transpiler options for transpiler. + Args: + **fields: The fields to update the options. + Returns: + self. + Raises: + QiskitError: if the instance has been closed. + """ + self._options.transpilation.update(**fields) + + def _postprocessing( + self, result: list[Result], circuits: list[QuantumCircuit] + ) -> SamplerResult: + counts = _prepare_counts(result) + shots = sum(counts[0].values()) + + probabilities = [] + metadata: list[dict[str, Any]] = [{} for _ in range(len(circuits))] + for count in counts: + prob_dist = {k: v / shots for k, v in count.items()} + probabilities.append( + QuasiDistribution(prob_dist, shots=shots, stddev_upper_bound=math.sqrt(1 / shots)) + ) + for metadatum in metadata: + metadatum["shots"] = shots + + return SamplerResult(probabilities, metadata) + + def _transpile(self, circuits: List[QuantumCircuit]) -> None: + if self._skip_transpilation: + ret = circuits + elif self._pass_manager: + ret = self._pass_manager.run(circuits) + else: + from qiskit.compiler import transpile + + ret = transpile( + circuits, + self.backend, + **self.options.transpilation, + ) + self._transpiled_circuits = ret if isinstance(ret, list) else [ret] + + def _run(self, tasks: List[SamplerTask]) -> PrimitiveJob[List[TaskResult]]: + job = PrimitiveJob(self._run_task, tasks) + job.submit() + return job + + def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: + shots = self.options.execution.shots + if shots is None: + raise QiskitError("`shots` should be a positive integer") + self._transpile([task.circuit for task in tasks]) + + results = [] + for task, circuit in zip(tasks, self._transpiled_circuits): + meas_info = _analyze_circuit(task.circuit) + parameter_values = task.parameter_values + bound_circuits = parameter_values.bind_all(circuit) + arrays = { + item.creg_name: np.zeros( + bound_circuits.shape + (shots, item.packed_size), dtype=np.uint8 + ) + for item in meas_info + } + flatten_circuits = np.ravel(bound_circuits).tolist() + result_memory, _ = _run_circuits( + flatten_circuits, self._backend, memory=True, **self.options.execution.__dict__ + ) + memory_list = _prepare_memory(result_memory) + + for samples, index in zip(memory_list, np.ndindex(*bound_circuits.shape)): + for item in meas_info: + ary = _samples_to_packed_array(samples, item.num_bits, item.start) + arrays[item.creg_name][index] = ary + + data_bin_cls = make_databin( + [(item.creg_name, BitArray) for item in meas_info], + shape=bound_circuits.shape, + ) + meas = { + item.creg_name: BitArray(arrays[item.creg_name], item.num_bits) + for item in meas_info + } + data_bin = data_bin_cls(**meas) + results.append(TaskResult(data_bin, metadata={"shots": shots})) + + return results + + +def _analyze_circuit(circuit: QuantumCircuit) -> List[_MeasureInfo]: + meas_info = [] + start = 0 + for creg in circuit.cregs: + name = creg.name + num_bits = creg.size + meas_info.append( + _MeasureInfo( + creg_name=name, + num_bits=num_bits, + packed_size=num_bits // 8 + (num_bits % 8 > 0), + start=start, + ) + ) + start += num_bits + return meas_info + + +def _prepare_memory(results: List[Result]): + memory_list = [] + for res in results: + for i, _ in enumerate(res.results): + memory = res.get_memory(i) + if len(memory) == 0 or not isinstance(memory[0], list): + memory_list.append(memory) + else: + memory_list.extend(memory) + return memory_list + + +def _samples_to_packed_array(samples: List[str], num_bits: int, start: int) -> NDArray[np.uint8]: + # samples of `Backend.run(memory=True)` will be the order of + # clbit_last, ..., clbit_1, clbit_0 + # separated cregs are separated by white space + ary = np.array( + [np.fromiter(sample[::-1].replace(" ", ""), dtype=np.uint8) for sample in samples] + ) + # place samples in the order of clbit_last, ..., clbit_1, clbit_0 + indices = range(start + num_bits - 1, start - 1, -1) + ary = ary[:, indices] + # pad 0 in the left to align the number to be mod 8 + # since np.packbits(bitorder='big') pads 0 to the right. + pad_size = (8 - num_bits % 8) % 8 + ary = np.pad(ary, ((0, 0), (pad_size, 0)), constant_values=0) + # pack bits in big endian order + ary = np.packbits(ary, axis=-1) + return ary From 8aa00077ef1d12c74ecf6fc67d9f2aa970ed528c Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Wed, 22 Nov 2023 22:23:37 +0900 Subject: [PATCH 43/55] refactor --- qiskit/primitives/backend_sampler_v2.py | 36 +++++++++++++++--------- qiskit/primitives/statevector_sampler.py | 12 ++++---- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/qiskit/primitives/backend_sampler_v2.py b/qiskit/primitives/backend_sampler_v2.py index f6aa96eadae2..6811da53fbfa 100644 --- a/qiskit/primitives/backend_sampler_v2.py +++ b/qiskit/primitives/backend_sampler_v2.py @@ -214,8 +214,11 @@ def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: memory_list = _prepare_memory(result_memory) for samples, index in zip(memory_list, np.ndindex(*bound_circuits.shape)): + samples_array = np.array( + [np.fromiter(sample, dtype=np.uint8) for sample in samples] + ) for item in meas_info: - ary = _samples_to_packed_array(samples, item.num_bits, item.start) + ary = _samples_to_packed_array(samples_array, item.num_bits, item.start) arrays[item.creg_name][index] = ary data_bin_cls = make_databin( @@ -250,28 +253,33 @@ def _analyze_circuit(circuit: QuantumCircuit) -> List[_MeasureInfo]: return meas_info -def _prepare_memory(results: List[Result]): +def _prepare_memory(results: List[Result]) -> List[List[str]]: + def convert(samples: List[str]) -> List[str]: + # samples of `Backend.run(memory=True)` will be the order of + # clbit_last, ..., clbit_1, clbit_0 + # separated cregs are separated by white space + # this function removes the white spaces and reorders samples in the order of + # clbit_0, clbit_1,..., clbit_last + return [sample[::-1].replace(" ", "") for sample in samples] + memory_list = [] for res in results: for i, _ in enumerate(res.results): memory = res.get_memory(i) if len(memory) == 0 or not isinstance(memory[0], list): - memory_list.append(memory) - else: - memory_list.extend(memory) + memory = [memory] + for mem in memory: + memory_list.append(convert(mem)) return memory_list -def _samples_to_packed_array(samples: List[str], num_bits: int, start: int) -> NDArray[np.uint8]: - # samples of `Backend.run(memory=True)` will be the order of - # clbit_last, ..., clbit_1, clbit_0 - # separated cregs are separated by white space - ary = np.array( - [np.fromiter(sample[::-1].replace(" ", ""), dtype=np.uint8) for sample in samples] - ) - # place samples in the order of clbit_last, ..., clbit_1, clbit_0 +def _samples_to_packed_array( + samples: NDArray[np.uint8], num_bits: int, start: int +) -> NDArray[np.uint8]: + # samples are in the order of clbit_0, clbit_1, ..., clbit_last + # place samples in the order of clbit_start+num_bits-1, ..., clbit_start+1, clbit_start indices = range(start + num_bits - 1, start - 1, -1) - ary = ary[:, indices] + ary = samples[:, indices] # pad 0 in the left to align the number to be mod 8 # since np.packbits(bitorder='big') pads 0 to the right. pad_size = (8 - num_bits % 8) % 8 diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index 89139274a607..805568029841 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -119,8 +119,11 @@ def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: final_state = Statevector(bound_circuit_to_instruction(bound_circuit)) final_state.seed(seed) samples = final_state.sample_memory(shots=shots, qargs=qargs) + samples_array = np.array( + [np.fromiter(sample, dtype=np.uint8) for sample in samples] + ) for item in meas_info: - ary = _samples_to_packed_array(samples, item.num_bits, item.qreg_indices) + ary = _samples_to_packed_array(samples_array, item.num_bits, item.qreg_indices) arrays[item.creg_name][index] = ary data_bin_cls = make_databin( @@ -163,13 +166,12 @@ def _preprocess_circuit(circuit: QuantumCircuit): def _samples_to_packed_array( - samples: NDArray[str], num_bits: int, indices: List[int] + samples: NDArray[np.uint8], num_bits: int, indices: List[int] ) -> NDArray[np.uint8]: - # samples of `Statevector.sample_memory` will be the order of + # samples of `Statevector.sample_memory` will be in the order of # qubit_0, qubit_1, ..., qubit_last - ary = np.array([np.fromiter(sample, dtype=np.uint8) for sample in samples]) # pad 0 in the rightmost to be used for the sentinel introduced by _preprocess_circuit - ary = np.pad(ary, ((0, 0), (0, 1)), constant_values=0) + ary = np.pad(samples, ((0, 0), (0, 1)), constant_values=0) # place samples in the order of clbit_last, ..., clbit_1, clbit_0 ary = ary[:, indices[::-1]] # pad 0 in the left to align the number to be mod 8 From 1e49b35e3105d4a0e55b39e15a491ea2b5efb87b Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Wed, 29 Nov 2023 16:08:53 +0900 Subject: [PATCH 44/55] add test of bitarray --- qiskit/primitives/containers/__init__.py | 1 + qiskit/primitives/containers/bit_array.py | 6 +- test/python/primitives/test_bit_array.py | 242 ++++++++++++++++++++++ 3 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 test/python/primitives/test_bit_array.py diff --git a/qiskit/primitives/containers/__init__.py b/qiskit/primitives/containers/__init__.py index 2e079b067dfb..d465246f380c 100644 --- a/qiskit/primitives/containers/__init__.py +++ b/qiskit/primitives/containers/__init__.py @@ -15,6 +15,7 @@ """ from .bindings_array import BindingsArray +from .bit_array import BitArray from .data_bin import make_data_bin from .estimator_pub import EstimatorPub, EstimatorPubLike from .observables_array import ObservablesArray diff --git a/qiskit/primitives/containers/bit_array.py b/qiskit/primitives/containers/bit_array.py index c2dcc042802c..0e3c04f722ca 100644 --- a/qiskit/primitives/containers/bit_array.py +++ b/qiskit/primitives/containers/bit_array.py @@ -186,8 +186,8 @@ def from_bool_array( num_bits = array.shape[-1] if remainder := (-num_bits) % 8: - # unpackbits has strange (incorrect?) behaviour when the number of bits doesn't align - # to a multiple of 8, so we pad with zeros + # unpackbits pads with zeros on the wrong side with respect to what we want, so + # we manually pad to the nearest byte pad = np.zeros(shape_tuple(array.shape[:-1], remainder), dtype=bool) array = np.concatenate([pad, array], axis=-1) @@ -329,7 +329,7 @@ def reshape(self, *shape: ShapeInput) -> "BitArray": :attr:`~size`, or the product of :attr:`~size` and :attr:`~num_samples`. """ shape = shape_tuple(shape) - if (size := np.product(shape, dtype=int)) == self.size: + if (size := np.prod(shape, dtype=int)) == self.size: shape = shape_tuple(shape, self._array.shape[-2:]) elif size == self.size * self.num_samples: shape = shape_tuple(shape, self._array.shape[-1:]) diff --git a/test/python/primitives/test_bit_array.py b/test/python/primitives/test_bit_array.py new file mode 100644 index 000000000000..ec03743e4067 --- /dev/null +++ b/test/python/primitives/test_bit_array.py @@ -0,0 +1,242 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +"""Unit tests for BitArray.""" + +from qiskit.test import QiskitTestCase +from itertools import product +import ddt +import numpy as np +from qiskit.result import Counts +from qiskit.primitives.containers import BitArray + + +@ddt.ddt +class BitArrayTestCase(QiskitTestCase): + """Test the DataBin class.""" + + @ddt.idata(product([(), (3, 4, 5)], [1, 6], [8, 13, 26])) + @ddt.unpack + def test_container(self, shape, num_samples, num_bits): + """Test the constructor and basic attributes.""" + num_bytes = num_bits // 8 + (num_bits % 8 > 0) + size = np.prod(shape).astype(int) * num_samples * num_bytes + arr = np.arange(size, dtype=np.uint8).reshape(shape + (num_samples, num_bytes)) + + bit_array = BitArray(arr, num_bits) + self.assertEqual(bit_array.shape, shape) + self.assertEqual(bit_array.size, np.prod(shape).astype(int)) + self.assertEqual(bit_array.ndim, len(shape)) + self.assertEqual(bit_array.num_samples, num_samples) + self.assertEqual(bit_array.num_bits, num_bits) + self.assertTrue(np.all(bit_array.array == arr)) + self.assertEqual(bit_array.array.shape[-1], num_bytes) + + def test_constructor_exceptions(self): + """Test the constructor raises exceptions properly.""" + with self.assertRaisesRegex(ValueError, "at least two axes"): + BitArray([], 1) + + with self.assertRaisesRegex(ValueError, "3 bytes per sample"): + BitArray(np.empty((2, 3, 4, 5)), 23) + + def test_get_counts(self): + """Test conversion to counts.""" + # note that [234, 100] requires 16 bits, not 15; we are testing that get_counts ignores the + # junk columns + bit_array = BitArray([[3, 5], [3, 5], [234, 100]], num_bits=15) + bs1 = "0000011" + "00000101" # 3, 5 + bs2 = "1101010" + "01100100" # 234, 100 + self.assertEqual(bit_array.get_counts(), {bs1: 2, bs2: 1}) + + bit_array = BitArray([[[3, 5], [3, 5], [234, 100]], [[0, 1], [1, 0], [1, 0]]], num_bits=15) + bs1 = "0000011" + "00000101" # 3, 5 + bs2 = "1101010" + "01100100" # 234, 100 + self.assertEqual(bit_array.get_counts(0), {bs1: 2, bs2: 1}) + bs1 = "0000000" + "00000001" # 0, 1 + bs2 = "0000001" + "00000000" # 1, 0 + self.assertEqual(bit_array.get_counts(1), {bs1: 1, bs2: 2}) + + with self.assertRaisesRegex(ValueError, "2 different count dictionaries"): + bit_array.get_counts() + + def test_get_int_counts(self): + """Test conversion to int counts.""" + # note that [234, 100] requires 16 bits, not 15; we are testing that get_counts ignores the + # junk columns + bit_array = BitArray([[3, 5], [3, 5], [234, 100]], num_bits=15) + val1 = (3 << 8) + 5 + val2 = ((234 & 127) << 8) + 100 + self.assertEqual(bit_array.get_int_counts(), {val1: 2, val2: 1}) + + bit_array = BitArray([[[3, 5], [3, 5], [234, 100]], [[0, 1], [1, 0], [1, 0]]], num_bits=15) + val1 = (3 << 8) + 5 + val2 = ((234 & 127) << 8) + 100 + self.assertEqual(bit_array.get_int_counts(0), {val1: 2, val2: 1}) + val1 = 1 + val2 = 1 << 8 + self.assertEqual(bit_array.get_int_counts(1), {val1: 1, val2: 2}) + + with self.assertRaisesRegex(ValueError, "2 different count dictionaries"): + bit_array.get_int_counts() + + def test_equality(self): + """Test the equality operator""" + ba1 = BitArray.from_bool_array([[1, 0, 0], [1, 1, 0]]) + ba2 = BitArray.from_bool_array([[1, 0, 0], [1, 1, 0]]) + ba3 = BitArray.from_bool_array([[1, 1, 0], [1, 1, 0]]) + ba4 = BitArray.from_bool_array([[[1, 0, 0], [1, 1, 0]]]) + self.assertEqual(ba1, ba1) + self.assertEqual(ba1, ba2) + self.assertNotEqual(ba1, ba3) + self.assertNotEqual(ba1, ba4) + + ba5 = BitArray([[4, 200], [255, 10]], num_bits=13) + ba6 = BitArray([[4, 200], [255, 10]], num_bits=12) + ba7 = BitArray([[4, 200], [31, 10]], num_bits=13) + self.assertNotEqual(ba5, ba6) + self.assertEqual(ba5, ba7) # test masking + + def test_logical_and(self): + """Test the logical AND operator.""" + ba1 = BitArray.from_bool_array([[1, 0, 0], [1, 1, 0]]) + ba2 = BitArray.from_bool_array([[1, 1, 1], [0, 1, 1]]) + self.assertEqual(ba1 & ba2, BitArray.from_bool_array([[1, 0, 0], [0, 1, 0]])) + + ba1 = BitArray.from_bool_array([[1, 0, 0], [1, 1, 0]]) + ba2 = BitArray.from_bool_array([[1, 1, 0]]) + self.assertEqual(ba1 & ba2, BitArray.from_bool_array([[1, 0, 0], [1, 1, 0]])) + + def test_logical_or(self): + """Test the logical OR operator.""" + ba1 = BitArray.from_bool_array([[1, 0, 0], [1, 1, 0]]) + ba2 = BitArray.from_bool_array([[1, 0, 1], [0, 1, 0]]) + self.assertEqual(ba1 | ba2, BitArray.from_bool_array([[1, 0, 1], [1, 1, 0]])) + + ba1 = BitArray.from_bool_array([[1, 0, 0], [1, 1, 0]]) + ba2 = BitArray.from_bool_array([[1, 1, 0]]) + self.assertEqual(ba1 | ba2, BitArray.from_bool_array([[1, 1, 0], [1, 1, 0]])) + + def test_logical_not(self): + """Test the logical OR operator.""" + ba = BitArray.from_bool_array([[1, 0, 0], [1, 1, 0]]) + self.assertEqual(~ba, BitArray.from_bool_array([[0, 1, 1], [0, 0, 1]])) + + def test_logical_xor(self): + """Test the logical XOR operator.""" + ba1 = BitArray.from_bool_array([[1, 0, 0], [1, 1, 0]]) + ba2 = BitArray.from_bool_array([[1, 0, 1], [0, 1, 0]]) + self.assertEqual(ba1 ^ ba2, BitArray.from_bool_array([[0, 0, 1], [1, 0, 0]])) + + ba1 = BitArray.from_bool_array([[1, 0, 0], [1, 1, 0]]) + ba2 = BitArray.from_bool_array([[1, 1, 0]]) + self.assertEqual(ba1 ^ ba2, BitArray.from_bool_array([[0, 1, 0], [0, 0, 0]])) + + def test_from_bool_array(self): + """Test the from_bool_array static_constructor.""" + + bit_array = BitArray.from_bool_array( + [[[1, 0, 1, 0], [0, 0, 1, 1]], [[1, 0, 0, 0], [0, 0, 0, 1]]] + ) + self.assertEqual(bit_array, BitArray([[[10], [3]], [[8], [1]]], 4)) + + bit_array = BitArray.from_bool_array( + [[[1, 0, 1, 0], [0, 0, 1, 1]], [[1, 0, 0, 0], [0, 0, 0, 1]]], order="little" + ) + self.assertEqual(bit_array, BitArray([[[5], [12]], [[1], [8]]], 4)) + + bit_array = BitArray.from_bool_array( + [[0, 0, 1, 1, 1] + [0, 0, 0, 0, 0, 0, 1, 1] + [0, 0, 0, 0, 0, 0, 0, 1]] + ) + self.assertEqual(bit_array, BitArray([[7, 3, 1]], 21)) + + bit_array = BitArray.from_bool_array( + [[1, 0, 0, 0, 0, 0, 0, 0] + [1, 1, 0, 0, 0, 0, 0, 0] + [1, 1, 1, 0, 0]], order="little" + ) + self.assertEqual(bit_array, BitArray([[7, 3, 1]], 21)) + + @ddt.data("counts", "int", "hex", "bit") + def test_from_counts(self, counts_type): + """Test the from_counts static constructor.""" + + def convert(counts: Counts): + if counts_type == "int": + return counts.int_outcomes() + if counts_type == "hex": + return counts.hex_outcomes() + if counts_type == "bit": + return {bin(val): count for val, count in counts.int_outcomes().items()} + return counts + + counts1 = convert(Counts({"0b101010": 2, "0b1": 3, "0x010203": 4})) + counts2 = convert(Counts({1: 3, 2: 6})) + + bit_array = BitArray.from_counts(counts1) + expected = BitArray([[0, 0, 42]] * 2 + [[0, 0, 1]] * 3 + [[1, 2, 3]] * 4, 17) + self.assertEqual(bit_array, expected) + + bit_array = BitArray.from_counts(iter([counts1])) + expected = BitArray([[[0, 0, 42]] * 2 + [[0, 0, 1]] * 3 + [[1, 2, 3]] * 4], 17) + self.assertEqual(bit_array, expected) + + bit_array = BitArray.from_counts(iter([counts1, counts2])) + expected = [ + [[0, 0, 42]] * 2 + [[0, 0, 1]] * 3 + [[1, 2, 3]] * 4, + [[0, 0, 1]] * 3 + [[0, 0, 2]] * 6, + ] + self.assertEqual(bit_array, BitArray(expected, 17)) + + def test_from_samples_bitstring(self): + """Test the from_samples static constructor.""" + bit_array = BitArray.from_samples(["110", "1", "1111111111"]) + self.assertEqual(bit_array, BitArray([[0, 6], [0, 1], [3, 255]], 10)) + + bit_array = BitArray.from_samples(["110", "1", "1111111111"], 20) + self.assertEqual(bit_array, BitArray([[0, 0, 6], [0, 0, 1], [0, 3, 255]], 20)) + + def test_from_samples_hex(self): + """Test the from_samples static constructor.""" + bit_array = BitArray.from_samples(["0x01", "0x0a12", "0x0105"]) + self.assertEqual(bit_array, BitArray([[0, 1], [10, 18], [1, 5]], 12)) + + bit_array = BitArray.from_samples(["0x01", "0x0a12", "0x0105"], 20) + self.assertEqual(bit_array, BitArray([[0, 0, 1], [0, 10, 18], [0, 1, 5]], 20)) + + def test_from_samples_int(self): + """Test the from_samples static constructor.""" + bit_array = BitArray.from_samples([1, 2578, 261]) + self.assertEqual(bit_array, BitArray([[0, 1], [10, 18], [1, 5]], 12)) + + bit_array = BitArray.from_samples([1, 2578, 261], 20) + self.assertEqual(bit_array, BitArray([[0, 0, 1], [0, 10, 18], [0, 1, 5]], 20)) + + def test_reshape(self): + """Test the reshape method.""" + # this creates incrementing bitstrings from 0 to 360 * 32 - 1 + data = np.frombuffer(np.arange(360 * 32, dtype=np.uint64).tobytes(), dtype=np.uint8) + data = data.reshape(-1, 32, 8)[..., 1::-1] + ba = BitArray(data, 15) + + self.assertEqual(ba.reshape(120, 3).shape, (120, 3)) + self.assertEqual(ba.reshape(120, 3).num_samples, 32) + self.assertEqual(ba.reshape(120, 3).num_bits, 15) + self.assertTrue( + np.array_equal(ba.reshape(60, 6).array[2, 3], data.reshape(60, 6, 32, 2)[2, 3]) + ) + + self.assertEqual(ba.reshape(360 * 32).shape, ()) + self.assertEqual(ba.reshape(360 * 32).num_samples, 360 * 32) + self.assertEqual(ba.reshape(360 * 32).num_bits, 15) + + self.assertEqual(ba.reshape(360 * 2, 16).shape, (720,)) + self.assertEqual(ba.reshape(360 * 2, 16).num_samples, 16) + self.assertEqual(ba.reshape(360 * 2, 16).num_bits, 15) From bd77bb4107250436fdcb0b61ef9aab5483f95844 Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Wed, 29 Nov 2023 17:09:44 +0900 Subject: [PATCH 45/55] update StatevectorSampler --- qiskit/primitives/__init__.py | 1 + qiskit/primitives/backend_sampler_v2.py | 8 ++-- qiskit/primitives/base/base_sampler.py | 17 +------ qiskit/primitives/containers/__init__.py | 1 + qiskit/primitives/containers/bit_array.py | 9 +++- qiskit/primitives/containers/sampler_task.py | 4 +- qiskit/primitives/statevector_sampler.py | 48 ++++++++++++++------ 7 files changed, 50 insertions(+), 38 deletions(-) diff --git a/qiskit/primitives/__init__.py b/qiskit/primitives/__init__.py index ccf2ebc920d5..4542274925b1 100644 --- a/qiskit/primitives/__init__.py +++ b/qiskit/primitives/__init__.py @@ -71,3 +71,4 @@ from .estimator import Estimator from .sampler import Sampler from .statevector_estimator import Estimator as StatevectorEstimator +from .statevector_sampler import Sampler as StatevectorSampler diff --git a/qiskit/primitives/backend_sampler_v2.py b/qiskit/primitives/backend_sampler_v2.py index 6811da53fbfa..f0fccdb93b35 100644 --- a/qiskit/primitives/backend_sampler_v2.py +++ b/qiskit/primitives/backend_sampler_v2.py @@ -31,7 +31,7 @@ from .backend_estimator import _prepare_counts, _run_circuits from .base import BaseSamplerV2, SamplerResult from .containers import BasePrimitiveOptions, BasePrimitiveOptionsLike, SamplerTask, TaskResult -from .containers.bit_array import BitArray +from .containers.bit_array import BitArray, _min_num_bytes from .containers.data_bin import make_databin from .containers.options import mutable_dataclass from .primitive_job import PrimitiveJob @@ -61,7 +61,7 @@ class Options(BasePrimitiveOptions): class _MeasureInfo: creg_name: str num_bits: int - packed_size: int + num_bytes: int start: int @@ -203,7 +203,7 @@ def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: bound_circuits = parameter_values.bind_all(circuit) arrays = { item.creg_name: np.zeros( - bound_circuits.shape + (shots, item.packed_size), dtype=np.uint8 + bound_circuits.shape + (shots, item.num_bytes), dtype=np.uint8 ) for item in meas_info } @@ -245,7 +245,7 @@ def _analyze_circuit(circuit: QuantumCircuit) -> List[_MeasureInfo]: _MeasureInfo( creg_name=name, num_bits=num_bits, - packed_size=num_bits // 8 + (num_bits % 8 > 0), + num_bytes=_min_num_bytes(num_bits), start=start, ) ) diff --git a/qiskit/primitives/base/base_sampler.py b/qiskit/primitives/base/base_sampler.py index 98cd37277129..409901e585cd 100644 --- a/qiskit/primitives/base/base_sampler.py +++ b/qiskit/primitives/base/base_sampler.py @@ -216,6 +216,7 @@ class BaseSamplerV2(BasePrimitiveV2, Generic[T]): def __init__(self, options: Optional[BasePrimitiveOptionsLike]): super().__init__(options=options) + @abstractmethod def run(self, tasks: SamplerTaskLike | Iterable[SamplerTaskLike]) -> T: """Run the tasks of samples. @@ -226,20 +227,4 @@ def run(self, tasks: SamplerTaskLike | Iterable[SamplerTaskLike]) -> T: Returns: The job object of Sampler's Result. """ - if isinstance(tasks, SamplerTask): - tasks = [tasks] - elif isinstance(tasks, QuantumCircuit): - tasks = [SamplerTask.coerce(tasks)] - elif isinstance(tasks, tuple) and isinstance(tasks[0], QuantumCircuit): - tasks = [SamplerTask.coerce(tasks)] - elif tasks is not SamplerTask: - tasks = [SamplerTask.coerce(task) for task in tasks] - - for task in tasks: - task.validate() - - return self._run(tasks) - - @abstractmethod - def _run(self, tasks: list[SamplerTask]) -> T: pass diff --git a/qiskit/primitives/containers/__init__.py b/qiskit/primitives/containers/__init__.py index d465246f380c..c65bd75b880c 100644 --- a/qiskit/primitives/containers/__init__.py +++ b/qiskit/primitives/containers/__init__.py @@ -21,5 +21,6 @@ from .observables_array import ObservablesArray from .options import BasePrimitiveOptions, BasePrimitiveOptionsLike from .primitive_result import PrimitiveResult +from .sampler_task import SamplerTask, SamplerTaskLike from .pub_result import PubResult from .sampler_task import SamplerTask diff --git a/qiskit/primitives/containers/bit_array.py b/qiskit/primitives/containers/bit_array.py index 0e3c04f722ca..3d1b7063e23f 100644 --- a/qiskit/primitives/containers/bit_array.py +++ b/qiskit/primitives/containers/bit_array.py @@ -39,6 +39,11 @@ _WEIGHT_LOOKUP = np.unpackbits(np.arange(256, dtype=np.uint8).reshape(-1, 1), axis=1).sum(axis=1) +def _min_num_bytes(num_bits: int) -> int: + """Return the minimum number of bytes needed to store ``num_bits``.""" + return num_bits // 8 + (num_bits % 8 > 0) + + class BitArray(ShapedMixin): """Stores an array of bit values. @@ -65,7 +70,7 @@ def __init__(self, array: NDArray[np.uint8], num_bits: int): if self._array.ndim < 2: raise ValueError("The input array must have at least two axes.") - if self._array.shape[-1] != (expected := num_bits // 8 + (num_bits % 8 > 0)): + if self._array.shape[-1] != (expected := _min_num_bytes(num_bits)): raise ValueError(f"The input array is expected to have {expected} bytes per sample.") def _prepare_broadcastable(self, other: "BitArray") -> Tuple[NDArray[np.uint8], ...]: @@ -273,7 +278,7 @@ def from_samples( ints = list(ints) num_bits = max(map(int.bit_length, ints)) - num_bytes = num_bits // 8 + (num_bits % 8 > 0) + num_bytes = _min_num_bytes(num_bits) data = b"".join(val.to_bytes(num_bytes, "big") for val in ints) array = np.frombuffer(data, dtype=np.uint8, count=len(data)) return BitArray(array.reshape(-1, num_bytes), num_bits) diff --git a/qiskit/primitives/containers/sampler_task.py b/qiskit/primitives/containers/sampler_task.py index 262e51fef3f4..40564c7c212c 100644 --- a/qiskit/primitives/containers/sampler_task.py +++ b/qiskit/primitives/containers/sampler_task.py @@ -77,4 +77,6 @@ def validate(self): ) -SamplerTaskLike = Union[SamplerTask, QuantumCircuit, Tuple[QuantumCircuit, BindingsArrayLike]] +SamplerTaskLike = Union[ + SamplerTask, QuantumCircuit, Tuple[QuantumCircuit], Tuple[QuantumCircuit, BindingsArrayLike] +] diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index 805568029841..f7f9041f9cd6 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -16,21 +16,30 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, Iterable, List, Optional, Tuple, Union import numpy as np from numpy.typing import NDArray from pydantic import Field +from pydantic.types import PositiveInt from qiskit import ClassicalRegister, QiskitError, QuantumCircuit from qiskit.quantum_info import Statevector from .base import BaseSamplerV2 from .base.validation import _has_measure -from .containers import BasePrimitiveOptions, BasePrimitiveOptionsLike, SamplerTask, TaskResult -from .containers.bit_array import BitArray -from .containers.data_bin import make_databin -from .containers.options import mutable_dataclass +from .containers import ( + BasePrimitiveOptions, + BasePrimitiveOptionsLike, + BitArray, + PrimitiveResult, + SamplerTask, + SamplerTaskLike, + TaskResult, + make_databin, +) +from .containers.bit_array import _min_num_bytes +from .containers.dataclasses import mutable_dataclass from .primitive_job import PrimitiveJob from .utils import bound_circuit_to_instruction @@ -39,7 +48,7 @@ class ExecutionOptions(BasePrimitiveOptions): """Options for execution.""" - shots: int = 1 # TODO: discuss the default number of shots + shots: PositiveInt = 1 # TODO: discuss the default number of shots seed: Optional[Union[int, np.random.Generator]] = None @@ -58,11 +67,11 @@ class Options(BasePrimitiveOptions): class _MeasureInfo: creg_name: str num_bits: int - packed_size: int + num_bytes: int qreg_indices: List[int] -class StatevectorSampler(BaseSamplerV2[PrimitiveJob[List[TaskResult]]]): +class Sampler(BaseSamplerV2[PrimitiveJob[PrimitiveResult[TaskResult]]]): """ Simple implementation of :class:`BaseSamplerV2` with Statevector. @@ -79,6 +88,7 @@ class StatevectorSampler(BaseSamplerV2[PrimitiveJob[List[TaskResult]]]): """ _options_class = Options + options: Options def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): """ @@ -87,19 +97,27 @@ def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): """ if options is None: options = Options() - elif not isinstance(options, Options): + elif not isinstance(options, BasePrimitiveOptions): options = Options(**options) super().__init__(options=options) - def _run(self, tasks: List[SamplerTask]) -> PrimitiveJob[List[TaskResult]]: - job = PrimitiveJob(self._run_task, tasks) + def run(self, tasks: Iterable[SamplerTaskLike]) -> PrimitiveJob[PrimitiveResult[TaskResult]]: + # Note: a QuantumCircuit and a tuple of QuantumCircuit and BindingsArray are + # valid SamplerTaskLike objects, but they are also iterable. + if isinstance(tasks, QuantumCircuit) or ( + isinstance(tasks, tuple) and len(tasks) > 0 and isinstance(tasks[0], QuantumCircuit) + ): + coerced_tasks = [SamplerTask.coerce(tasks)] + else: + coerced_tasks = [SamplerTask.coerce(task) for task in tasks] + for task in coerced_tasks: + task.validate() + job: PrimitiveJob[PrimitiveResult[TaskResult]] = PrimitiveJob(self._run_task, coerced_tasks) job.submit() return job def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: shots = self.options.execution.shots - if shots is None: - raise QiskitError("`shots` should be a positive integer") seed = self.options.execution.seed results = [] @@ -109,7 +127,7 @@ def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: bound_circuits = parameter_values.bind_all(circuit) arrays = { item.creg_name: np.zeros( - bound_circuits.shape + (shots, item.packed_size), dtype=np.uint8 + bound_circuits.shape + (shots, item.num_bytes), dtype=np.uint8 ) for item in meas_info } @@ -157,8 +175,8 @@ def _preprocess_circuit(circuit: QuantumCircuit): _MeasureInfo( creg_name=name, num_bits=num_bits, + num_bytes=_min_num_bytes(num_bits), qreg_indices=indices[name], - packed_size=num_bits // 8 + (num_bits % 8 > 0), ) for name, num_bits in num_bits_dict.items() ] From d124ead95712ac93a40d356ace959c41fb5e6749 Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Thu, 30 Nov 2023 23:47:20 +0900 Subject: [PATCH 46/55] update BackendSampler --- qiskit/primitives/backend_sampler_v2.py | 41 ++++++++++++------- qiskit/primitives/base/base_sampler.py | 8 ++-- qiskit/primitives/containers/bit_array.py | 12 +++--- qiskit/primitives/statevector_sampler.py | 41 +++++++------------ .../{ => containers}/test_bit_array.py | 0 5 files changed, 52 insertions(+), 50 deletions(-) rename test/python/primitives/{ => containers}/test_bit_array.py (100%) diff --git a/qiskit/primitives/backend_sampler_v2.py b/qiskit/primitives/backend_sampler_v2.py index f0fccdb93b35..99d6da8f15b0 100644 --- a/qiskit/primitives/backend_sampler_v2.py +++ b/qiskit/primitives/backend_sampler_v2.py @@ -16,7 +16,7 @@ import math from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, Iterable, List, Optional, Union import numpy as np from numpy.typing import NDArray @@ -30,9 +30,17 @@ from .backend_estimator import _prepare_counts, _run_circuits from .base import BaseSamplerV2, SamplerResult -from .containers import BasePrimitiveOptions, BasePrimitiveOptionsLike, SamplerTask, TaskResult -from .containers.bit_array import BitArray, _min_num_bytes -from .containers.data_bin import make_databin +from .containers import ( + BasePrimitiveOptions, + BasePrimitiveOptionsLike, + BitArray, + PrimitiveResult, + SamplerTask, + SamplerTaskLike, + TaskResult, + make_data_bin, +) +from .containers.bit_array import _min_num_bytes from .containers.options import mutable_dataclass from .primitive_job import PrimitiveJob @@ -65,7 +73,7 @@ class _MeasureInfo: start: int -class BackendSamplerV2(BaseSamplerV2[PrimitiveJob[List[TaskResult]]]): +class BackendSampler(BaseSamplerV2): """A :class:`~.BaseSampler` implementation that provides an interface for leveraging the sampler interface from any backend. @@ -83,6 +91,7 @@ class BackendSamplerV2(BaseSamplerV2[PrimitiveJob[List[TaskResult]]]): """ _options_class = Options + options: Options def __init__( self, @@ -185,19 +194,22 @@ def _transpile(self, circuits: List[QuantumCircuit]) -> None: ) self._transpiled_circuits = ret if isinstance(ret, list) else [ret] - def _run(self, tasks: List[SamplerTask]) -> PrimitiveJob[List[TaskResult]]: - job = PrimitiveJob(self._run_task, tasks) + def run(self, tasks: Iterable[SamplerTaskLike]) -> PrimitiveJob[PrimitiveResult[TaskResult]]: + job = PrimitiveJob(self._run, tasks) job.submit() return job - def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: + def _run(self, tasks: Iterable[SamplerTask]) -> PrimitiveResult[TaskResult]: + coerced_tasks = [SamplerTask.coerce(task) for task in tasks] + for task in coerced_tasks: + task.validate() + shots = self.options.execution.shots - if shots is None: - raise QiskitError("`shots` should be a positive integer") - self._transpile([task.circuit for task in tasks]) + + self._transpile([task.circuit for task in coerced_tasks]) results = [] - for task, circuit in zip(tasks, self._transpiled_circuits): + for task, circuit in zip(coerced_tasks, self._transpiled_circuits): meas_info = _analyze_circuit(task.circuit) parameter_values = task.parameter_values bound_circuits = parameter_values.bind_all(circuit) @@ -221,7 +233,7 @@ def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: ary = _samples_to_packed_array(samples_array, item.num_bits, item.start) arrays[item.creg_name][index] = ary - data_bin_cls = make_databin( + data_bin_cls = make_data_bin( [(item.creg_name, BitArray) for item in meas_info], shape=bound_circuits.shape, ) @@ -231,8 +243,7 @@ def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: } data_bin = data_bin_cls(**meas) results.append(TaskResult(data_bin, metadata={"shots": shots})) - - return results + return PrimitiveResult(results) def _analyze_circuit(circuit: QuantumCircuit) -> List[_MeasureInfo]: diff --git a/qiskit/primitives/base/base_sampler.py b/qiskit/primitives/base/base_sampler.py index 409901e585cd..5bca1699f0e9 100644 --- a/qiskit/primitives/base/base_sampler.py +++ b/qiskit/primitives/base/base_sampler.py @@ -207,21 +207,21 @@ def parameters(self) -> tuple[ParameterView, ...]: BaseSampler = BaseSamplerV1 -class BaseSamplerV2(BasePrimitiveV2, Generic[T]): +class BaseSamplerV2(BasePrimitiveV2): """Sampler base class version 2. - Sampler returns samples of bitstrings of quantum circuits. + A Sampler returns samples of bitstrings of quantum circuits. """ def __init__(self, options: Optional[BasePrimitiveOptionsLike]): super().__init__(options=options) @abstractmethod - def run(self, tasks: SamplerTaskLike | Iterable[SamplerTaskLike]) -> T: + def run(self, tasks: Iterable[SamplerTaskLike]) -> T: """Run the tasks of samples. Args: - tasks: a task-like object. Typically, list of tuple + tasks: an iterable of task-like object. Typically, list of tuple ``(QuantumCircuit, parameter_values)`` Returns: diff --git a/qiskit/primitives/containers/bit_array.py b/qiskit/primitives/containers/bit_array.py index 3d1b7063e23f..b6cebf6e4472 100644 --- a/qiskit/primitives/containers/bit_array.py +++ b/qiskit/primitives/containers/bit_array.py @@ -141,7 +141,9 @@ def _bytes_to_bitstring(data: bytes, num_bits: int, mask: int) -> str: def _bytes_to_int(data: bytes, mask: int) -> int: return int.from_bytes(data, "big") & mask - def _get_counts(self, *, loc: Tuple[int, ...] | None, converter: Callable) -> Dict[str, int]: + def _get_counts( + self, *, loc: int | Tuple[int, ...] | None, converter: Callable + ) -> Dict[str | int, int]: if loc is None and self.size == 1: loc = (0,) * self.ndim @@ -167,7 +169,7 @@ def bitcount(self) -> NDArray[np.uint64]: @staticmethod def from_bool_array( - array: NDArray[np.bool], order: Literal["big", "little"] = "big" + array: NDArray[bool], order: Literal["big", "little"] = "big" ) -> "BitArray": """Construct a new bit array from an array of bools. @@ -206,7 +208,7 @@ def from_counts( """Construct a new bit array from one or more ``Counts``-like objects. The ``counts`` can have keys that are (uniformly) integers, hexstrings, or bitstrings. - Their values represent numbers of occurences of that value. + Their values represent numbers of occurrences of that value. Args: counts: One or more counts-like mappings. @@ -283,7 +285,7 @@ def from_samples( array = np.frombuffer(data, dtype=np.uint8, count=len(data)) return BitArray(array.reshape(-1, num_bytes), num_bits) - def get_counts(self, loc: Tuple[int, ...] | None = None) -> Dict[str, int]: + def get_counts(self, loc: int | Tuple[int, ...] | None = None) -> Dict[str, int]: """Return a counts dictionary. Args: @@ -299,7 +301,7 @@ def get_counts(self, loc: Tuple[int, ...] | None = None) -> Dict[str, int]: converter = partial(self._bytes_to_bitstring, num_bits=self.num_bits, mask=mask) return self._get_counts(loc=loc, converter=converter) - def get_int_counts(self, loc: Tuple[int, ...] | None = None) -> Dict[int, int]: + def get_int_counts(self, loc: int | Tuple[int, ...] | None = None) -> Dict[int, int]: r"""Return a counts dictionary, where bitstrings are stored as ``int``\s. Args: diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index f7f9041f9cd6..c885928a9639 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -36,7 +36,7 @@ SamplerTask, SamplerTaskLike, TaskResult, - make_databin, + make_data_bin, ) from .containers.bit_array import _min_num_bytes from .containers.dataclasses import mutable_dataclass @@ -71,20 +71,17 @@ class _MeasureInfo: qreg_indices: List[int] -class Sampler(BaseSamplerV2[PrimitiveJob[PrimitiveResult[TaskResult]]]): +class Sampler(BaseSamplerV2): """ Simple implementation of :class:`BaseSamplerV2` with Statevector. :Run Options: - - **shots** (None or int) -- - The number of shots. If None, it calculates the exact expectation - values. Otherwise, it samples from normal distributions with standard errors as standard - deviations using normal distribution approximation. + - **shots** (int) -- + The number of shots. - **seed** (np.random.Generator or int) -- - Set a fixed seed or generator for the normal distribution. If shots is None, - this option is ignored. + Set a fixed seed or generator for the normal distribution. """ _options_class = Options @@ -102,26 +99,20 @@ def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): super().__init__(options=options) def run(self, tasks: Iterable[SamplerTaskLike]) -> PrimitiveJob[PrimitiveResult[TaskResult]]: - # Note: a QuantumCircuit and a tuple of QuantumCircuit and BindingsArray are - # valid SamplerTaskLike objects, but they are also iterable. - if isinstance(tasks, QuantumCircuit) or ( - isinstance(tasks, tuple) and len(tasks) > 0 and isinstance(tasks[0], QuantumCircuit) - ): - coerced_tasks = [SamplerTask.coerce(tasks)] - else: - coerced_tasks = [SamplerTask.coerce(task) for task in tasks] - for task in coerced_tasks: - task.validate() - job: PrimitiveJob[PrimitiveResult[TaskResult]] = PrimitiveJob(self._run_task, coerced_tasks) + job: PrimitiveJob[PrimitiveResult[TaskResult]] = PrimitiveJob(self._run, tasks) job.submit() return job - def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: + def _run(self, tasks: Iterable[SamplerTask]) -> PrimitiveResult[TaskResult]: + coerced_tasks = [SamplerTask.coerce(task) for task in tasks] + for task in coerced_tasks: + task.validate() + shots = self.options.execution.shots seed = self.options.execution.seed results = [] - for task in tasks: + for task in coerced_tasks: circuit, qargs, meas_info = _preprocess_circuit(task.circuit) parameter_values = task.parameter_values bound_circuits = parameter_values.bind_all(circuit) @@ -131,7 +122,6 @@ def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: ) for item in meas_info } - for index in np.ndindex(*bound_circuits.shape): bound_circuit = bound_circuits[index] final_state = Statevector(bound_circuit_to_instruction(bound_circuit)) @@ -144,7 +134,7 @@ def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: ary = _samples_to_packed_array(samples_array, item.num_bits, item.qreg_indices) arrays[item.creg_name][index] = ary - data_bin_cls = make_databin( + data_bin_cls = make_data_bin( [(item.creg_name, BitArray) for item in meas_info], shape=bound_circuits.shape, ) @@ -154,8 +144,7 @@ def _run_task(self, tasks: List[SamplerTask]) -> List[TaskResult]: } data_bin = data_bin_cls(**meas) results.append(TaskResult(data_bin, metadata={"shots": shots})) - - return results + return PrimitiveResult(results) def _preprocess_circuit(circuit: QuantumCircuit): @@ -194,7 +183,7 @@ def _samples_to_packed_array( ary = ary[:, indices[::-1]] # pad 0 in the left to align the number to be mod 8 # since np.packbits(bitorder='big') pads 0 to the right. - pad_size = (8 - num_bits % 8) % 8 + pad_size = -num_bits % 8 ary = np.pad(ary, ((0, 0), (pad_size, 0)), constant_values=0) # pack bits in big endian order ary = np.packbits(ary, axis=-1) diff --git a/test/python/primitives/test_bit_array.py b/test/python/primitives/containers/test_bit_array.py similarity index 100% rename from test/python/primitives/test_bit_array.py rename to test/python/primitives/containers/test_bit_array.py From c6eeb3cdb36af9dbaaafede4a0dd4498049040d5 Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Fri, 1 Dec 2023 15:37:37 +0900 Subject: [PATCH 47/55] rename task with pub --- qiskit/primitives/backend_sampler_v2.py | 33 ++++++------- qiskit/primitives/base/base_sampler.py | 8 ++-- qiskit/primitives/containers/__init__.py | 3 +- qiskit/primitives/containers/bit_array.py | 2 +- .../{sampler_task.py => sampler_pub.py} | 46 +++++++++---------- qiskit/primitives/statevector_sampler.py | 26 +++++------ 6 files changed, 56 insertions(+), 62 deletions(-) rename qiskit/primitives/containers/{sampler_task.py => sampler_pub.py} (61%) diff --git a/qiskit/primitives/backend_sampler_v2.py b/qiskit/primitives/backend_sampler_v2.py index 99d6da8f15b0..119b518f516a 100644 --- a/qiskit/primitives/backend_sampler_v2.py +++ b/qiskit/primitives/backend_sampler_v2.py @@ -22,7 +22,6 @@ from numpy.typing import NDArray from pydantic import Field -from qiskit import QiskitError from qiskit.circuit.quantumcircuit import QuantumCircuit from qiskit.providers.backend import BackendV1, BackendV2 from qiskit.result import QuasiDistribution, Result @@ -35,9 +34,9 @@ BasePrimitiveOptionsLike, BitArray, PrimitiveResult, - SamplerTask, - SamplerTaskLike, - TaskResult, + PubResult, + SamplerPub, + SamplerPubLike, make_data_bin, ) from .containers.bit_array import _min_num_bytes @@ -132,8 +131,6 @@ def transpiled_circuits(self) -> list[QuantumCircuit]: Transpiled quantum circuits. Returns: List of the transpiled quantum circuit - Raises: - QiskitError: if the instance has been closed. """ return self._transpiled_circuits @@ -156,8 +153,6 @@ def set_transpile_options(self, **fields): **fields: The fields to update the options. Returns: self. - Raises: - QiskitError: if the instance has been closed. """ self._options.transpilation.update(**fields) @@ -194,24 +189,24 @@ def _transpile(self, circuits: List[QuantumCircuit]) -> None: ) self._transpiled_circuits = ret if isinstance(ret, list) else [ret] - def run(self, tasks: Iterable[SamplerTaskLike]) -> PrimitiveJob[PrimitiveResult[TaskResult]]: - job = PrimitiveJob(self._run, tasks) + def run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveJob[PrimitiveResult[PubResult]]: + job = PrimitiveJob(self._run, pubs) job.submit() return job - def _run(self, tasks: Iterable[SamplerTask]) -> PrimitiveResult[TaskResult]: - coerced_tasks = [SamplerTask.coerce(task) for task in tasks] - for task in coerced_tasks: - task.validate() + def _run(self, pubs: Iterable[SamplerPub]) -> PrimitiveResult[PubResult]: + coerced_pubs = [SamplerPub.coerce(pub) for pub in pubs] + for pub in coerced_pubs: + pub.validate() shots = self.options.execution.shots - self._transpile([task.circuit for task in coerced_tasks]) + self._transpile([pub.circuit for pub in coerced_pubs]) results = [] - for task, circuit in zip(coerced_tasks, self._transpiled_circuits): - meas_info = _analyze_circuit(task.circuit) - parameter_values = task.parameter_values + for pub, circuit in zip(coerced_pubs, self._transpiled_circuits): + meas_info = _analyze_circuit(pub.circuit) + parameter_values = pub.parameter_values bound_circuits = parameter_values.bind_all(circuit) arrays = { item.creg_name: np.zeros( @@ -242,7 +237,7 @@ def _run(self, tasks: Iterable[SamplerTask]) -> PrimitiveResult[TaskResult]: for item in meas_info } data_bin = data_bin_cls(**meas) - results.append(TaskResult(data_bin, metadata={"shots": shots})) + results.append(PubResult(data_bin, metadata={"shots": shots})) return PrimitiveResult(results) diff --git a/qiskit/primitives/base/base_sampler.py b/qiskit/primitives/base/base_sampler.py index 5bca1699f0e9..28e5524b00bf 100644 --- a/qiskit/primitives/base/base_sampler.py +++ b/qiskit/primitives/base/base_sampler.py @@ -87,7 +87,7 @@ from qiskit.utils.deprecation import deprecate_func from ..containers.options import BasePrimitiveOptionsLike -from ..containers.sampler_task import SamplerTask, SamplerTaskLike +from ..containers.sampler_pub import SamplerPub, SamplerPubLike from . import validation from .base_primitive import BasePrimitiveV1, BasePrimitiveV2 @@ -217,11 +217,11 @@ def __init__(self, options: Optional[BasePrimitiveOptionsLike]): super().__init__(options=options) @abstractmethod - def run(self, tasks: Iterable[SamplerTaskLike]) -> T: - """Run the tasks of samples. + def run(self, pubs: Iterable[SamplerPubLike]) -> T: + """Run the pubs of samples. Args: - tasks: an iterable of task-like object. Typically, list of tuple + pubs: an iterable of pub-like object. Typically, list of tuple ``(QuantumCircuit, parameter_values)`` Returns: diff --git a/qiskit/primitives/containers/__init__.py b/qiskit/primitives/containers/__init__.py index c65bd75b880c..b670da0ee4dd 100644 --- a/qiskit/primitives/containers/__init__.py +++ b/qiskit/primitives/containers/__init__.py @@ -21,6 +21,5 @@ from .observables_array import ObservablesArray from .options import BasePrimitiveOptions, BasePrimitiveOptionsLike from .primitive_result import PrimitiveResult -from .sampler_task import SamplerTask, SamplerTaskLike from .pub_result import PubResult -from .sampler_task import SamplerTask +from .sampler_pub import SamplerPub, SamplerPubLike diff --git a/qiskit/primitives/containers/bit_array.py b/qiskit/primitives/containers/bit_array.py index b6cebf6e4472..0ae102e15ca3 100644 --- a/qiskit/primitives/containers/bit_array.py +++ b/qiskit/primitives/containers/bit_array.py @@ -49,7 +49,7 @@ class BitArray(ShapedMixin): This object contains a single, contiguous block of data that represents an array of bitstrings. The last axis is over packed bits, the second last axis is over samples (aka shots), and the - preceding axes correspond to the shape of the task that was executed. + preceding axes correspond to the shape of the pub that was executed. """ def __init__(self, array: NDArray[np.uint8], num_bits: int): diff --git a/qiskit/primitives/containers/sampler_task.py b/qiskit/primitives/containers/sampler_pub.py similarity index 61% rename from qiskit/primitives/containers/sampler_task.py rename to qiskit/primitives/containers/sampler_pub.py index 40564c7c212c..a911f5c6f7de 100644 --- a/qiskit/primitives/containers/sampler_task.py +++ b/qiskit/primitives/containers/sampler_pub.py @@ -12,7 +12,7 @@ """ -Sampler Task class +Sampler Pub class """ from __future__ import annotations @@ -21,17 +21,17 @@ from qiskit import QuantumCircuit -from .base_task import BaseTask +from .base_pub import BasePub from .bindings_array import BindingsArray, BindingsArrayLike from .dataclasses import frozen_dataclass from .shape import ShapedMixin @frozen_dataclass -class SamplerTask(BaseTask, ShapedMixin): - """Task for Sampler. +class SamplerPub(BasePub, ShapedMixin): + """Pub (Primitive Unified Bloc) for Sampler. - Task is composed of triple (circuit, parameter_values). + Pub is composed of double (circuit, parameter_values). """ parameter_values: BindingsArray = BindingsArray(shape=()) @@ -41,30 +41,30 @@ def __post_init__(self): self._shape = self.parameter_values.shape @classmethod - def coerce(cls, task: SamplerTaskLike) -> SamplerTask: - """Coerce SamplerTaskLike into SamplerTask. + def coerce(cls, pub: SamplerPubLike) -> SamplerPub: + """Coerce SamplerPubLike into SamplerPub. Args: - task: an object to be Sampler task. + pub: an object to be Sampler pub. Returns: - A coerced sampler task. + A coerced sampler pub. """ - if isinstance(task, SamplerTask): - return task - if isinstance(task, QuantumCircuit): - return cls(circuit=task) - if len(task) not in [1, 2]: - raise ValueError(f"The length of task must be 1 or 2, but length {len(task)} is given.") - circuit = task[0] - if len(task) == 1: - return cls(circuit=task) - parameter_values = BindingsArray.coerce(task[1]) + if isinstance(pub, SamplerPub): + return pub + if isinstance(pub, QuantumCircuit): + return cls(circuit=pub) + if len(pub) not in [1, 2]: + raise ValueError(f"The length of pub must be 1 or 2, but length {len(pub)} is given.") + circuit = pub[0] + if len(pub) == 1: + return cls(circuit=pub) + parameter_values = BindingsArray.coerce(pub[1]) return cls(circuit=circuit, parameter_values=parameter_values) def validate(self): - """Validate the task.""" - super(SamplerTask, self).validate() # pylint: disable=super-with-arguments + """Validate the pub.""" + super(SamplerPub, self).validate() # pylint: disable=super-with-arguments # I'm not sure why these arguments for super are needed. But if no args, tests are failed # for Python >=3.10. Seems to be some bug, but I can't fix. self.parameter_values.validate() @@ -77,6 +77,6 @@ def validate(self): ) -SamplerTaskLike = Union[ - SamplerTask, QuantumCircuit, Tuple[QuantumCircuit], Tuple[QuantumCircuit, BindingsArrayLike] +SamplerPubLike = Union[ + SamplerPub, QuantumCircuit, Tuple[QuantumCircuit], Tuple[QuantumCircuit, BindingsArrayLike] ] diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index c885928a9639..7cb662c9ac72 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -33,9 +33,9 @@ BasePrimitiveOptionsLike, BitArray, PrimitiveResult, - SamplerTask, - SamplerTaskLike, - TaskResult, + PubResult, + SamplerPub, + SamplerPubLike, make_data_bin, ) from .containers.bit_array import _min_num_bytes @@ -98,23 +98,23 @@ def __init__(self, *, options: Optional[BasePrimitiveOptionsLike] = None): options = Options(**options) super().__init__(options=options) - def run(self, tasks: Iterable[SamplerTaskLike]) -> PrimitiveJob[PrimitiveResult[TaskResult]]: - job: PrimitiveJob[PrimitiveResult[TaskResult]] = PrimitiveJob(self._run, tasks) + def run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveJob[PrimitiveResult[PubResult]]: + job: PrimitiveJob[PrimitiveResult[PubResult]] = PrimitiveJob(self._run, pubs) job.submit() return job - def _run(self, tasks: Iterable[SamplerTask]) -> PrimitiveResult[TaskResult]: - coerced_tasks = [SamplerTask.coerce(task) for task in tasks] - for task in coerced_tasks: - task.validate() + def _run(self, pubs: Iterable[SamplerPub]) -> PrimitiveResult[PubResult]: + coerced_pubs = [SamplerPub.coerce(pub) for pub in pubs] + for pub in coerced_pubs: + pub.validate() shots = self.options.execution.shots seed = self.options.execution.seed results = [] - for task in coerced_tasks: - circuit, qargs, meas_info = _preprocess_circuit(task.circuit) - parameter_values = task.parameter_values + for pub in coerced_pubs: + circuit, qargs, meas_info = _preprocess_circuit(pub.circuit) + parameter_values = pub.parameter_values bound_circuits = parameter_values.bind_all(circuit) arrays = { item.creg_name: np.zeros( @@ -143,7 +143,7 @@ def _run(self, tasks: Iterable[SamplerTask]) -> PrimitiveResult[TaskResult]: for item in meas_info } data_bin = data_bin_cls(**meas) - results.append(TaskResult(data_bin, metadata={"shots": shots})) + results.append(PubResult(data_bin, metadata={"shots": shots})) return PrimitiveResult(results) From 1c601c0e0e583cb06f59321cbd3326379dd02472 Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Tue, 5 Dec 2023 22:21:29 +0900 Subject: [PATCH 48/55] update --- qiskit/primitives/backend_sampler_v2.py | 2 +- qiskit/primitives/base/base_sampler.py | 67 +++++++++++++++++-- qiskit/primitives/containers/sampler_pub.py | 34 +++++++--- .../primitives/containers/test_bit_array.py | 6 +- 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/qiskit/primitives/backend_sampler_v2.py b/qiskit/primitives/backend_sampler_v2.py index 119b518f516a..a7825ba41670 100644 --- a/qiskit/primitives/backend_sampler_v2.py +++ b/qiskit/primitives/backend_sampler_v2.py @@ -40,7 +40,7 @@ make_data_bin, ) from .containers.bit_array import _min_num_bytes -from .containers.options import mutable_dataclass +from .containers.dataclasses import mutable_dataclass from .primitive_job import PrimitiveJob diff --git a/qiskit/primitives/base/base_sampler.py b/qiskit/primitives/base/base_sampler.py index 28e5524b00bf..6dfb438881db 100644 --- a/qiskit/primitives/base/base_sampler.py +++ b/qiskit/primitives/base/base_sampler.py @@ -11,9 +11,66 @@ # that they have been altered from the originals. r""" -=================== -Overview of Sampler -=================== +===================== +Overview of SamplerV2 +===================== + +:class:`~BaseSamplerV2` is a primitive that samples bitstrings from quantum circuits. + +Following construction, a sampler is used by calling its :meth:`~.BaseSamplerV2.run` method +with a list of pubs (Primitive Unified Blocks). Each pub contains two values that, together, +define a computation unit of work for the sampler to complete: + +* a single :class:`~qiskit.circuit.QuantumCircuit`, possibly parameterized, whose final state we + define as :math:`\psi(\theta)`, + +* a collection parameter value sets to bind the circuit against, :math:`\theta_k`. + +Running a sampler returns a :class:`~qiskit.provider.JobV1 object, where calling +the method :meth:`~qiskit.provider.JobV1.result` results in bitstring samples and metadata +for each pub. + +Here is an example of how sampler is used. + + +.. code-block:: python + + from qiskit.primitives.statevector_sampler import Sampler + from qiskit import QuantumCircuit + from qiskit.circuit.library import RealAmplitudes + + # a Bell circuit + bell = QuantumCircuit(2) + bell.h(0) + bell.cx(0, 1) + bell.measure_all() + + # two parameterized circuits + pqc = RealAmplitudes(num_qubits=2, reps=2) + pqc.measure_all() + pqc2 = RealAmplitudes(num_qubits=2, reps=3) + pqc2.measure_all() + + theta1 = [0, 1, 1, 2, 3, 5] + theta2 = [0, 1, 2, 3, 4, 5, 6, 7] + + # initialization of the sampler + sampler = Sampler() + + # Sampler runs a job on the Bell circuit + job = sampler.run([bell]) + job_result = job.result() + print(f"The primitive-job finished with result {job_result}")) + + # Sampler runs a job on the parameterized circuits + job2 = sampler.run([(pqc, theta1), (pqc2, theta2)] + job_result = job2.result() + print(f"The primitive-job finished with result {job_result}")) + + +===================== +Overview of SamplerV1 +===================== Sampler class calculates probabilities or quasi-probabilities of bitstrings from quantum circuits. @@ -87,7 +144,7 @@ from qiskit.utils.deprecation import deprecate_func from ..containers.options import BasePrimitiveOptionsLike -from ..containers.sampler_pub import SamplerPub, SamplerPubLike +from ..containers.sampler_pub import SamplerPubLike from . import validation from .base_primitive import BasePrimitiveV1, BasePrimitiveV2 @@ -217,7 +274,7 @@ def __init__(self, options: Optional[BasePrimitiveOptionsLike]): super().__init__(options=options) @abstractmethod - def run(self, pubs: Iterable[SamplerPubLike]) -> T: + def run(self, pubs: Iterable[SamplerPubLike]) -> Job: """Run the pubs of samples. Args: diff --git a/qiskit/primitives/containers/sampler_pub.py b/qiskit/primitives/containers/sampler_pub.py index a911f5c6f7de..a92add14159a 100644 --- a/qiskit/primitives/containers/sampler_pub.py +++ b/qiskit/primitives/containers/sampler_pub.py @@ -23,22 +23,38 @@ from .base_pub import BasePub from .bindings_array import BindingsArray, BindingsArrayLike -from .dataclasses import frozen_dataclass from .shape import ShapedMixin -@frozen_dataclass class SamplerPub(BasePub, ShapedMixin): """Pub (Primitive Unified Bloc) for Sampler. Pub is composed of double (circuit, parameter_values). """ - parameter_values: BindingsArray = BindingsArray(shape=()) - _shape: Tuple[int, ...] = () + __slots__ = ("_parameter_values",) - def __post_init__(self): - self._shape = self.parameter_values.shape + def __init__( + self, + circuit: QuantumCircuit, + parameter_values: BindingsArray | None = None, + validate: bool = False, + ): + """Initialize a sampler pub. + + Args: + circuit: a quantum circuit. + parameter_values: a bindings array. + validate: if True, the input data is validated during initialization. + """ + super().__init__(circuit, validate) + self._parameter_values = parameter_values or BindingsArray() + self._shape = self._parameter_values.shape + + @property + def parameter_values(self) -> BindingsArray: + """A bindings array""" + return self._parameter_values @classmethod def coerce(cls, pub: SamplerPubLike) -> SamplerPub: @@ -58,15 +74,13 @@ def coerce(cls, pub: SamplerPubLike) -> SamplerPub: raise ValueError(f"The length of pub must be 1 or 2, but length {len(pub)} is given.") circuit = pub[0] if len(pub) == 1: - return cls(circuit=pub) + return cls(circuit=circuit) parameter_values = BindingsArray.coerce(pub[1]) return cls(circuit=circuit, parameter_values=parameter_values) def validate(self): """Validate the pub.""" - super(SamplerPub, self).validate() # pylint: disable=super-with-arguments - # I'm not sure why these arguments for super are needed. But if no args, tests are failed - # for Python >=3.10. Seems to be some bug, but I can't fix. + super().validate() self.parameter_values.validate() # Cross validate circuits and parameter values num_parameters = self.parameter_values.num_parameters diff --git a/test/python/primitives/containers/test_bit_array.py b/test/python/primitives/containers/test_bit_array.py index ec03743e4067..14bf4db71ff5 100644 --- a/test/python/primitives/containers/test_bit_array.py +++ b/test/python/primitives/containers/test_bit_array.py @@ -12,12 +12,14 @@ """Unit tests for BitArray.""" -from qiskit.test import QiskitTestCase from itertools import product + import ddt import numpy as np -from qiskit.result import Counts + from qiskit.primitives.containers import BitArray +from qiskit.result import Counts +from qiskit.test import QiskitTestCase @ddt.ddt From 2d1045005a86df5fe52b8566d7b5217d1078ed35 Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Fri, 8 Dec 2023 19:25:19 +0900 Subject: [PATCH 49/55] add sampler v2 tests --- qiskit/primitives/backend_sampler_v2.py | 2 +- qiskit/primitives/statevector_sampler.py | 28 +- test/python/primitives/test_sampler_v2.py | 445 ++++++++++++++++++++++ 3 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 test/python/primitives/test_sampler_v2.py diff --git a/qiskit/primitives/backend_sampler_v2.py b/qiskit/primitives/backend_sampler_v2.py index a7825ba41670..daed62d01de5 100644 --- a/qiskit/primitives/backend_sampler_v2.py +++ b/qiskit/primitives/backend_sampler_v2.py @@ -194,7 +194,7 @@ def run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveJob[PrimitiveResult[Pu job.submit() return job - def _run(self, pubs: Iterable[SamplerPub]) -> PrimitiveResult[PubResult]: + def _run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveResult[PubResult]: coerced_pubs = [SamplerPub.coerce(pub) for pub in pubs] for pub in coerced_pubs: pub.validate() diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index 7cb662c9ac72..67fd0368c9fe 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -20,11 +20,11 @@ import numpy as np from numpy.typing import NDArray -from pydantic import Field -from pydantic.types import PositiveInt from qiskit import ClassicalRegister, QiskitError, QuantumCircuit +from qiskit.circuit import ControlFlowOp from qiskit.quantum_info import Statevector +from qiskit.utils.optionals import HAS_PYDANTIC from .base import BaseSamplerV2 from .base.validation import _has_measure @@ -43,7 +43,14 @@ from .primitive_job import PrimitiveJob from .utils import bound_circuit_to_instruction +if HAS_PYDANTIC: + from pydantic import Field + from pydantic.types import PositiveInt +else: + from dataclasses import field as Field + +@HAS_PYDANTIC.require_in_instance @mutable_dataclass class ExecutionOptions(BasePrimitiveOptions): """Options for execution.""" @@ -52,6 +59,7 @@ class ExecutionOptions(BasePrimitiveOptions): seed: Optional[Union[int, np.random.Generator]] = None +@HAS_PYDANTIC.require_in_instance @mutable_dataclass class Options(BasePrimitiveOptions): """Options for the primitives. @@ -71,6 +79,7 @@ class _MeasureInfo: qreg_indices: List[int] +@HAS_PYDANTIC.require_in_instance class Sampler(BaseSamplerV2): """ Simple implementation of :class:`BaseSamplerV2` with Statevector. @@ -103,7 +112,7 @@ def run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveJob[PrimitiveResult[Pu job.submit() return job - def _run(self, pubs: Iterable[SamplerPub]) -> PrimitiveResult[PubResult]: + def _run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveResult[PubResult]: coerced_pubs = [SamplerPub.coerce(pub) for pub in pubs] for pub in coerced_pubs: pub.validate() @@ -151,6 +160,8 @@ def _preprocess_circuit(circuit: QuantumCircuit): mapping = _final_measurement_mapping(circuit) qargs = sorted(set(mapping.values())) circuit = circuit.remove_final_measurements(inplace=False) + if _has_control_flow(circuit): + raise QiskitError("StatevectorSampler cannot handle ControlFlowOp") if _has_measure(circuit): raise QiskitError("StatevectorSampler cannot handle mid-circuit measurements") num_qubits = circuit.num_qubits @@ -176,9 +187,10 @@ def _samples_to_packed_array( samples: NDArray[np.uint8], num_bits: int, indices: List[int] ) -> NDArray[np.uint8]: # samples of `Statevector.sample_memory` will be in the order of - # qubit_0, qubit_1, ..., qubit_last - # pad 0 in the rightmost to be used for the sentinel introduced by _preprocess_circuit - ary = np.pad(samples, ((0, 0), (0, 1)), constant_values=0) + # qubit_last, ..., qubit_1, qubit_0. + # reverse the sample order into qubit_0, qubit_1, ..., qubit_last and + # pad 0 in the rightmost to be used for the sentinel introduced by _preprocess_circuit. + ary = np.pad(samples[:, ::-1], ((0, 0), (0, 1)), constant_values=0) # place samples in the order of clbit_last, ..., clbit_1, clbit_0 ary = ary[:, indices[::-1]] # pad 0 in the left to align the number to be mod 8 @@ -223,3 +235,7 @@ def _final_measurement_mapping(circuit: QuantumCircuit) -> Dict[Tuple[ClassicalR break return mapping + + +def _has_control_flow(circuit: QuantumCircuit) -> bool: + return any(isinstance(instruction.operation, ControlFlowOp) for instruction in circuit) diff --git a/test/python/primitives/test_sampler_v2.py b/test/python/primitives/test_sampler_v2.py new file mode 100644 index 000000000000..0f94ab4a5772 --- /dev/null +++ b/test/python/primitives/test_sampler_v2.py @@ -0,0 +1,445 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# 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. + +"""Tests for Sampler V2.""" + +from __future__ import annotations + +import unittest +from unittest import skip + +import numpy as np +from numpy.typing import NDArray + +from qiskit import QiskitError, QuantumCircuit +from qiskit.circuit import Parameter +from qiskit.circuit.library import RealAmplitudes, UnitaryGate +from qiskit.primitives import PrimitiveResult, PubResult +from qiskit.primitives.containers import BitArray +from qiskit.primitives.containers.data_bin import DataBin +from qiskit.primitives.statevector_sampler import Sampler +from qiskit.providers import JobStatus, JobV1 +from qiskit.test import QiskitTestCase +from qiskit.utils.optionals import HAS_PYDANTIC + + +@unittest.skipUnless(HAS_PYDANTIC, "pydantic not installed.") +class TestSampler(QiskitTestCase): + """Test Sampler""" + + def setUp(self): + super().setUp() + self._shots = 10000 + self._options = {"execution": {"shots": self._shots, "seed": 123}} + + self._cases = [] + hadamard = QuantumCircuit(1, 1, name="Hadamard") + hadamard.h(0) + hadamard.measure(0, 0) + self._cases.append((hadamard, None, {0: 5000, 1: 5000})) # case 0 + + bell = QuantumCircuit(2, name="Bell") + bell.h(0) + bell.cx(0, 1) + bell.measure_all() + self._cases.append((bell, None, {0: 5000, 3: 5000})) # case 1 + + pqc = RealAmplitudes(num_qubits=2, reps=2) + pqc.measure_all() + self._cases.append((pqc, [0] * 6, {0: 10000})) # case 2 + self._cases.append((pqc, [1] * 6, {0: 168, 1: 3389, 2: 470, 3: 5973})) # case 3 + self._cases.append((pqc, [0, 1, 1, 2, 3, 5], {0: 1339, 1: 3534, 2: 912, 3: 4215})) # case 4 + self._cases.append((pqc, [1, 2, 3, 4, 5, 6], {0: 634, 1: 291, 2: 6039, 3: 3036})) # case 5 + + pqc2 = RealAmplitudes(num_qubits=2, reps=3) + pqc2.measure_all() + self._cases.append( + (pqc2, [0, 1, 2, 3, 4, 5, 6, 7], {0: 1898, 1: 6864, 2: 928, 3: 311}) + ) # case 6 + + def _assert_allclose(self, ba: BitArray, target: NDArray | BitArray, rtol=1e-1): + self.assertEqual(ba.shape, target.shape) + for idx in np.ndindex(ba.shape): + int_counts = ba.get_int_counts(idx) + target_counts = ( + target.get_int_counts(idx) if isinstance(target, BitArray) else target[idx] + ) + max_key = max(max(int_counts.keys()), max(target_counts.keys())) + ary = np.array([int_counts.get(i, 0) for i in range(max_key + 1)]) + tgt = np.array([target_counts.get(i, 0) for i in range(max_key + 1)]) + np.testing.assert_allclose(ary, tgt, rtol=rtol, err_msg=f"index: {idx}") + + def test_sampler_run(self): + """Test Sampler.run().""" + bell, _, target = self._cases[1] + + with self.subTest("single"): + sampler = Sampler(options=self._options) + job = sampler.run([bell]) + self.assertIsInstance(job, JobV1) + result = job.result() + self.assertIsInstance(result, PrimitiveResult) + self.assertIsInstance(result.metadata, dict) + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], PubResult) + self.assertIsInstance(result[0].data, DataBin) + self.assertIsInstance(result[0].data.meas, BitArray) + self._assert_allclose(result[0].data.meas, np.array(target)) + + with self.subTest("single with param"): + sampler = Sampler(options=self._options) + job = sampler.run([(bell, ())]) + self.assertIsInstance(job, JobV1) + result = job.result() + self.assertIsInstance(result, PrimitiveResult) + self.assertIsInstance(result.metadata, dict) + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], PubResult) + self.assertIsInstance(result[0].data, DataBin) + self.assertIsInstance(result[0].data.meas, BitArray) + self._assert_allclose(result[0].data.meas, np.array(target)) + + with self.subTest("single array"): + sampler = Sampler(options=self._options) + job = sampler.run([(bell, [()])]) + self.assertIsInstance(job, JobV1) + result = job.result() + self.assertIsInstance(result, PrimitiveResult) + self.assertIsInstance(result.metadata, dict) + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], PubResult) + self.assertIsInstance(result[0].data, DataBin) + self.assertIsInstance(result[0].data.meas, BitArray) + self._assert_allclose(result[0].data.meas, np.array([target])) + + with self.subTest("multiple"): + sampler = Sampler(options=self._options) + job = sampler.run([(bell, [(), (), ()])]) + self.assertIsInstance(job, JobV1) + result = job.result() + self.assertIsInstance(result, PrimitiveResult) + self.assertIsInstance(result.metadata, dict) + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], PubResult) + self.assertIsInstance(result[0].data, DataBin) + self.assertIsInstance(result[0].data.meas, BitArray) + self._assert_allclose(result[0].data.meas, np.array([target, target, target])) + + def test_sample_run_multiple_circuits(self): + """Test Sampler.run() with multiple circuits.""" + bell, _, target = self._cases[1] + sampler = Sampler(options=self._options) + result = sampler.run([bell, bell, bell]).result() + self.assertEqual(len(result), 3) + self._assert_allclose(result[0].data.meas, np.array(target)) + self._assert_allclose(result[1].data.meas, np.array(target)) + self._assert_allclose(result[2].data.meas, np.array(target)) + + def test_sampler_run_with_parameterized_circuits(self): + """Test Sampler.run() with parameterized circuits.""" + + pqc1, param1, target1 = self._cases[4] + pqc2, param2, target2 = self._cases[5] + pqc3, param3, target3 = self._cases[6] + + sampler = Sampler(options=self._options) + result = sampler.run([(pqc1, param1), (pqc2, param2), (pqc3, param3)]).result() + self.assertEqual(len(result), 3) + self._assert_allclose(result[0].data.meas, np.array(target1)) + self._assert_allclose(result[1].data.meas, np.array(target2)) + self._assert_allclose(result[2].data.meas, np.array(target3)) + + def test_run_1qubit(self): + """test for 1-qubit cases""" + qc = QuantumCircuit(1) + qc.measure_all() + qc2 = QuantumCircuit(1) + qc2.x(0) + qc2.measure_all() + + sampler = Sampler(options=self._options) + result = sampler.run([qc, qc2]).result() + self.assertEqual(len(result), 2) + for i in range(2): + self._assert_allclose(result[i].data.meas, np.array({i: self._shots})) + + def test_run_2qubit(self): + """test for 2-qubit cases""" + qc0 = QuantumCircuit(2) + qc0.measure_all() + qc1 = QuantumCircuit(2) + qc1.x(0) + qc1.measure_all() + qc2 = QuantumCircuit(2) + qc2.x(1) + qc2.measure_all() + qc3 = QuantumCircuit(2) + qc3.x([0, 1]) + qc3.measure_all() + + sampler = Sampler(options=self._options) + result = sampler.run([qc0, qc1, qc2, qc3]).result() + self.assertEqual(len(result), 4) + for i in range(4): + self._assert_allclose(result[i].data.meas, np.array({i: self._shots})) + + def test_run_single_circuit(self): + """Test for single circuit case.""" + + sampler = Sampler(options=self._options) + + with self.subTest("No parameter"): + circuit, _, target = self._cases[1] + param_target = [ + (None, np.array(target)), + ((), np.array(target)), + ([], np.array(target)), + (np.array([]), np.array(target)), + (((),), np.array([target])), + (([],), np.array([target])), + ([[]], np.array([target])), + ([()], np.array([target])), + (np.array([[]]), np.array([target])), + ] + for param, target in param_target: + with self.subTest(f"{circuit.name} w/ {param}"): + result = sampler.run([(circuit, param)]).result() + self.assertEqual(len(result), 1) + self._assert_allclose(result[0].data.meas, target) + + with self.subTest("One parameter"): + circuit = QuantumCircuit(1, 1, name="X gate") + param = Parameter("x") + circuit.ry(param, 0) + circuit.measure(0, 0) + param_target = [ + ([np.pi], np.array({1: self._shots})), + ((np.pi,), np.array({1: self._shots})), + (np.array([np.pi]), np.array({1: self._shots})), + ([[np.pi]], np.array([{1: self._shots}])), + (((np.pi,),), np.array([{1: self._shots}])), + (np.array([[np.pi]]), np.array([{1: self._shots}])), + ] + for param, target in param_target: + with self.subTest(f"{circuit.name} w/ {param}"): + result = sampler.run([(circuit, param)]).result() + self.assertEqual(len(result), 1) + self._assert_allclose(result[0].data.c, target) + + with self.subTest("More than one parameter"): + circuit, param, target = self._cases[3] + param_target = [ + (param, np.array(target)), + (tuple(param), np.array(target)), + (np.array(param), np.array(target)), + ((param,), np.array([target])), + ([param], np.array([target])), + (np.array([param]), np.array([target])), + ] + for param, target in param_target: + with self.subTest(f"{circuit.name} w/ {param}"): + result = sampler.run([(circuit, param)]).result() + self.assertEqual(len(result), 1) + self._assert_allclose(result[0].data.meas, target) + + def test_run_reverse_meas_order(self): + """test for sampler with reverse measurement order""" + x = Parameter("x") + y = Parameter("y") + + qc = QuantumCircuit(3, 3) + qc.rx(x, 0) + qc.rx(y, 1) + qc.x(2) + qc.measure(0, 2) + qc.measure(1, 1) + qc.measure(2, 0) + + sampler = Sampler(options=self._options) + result = sampler.run([(qc, [0, 0]), (qc, [np.pi / 2, 0])]).result() + self.assertEqual(len(result), 2) + + # qc({x: 0, y: 0}) + self._assert_allclose(result[0].data.c, np.array({1: self._shots})) + + # qc({x: pi/2, y: 0}) + self._assert_allclose(result[1].data.c, np.array({1: self._shots / 2, 5: self._shots / 2})) + + def test_run_errors(self): + """Test for errors with run method""" + qc1 = QuantumCircuit(1) + qc1.measure_all() + qc2 = RealAmplitudes(num_qubits=1, reps=1) + qc2.measure_all() + qc3 = QuantumCircuit(1) + qc4 = QuantumCircuit(1, 1) + qc5 = QuantumCircuit(1, 1) + with qc5.for_loop(range(5)): + qc5.h(0) + + sampler = Sampler(options=self._options) + with self.subTest("set parameter values to a non-parameterized circuit"): + with self.assertRaises(ValueError): + _ = sampler.run([(qc1, [1e2])]).result() + with self.subTest("missing all parameter values for a parameterized circuit"): + with self.assertRaises(ValueError): + _ = sampler.run([qc2]).result() + with self.assertRaises(ValueError): + _ = sampler.run([(qc2, [])]).result() + with self.assertRaises(ValueError): + _ = sampler.run([(qc2, None)]).result() + with self.subTest("missing some parameter values for a parameterized circuit"): + with self.assertRaises(ValueError): + _ = sampler.run([(qc2, [1e2])]).result() + with self.subTest("too many parameter values for a parameterized circuit"): + with self.assertRaises(ValueError): + _ = sampler.run([(qc2, [1e2] * 100)]).result() + with self.subTest("no classical bits"): + with self.assertRaises(ValueError): + _ = sampler.run([qc3]).result() + with self.subTest("no measurement"): + with self.assertRaises(ValueError): + _ = sampler.run([qc4]).result() + with self.subTest("with control flow"): + with self.assertRaises(QiskitError): + _ = sampler.run([qc5]).result() + + def test_run_empty_parameter(self): + """Test for empty parameter""" + n = 5 + qc = QuantumCircuit(n, n - 1) + qc.measure(range(n - 1), range(n - 1)) + sampler = Sampler(options=self._options) + with self.subTest("one circuit"): + result = sampler.run([qc]).result() + self.assertEqual(len(result), 1) + self._assert_allclose(result[0].data.c, np.array({0: self._shots})) + + with self.subTest("two circuits"): + result = sampler.run([qc, qc]).result() + self.assertEqual(len(result), 2) + for i in range(2): + self._assert_allclose(result[i].data.c, np.array({0: self._shots})) + + def test_run_numpy_params(self): + """Test for numpy array as parameter values""" + qc = RealAmplitudes(num_qubits=2, reps=2) + qc.measure_all() + k = 5 + params_array = np.random.rand(k, qc.num_parameters) + params_list = params_array.tolist() + sampler = Sampler(options=self._options) + target = sampler.run([(qc, params_list)]).result() + + with self.subTest("ndarray"): + result = sampler.run([(qc, params_array)]).result() + self.assertEqual(len(result), 1) + self._assert_allclose(result[0].data.meas, target[0].data.meas) + + with self.subTest("split a list"): + result = sampler.run([(qc, params) for params in params_list]).result() + self.assertEqual(len(result), k) + for i in range(k): + self._assert_allclose( + result[i].data.meas, np.array(target[0].data.meas.get_int_counts(i)) + ) + + @skip + def test_run_with_shots_option(self): + """test with shots option.""" + params, target = self._generate_params_target([1]) + sampler = Sampler() + result = sampler.run( + circuits=[self._pqc], parameter_values=params, shots=1024, seed=15 + ).result() + self._compare_probs(result.quasi_dists, target) + + @skip + def test_run_with_shots_option_none(self): + """test with shots=None option. Seed is ignored then.""" + sampler = Sampler() + result_42 = sampler.run( + [self._pqc], parameter_values=[[0, 1, 1, 2, 3, 5]], shots=None, seed=42 + ).result() + result_15 = sampler.run( + [self._pqc], parameter_values=[[0, 1, 1, 2, 3, 5]], shots=None, seed=15 + ).result() + self.assertDictAlmostEqual(result_42.quasi_dists, result_15.quasi_dists) + + @skip + def test_run_shots_result_size(self): + """test with shots option to validate the result size""" + n = 10 + shots = 100 + qc = QuantumCircuit(n) + qc.h(range(n)) + qc.measure_all() + sampler = Sampler() + result = sampler.run(qc, [], shots=shots, seed=42).result() + self.assertEqual(len(result.quasi_dists), 1) + self.assertLessEqual(len(result.quasi_dists[0]), shots) + self.assertAlmostEqual(sum(result.quasi_dists[0].values()), 1.0) + + def test_primitive_job_status_done(self): + """test primitive job's status""" + bell, _, _ = self._cases[1] + sampler = Sampler(options=self._options) + job = sampler.run([bell]) + _ = job.result() + self.assertEqual(job.status(), JobStatus.DONE) + + @skip + def test_options(self): + """Test for options""" + with self.subTest("init"): + sampler = Sampler(options={"shots": 3000}) + self.assertEqual(sampler.options.get("shots"), 3000) + with self.subTest("set_options"): + sampler.set_options(shots=1024, seed=15) + self.assertEqual(sampler.options.get("shots"), 1024) + self.assertEqual(sampler.options.get("seed"), 15) + with self.subTest("run"): + params, target = self._generate_params_target([1]) + result = sampler.run([self._pqc], parameter_values=params).result() + self._compare_probs(result.quasi_dists, target) + self.assertEqual(result.quasi_dists[0].shots, 1024) + + def test_circuit_with_unitary(self): + """Test for circuit with unitary gate.""" + + with self.subTest("identity"): + gate = UnitaryGate(np.eye(2)) + + circuit = QuantumCircuit(1) + circuit.append(gate, [0]) + circuit.measure_all() + + sampler = Sampler(options=self._options) + result = sampler.run([circuit]).result() + self.assertEqual(len(result), 1) + self._assert_allclose(result[0].data.meas, np.array({0: self._shots})) + + with self.subTest("X"): + gate = UnitaryGate([[0, 1], [1, 0]]) + + circuit = QuantumCircuit(1) + circuit.append(gate, [0]) + circuit.measure_all() + + sampler = Sampler(options=self._options) + result = sampler.run([circuit]).result() + self.assertEqual(len(result), 1) + self._assert_allclose(result[0].data.meas, np.array({1: self._shots})) + + +if __name__ == "__main__": + unittest.main() From 9bb6ad5ad218d2333ef572bb02cae24e278dc469 Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Tue, 12 Dec 2023 18:35:20 +0900 Subject: [PATCH 50/55] add tests of options --- test/python/primitives/test_sampler_v2.py | 63 +++++++++-------------- 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/test/python/primitives/test_sampler_v2.py b/test/python/primitives/test_sampler_v2.py index 0f94ab4a5772..88a4020d036c 100644 --- a/test/python/primitives/test_sampler_v2.py +++ b/test/python/primitives/test_sampler_v2.py @@ -15,7 +15,6 @@ from __future__ import annotations import unittest -from unittest import skip import numpy as np from numpy.typing import NDArray @@ -353,29 +352,16 @@ def test_run_numpy_params(self): result[i].data.meas, np.array(target[0].data.meas.get_int_counts(i)) ) - @skip def test_run_with_shots_option(self): """test with shots option.""" - params, target = self._generate_params_target([1]) - sampler = Sampler() - result = sampler.run( - circuits=[self._pqc], parameter_values=params, shots=1024, seed=15 - ).result() - self._compare_probs(result.quasi_dists, target) - - @skip - def test_run_with_shots_option_none(self): - """test with shots=None option. Seed is ignored then.""" - sampler = Sampler() - result_42 = sampler.run( - [self._pqc], parameter_values=[[0, 1, 1, 2, 3, 5]], shots=None, seed=42 - ).result() - result_15 = sampler.run( - [self._pqc], parameter_values=[[0, 1, 1, 2, 3, 5]], shots=None, seed=15 - ).result() - self.assertDictAlmostEqual(result_42.quasi_dists, result_15.quasi_dists) - - @skip + bell, _, _ = self._cases[1] + sampler = Sampler(options=self._options) + shots = 100 + sampler.options.execution.shots = shots + result = sampler.run([bell]).result() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].data.meas.num_samples, shots) + def test_run_shots_result_size(self): """test with shots option to validate the result size""" n = 10 @@ -383,11 +369,11 @@ def test_run_shots_result_size(self): qc = QuantumCircuit(n) qc.h(range(n)) qc.measure_all() - sampler = Sampler() - result = sampler.run(qc, [], shots=shots, seed=42).result() - self.assertEqual(len(result.quasi_dists), 1) - self.assertLessEqual(len(result.quasi_dists[0]), shots) - self.assertAlmostEqual(sum(result.quasi_dists[0].values()), 1.0) + sampler = Sampler(options=self._options) + result = sampler.run([qc]).result() + self.assertEqual(len(result), 1) + self.assertLessEqual(result[0].data.meas.num_samples, self._shots) + self.assertEqual(sum(result[0].data.meas.get_counts().values()), self._shots) def test_primitive_job_status_done(self): """test primitive job's status""" @@ -397,21 +383,20 @@ def test_primitive_job_status_done(self): _ = job.result() self.assertEqual(job.status(), JobStatus.DONE) - @skip def test_options(self): """Test for options""" with self.subTest("init"): - sampler = Sampler(options={"shots": 3000}) - self.assertEqual(sampler.options.get("shots"), 3000) - with self.subTest("set_options"): - sampler.set_options(shots=1024, seed=15) - self.assertEqual(sampler.options.get("shots"), 1024) - self.assertEqual(sampler.options.get("seed"), 15) - with self.subTest("run"): - params, target = self._generate_params_target([1]) - result = sampler.run([self._pqc], parameter_values=params).result() - self._compare_probs(result.quasi_dists, target) - self.assertEqual(result.quasi_dists[0].shots, 1024) + sampler = Sampler(options={"execution": {"shots": 3000}}) + self.assertEqual(sampler.options.execution.shots, 3000) + with self.subTest("set options"): + sampler.options.execution.shots = 1024 + sampler.options.execution.seed = 15 + self.assertEqual(sampler.options.execution.shots, 1024) + self.assertEqual(sampler.options.execution.seed, 15) + with self.subTest("update options"): + sampler.options.execution.update({"shots": 100, "seed": 12}) + self.assertEqual(sampler.options.execution.shots, 100) + self.assertEqual(sampler.options.execution.seed, 12) def test_circuit_with_unitary(self): """Test for circuit with unitary gate.""" From 39e2dbe65b4cac2000e26d0695c52a2ed68298ac Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Tue, 12 Dec 2023 18:57:54 +0900 Subject: [PATCH 51/55] add tests of metadata and multiple cregs --- test/python/primitives/test_sampler_v2.py | 54 ++++++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/test/python/primitives/test_sampler_v2.py b/test/python/primitives/test_sampler_v2.py index 88a4020d036c..6d75e81fc341 100644 --- a/test/python/primitives/test_sampler_v2.py +++ b/test/python/primitives/test_sampler_v2.py @@ -15,11 +15,12 @@ from __future__ import annotations import unittest +from dataclasses import astuple import numpy as np from numpy.typing import NDArray -from qiskit import QiskitError, QuantumCircuit +from qiskit import ClassicalRegister, QiskitError, QuantumCircuit, QuantumRegister from qiskit.circuit import Parameter from qiskit.circuit.library import RealAmplitudes, UnitaryGate from qiskit.primitives import PrimitiveResult, PubResult @@ -65,10 +66,21 @@ def setUp(self): (pqc2, [0, 1, 2, 3, 4, 5, 6, 7], {0: 1898, 1: 6864, 2: 928, 3: 311}) ) # case 6 - def _assert_allclose(self, ba: BitArray, target: NDArray | BitArray, rtol=1e-1): - self.assertEqual(ba.shape, target.shape) - for idx in np.ndindex(ba.shape): - int_counts = ba.get_int_counts(idx) + a = ClassicalRegister(1, "a") + b = ClassicalRegister(2, "b") + c = ClassicalRegister(3, "c") + + qc = QuantumCircuit(QuantumRegister(3), a, b, c) + qc.h(range(3)) + qc.measure([0, 1, 2, 2], [0, 2, 4, 5]) + self._cases.append( + (qc, None, {"a": {0: 5000, 1: 5000}, "b": {0: 5000, 2: 5000}, "c": {0: 5000, 6: 5000}}) + ) # case 7 + + def _assert_allclose(self, bitarray: BitArray, target: NDArray | BitArray, rtol=1e-1): + self.assertEqual(bitarray.shape, target.shape) + for idx in np.ndindex(bitarray.shape): + int_counts = bitarray.get_int_counts(idx) target_counts = ( target.get_int_counts(idx) if isinstance(target, BitArray) else target[idx] ) @@ -355,17 +367,17 @@ def test_run_numpy_params(self): def test_run_with_shots_option(self): """test with shots option.""" bell, _, _ = self._cases[1] - sampler = Sampler(options=self._options) + sampler = Sampler() shots = 100 sampler.options.execution.shots = shots result = sampler.run([bell]).result() self.assertEqual(len(result), 1) self.assertEqual(result[0].data.meas.num_samples, shots) + self.assertEqual(sum(result[0].data.meas.get_counts().values()), shots) def test_run_shots_result_size(self): """test with shots option to validate the result size""" n = 10 - shots = 100 qc = QuantumCircuit(n) qc.h(range(n)) qc.measure_all() @@ -425,6 +437,34 @@ def test_circuit_with_unitary(self): self.assertEqual(len(result), 1) self._assert_allclose(result[0].data.meas, np.array({1: self._shots})) + def test_metadata(self): + """Test for metatdata.""" + qc, _, _ = self._cases[1] + sampler = Sampler(options=self._options) + result = sampler.run([qc]).result() + self.assertEqual(len(result), 1) + self.assertIn("shots", result[0].metadata) + self.assertEqual(result[0].metadata["shots"], self._shots) + + shots = 100 + sampler.options.execution.shots = 100 + result = sampler.run([qc]).result() + self.assertEqual(len(result), 1) + self.assertIn("shots", result[0].metadata) + self.assertEqual(result[0].metadata["shots"], shots) + + def test_circuit_with_multiple_cregs(self): + """Test for circuit with multiple classical registers.""" + qc, _, target = self._cases[7] + sampler = Sampler(options=self._options) + result = sampler.run([qc]).result() + self.assertEqual(len(result), 1) + data = result[0].data + self.assertEqual(len(astuple(data)), 3) + for creg in qc.cregs: + self.assertTrue(hasattr(data, creg.name)) + self._assert_allclose(getattr(data, creg.name), np.array(target[creg.name])) + if __name__ == "__main__": unittest.main() From 5b17cad9835060b5e984818b22dc4e2c6fdf66c4 Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Tue, 12 Dec 2023 22:57:26 +0900 Subject: [PATCH 52/55] refactor BackendSamplerV2 --- qiskit/primitives/backend_sampler_v2.py | 72 ++++++++----------------- 1 file changed, 23 insertions(+), 49 deletions(-) diff --git a/qiskit/primitives/backend_sampler_v2.py b/qiskit/primitives/backend_sampler_v2.py index daed62d01de5..bb3f9599bdcf 100644 --- a/qiskit/primitives/backend_sampler_v2.py +++ b/qiskit/primitives/backend_sampler_v2.py @@ -16,7 +16,7 @@ import math from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Optional, Union +from typing import Any, Dict, Iterable, List, Optional, Tuple, Union import numpy as np from numpy.typing import NDArray @@ -156,24 +156,6 @@ def set_transpile_options(self, **fields): """ self._options.transpilation.update(**fields) - def _postprocessing( - self, result: list[Result], circuits: list[QuantumCircuit] - ) -> SamplerResult: - counts = _prepare_counts(result) - shots = sum(counts[0].values()) - - probabilities = [] - metadata: list[dict[str, Any]] = [{} for _ in range(len(circuits))] - for count in counts: - prob_dist = {k: v / shots for k, v in count.items()} - probabilities.append( - QuasiDistribution(prob_dist, shots=shots, stddev_upper_bound=math.sqrt(1 / shots)) - ) - for metadatum in metadata: - metadatum["shots"] = shots - - return SamplerResult(probabilities, metadata) - def _transpile(self, circuits: List[QuantumCircuit]) -> None: if self._skip_transpilation: ret = circuits @@ -205,7 +187,8 @@ def _run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveResult[PubResult]: results = [] for pub, circuit in zip(coerced_pubs, self._transpiled_circuits): - meas_info = _analyze_circuit(pub.circuit) + meas_info, max_num_bits = _analyze_circuit(pub.circuit) + max_num_bytes = _min_num_bytes(max_num_bits) parameter_values = pub.parameter_values bound_circuits = parameter_values.bind_all(circuit) arrays = { @@ -218,14 +201,11 @@ def _run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveResult[PubResult]: result_memory, _ = _run_circuits( flatten_circuits, self._backend, memory=True, **self.options.execution.__dict__ ) - memory_list = _prepare_memory(result_memory) + memory_list = _prepare_memory(result_memory, max_num_bits, max_num_bytes) for samples, index in zip(memory_list, np.ndindex(*bound_circuits.shape)): - samples_array = np.array( - [np.fromiter(sample, dtype=np.uint8) for sample in samples] - ) for item in meas_info: - ary = _samples_to_packed_array(samples_array, item.num_bits, item.start) + ary = _samples_to_packed_array(samples, item.num_bits, item.start) arrays[item.creg_name][index] = ary data_bin_cls = make_data_bin( @@ -241,7 +221,7 @@ def _run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveResult[PubResult]: return PrimitiveResult(results) -def _analyze_circuit(circuit: QuantumCircuit) -> List[_MeasureInfo]: +def _analyze_circuit(circuit: QuantumCircuit) -> Tuple[List[_MeasureInfo], int]: meas_info = [] start = 0 for creg in circuit.cregs: @@ -256,40 +236,34 @@ def _analyze_circuit(circuit: QuantumCircuit) -> List[_MeasureInfo]: ) ) start += num_bits - return meas_info - + return meas_info, start -def _prepare_memory(results: List[Result]) -> List[List[str]]: - def convert(samples: List[str]) -> List[str]: - # samples of `Backend.run(memory=True)` will be the order of - # clbit_last, ..., clbit_1, clbit_0 - # separated cregs are separated by white space - # this function removes the white spaces and reorders samples in the order of - # clbit_0, clbit_1,..., clbit_last - return [sample[::-1].replace(" ", "") for sample in samples] - memory_list = [] +def _prepare_memory(results: List[Result], num_bits: int, num_bytes: int) -> NDArray[np.uint8]: + lst = [] for res in results: - for i, _ in enumerate(res.results): - memory = res.get_memory(i) - if len(memory) == 0 or not isinstance(memory[0], list): - memory = [memory] - for mem in memory: - memory_list.append(convert(mem)) - return memory_list + for exp in res.results: + data = b"".join(int(i, 16).to_bytes(num_bytes, "big") for i in exp.data.memory) + data = np.frombuffer(data, dtype=np.uint8).reshape(-1, num_bytes) + lst.append(data) + ary = np.array(lst, copy=False) + return np.unpackbits(ary, axis=-1, bitorder="big") def _samples_to_packed_array( samples: NDArray[np.uint8], num_bits: int, start: int ) -> NDArray[np.uint8]: - # samples are in the order of clbit_0, clbit_1, ..., clbit_last + # samples of `Backend.run(memory=True)` will be the order of + # clbit_last, ..., clbit_1, clbit_0 # place samples in the order of clbit_start+num_bits-1, ..., clbit_start+1, clbit_start - indices = range(start + num_bits - 1, start - 1, -1) - ary = samples[:, indices] + if start == 0: + ary = samples[:, -start - num_bits :] + else: + ary = samples[:, -start - num_bits : -start] # pad 0 in the left to align the number to be mod 8 # since np.packbits(bitorder='big') pads 0 to the right. - pad_size = (8 - num_bits % 8) % 8 + pad_size = -num_bits % 8 ary = np.pad(ary, ((0, 0), (pad_size, 0)), constant_values=0) # pack bits in big endian order - ary = np.packbits(ary, axis=-1) + ary = np.packbits(ary, axis=-1, bitorder="big") return ary From f3fb4cad0d053718611bc90379dac442e882f46f Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Wed, 13 Dec 2023 17:35:30 +0900 Subject: [PATCH 53/55] update to allow no measure --- qiskit/primitives/statevector_sampler.py | 14 ++-- test/python/primitives/test_sampler_v2.py | 93 +++++++++++++++++------ 2 files changed, 79 insertions(+), 28 deletions(-) diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index 67fd0368c9fe..be50739ea232 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -135,7 +135,10 @@ def _run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveResult[PubResult]: bound_circuit = bound_circuits[index] final_state = Statevector(bound_circuit_to_instruction(bound_circuit)) final_state.seed(seed) - samples = final_state.sample_memory(shots=shots, qargs=qargs) + if qargs: + samples = final_state.sample_memory(shots=shots, qargs=qargs) + else: + samples = [""] * shots samples_array = np.array( [np.fromiter(sample, dtype=np.uint8) for sample in samples] ) @@ -157,20 +160,21 @@ def _run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveResult[PubResult]: def _preprocess_circuit(circuit: QuantumCircuit): + num_bits_dict = {creg.name: creg.size for creg in circuit.cregs} mapping = _final_measurement_mapping(circuit) qargs = sorted(set(mapping.values())) + qargs_index = {v: k for k, v in enumerate(qargs)} circuit = circuit.remove_final_measurements(inplace=False) if _has_control_flow(circuit): raise QiskitError("StatevectorSampler cannot handle ControlFlowOp") if _has_measure(circuit): raise QiskitError("StatevectorSampler cannot handle mid-circuit measurements") - num_qubits = circuit.num_qubits - num_bits_dict = {key[0].name: key[0].size for key in mapping} # num_qubits is used as sentinel to fill 0 in _samples_to_packed_array - indices = {key: [num_qubits] * val for key, val in num_bits_dict.items()} + sentinel = len(qargs) + indices = {key: [sentinel] * val for key, val in num_bits_dict.items()} for key, qreg in mapping.items(): creg, ind = key - indices[creg.name][ind] = qreg + indices[creg.name][ind] = qargs_index[qreg] meas_info = [ _MeasureInfo( creg_name=name, diff --git a/test/python/primitives/test_sampler_v2.py b/test/python/primitives/test_sampler_v2.py index 6d75e81fc341..e130e98fdddf 100644 --- a/test/python/primitives/test_sampler_v2.py +++ b/test/python/primitives/test_sampler_v2.py @@ -66,17 +66,6 @@ def setUp(self): (pqc2, [0, 1, 2, 3, 4, 5, 6, 7], {0: 1898, 1: 6864, 2: 928, 3: 311}) ) # case 6 - a = ClassicalRegister(1, "a") - b = ClassicalRegister(2, "b") - c = ClassicalRegister(3, "c") - - qc = QuantumCircuit(QuantumRegister(3), a, b, c) - qc.h(range(3)) - qc.measure([0, 1, 2, 2], [0, 2, 4, 5]) - self._cases.append( - (qc, None, {"a": {0: 5000, 1: 5000}, "b": {0: 5000, 2: 5000}, "c": {0: 5000, 6: 5000}}) - ) # case 7 - def _assert_allclose(self, bitarray: BitArray, target: NDArray | BitArray, rtol=1e-1): self.assertEqual(bitarray.shape, target.shape) for idx in np.ndindex(bitarray.shape): @@ -317,9 +306,6 @@ def test_run_errors(self): with self.subTest("no classical bits"): with self.assertRaises(ValueError): _ = sampler.run([qc3]).result() - with self.subTest("no measurement"): - with self.assertRaises(ValueError): - _ = sampler.run([qc4]).result() with self.subTest("with control flow"): with self.assertRaises(QiskitError): _ = sampler.run([qc5]).result() @@ -455,15 +441,76 @@ def test_metadata(self): def test_circuit_with_multiple_cregs(self): """Test for circuit with multiple classical registers.""" - qc, _, target = self._cases[7] - sampler = Sampler(options=self._options) - result = sampler.run([qc]).result() - self.assertEqual(len(result), 1) - data = result[0].data - self.assertEqual(len(astuple(data)), 3) - for creg in qc.cregs: - self.assertTrue(hasattr(data, creg.name)) - self._assert_allclose(getattr(data, creg.name), np.array(target[creg.name])) + cases = [] + + # case 1 + a = ClassicalRegister(1, "a") + b = ClassicalRegister(2, "b") + c = ClassicalRegister(3, "c") + + qc = QuantumCircuit(QuantumRegister(3), a, b, c) + qc.h(range(3)) + qc.measure([0, 1, 2, 2], [0, 2, 4, 5]) + target = {"a": {0: 5000, 1: 5000}, "b": {0: 5000, 2: 5000}, "c": {0: 5000, 6: 5000}} + cases.append(("use all cregs", qc, target)) + + # case 2 + a = ClassicalRegister(1, "a") + b = ClassicalRegister(5, "b") + c = ClassicalRegister(3, "c") + + qc = QuantumCircuit(QuantumRegister(3), a, b, c) + qc.h(range(3)) + qc.measure([0, 1, 2, 2], [0, 2, 4, 5]) + target = { + "a": {0: 5000, 1: 5000}, + "b": {0: 2500, 2: 2500, 24: 2500, 26: 2500}, + "c": {0: 10000}, + } + cases.append(("use only a and b", qc, target)) + + # case 3 + a = ClassicalRegister(1, "a") + b = ClassicalRegister(2, "b") + c = ClassicalRegister(3, "c") + + qc = QuantumCircuit(QuantumRegister(3), a, b, c) + qc.h(range(3)) + qc.measure(1, 5) + target = {"a": {0: 10000}, "b": {0: 10000}, "c": {0: 5000, 4: 5000}} + cases.append(("use only c", qc, target)) + + # case 4 + a = ClassicalRegister(1, "a") + b = ClassicalRegister(2, "b") + c = ClassicalRegister(3, "c") + + qc = QuantumCircuit(QuantumRegister(3), a, b, c) + qc.h(range(3)) + qc.measure([0, 1, 2], [5, 5, 5]) + target = {"a": {0: 10000}, "b": {0: 10000}, "c": {0: 5000, 4: 5000}} + cases.append(("use only c multiple qubits", qc, target)) + + # case 5 + a = ClassicalRegister(1, "a") + b = ClassicalRegister(2, "b") + c = ClassicalRegister(3, "c") + + qc = QuantumCircuit(QuantumRegister(3), a, b, c) + qc.h(range(3)) + target = {"a": {0: 10000}, "b": {0: 10000}, "c": {0: 10000}} + cases.append(("no measure", qc, target)) + + for title, qc, target in cases: + with self.subTest(title): + sampler = Sampler(options=self._options) + result = sampler.run([qc]).result() + self.assertEqual(len(result), 1) + data = result[0].data + self.assertEqual(len(astuple(data)), 3) + for creg in qc.cregs: + self.assertTrue(hasattr(data, creg.name)) + self._assert_allclose(getattr(data, creg.name), np.array(target[creg.name])) if __name__ == "__main__": From a079fd45f5b3e576f35fac37e806a894b503dded Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Wed, 13 Dec 2023 17:58:16 +0900 Subject: [PATCH 54/55] update BackendSampler --- qiskit/primitives/backend_sampler_v2.py | 20 +++++++++++--------- qiskit/primitives/statevector_sampler.py | 3 +-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/qiskit/primitives/backend_sampler_v2.py b/qiskit/primitives/backend_sampler_v2.py index bb3f9599bdcf..73850a9b0ea4 100644 --- a/qiskit/primitives/backend_sampler_v2.py +++ b/qiskit/primitives/backend_sampler_v2.py @@ -187,10 +187,8 @@ def _run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveResult[PubResult]: results = [] for pub, circuit in zip(coerced_pubs, self._transpiled_circuits): - meas_info, max_num_bits = _analyze_circuit(pub.circuit) - max_num_bytes = _min_num_bytes(max_num_bits) - parameter_values = pub.parameter_values - bound_circuits = parameter_values.bind_all(circuit) + meas_info, max_num_bytes = _analyze_circuit(pub.circuit) + bound_circuits = pub.parameter_values.bind_all(circuit) arrays = { item.creg_name: np.zeros( bound_circuits.shape + (shots, item.num_bytes), dtype=np.uint8 @@ -201,7 +199,7 @@ def _run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveResult[PubResult]: result_memory, _ = _run_circuits( flatten_circuits, self._backend, memory=True, **self.options.execution.__dict__ ) - memory_list = _prepare_memory(result_memory, max_num_bits, max_num_bytes) + memory_list = _prepare_memory(result_memory, max_num_bytes) for samples, index in zip(memory_list, np.ndindex(*bound_circuits.shape)): for item in meas_info: @@ -236,15 +234,19 @@ def _analyze_circuit(circuit: QuantumCircuit) -> Tuple[List[_MeasureInfo], int]: ) ) start += num_bits - return meas_info, start + return meas_info, _min_num_bytes(start) -def _prepare_memory(results: List[Result], num_bits: int, num_bytes: int) -> NDArray[np.uint8]: +def _prepare_memory(results: List[Result], num_bytes: int) -> NDArray[np.uint8]: lst = [] for res in results: for exp in res.results: - data = b"".join(int(i, 16).to_bytes(num_bytes, "big") for i in exp.data.memory) - data = np.frombuffer(data, dtype=np.uint8).reshape(-1, num_bytes) + if hasattr(exp.data, "memory"): + data = b"".join(int(i, 16).to_bytes(num_bytes, "big") for i in exp.data.memory) + data = np.frombuffer(data, dtype=np.uint8).reshape(-1, num_bytes) + else: + # no measure in a circuit + data = np.zeros((exp.shots, num_bytes), dtype=np.uint8) lst.append(data) ary = np.array(lst, copy=False) return np.unpackbits(ary, axis=-1, bitorder="big") diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index be50739ea232..2fec6c62a570 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -123,8 +123,7 @@ def _run(self, pubs: Iterable[SamplerPubLike]) -> PrimitiveResult[PubResult]: results = [] for pub in coerced_pubs: circuit, qargs, meas_info = _preprocess_circuit(pub.circuit) - parameter_values = pub.parameter_values - bound_circuits = parameter_values.bind_all(circuit) + bound_circuits = pub.parameter_values.bind_all(circuit) arrays = { item.creg_name: np.zeros( bound_circuits.shape + (shots, item.num_bytes), dtype=np.uint8 From 2d6d2b52ef89165ef86c990980acba8fb9eb1750 Mon Sep 17 00:00:00 2001 From: Takashi Imamichi Date: Thu, 14 Dec 2023 23:28:21 +0900 Subject: [PATCH 55/55] lint --- qiskit/primitives/backend_sampler_v2.py | 7 +++---- qiskit/primitives/statevector_sampler.py | 3 +-- test/python/primitives/test_sampler_v2.py | 7 +++---- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/qiskit/primitives/backend_sampler_v2.py b/qiskit/primitives/backend_sampler_v2.py index 73850a9b0ea4..ae981c8aa569 100644 --- a/qiskit/primitives/backend_sampler_v2.py +++ b/qiskit/primitives/backend_sampler_v2.py @@ -14,7 +14,6 @@ from __future__ import annotations -import math from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Optional, Tuple, Union @@ -24,11 +23,11 @@ from qiskit.circuit.quantumcircuit import QuantumCircuit from qiskit.providers.backend import BackendV1, BackendV2 -from qiskit.result import QuasiDistribution, Result +from qiskit.result import Result from qiskit.transpiler.passmanager import PassManager -from .backend_estimator import _prepare_counts, _run_circuits -from .base import BaseSamplerV2, SamplerResult +from .backend_estimator import _run_circuits +from .base import BaseSamplerV2 from .containers import ( BasePrimitiveOptions, BasePrimitiveOptionsLike, diff --git a/qiskit/primitives/statevector_sampler.py b/qiskit/primitives/statevector_sampler.py index 2fec6c62a570..8c37e54fedde 100644 --- a/qiskit/primitives/statevector_sampler.py +++ b/qiskit/primitives/statevector_sampler.py @@ -15,7 +15,6 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Dict, Iterable, List, Optional, Tuple, Union import numpy as np @@ -39,7 +38,7 @@ make_data_bin, ) from .containers.bit_array import _min_num_bytes -from .containers.dataclasses import mutable_dataclass +from .containers.dataclasses import dataclass, mutable_dataclass from .primitive_job import PrimitiveJob from .utils import bound_circuit_to_instruction diff --git a/test/python/primitives/test_sampler_v2.py b/test/python/primitives/test_sampler_v2.py index e130e98fdddf..e8bb7f68276b 100644 --- a/test/python/primitives/test_sampler_v2.py +++ b/test/python/primitives/test_sampler_v2.py @@ -282,9 +282,8 @@ def test_run_errors(self): qc2.measure_all() qc3 = QuantumCircuit(1) qc4 = QuantumCircuit(1, 1) - qc5 = QuantumCircuit(1, 1) - with qc5.for_loop(range(5)): - qc5.h(0) + with qc4.for_loop(range(5)): + qc4.h(0) sampler = Sampler(options=self._options) with self.subTest("set parameter values to a non-parameterized circuit"): @@ -308,7 +307,7 @@ def test_run_errors(self): _ = sampler.run([qc3]).result() with self.subTest("with control flow"): with self.assertRaises(QiskitError): - _ = sampler.run([qc5]).result() + _ = sampler.run([qc4]).result() def test_run_empty_parameter(self): """Test for empty parameter"""