Skip to content

Support SparseObservable to SparsePauliOp conversions #13758

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

Merged
merged 11 commits into from
Feb 5, 2025

Conversation

Cryoris
Copy link
Contributor

@Cryoris Cryoris commented Jan 30, 2025

Summary

Add SparseObservable.to_sparse_list to generate a sparse Pauli list representation, which is used to implement SparsePauliOp.from_sparse_observable. This method will also allow using SparseObservables in a PauliEvolutionGate (follow up PR).

Details and comments

In as many places as I could, I added a warning that this conversion will blow up if one attempts to convert a large number of projectors to a Pauli representation.

and SparseObservable.to_sparse_list, which can also be used for the PauliEvolutionGate compat
@Cryoris Cryoris added mod: quantum info Related to the Quantum Info module (States & Operators) Rust This PR or issue is related to Rust code in the repository labels Jan 30, 2025
@Cryoris Cryoris added this to the 2.0.0 milestone Jan 30, 2025
@Cryoris Cryoris requested a review from a team as a code owner January 30, 2025 12:56
@qiskit-bot
Copy link
Collaborator

One or more of the following people are relevant to this code:

  • @Qiskit/terra-core

@coveralls
Copy link

coveralls commented Jan 30, 2025

Pull Request Test Coverage Report for Build 13157527333

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 75 of 77 (97.4%) changed or added relevant lines in 2 files are covered.
  • 243 unchanged lines in 20 files lost coverage.
  • Overall coverage decreased (-0.1%) to 88.678%

Changes Missing Coverage Covered Lines Changed/Added Lines %
crates/accelerate/src/sparse_observable.rs 70 72 97.22%
Files with Coverage Reduction New Missed Lines %
crates/circuit/src/dag_circuit.rs 1 89.36%
crates/qasm2/src/lex.rs 1 92.73%
qiskit/providers/fake_provider/fake_backend.py 1 85.51%
qiskit/providers/basic_provider/basic_provider_tools.py 1 83.33%
crates/circuit/src/operations.rs 1 89.61%
qiskit/pulse/transforms/canonicalization.py 1 93.44%
qiskit/pulse/transforms/base_transforms.py 2 86.96%
qiskit/pulse/schedule.py 3 88.6%
qiskit/result/result.py 3 83.74%
crates/accelerate/src/sparse_observable.rs 3 94.46%
Totals Coverage Status
Change from base Build 13055798857: -0.1%
Covered Lines: 79040
Relevant Lines: 89131

💛 - Coveralls

Copy link
Member

@jakelishman jakelishman left a comment

Choose a reason for hiding this comment

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

Thanks for doing this - one main question about the exposed interfaces, and a couple of minor bits on testing.

- to_paulis -> SparseObservable
- test more edge cases
- don't guarantee any ordering
... which was somehow not captured by my pre commit hook
Copy link
Member

@jakelishman jakelishman left a comment

Choose a reason for hiding this comment

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

Thanks for doing this - it'll be a big help for tests, and it's good to add the conversion, even if it's expensive in some cases.

We need to add the new methods into sensible places into the class-level documentation - they're not linked at the moment.

Comment on lines 669 to 707
pub fn to_paulis(&self) -> Result<Self, CoherenceError> {
let mut paulis: Vec<BitTerm> = Vec::new(); // maybe get capacity here
let mut indices: Vec<u32> = Vec::new();
let mut coeffs: Vec<Complex64> = Vec::new();
let mut boundaries: Vec<usize> = vec![0];

for view in self.iter() {
let num_projectors = view
.bit_terms
.iter()
.filter(|&bit| bit.is_projector())
.count();
let div = 2_f64.powi(num_projectors as i32);

let combinations = view
.bit_terms
.iter()
.map(bit_term_as_pauli)
.multi_cartesian_product();

for combination in combinations {
let mut positive = true;

for (index, (sign, bit)) in combination.iter().enumerate() {
positive &= sign;
if let Some(bit) = bit {
paulis.push(*bit);
indices.push(view.indices[index]);
}
}
boundaries.push(paulis.len());

let coeff = if positive { view.coeff } else { -view.coeff };
coeffs.push(coeff / div)
}
}

SparseObservable::new(self.num_qubits, coeffs, paulis, indices, boundaries)
}
Copy link
Member

Choose a reason for hiding this comment

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

This function should never return a CoherenceError - the input SparseObservable is guaranteed valid, and all valid SparseObservables have a valid representation in the pure-Pauli-operator basis. We can construct the output SparseObservable directly using the record-struct constructor (SparseObservable {num_qubits: self.num_qubits, ..}) or using SparseObservable::new_unchecked with a mild preference on my side for the record-struct constructor.

