Skip to content
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
36 changes: 26 additions & 10 deletions qiskit/primitives/containers/bit_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,38 +610,54 @@ def postselect(
num_bits=self.num_bits,
)

def expectation_values(self, observables: ObservablesArrayLike) -> NDArray[np.float64]:
def expectation_values(
self,
observables: ObservablesArrayLike,
allow_non_hermitian: bool = False,
) -> NDArray[np.float64] | NDArray[np.complex128]:
"""Compute the expectation values of the provided observables, broadcasted against
this bit array.

.. note::

This method returns the real part of the expectation value even if
the operator has complex coefficients due to the specification of
:func:`~.sampled_expectation_value`.

Args:
observables: The observable(s) to take the expectation value of.
Must have a shape broadcastable with this bit array and
the same number of qubits as the number of bits of this bit array.
The observables must be diagonal (I, Z, 0 or 1) too.
allow_non_hermitian: If True, non-Hermitian observables with complex
coefficients are accepted. Requires
``ObservablesArray(obs, validate=False)`` to bypass upstream
Hermiticity enforcement. Default: False.

Returns:
An array of expectation values whose shape is the broadcast shape of ``observables``
and this bit array.
and this bit array. The dtype is ``float64``; when ``allow_non_hermitian=True``
and the input has complex-valued coefficients, ``complex128``
(collapsed to ``float64`` by ``np.real_if_close`` when all
imaginary parts are negligible).

Raises:
ValueError: If the provided observables does not have a shape broadcastable with
this bit array.
ValueError: If the provided observables does not have the same number of qubits as
the number of bits of this bit array.
ValueError: If the provided observables are not diagonal.
ValueError: If ``allow_non_hermitian=False`` and the input is non-Hermitian.
"""
observables = ObservablesArray.coerce(observables)
arr_indices = np.fromiter(np.ndindex(self.shape), dtype=object).reshape(self.shape)
bc_indices, bc_obs = np.broadcast_arrays(arr_indices, observables)
counts = {}
arr = np.zeros_like(bc_indices, dtype=float)
arr = np.zeros_like(bc_indices, dtype=complex)

if allow_non_hermitian:
# Rebuild dicts from raw SparseObservable to keep complex coeffs.
# Copy bc_obs since broadcast_arrays returns read-only views.
bc_obs = bc_obs.copy()
raw_obs = observables.sparse_observables_array() # type: ignore[union-attr]
bc_raw = np.broadcast_to(raw_obs, bc_obs.shape)
for ndi in np.ndindex(bc_obs.shape):
bc_obs[ndi] = ObservablesArray._obs_to_complex_dict(bc_raw[ndi])

