Skip to content

Commit 6274843

Browse files
Cryorisjakelishman
andauthored
Support SparseObservable to SparsePauliOp conversions (#13758)
* SparseObservable -> SparsePauliOp and SparseObservable.to_sparse_list, which can also be used for the PauliEvolutionGate compat * reno * allow observable alphabet as well * Jake's review comments - to_paulis -> SparseObservable - test more edge cases - don't guarantee any ordering * update reno * fix ruff ... which was somehow not captured by my pre commit hook * round 2 - use &'static over Vec - reverse bit/index order - rename as_paulis - direct construciton w/o CoherenceError * fix tests and improve docs * Update documentation --------- Co-authored-by: Jake Lishman <jake.lishman@ibm.com>
1 parent 1ef0d79 commit 6274843

5 files changed

Lines changed: 407 additions & 8 deletions

File tree

crates/accelerate/src/sparse_observable.rs

Lines changed: 186 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
// that they have been altered from the originals.
1212

1313
use hashbrown::HashSet;
14+
use itertools::Itertools;
1415
use ndarray::Array2;
1516
use num_complex::Complex64;
1617
use num_traits::Zero;
@@ -23,7 +24,7 @@ use pyo3::{
2324
intern,
2425
prelude::*,
2526
sync::GILOnceCell,
26-
types::{IntoPyDict, PyList, PyTuple, PyType},
27+
types::{IntoPyDict, PyList, PyString, PyTuple, PyType},
2728
IntoPyObjectExt, PyErr,
2829
};
2930
use std::{
@@ -184,6 +185,24 @@ impl BitTerm {
184185
pub fn has_z_component(&self) -> bool {
185186
((*self as u8) & (Self::Z as u8)) != 0
186187
}
188+
189+
pub fn is_projector(&self) -> bool {
190+
!matches!(self, BitTerm::X | BitTerm::Y | BitTerm::Z)
191+
}
192+
}
193+
194+
fn bit_term_as_pauli(bit: &BitTerm) -> &'static [(bool, Option<BitTerm>)] {
195+
match bit {
196+
BitTerm::X => &[(true, Some(BitTerm::X))],
197+
BitTerm::Y => &[(true, Some(BitTerm::Y))],
198+
BitTerm::Z => &[(true, Some(BitTerm::Z))],
199+
BitTerm::Plus => &[(true, None), (true, Some(BitTerm::X))],
200+
BitTerm::Minus => &[(true, None), (false, Some(BitTerm::X))],
201+
BitTerm::Left => &[(true, None), (true, Some(BitTerm::Y))],
202+
BitTerm::Right => &[(true, None), (false, Some(BitTerm::Y))],
203+
BitTerm::Zero => &[(true, None), (true, Some(BitTerm::Z))],
204+
BitTerm::One => &[(true, None), (false, Some(BitTerm::Z))],
205+
}
187206
}
188207

189208
/// The error type for a failed conversion into `BitTerm`.
@@ -641,6 +660,58 @@ impl SparseObservable {
641660
}
642661
}
643662