Comment on lines 194 to 206
fn bit_term_as_pauli(bit: &BitTerm) -> Vec<(bool, Option<BitTerm>)> {
match bit {
BitTerm::X => vec![(true, Some(BitTerm::X))],
BitTerm::Y => vec![(true, Some(BitTerm::Y))],
BitTerm::Z => vec![(true, Some(BitTerm::Z))],
BitTerm::Plus => vec![(true, None), (true, Some(BitTerm::X))],
BitTerm::Minus => vec![(true, None), (false, Some(BitTerm::X))],
BitTerm::Left => vec![(true, None), (true, Some(BitTerm::Y))],
BitTerm::Right => vec![(true, None), (false, Some(BitTerm::Y))],
BitTerm::Zero => vec![(true, None), (true, Some(BitTerm::Z))],
BitTerm::One => vec![(true, None), (false, Some(BitTerm::Z))],
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Rather unimportant, but these could all return &'static [(bool, Option<BitTerm>)], rather than eagerly allocating a Vec.

///
/// This can be seen as counter-operation of :meth:`.SparseObservable.from_sparse_list`, however
/// the order of terms is not guaranteed to be the same at after a roundtrip to a sparse
/// list and back. Use :meth:`SparseObservable.simplify` to obtain a canonical representation.
Copy link
Member

Choose a reason for hiding this comment

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

I think this note about SparseObservable.simplify actually belongs in the to_paulis method above - the recipe for a completely canonical representation of a SparseObservable is obs.to_paulis().simplify(), nothing to do with to_sparse_list.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point, I'll add it to the as_paulis method. For to_sparse_list I also thought we didn't want to guarantee an order, so I left the comment that doing the roundtrip SparseObservable -> sparse list -> SparseObservable might not maintain the order of terms.

Comment on lines 2440 to 2459
// turn a 3-tuple of (bit terms, indices, coeff) into a Python tuple
let to_py_tuple = |bits: &[BitTerm], indices: &[u32], coeff: Complex64| {
let mut pauli_string = String::new();
for bit in bits {
pauli_string.push_str(bit.py_label());
}
let py_string = PyString::new(py, &pauli_string).unbind();
let py_indices = PyList::new(py, indices)?.unbind();
let py_coeff = coeff.into_py_any(py)?;

PyTuple::new(py, vec![py_string.as_any(), py_indices.as_any(), &py_coeff])
};

let sparse_list = inner
.iter()
.map(|view| to_py_tuple(view.bit_terms, view.indices, view.coeff))
.collect::<PyResult<Vec<_>>>()?;

let out = PyList::new(py, sparse_list)?;
Ok(out.unbind())
Copy link
Member

Choose a reason for hiding this comment

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

Two points here, both pretty minor:

  1. why deconstruct the SparseView before to_py_tuple and not just accept that in the closure?
  2. it'd probably be a bit more efficient to construct a let out = PyList::empty(py)?; and do for term in inner.iter(): out.append(to_py_tuple(term)?)?; than to collect into a Vec and then convert that into a list

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. that's legacy from when to_paulis returned the bits/indices/coeffs separately
  2. good idea, I was looking for a way to avoid this!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in 984a75c

Comment on lines +933 to +934
@staticmethod
def from_sparse_observable(obs: SparseObservable) -> SparsePauliOp:
Copy link
Member

Choose a reason for hiding this comment

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

Do you want to add a variant to the __init__ method that handles this case too? quantum_info types (for better or worse) check everything under the sun in their default constructors.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think we said we don't want to add this because it would give a very easy path to a potentially very expensive representation -- so it's after a "you-need-an-explicit-constructor"-wall 😄

Copy link
Member

Choose a reason for hiding this comment

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

Ok sure, that's valid too. I forgot we'd said that haha

Comment on lines 2442 to 2448
let mut pauli_string = String::new();
for bit in bits {
pauli_string.push_str(bit.py_label());
}
let py_string = PyString::new(py, &pauli_string).unbind();
let py_indices = PyList::new(py, indices)?.unbind();
let py_coeff = coeff.into_py_any(py)?;
Copy link
Member

Choose a reason for hiding this comment

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

What do you think about reversing the bit terms and the indices on output, so the Pauli string comes out in "reading" order?

Minor, but the necessary capacity of pauli_string is known immediately; all the py_label return values are (necessarily) a single ASCII letter, so pauli_string needs a capacity of bits.len(), and we could use String::with_capacity on it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good, that'd be a bit more intuitive 🙂 Also that'll verify that the tests are indeed order-independent 😉

Good call on the with_capacity 👍🏻

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in 984a75c

/// >>> obs = SparseObservable("+")
/// >>> obs.to_paulis()
/// <SparseObservable with 2 terms on 1 qubit: (0.5+0j)() + (0.5+0j)(X_0)>
fn to_paulis(&self) -> PyResult<Self> {
Copy link
Member

Choose a reason for hiding this comment

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

Very minor: the name to_paulis in the context of Qiskit makes me think it might come out related to the Pauli class. Perhaps as_paulis makes it clearer from reading that it remains a SparseObservable?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in 984a75c

Cryoris and others added 3 commits February 4, 2025 13:30
- use &'static over Vec
- reverse bit/index order
- rename as_paulis
- direct construciton w/o CoherenceError
Copy link
Member

@jakelishman jakelishman left a comment

Choose a reason for hiding this comment

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

Thanks for this. I tweaked the docs slightly to add a couple more examples, and modify the cross-links a little bit, but other than that it looks good to go to me.

@jakelishman jakelishman enabled auto-merge February 5, 2025 12:38
@jakelishman jakelishman added this pull request to the merge queue Feb 5, 2025
Merged via the queue into Qiskit:main with commit 6274843 Feb 5, 2025
17 checks passed
@Cryoris Cryoris deleted the observable-interconnect branch March 10, 2025 13:18
@ElePT ElePT added the Changelog: New Feature Include in the "Added" section of the changelog label Mar 31, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Changelog: New Feature Include in the "Added" section of the changelog mod: quantum info Related to the Quantum Info module (States & Operators) Rust This PR or issue is related to Rust code in the repository
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants