Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Observable container type for Estimator #11594

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions qiskit/primitives/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
from .base.sampler_result import SamplerResult
from .containers import (
BindingsArray,
Observable,
ObservablesArray,
PrimitiveResult,
PubResult,
Expand Down
1 change: 1 addition & 0 deletions qiskit/primitives/containers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .bit_array import BitArray
from .data_bin import make_data_bin, DataBin
from .estimator_pub import EstimatorPub, EstimatorPubLike
from .observable import Observable
from .observables_array import ObservablesArray
from .primitive_result import PrimitiveResult
from .pub_result import PubResult
Expand Down
169 changes: 169 additions & 0 deletions qiskit/primitives/containers/observable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# This code is part of Qiskit.
#
# (C) Copyright IBM 2024.
#
# 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.


"""
Sparse container class for an Estimator observable.
"""
from __future__ import annotations

from typing import Union, Mapping as MappingType
from collections.abc import Mapping
from collections import defaultdict
from numbers import Real, Complex

from qiskit.quantum_info import Pauli, SparsePauliOp


ObservableKey = "tuple[tuple[int, ...], str]" # pylint: disable = invalid-name
Copy link
Member

@t-imamichi t-imamichi Jan 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be good to make a dataclass of ObservableKey and add a validation method for #11594 (comment).

Copy link
Contributor

@ihincks ihincks Jan 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer the name ObservableTerm here, but at the end of the day, it's just a name. I wonder if you can comment on the feasibility of leaving this as tuple[tuple[int, ...], str] for now and changing it to a class (eg as suggested by @t-imamichi) in the future? In terms of breaking changes and possible subclassing.

Copy link
Member Author

@chriseclectic chriseclectic Jan 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im mostly worried about this container being slow and memory intensive with all the nested classes. I did have a prototype using a class that function like a tuple (had getitem), but im a bit skeptical of validation other than via type since it might be slow. We can't validate the strings, so validation would be that qubits is a tuple with no duplicates and (if we want to enforce this) that the string is same lenght as qubits.

In the current version the design doesn't actually require the string to represent a bunch of operators for each qubit, its up to an estimator to decide what it does with it, but we are using it to represent a Pauli label

(side note: our reference estimator is going to be rather inefficient since it unpacks SparsePauliOps into this potentially much more memory heavy format, and then has to convert back to SparsePauliOp for its simulation)

ObservableKeyLike = Union[Pauli, str, ObservableKey]
ObservableLike = Union[
SparsePauliOp,
ObservableKeyLike,
MappingType[ObservableKeyLike, Real],
]
"""Types that can be natively used to construct an observable."""


class Observable(Mapping[ObservableKey, float]):
"""A sparse container for a observable for an estimator primitive."""

__slots__ = ("_data", "_num_qubits")

def __init__(
self,
data: Mapping[ObservableKey, float],
num_qubits: int = None,
validate: bool = True,
):
"""Initialize an observables array.

Args:
data: The observable data.
num_qubits: The number of qubits in the data.
validate: If ``True``, the input data is validated during initialization.

Raises:
ValueError: If ``validate=True`` and the input observable-like is not valid.
"""
self._data = data
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we care about not sharing a reference to the internal thing:

Suggested change
self._data = data
self._data = dict(data.items())

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its a trade off with that vs performance since this requires an iteration over the data at init

self._num_qubits = num_qubits
if validate:
self.validate()

def __repr__(self):
return f"{type(self).__name__}({self._data})"

def __getitem__(self, key: ObservableKey) -> float:
return self._data[key]

def __iter__(self):
return iter(self._data)

def __len__(self):
return len(self._data)

@property
def num_qubits(self) -> int:
"""The number of qubits in the observable"""
if self._num_qubits is None:
num_qubits = 0
for key in self._data:
num_qubits = max(num_qubits, 1 + max(key[0]))
self._num_qubits = num_qubits
return self._num_qubits

def validate(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This validation allows no terms, which I guess corresponds to the identity observable?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should fail if emtpy so identity has to be represented as ((0,), "I") or similar?

"""Validate the consistency in observables array."""
if not isinstance(self._data, Mapping):
raise TypeError(f"Observable data type {type(self._data)} is not a Mapping.")

# If not already set, we compute num_qubits while iterating over data
# to avoid having to do another iteration later
data_num_qubits = 0
for key, value in self._data.items():
if not isinstance(key, tuple):
raise TypeError("Invalid Observable key type")
if len(key) != 2:
raise ValueError(f"Invalid Observable key value {key}")
# Check tuple pos types after checking length
if not isinstance(key[0], tuple) or not isinstance(key[1], str):
raise TypeError("Invalid Observable key type")
data_num_qubits = max(data_num_qubits, 1 + max(key[0]))
if not isinstance(value, Real):
raise TypeError(f"Value {value} is not a real number")
if self._num_qubits is None:
self._num_qubits = data_num_qubits
elif self._num_qubits < data_num_qubits:
raise ValueError("Num qubits is less than the maximum qubit in observable keys")

@classmethod
def coerce(cls, observable: ObservableLike) -> Observable:
"""Coerce an observable-like object into an :class:`.Observable`.

Args:
observable: The observable-like input.

Returns:
A coerced observables array.

Raises:
TypeError: If the input cannot be formatted because its type is not valid.
ValueError: If the input observable is invalid.
"""
return cls._coerce(observable, num_qubits=None)

@classmethod
def _coerce(cls, observable, num_qubits=None):
# Pauli-type conversions
if isinstance(observable, SparsePauliOp):
# TODO: Make sparse by removing identity qubits in keys
data = dict(observable.simplify(atol=0).to_list())
return cls._coerce(data, num_qubits=observable.num_qubits)

if isinstance(observable, (Pauli, str, tuple)):
return cls._coerce({observable: 1}, num_qubits=num_qubits)

# Mapping conversion (with possible Pauli keys)
if isinstance(observable, Mapping):
key_qubits = set()
unique = defaultdict(float)
for key, coeff in observable.items():
if isinstance(key, Pauli):
# TODO: Make sparse by removing identity qubits in keys
label, phase = key[:].to_label(), key.phase
if phase != 0:
coeff = coeff * (-1j) ** phase
chriseclectic marked this conversation as resolved.
Show resolved Hide resolved
qubits = tuple(range(key.num_qubits))
key = (qubits, label)
elif isinstance(key, str):
qubits = tuple(range(len(key)))
key = (qubits, key)
Comment on lines +149 to +150
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have the same TODO as Pauli.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes we always mean "I" in a key to mean identity, which if we do we should be more explicit about (and in general with strings being single characters same length as qubits etc)

if not isinstance(key, tuple):
raise TypeError(f"Invalid key type {type(key)}")
if len(key) != 2:
raise ValueError(f"Invalid key {key}")
if not isinstance(key[0], tuple) or not isinstance(key[1], str):
raise TypeError("Invalid key type")
# Truncate complex numbers to real
if isinstance(coeff, Complex):
if abs(coeff.imag) > 1e-7:
raise TypeError(f"Invalid coeff for key {key}, coeff must be real.")
coeff = coeff.real
unique[key] += coeff
key_qubits.update(key[0])
if num_qubits is None:
num_qubits = 1 + max(key_qubits)
obs = cls(dict(unique), num_qubits=num_qubits)
return obs

raise TypeError(f"Invalid observable type: {type(observable)}")
Loading