Skip to content

SparseObservable evolution #13836

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
Mar 5, 2025
353 changes: 259 additions & 94 deletions crates/accelerate/src/circuit_library/pauli_evolution.rs

Large diffs are not rendered by default.

8 changes: 2 additions & 6 deletions crates/accelerate/src/circuit_library/pauli_feature_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,17 +178,13 @@ fn _get_evolution_layer<'a>(
// to call CircuitData::from_packed_operations. This is needed since we might
// have to interject barriers, which are not a standard gate and prevents us
// from using CircuitData::from_standard_gates.
let evo = pauli_evolution::pauli_evolution(
let evo = pauli_evolution::sparse_term_evolution(
pauli,
indices.into_iter().rev().collect(),
multiply_param(&angle, alpha, py),
true,
false,
)
.map(|(gate, params, qargs)| {
(gate.into(), params, qargs.to_vec(), vec![] as Vec<Clbit>)
})
.collect::<Vec<Instruction>>();
);
insts.extend(evo);
}
}
Expand Down
21 changes: 21 additions & 0 deletions crates/accelerate/src/sparse_observable/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1836,6 +1836,10 @@ impl PySparseTerm {
Ok(obs.into())
}

fn to_label(&self) -> PyResult<String> {
Ok(self.inner.view().to_sparse_str())
}

fn __eq__(slf: Bound<Self>, other: Bound<PyAny>) -> PyResult<bool> {
if slf.is(&other) {
return Ok(true);
Expand Down Expand Up @@ -1954,6 +1958,23 @@ impl PySparseTerm {
.get_bound(py)
.call1(((PyArray1::from_vec(py, z), PyArray1::from_vec(py, x)),))
}

/// Return the bit labels of the term as string.
///
/// The bit labels will match the order of :attr:`.SparseTerm.indices`, such that the
/// i-th character in the string is applied to the qubit index at ``term.indices[i]``.
///
/// Returns:
/// The non-identity bit terms as concatenated string.
fn bit_labels<'py>(&self, py: Python<'py>) -> Bound<'py, PyString> {
let string: String = self
.inner
.bit_terms()
.iter()
.map(|bit| bit.py_label())
.collect();
PyString::new(py, string.as_str())
}
}

/// An observable over Pauli bases that stores its data in a qubit-sparse format.
Expand Down
67 changes: 45 additions & 22 deletions qiskit/circuit/library/pauli_evolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,23 @@
from qiskit.circuit.gate import Gate
from qiskit.circuit.quantumcircuit import ParameterValueType
from qiskit.circuit.parameterexpression import ParameterExpression
from qiskit.quantum_info import Pauli, SparsePauliOp
from qiskit.quantum_info import Pauli, SparsePauliOp, SparseObservable

if TYPE_CHECKING:
from qiskit.synthesis.evolution import EvolutionSynthesis

BIT_LABELS = {
0b0001: "Z",
0b1001: "0",
0b0101: "1",
0b0010: "X",
0b1010: "+",
0b0110: "-",
0b0011: "Y",
0b1011: "r",
0b0111: "l",
}


class PauliEvolutionGate(Gate):
r"""Time-evolution of an operator consisting of Paulis.
Expand Down Expand Up @@ -90,15 +102,19 @@ class PauliEvolutionGate(Gate):

def __init__(
self,
operator: Pauli | SparsePauliOp | list[Pauli | SparsePauliOp],
operator: (
Pauli
| SparsePauliOp
| SparseObservable
| list[Pauli | SparsePauliOp | SparseObservable]
),
time: ParameterValueType = 1.0,
label: str | None = None,
synthesis: EvolutionSynthesis | None = None,
) -> None:
"""
Args:
operator (Pauli | SparsePauliOp | list):
The operator to evolve. Can also be provided as list of non-commuting
operator: The operator to evolve. Can also be provided as list of non-commuting
operators where the elements are sums of commuting operators.
For example: ``[XY + YX, ZZ + ZI + IZ, YY]``.
time: The evolution time.
Expand All @@ -110,9 +126,9 @@ class docstring for an example.
product formula with a single repetition.
"""
if isinstance(operator, list):
operator = [_to_sparse_pauli_op(op) for op in operator]
operator = [_to_sparse_op(op) for op in operator]
else:
operator = _to_sparse_pauli_op(operator)
operator = _to_sparse_op(operator)

if label is None:
label = _get_default_label(operator)
Expand Down Expand Up @@ -159,32 +175,39 @@ def validate_parameter(self, parameter: ParameterValueType) -> ParameterValueTyp
return super().validate_parameter(parameter)


def _to_sparse_pauli_op(operator):
def _to_sparse_op(
operator: Pauli | SparsePauliOp | SparseObservable,
) -> SparsePauliOp | SparseObservable:
"""Cast the operator to a SparsePauliOp."""

if isinstance(operator, Pauli):
sparse_pauli = SparsePauliOp(operator)
elif isinstance(operator, SparsePauliOp):
sparse_pauli = operator
sparse = SparsePauliOp(operator)
elif isinstance(operator, (SparseObservable, SparsePauliOp)):
sparse = operator
else:
raise ValueError(f"Unsupported operator type for evolution: {type(operator)}.")

if any(np.iscomplex(sparse_pauli.coeffs)):
if any(np.iscomplex(sparse.coeffs)):
raise ValueError("Operator contains complex coefficients, which are not supported.")
if any(isinstance(coeff, ParameterExpression) for coeff in sparse_pauli.coeffs):
if any(isinstance(coeff, ParameterExpression) for coeff in sparse.coeffs):
raise ValueError("Operator contains ParameterExpression, which are not supported.")

return sparse_pauli
return sparse


def _operator_label(operator):
if isinstance(operator, SparseObservable):
if len(operator) == 1:
return operator[0].bit_labels()[::-1]
return "(" + " + ".join(term.bit_labels()[::-1] for term in operator) + ")"

# else: is a SparsePauliOp
if len(operator.paulis) == 1:
return operator.paulis.to_labels()[0]
return "(" + " + ".join(operator.paulis.to_labels()) + ")"


def _get_default_label(operator):
if isinstance(operator, list):
label = f"exp(-it ({[' + '.join(op.paulis.to_labels()) for op in operator]}))"
else:
if len(operator.paulis) == 1:
label = f"exp(-it {operator.paulis.to_labels()[0]})"
# for just a single Pauli don't add brackets around the sum
else:
label = f"exp(-it ({' + '.join(operator.paulis.to_labels())}))"

return label
return f"exp(-it ({[_operator_label(op) for op in operator]}))"
return f"exp(-it {_operator_label(operator)})"
10 changes: 9 additions & 1 deletion qiskit/synthesis/evolution/lie_trotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ def __init__(
) = None,
wrap: bool = False,
preserve_order: bool = True,
*,
atomic_evolution_sparse_observable: bool = False,
) -> None:
"""
r"""
Args:
reps: The number of time steps.
insert_barriers: Whether to insert barriers between the atomic evolutions.
Expand All @@ -78,6 +80,11 @@ def __init__(
preserve_order: If ``False``, allows reordering the terms of the operator to
potentially yield a shallower evolution circuit. Not relevant
when synthesizing operator with a single term.
atomic_evolution_sparse_observable: If a custom ``atomic_evolution`` is passed,
which does not yet support :class:`.SparseObservable`\ s as input, set this
argument to ``False`` to automatically apply a conversion to :class:`.SparsePauliOp`.
This argument is supported until Qiskit 2.2, at which point all atomic evolutions
are required to support :class:`.SparseObservable`\ s as input.
"""
super().__init__(
1,
Expand All @@ -87,6 +94,7 @@ def __init__(
atomic_evolution,
wrap,
preserve_order=preserve_order,
atomic_evolution_sparse_observable=atomic_evolution_sparse_observable,
)

@property
Expand Down
57 changes: 43 additions & 14 deletions qiskit/synthesis/evolution/product_formula.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from __future__ import annotations

import inspect
import warnings
import itertools
from collections.abc import Callable, Sequence
from collections import defaultdict
Expand All @@ -24,7 +24,7 @@
import rustworkx as rx
from qiskit.circuit.parameterexpression import ParameterExpression
from qiskit.circuit.quantumcircuit import QuantumCircuit, ParameterValueType
from qiskit.quantum_info import SparsePauliOp, Pauli
from qiskit.quantum_info import SparsePauliOp, Pauli, SparseObservable
from qiskit._accelerate.circuit_library import pauli_evolution

from .evolution_synthesis import EvolutionSynthesis
Expand Down Expand Up @@ -52,8 +52,10 @@ def __init__(
) = None,
wrap: bool = False,
preserve_order: bool = True,
*,
atomic_evolution_sparse_observable: bool = False,
) -> None:
"""
r"""
Args:
order: The order of the product formula.
reps: The number of time steps.
Expand All @@ -73,6 +75,11 @@ def __init__(
preserve_order: If ``False``, allows reordering the terms of the operator to
potentially yield a shallower evolution circuit. Not relevant
when synthesizing operator with a single term.
atomic_evolution_sparse_observable: If a custom ``atomic_evolution`` is passed,
which does not yet support :class:`.SparseObservable`\ s as input, set this
argument to ``False`` to automatically apply a conversion to :class:`.SparsePauliOp`.
This argument is supported until Qiskit 2.2, at which point all atomic evolutions
are required to support :class:`.SparseObservable`\ s as input.
"""
super().__init__()
self.order = order
Expand All @@ -92,17 +99,10 @@ def __init__(
# if atomic evolution is not provided, set a default
if atomic_evolution is None:
self.atomic_evolution = None

elif len(inspect.signature(atomic_evolution).parameters) == 2:

def wrap_atomic_evolution(output, operator, time):
definition = atomic_evolution(operator, time)
output.compose(definition, wrap=wrap, inplace=True)

self.atomic_evolution = wrap_atomic_evolution

else:
self.atomic_evolution = atomic_evolution
self.atomic_evolution = wrap_custom_atomic_evolution(
atomic_evolution, atomic_evolution_sparse_observable
)

def expand(
self, evolution: PauliEvolutionGate
Expand Down Expand Up @@ -183,7 +183,7 @@ def _custom_evolution(self, num_qubits, pauli_rotations):
for i, pauli_rotation in enumerate(pauli_rotations):
if self._atomic_evolution is not None:
# use the user-provided evolution with a global operator
operator = SparsePauliOp.from_sparse_list([pauli_rotation], num_qubits)
operator = SparseObservable.from_sparse_list([pauli_rotation], num_qubits)
self.atomic_evolution(circuit, operator, time=1) # time is inside the Pauli coeff

else: # this means self._wrap is True
Expand Down Expand Up @@ -282,3 +282,32 @@ def _term_sort_key(term: SparsePauliLabel) -> typing.Any:

terms = list(itertools.chain(*terms_by_color.values()))
return terms


def wrap_custom_atomic_evolution(atomic_evolution, support_sparse_observable):
r"""Wrap a custom atomic evolution into compatible format for the product formula.

This includes an inplace action, i.e. the signature is (circuit, operator, time) and
ensuring that ``SparseObservable``\ s are supported.
"""
# next, enable backward compatible use of atomic evolutions, that did not support
# SparseObservable inputs
if support_sparse_observable is False:
warnings.warn(
"The atomic_evolution should support SparseObservables as operator input. "
"Until Qiskit 2.2, an automatic conversion to SparsePauliOp is done, which can "
"be turned off by passing the argument atomic_evolution_sparse_observable=True.",
category=PendingDeprecationWarning,
stacklevel=2,
)

def sparseobs_atomic_evolution(output, operator, time):
if isinstance(operator, SparseObservable):
operator = SparsePauliOp.from_sparse_observable(operator)

atomic_evolution(output, operator, time)

else:
sparseobs_atomic_evolution = atomic_evolution

return sparseobs_atomic_evolution
16 changes: 15 additions & 1 deletion qiskit/synthesis/evolution/qdrift.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ def __init__(
seed: int | None = None,
wrap: bool = False,
preserve_order: bool = True,
*,
atomic_evolution_sparse_observable: bool = False,
) -> None:
r"""
Args:
Expand All @@ -70,9 +72,21 @@ def __init__(
preserve_order: If ``False``, allows reordering the terms of the operator to
potentially yield a shallower evolution circuit. Not relevant
when synthesizing operator with a single term.
atomic_evolution_sparse_observable: If a custom ``atomic_evolution`` is passed,
which does not yet support :class:`.SparseObservable`\ s as input, set this
argument to ``False`` to automatically apply a conversion to :class:`.SparsePauliOp`.
This argument is supported until Qiskit 2.2, at which point all atomic evolutions
are required to support :class:`.SparseObservable`\ s as input.
"""
super().__init__(
1, reps, insert_barriers, cx_structure, atomic_evolution, wrap, preserve_order
1,
reps,
insert_barriers,
cx_structure,
atomic_evolution,
wrap,
preserve_order,
atomic_evolution_sparse_observable=atomic_evolution_sparse_observable,
)
self.sampled_ops = None
self.rng = np.random.default_rng(seed)
Expand Down
Loading
Loading