663+
/// Expand all projectors into Pauli representation.
664+
///
665+
/// # Warning
666+
///
667+
/// This representation is highly inefficient for projectors. For example, a term with
668+
/// :math:`n` projectors :math:`|+\rangle\langle +|` will use :math:`2^n` Pauli terms.
669+
pub fn as_paulis(&self) -> Self {
670+
let mut paulis: Vec<BitTerm> = Vec::new(); // maybe get capacity here
671+
let mut indices: Vec<u32> = Vec::new();
672+
let mut coeffs: Vec<Complex64> = Vec::new();
673+
let mut boundaries: Vec<usize> = vec![0];
674+
675+
for view in self.iter() {
676+
let num_projectors = view
677+
.bit_terms
678+
.iter()
679+
.filter(|&bit| bit.is_projector())
680+
.count();
681+
let div = 2_f64.powi(num_projectors as i32);
682+
683+
let combinations = view
684+
.bit_terms
685+
.iter()
686+
.map(bit_term_as_pauli)
687+
.multi_cartesian_product();
688+
689+
for combination in combinations {
690+
let mut positive = true;
691+
692+
for (index, (sign, bit)) in combination.iter().enumerate() {
693+
positive &= sign;
694+
if let Some(bit) = bit {
695+
paulis.push(*bit);
696+
indices.push(view.indices[index]);
697+
}
698+
}
699+
boundaries.push(paulis.len());
700+
701+
let coeff = if positive { view.coeff } else { -view.coeff };
702+
coeffs.push(coeff / div)
703+
}
704+
}
705+
706+
Self {
707+
num_qubits: self.num_qubits,
708+
coeffs,
709+
bit_terms: paulis,
710+
indices,
711+
boundaries,
712+
}
713+
}
714+
644715
/// Add the term implied by a dense string label onto this observable.
645716
pub fn add_dense_label<L: AsRef<[u8]>>(
646717
&mut self,
@@ -1741,10 +1812,11 @@ impl PySparseTerm {
17411812
///
17421813
/// .. note::
17431814
///
1744-
/// The canonical form produced by :meth:`simplify` will still not universally detect all
1745-
/// observables that are equivalent due to the over-complete basis alphabet; it is not
1746-
/// computationally feasible to do this at scale. For example, on observable built from ``+``
1747-
/// and ``-`` components will not canonicalize to a single ``X`` term.
1815+
/// The canonical form produced by :meth:`simplify` alone will not universally detect all
1816+
/// observables that are equivalent due to the over-complete basis alphabet. To obtain a
1817+
/// unique expression, you can first represent the observable using Pauli terms only by
1818+
/// calling :meth:`as_paulis`, followed by :meth:`simplify`. Note that the projector
1819+
/// expansion (e.g. ``+`` into ``I`` and ``X``) is not computationally feasible at scale.
17481820
///
17491821
/// Indexing
17501822
/// --------
@@ -1824,6 +1896,29 @@ impl PySparseTerm {
18241896
/// :meth:`identity` The identity operator on a given number of qubits.
18251897
/// ============================ ================================================================
18261898
///
1899+
/// Conversions
1900+
/// ===========
1901+
///
1902+
/// An existing :class:`SparseObservable` can be converted into other :mod:`~qiskit.quantum_info`
1903+
/// operators or generic formats. Beware that other objects may not be able to represent the same
1904+
/// observable as efficiently as :class:`SparseObservable`, including potentially needed
1905+
/// exponentially more memory.
1906+
///
1907+
/// .. table:: Conversion methods to other observable forms.
1908+
///
1909+
/// =========================== =================================================================
1910+
/// Method Summary
1911+
/// =========================== =================================================================
1912+
/// :meth:`as_paulis` Create a new :class:`SparseObservable`, expanding in terms
1913+
/// of Pauli operators only.
1914+
///
1915+
/// :meth:`to_sparse_list` Express the observable in a sparse list format with elements
1916+
/// ``(bit_terms, indices, coeff)``.
1917+
/// =========================== =================================================================
1918+
///
1919+
/// In addition, :meth:`.SparsePauliOp.from_sparse_observable` is available for conversion from this
1920+
/// class to :class:`.SparsePauliOp`. Beware that this method suffers from the same
1921+
/// exponential-memory usage concerns as :meth:`as_paulis`.
18271922
///
18281923
/// Mathematical manipulation
18291924
/// =========================
@@ -1925,8 +2020,8 @@ impl PySparseObservable {
19252020
let inner = borrowed.inner.read().map_err(|_| InnerReadError)?;
19262021
return Ok(inner.clone().into());
19272022
}
1928-
// The type of `vec` is inferred from the subsequent calls to `Self::py_from_list` or
1929-
// `Self::py_from_sparse_list` to be either the two-tuple or the three-tuple form during the
2023+
// The type of `vec` is inferred from the subsequent calls to `Self::from_list` or
2024+
// `Self::from_sparse_list` to be either the two-tuple or the three-tuple form during the
19302025
// `extract`. The empty list will pass either, but it means the same to both functions.
19312026
if let Ok(vec) = data.extract() {
19322027
return Self::from_list(vec, num_qubits);
@@ -2289,6 +2384,10 @@ impl PySparseObservable {
22892384
/// ... for label, coeff in zip(labels, coeffs)
22902385
/// ... ])
22912386
/// >>> assert from_list == from_sparse_list
2387+
///
2388+
/// See also:
2389+
/// :meth:`to_sparse_list`
2390+
/// The reverse of this method.
22922391
#[staticmethod]
22932392
#[pyo3(signature = (iter, /, num_qubits))]
22942393
fn from_sparse_list(
@@ -2337,6 +2436,86 @@ impl PySparseObservable {
23372436
Ok(inner.into())
23382437
}
23392438

2439+
/// Express the observable in Pauli terms only, by writing each projector as sum of Pauli terms.
2440+
///
2441+
/// Note that there is no guarantee of the order the resulting Pauli terms. Use
2442+
/// :meth:`SparseObservable.simplify` in addition to obtain a canonical representation.
2443+
///
2444+
/// .. warning::
2445+
///
2446+
/// Beware that this will use at least :math:`2^n` terms if there are :math:`n`
2447+
/// single-qubit projectors present, which can lead to an exponential number of terms.
2448+
///
2449+
/// Returns:
2450+
/// The same observable, but expressed in Pauli terms only.
2451+
///
2452+
/// Examples:
2453+
///
2454+
/// Rewrite an observable in terms of projectors into Pauli operators::
2455+
///
2456+
/// >>> obs = SparseObservable("+")
2457+
/// >>> obs.as_paulis()
2458+
/// <SparseObservable with 2 terms on 1 qubit: (0.5+0j)() + (0.5+0j)(X_0)>
2459+
/// >>> direct = SparseObservable.from_list([("I", 0.5), ("Z", 0.5)])
2460+
/// >>> assert direct.simplify() == obs.as_paulis().simplify()
2461+
///
2462+
/// For small operators, this can be used with :meth:`simplify` as a unique canonical form::
2463+
///
2464+
/// >>> left = SparseObservable.from_list([("+", 0.5), ("-", 0.5)])
2465+
/// >>> right = SparseObservable.from_list([("r", 0.5), ("l", 0.5)])
2466+
/// >>> assert left.as_paulis().simplify() == right.as_paulis().simplify()
2467+
///
2468+
/// See also:
2469+
/// :meth:`.SparsePauliOp.from_sparse_observable`
2470+
/// A constructor of :class:`.SparsePauliOp` that can convert a
2471+
/// :class:`SparseObservable` in the :class:`.SparsePauliOp` dense Pauli representation.
2472+
fn as_paulis(&self) -> PyResult<Self> {
2473+
let inner = self.inner.read().map_err(|_| InnerReadError)?;
2474+
Ok(inner.as_paulis().into())
2475+
}
2476+
2477+
/// Express the observable in terms of a sparse list format.
2478+
///
2479+
/// This can be seen as counter-operation of :meth:`.SparseObservable.from_sparse_list`, however
2480+
/// the order of terms is not guaranteed to be the same at after a roundtrip to a sparse
2481+
/// list and back.
2482+
///
2483+
/// Examples:
2484+
///
2485+
/// >>> obs = SparseObservable.from_list([("IIXIZ", 2j), ("IIZIX", 2j)])
2486+
/// >>> reconstructed = SparseObservable.from_sparse_list(obs.to_sparse_list(), obs.num_qubits)
2487+
///
2488+
/// See also:
2489+
/// :meth:`from_sparse_list`
2490+
/// The constructor that can interpret these lists.
2491+
#[pyo3(signature = ())]
2492+
fn to_sparse_list(&self, py: Python) -> PyResult<Py<PyList>> {
2493+
let inner = self.inner.read().map_err(|_| InnerReadError)?;
2494+
2495+
// turn a SparseView into a Python tuple of (bit terms, indices, coeff)
2496+
let to_py_tuple = |view: SparseTermView| {
2497+
let mut pauli_string = String::with_capacity(view.bit_terms.len());
2498+
2499+
// we reverse the order of bits and indices so the Pauli string comes out in
2500+
// "reading order", consistent with how one would write the label in
2501+
// SparseObservable.from_list or .from_label
2502+
for bit in view.bit_terms.iter().rev() {
2503+
pauli_string.push_str(bit.py_label());
2504+
}
2505+
let py_string = PyString::new(py, &pauli_string).unbind();
2506+
let py_indices = PyList::new(py, view.indices.iter().rev())?.unbind();
2507+
let py_coeff = view.coeff.into_py_any(py)?;
2508+
2509+
PyTuple::new(py, vec![py_string.as_any(), py_indices.as_any(), &py_coeff])
2510+
};
2511+
2512+
let out = PyList::empty(py);
2513+
for view in inner.iter() {
2514+
out.append(to_py_tuple(view)?)?;
2515+
}
2516+
Ok(out.unbind())
2517+
}
2518+
23402519
/// Construct a :class:`.SparseObservable` from a :class:`.SparsePauliOp` instance.
23412520
///
23422521
/// This will be a largely direct translation of the :class:`.SparsePauliOp`; in particular,

qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
to_matrix_sparse,
3131
unordered_unique,
3232
)
33+
from qiskit._accelerate.sparse_observable import SparseObservable
3334
from qiskit.circuit.parameter import Parameter
3435
from qiskit.circuit.parameterexpression import ParameterExpression
3536
from qiskit.circuit.parametertable import ParameterView
@@ -929,6 +930,27 @@ def from_sparse_list(
929930
paulis = PauliList(labels)
930931
return SparsePauliOp(paulis, coeffs, copy=False)
931932

933+
@staticmethod
934+
def from_sparse_observable(obs: SparseObservable) -> SparsePauliOp:
935+
r"""Initialize from a :class:`.SparseObservable`.
936+
937+
.. warning::
938+
939+
A :class:`.SparseObservable` can efficiently represent eigenstate projectors
940+
(such as :math:`|0\langle\rangle 0|`), but a :class:`.SparsePauliOp` **cannot**.
941+
If the input ``obs`` has :math:`n` single-qubit projectors, the resulting
942+
:class:`.SparsePauliOp` will use :math:`2^n` terms, which is an exponentially
943+
expensive representation that can quickly run out of memory.
944+
945+
Args:
946+
obs: The :class:`.SparseObservable` to convert.
947+
948+
Returns:
949+
A :class:`.SparsePauliOp` version of the observable.
950+
"""
951+
as_sparse_list = obs.as_paulis().to_sparse_list()
952+
return SparsePauliOp.from_sparse_list(as_sparse_list, obs.num_qubits)
953+
932954
def to_list(self, array: bool = False):
933955
"""Convert to a list Pauli string labels and coefficients.
934956
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
features_quantum_info:
3+
- |
4+
Added :meth:`.SparseObservable.to_sparse_list` to obtain a sparse list representation
5+
of a :class:`.SparseObservable`. For example::
6+
7+
from qiskit.quantum_info import SparseObservable
8+
9+
obs = SparseObservable.from_list([("+II", 1), ("-II", 1)])
10+
print(obs.to_sparse_list()) # [("+", [2], 1), ("-", [2], 1)]
11+
12+
- |
13+
Added :meth:`.SparseObservable.as_paulis` to express a sparse observable in terms of Paulis
14+
only by expanding all projectors. For example::
15+
16+
from qiskit.quantum_info import SparseObservable
17+
18+
obs = SparseObservable("+-")
19+
obs_paulis = obs.as_paulis() # 1/4 ( II + XI - IX - XX )
20+
21+
- |
22+
Support construction of a :class:`.SparsePauliOp` from a :class:`.SparseObservable`
23+
via the new method :class:`.SparsePauliOp.from_sparse_observable`. It is important
24+
to remember that :class:`.SparseObservable`\ s can efficiently represent projectors,
25+
which require an exponential number of terms in the :class:`.SparsePauliOp`.

test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@
2828
from qiskit.compiler.transpiler import transpile
2929
from qiskit.primitives import BackendEstimator
3030
from qiskit.providers.fake_provider import GenericBackendV2
31-
from qiskit.quantum_info.operators import Operator, Pauli, PauliList, SparsePauliOp
31+
from qiskit.quantum_info import SparseObservable
32+
from qiskit.quantum_info.operators import (
33+
Operator,
34+
Pauli,
35+
PauliList,
36+
SparsePauliOp,
37+
)
3238
from qiskit.utils import optionals
3339

3440

@@ -361,6 +367,66 @@ def test_to_list_parameters(self):
361367
target = list(zip(labels, coeffs))
362368
self.assertEqual(op.to_list(), target)
363369

370+
def test_from_sparse_observable(self):
371+
"""Test from a SparseObservable."""
372+
with self.subTest("zero(0)"):
373+
obs = SparseObservable.zero(0)
374+
expected = SparsePauliOp([""], coeffs=[0])
375+
self.assertEqual(expected, SparsePauliOp.from_sparse_observable(obs))
376+
377+
with self.subTest("identity(0)"):
378+
obs = SparseObservable.identity(0)
379+
expected = SparsePauliOp([""], coeffs=[1])
380+
self.assertEqual(expected, SparsePauliOp.from_sparse_observable(obs))
381+
382+
with self.subTest("zero(10)"):
383+
obs = SparseObservable.zero(10)
384+
expected = SparsePauliOp(["I" * 10], coeffs=[0])
385+
self.assertEqual(expected, SparsePauliOp.from_sparse_observable(obs))
386+
387+
with self.subTest("identity(10)"):
388+
obs = SparseObservable.identity(10)
389+
expected = SparsePauliOp(["I" * 10], coeffs=[1])
390+
self.assertEqual(expected, SparsePauliOp.from_sparse_observable(obs))
391+
392+
with self.subTest("XrZ"):
393+
obs = SparseObservable("XrZ")
394+
spo = SparsePauliOp.from_sparse_observable(obs)
395+
expected = SparsePauliOp(["XIZ", "XYZ"], coeffs=[0.5, -0.5])
396+
397+
# we don't guarantee the order of Paulis, so check equality by comparing
398+
# the matrix representation and that all Pauli strings are present
399+
self.assertEqual(Operator(expected), Operator(spo))
400+
self.assertTrue(set(spo.paulis.to_labels()) == set(expected.paulis.to_labels()))
401+
402+
def test_sparse_observable_roundtrip(self):
403+
"""Test SPO -> OBS -> SPO."""
404+
with self.subTest(msg="empty"):
405+
op = SparsePauliOp([""], coeffs=[1])
406+
obs = SparseObservable.from_sparse_pauli_op(op)
407+
roundtrip = SparsePauliOp.from_sparse_observable(obs)
408+
self.assertEqual(op, roundtrip)
409+
410+
with self.subTest(msg="zero"):
411+
op = SparsePauliOp(["I"], coeffs=[0])
412+
obs = SparseObservable.from_sparse_pauli_op(op)
413+
roundtrip = SparsePauliOp.from_sparse_observable(obs)
414+
self.assertEqual(op, roundtrip)
415+
416+
with self.subTest(msg="identity"):
417+
op = SparsePauliOp(["I" * 25])
418+
obs = SparseObservable.from_sparse_pauli_op(op)
419+
roundtrip = SparsePauliOp.from_sparse_observable(obs)
420+
self.assertEqual(op, roundtrip)
421+
422+
with self.subTest(msg="ising like"):
423+
op = SparsePauliOp(["ZZI", "IZZ", "IIX", "IXI", "YII"])
424+
obs = SparseObservable.from_sparse_pauli_op(op)
425+
roundtrip = SparsePauliOp.from_sparse_observable(obs)
426+
427+
self.assertEqual(Operator(op), Operator(roundtrip))
428+
self.assertTrue(set(op.paulis.to_labels()) == set(roundtrip.paulis.to_labels()))
429+
364430

365431
class TestSparsePauliOpIteration(QiskitTestCase):
366432
"""Tests for SparsePauliOp iterators class."""

0 commit comments

Comments
 (0)