for index in np.ndindex(bc_indices.shape):
loc = bc_indices[index]
for pauli, coeff in bc_obs[index].items():
Expand All @@ -652,7 +668,7 @@ def expectation_values(self, observables: ObservablesArrayLike) -> NDArray[np.fl
except QiskitError as ex:
raise ValueError(ex.message) from ex
arr[index] += expval * coeff
return arr
return np.real_if_close(arr)

@staticmethod
def concatenate(bit_arrays: Sequence[BitArray], axis: int = 0) -> BitArray:
Expand Down
22 changes: 21 additions & 1 deletion qiskit/primitives/containers/observables_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"""
ND-Array container class for Estimator observables.
"""

from __future__ import annotations

from copy import deepcopy
Expand All @@ -29,7 +30,6 @@
from .object_array import object_array
from .shape import ShapedMixin, shape_tuple


if TYPE_CHECKING:
from qiskit.transpiler.layout import TranspileLayout

Expand Down Expand Up @@ -124,6 +124,26 @@ def _obs_to_dict(obs: SparseObservable) -> Mapping[str, float]:

return result

@staticmethod
def _obs_to_complex_dict(obs: SparseObservable) -> Mapping[str, complex]:
"""Convert a sparse observable to a mapping from Pauli strings to
coefficients, preserving complex values (no ``np.real()`` strip)."""
result = {}
for sparse_pauli_str, pauli_qubits, coeff in obs.to_sparse_list():
if len(sparse_pauli_str) == 0:
full_pauli_str = "I" * obs.num_qubits
else:
sorted_lists = sorted(zip(pauli_qubits, sparse_pauli_str))
string_fragments = []
prev_qubit = -1
for qubit, pauli in sorted_lists:
string_fragments.append("I" * (qubit - prev_qubit - 1) + pauli)
prev_qubit = qubit
string_fragments.append("I" * (obs.num_qubits - max(pauli_qubits) - 1))
full_pauli_str = "".join(string_fragments)[::-1]
result[full_pauli_str] = coeff # keep complex!
return result

def __repr__(self):
prefix = f"{type(self).__name__}("
suffix = f", shape={self.shape})"
Expand Down
10 changes: 10 additions & 0 deletions releasenotes/notes/allow-non-hermitian-expectation-values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
features:
- |
Added ``allow_non_hermitian`` parameter to
:meth:`.BitArray.expectation_values`. When ``True``, non-Hermitian
observables with complex coefficients are accepted and the method
returns ``complex128`` arrays when results have non-zero imaginary
parts (may remain ``float64`` via ``np.real_if_close``). Requires both
``ObservablesArray(obs, validate=False)`` (to bypass Hermiticity
enforcement) and ``allow_non_hermitian=True`` for complex return.
96 changes: 95 additions & 1 deletion test/python/primitives/containers/test_bit_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import ddt
import numpy as np

from qiskit.primitives.containers import BitArray
from qiskit.primitives.containers import BitArray, ObservablesArray
from qiskit.quantum_info import Pauli, SparsePauliOp
from qiskit.result import Counts

Expand Down Expand Up @@ -774,6 +774,100 @@ def test_expectation_values(self):
with self.assertRaisesRegex(ValueError, "is not diagonal"):
_ = ba.expectation_values("X" * ba.num_bits)

def test_expectation_values_dtype(self):
"""expectation_values returns float64 (ObservablesArray enforces
Hermiticity, so results are always real; np.real_if_close handles
SparsePauliOp's internal complex storage)."""
ba = BitArray.from_counts([{0: 1}, {1: 1}]).reshape(2, 1)
result = ba.expectation_values("Z")
self.assertEqual(result.dtype, np.float64)

def test_expectation_values_complex_return(self):
"""expectation_values with allow_non_hermitian=True can return
complex128 when the observable has complex coefficients.
Z|0⟩ = +1|0⟩, Z|1⟩ = -1|1⟩, coeff = -1j
→ |0⟩: (+1) × (-1j) = -1j
→ |1⟩: (-1) × (-1j) = +1j"""
sp = SparsePauliOp.from_list([["Z", -1j]])
obs = ObservablesArray(sp, validate=False)
ba = BitArray.from_counts([{0: 1}, {1: 1}]).reshape(2, 1)
result = ba.expectation_values(obs, allow_non_hermitian=True)
self.assertEqual(result.dtype, np.complex128)
self.assertTrue(np.iscomplexobj(result))
self.assertIsInstance(result.flat[0], np.complex128)
np.testing.assert_array_almost_equal(result, [[-1j], [1j]])

def test_expectation_values_sparse_observable_complex(self):
"""SparseObservable with complex coefficients works through
expectation_values when allow_non_hermitian=True."""
from qiskit.quantum_info import SparseObservable

so = SparseObservable.from_list([("Z", -1j)])
obs = ObservablesArray(so, validate=False)
ba = BitArray.from_counts([{0: 1}, {1: 1}]).reshape(2, 1)
result = ba.expectation_values(obs, allow_non_hermitian=True)
self.assertEqual(result.dtype, np.complex128)
np.testing.assert_array_almost_equal(result, [[-1j], [1j]])

def test_expectation_values_hermitian_with_allow(self):
"""allow_non_hermitian=True with Hermitian (real) input still
returns float64 — np.real_if_close collapses zero imaginary."""
ba = BitArray.from_counts([{0: 1}, {1: 1}]).reshape(2, 1)
result = ba.expectation_values("Z", allow_non_hermitian=True)
self.assertEqual(result.dtype, np.float64)

def test_expectation_values_bypass_without_allow(self):
"""validate=False + allow_non_hermitian=False: complex coeffs
get silently stripped by _obs_to_dict (no complex rebuild)."""
sp = SparsePauliOp.from_list([["Z", -1j]])
obs = ObservablesArray(sp, validate=False)
ba = BitArray.from_counts([{0: 1}, {1: 1}]).reshape(2, 1)
result = ba.expectation_values(obs)
self.assertEqual(result.dtype, np.float64)

def test_expectation_values_validate_true_with_allow(self):
"""validate=True + allow_non_hermitian=True: coerce() blocks
non-Hermitian before the flag takes effect."""
sp = SparsePauliOp.from_list([["Z", -1j]])
with self.assertRaises(ValueError):
# ObservablesArray with validate=True (default) blocks non-Hermitian
ba = BitArray.from_counts([{0: 1}])
ba.expectation_values(sp, allow_non_hermitian=True)

def test_expectation_values_complex_edge_cases(self):
"""Complex edge cases: multi-qubit, mixed real/complex, large imag.
Uses subTest so all cases run even if one fails."""
test_cases = {
"multi_qubit_ZZ": {
"sp": SparsePauliOp.from_list([["ZZ", 3 + 2j]]),
"ba": BitArray.from_samples(["11"], num_bits=2),
"dtype": np.complex128,
"check": lambda r: np.isclose(r.flat[0], 3 + 2j),
},
"mixed_real_complex": {
"sp": [
SparsePauliOp.from_list([["Z", 1.0]]),
SparsePauliOp.from_list([["Z", -1j]]),
],
"ba": BitArray.from_counts([{0: 1}, {1: 1}]).reshape(2, 1),
"dtype": np.complex128,
"check": lambda r: r.shape == (2, 2) and np.isclose(r[0, 1], -1j),
},
"large_imag": {
"sp": SparsePauliOp.from_list([["Z", 999j]]),
"ba": BitArray.from_counts([{0: 1}, {1: 1}]).reshape(2, 1),
"dtype": np.complex128,
"check": lambda r: np.isclose(r.flat[0], 999j),
},
}
for name, case in test_cases.items():
with self.subTest(name):
sp = case["sp"]
obs = ObservablesArray(sp, validate=False)
result = case["ba"].expectation_values(obs, allow_non_hermitian=True)
self.assertEqual(result.dtype, case["dtype"])
self.assertTrue(case["check"](result))

def test_postselection(self):
"""Test the postselection method."""

Expand Down
17 changes: 17 additions & 0 deletions test/python/primitives/containers/test_observables_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,20 @@ def test_invalid_basis_type_raises_type_error(self):
invalid_basis = {1: "value", 2: "another_value"} # Invalid keys (integers)
with self.assertRaises(TypeError):
ObservablesArray.coerce_observable(invalid_basis)

def test_obs_to_complex_dict_preserves_complex(self):
"""_obs_to_complex_dict keeps complex coefficients;
_obs_to_dict strips them."""
obs = qi.SparseObservable.from_list([("Z", 1j), ("X", 2 + 3j), ("Y", -0.5)])
real_dict = ObservablesArray._obs_to_dict(obs)
complex_dict = ObservablesArray._obs_to_complex_dict(obs)
# _obs_to_dict strips imaginary
self.assertEqual(real_dict["Z"], 0.0)
self.assertEqual(real_dict["X"], 2.0)
self.assertEqual(real_dict["Y"], -0.5)
# _obs_to_complex_dict preserves
self.assertEqual(complex_dict["Z"], 1j)
self.assertEqual(complex_dict["X"], 2 + 3j)
self.assertEqual(complex_dict["Y"], -0.5 + 0j)
# Same keys
self.assertEqual(set(real_dict.keys()), set(complex_dict.keys()))