qml.transforms.split_non_commuting

split_non_commuting(tape, grouping_strategy='default', shot_dist=None, seed=None)[source]

Splits a circuit into tapes measuring groups of commuting observables.

Parameters:
  • tape (QNode or QuantumScript or Callable) – The quantum circuit to be split.

  • grouping_strategy (str) – The strategy to use for computing disjoint groups of commuting observables, can be "default", "wires", "qwc", or None to disable grouping.

  • shot_dist (str or Callable or None) – The strategy to use for shot distribution over the disjoint groups of commuting observables. Values can be "uniform" (evenly distributes the number of shots across all groups of commuting terms), "weighted" (distributes the number of shots according to weights proportional to the L1 norm of the coefficients in each group), "weighted_random" (same as "weighted", but the numbers of shots are sampled from a multinomial distribution) or a custom callable. None will disable any shot distribution strategy. See Usage Details for more information.

  • seed (Generator or int or None) – A seed-like parameter used only when the shot distribution strategy involves a non-deterministic sampling process (e.g. "weighted_random").

Returns:

the transformed circuit as described in qml.transform.

Return type:

qnode (QNode) or quantum function (Callable) or tuple[List[QuantumScript], function]

Raises:
  • TypeError – if shot_dist is not a str or Callable or None.

  • ValueError – if shot_dist is a str but not an available strategy.

Note

This transform splits expectation values of sums into separate terms, and also distributes the terms into multiple executions if there are terms that do not commute with one another. For state-based simulators that are able to handle non-commuting measurements in a single execution, but don’t natively support sums of observables, consider split_to_single_terms instead.

Examples:

This transform allows us to transform a QNode measuring multiple observables into multiple circuit executions, each measuring a group of commuting observables.

dev = qml.device("default.qubit", wires=2)

@qml.transforms.split_non_commuting
@qml.qnode(dev)
def circuit(x):
    qml.RY(x[0], wires=0)
    qml.RX(x[1], wires=1)
    return [
        qml.expval(qml.X(0)),
        qml.expval(qml.Y(1)),
        qml.expval(qml.Z(0) @ qml.Z(1)),
        qml.expval(qml.X(0) @ qml.Z(1) + 0.5 * qml.Y(1) + qml.Z(0)),
    ]

Instead of decorating the QNode, we can also create a new function that yields the same result in the following way:

@qml.qnode(dev)
def circuit(x):
    qml.RY(x[0], wires=0)
    qml.RX(x[1], wires=1)
    return [
        qml.expval(qml.X(0)),
        qml.expval(qml.Y(1)),
        qml.expval(qml.Z(0) @ qml.Z(1)),
        qml.expval(qml.X(0) @ qml.Z(1) + 0.5 * qml.Y(1) + qml.Z(0)),
    ]

circuit = qml.transforms.split_non_commuting(circuit)

Internally, the QNode is split into multiple circuits when executed:

>>> print(qml.draw(circuit)([np.pi/4, np.pi/4]))
0: ──RY(0.79)─┤  <X> ╭<X@Z>
1: ──RX(0.79)─┤      ╰<X@Z>

0: ──RY(0.79)─┤
1: ──RX(0.79)─┤  <Y>

0: ──RY(0.79)─┤ ╭<Z@Z>  <Z>
1: ──RX(0.79)─┤ ╰<Z@Z>

Note that the observable Y(1) occurs twice in the original QNode, but only once in the transformed circuits. When there are multiple expectation value measurements that rely on the same observable, this observable is measured only once, and the result is copied to each original measurement.

While internally multiple tapes are created, the end result has the same ordering as the user provides in the return statement. Executing the above QNode returns the original ordering of the expectation values.

>>> circuit([np.pi/4, np.pi/4])
[0.7071067811865475,
 -0.7071067811865475,
 0.49999999999999994,
 0.8535533905932737]

There are two algorithms used to compute disjoint groups of commuting observables: "qwc" grouping uses group_observables() which computes groups of qubit-wise commuting observables, producing the fewest number of circuit executions, but can be expensive to compute for large multi-term Hamiltonians, while "wires" grouping simply ensures that no circuit contains two measurements with overlapping wires, disregarding commutativity between the observables being measured.

The grouping_strategy keyword argument can be used to specify the grouping strategy. By default, qwc grouping is used whenever possible, except when the circuit contains multiple measurements that includes an expectation value of a qml.Hamiltonian, in which case wires grouping is used in case the Hamiltonian is very large, to save on classical runtime. To force qwc grouping in all cases, set grouping_strategy="qwc". Similarly, to force wires grouping, set grouping_strategy="wires":

@functools.partial(qml.transforms.split_non_commuting, grouping_strategy="wires")
@qml.qnode(dev)
def circuit(x):
    qml.RY(x[0], wires=0)
    qml.RX(x[1], wires=1)
    return [
        qml.expval(qml.X(0)),
        qml.expval(qml.Y(1)),
        qml.expval(qml.Z(0) @ qml.Z(1)),
        qml.expval(qml.X(0) @ qml.Z(1) + 0.5 * qml.Y(1) + qml.Z(0)),
    ]

In this case, four circuits are created as follows:

>>> print(qml.draw(circuit)([np.pi/4, np.pi/4]))
0: ──RY(0.79)─┤  <X>
1: ──RX(0.79)─┤  <Y>

0: ──RY(0.79)─┤ ╭<Z@Z>
1: ──RX(0.79)─┤ ╰<Z@Z>

0: ──RY(0.79)─┤ ╭<X@Z>
1: ──RX(0.79)─┤ ╰<X@Z>

0: ──RY(0.79)─┤  <Z>
1: ──RX(0.79)─┤

Alternatively, to disable grouping completely, set grouping_strategy=None:

@functools.partial(qml.transforms.split_non_commuting, grouping_strategy=None)
@qml.qnode(dev)
def circuit(x):
    qml.RY(x[0], wires=0)
    qml.RX(x[1], wires=1)
    return [
        qml.expval(qml.X(0)),
        qml.expval(qml.Y(1)),
        qml.expval(qml.Z(0) @ qml.Z(1)),
        qml.expval(qml.X(0) @ qml.Z(1) + 0.5 * qml.Y(1) + qml.Z(0)),
    ]

In this case, each observable is measured in a separate circuit execution.

>>> print(qml.draw(circuit)([np.pi/4, np.pi/4]))
0: ──RY(0.79)─┤  <X>
1: ──RX(0.79)─┤

0: ──RY(0.79)─┤
1: ──RX(0.79)─┤  <Y>

0: ──RY(0.79)─┤ ╭<Z@Z>
1: ──RX(0.79)─┤ ╰<Z@Z>

0: ──RY(0.79)─┤ ╭<X@Z>
1: ──RX(0.79)─┤ ╰<X@Z>

0: ──RY(0.79)─┤  <Z>
1: ──RX(0.79)─┤

Note that there is an exception to the above rules: if the circuit only contains a single expectation value measurement of a Hamiltonian or Sum with pre-computed grouping indices, the grouping information will be used regardless of the requested grouping_strategy

Shot distribution

With finite-shot measurements, the default behaviour of split_non_commuting is to perform one execution with the total number of shots for each group of commuting terms. With the shot_dist argument, this behaviour can be changed. For example, shot_dist = "weighted" will partition the number of shots performed for each commuting group according to the L1 norm of each group’s coefficients:

import pennylane as qml
from pennylane.transforms import split_non_commuting
from functools import partial

ham = qml.Hamiltonian(
    coeffs=[10, 0.1, 20, 100, 0.2],
    observables=[
        qml.X(0) @ qml.Y(1),
        qml.Z(0) @ qml.Z(2),
        qml.Y(1),
        qml.X(1) @ qml.X(2),
        qml.Z(0) @ qml.Z(1) @ qml.Z(2)
    ]
)

dev = qml.device("default.qubit")

@partial(split_non_commuting, shot_dist="weighted")
@qml.qnode(dev, shots=10000)
def circuit():
    return qml.expval(ham)

with qml.Tracker(dev) as tracker:
    circuit()
>>> print(tracker.history["shots"])
[2303, 23, 7674]

The shot_dist strategy can be also defined by a custom function. For example:

import numpy as np

def my_shot_dist(total_shots, coeffs_per_group, seed):
    max_per_group = [np.max(np.abs(coeffs)) for coeffs in coeffs_per_group]
    prob_shots = np.array(max_per_group) / np.sum(max_per_group)
    return np.round(total_shots * prob_shots)

@partial(split_non_commuting, shot_dist=my_shot_dist)
@qml.qnode(dev, shots=10000)
def circuit():
    return qml.expval(ham)

with qml.Tracker(dev) as tracker:
    circuit()
>>> print(tracker.history["shots"])
[1664, 17, 8319]

Internal details

Internally, this function works with tapes. We can create a tape with multiple measurements of non-commuting observables:

measurements = [
    qml.expval(qml.Z(0) @ qml.Z(1)),
    qml.expval(qml.X(0) @ qml.X(1)),
    qml.expval(qml.Z(0)),
    qml.expval(qml.X(0))
]
tape = qml.tape.QuantumScript(measurements=measurements)
tapes, processing_fn = qml.transforms.split_non_commuting(tape)

Now tapes is a list of two tapes, each contains a group of commuting observables:

>>> [t.measurements for t in tapes]
[[expval(Z(0) @ Z(1)), expval(Z(0))], [expval(X(0) @ X(1)), expval(X(0))]]

The processing function becomes important as the order of the inputs has been modified.

>>> dev = qml.device("default.qubit", wires=2)
>>> result_batch = [dev.execute(t) for t in tapes]
>>> result_batch
[(1.0, 1.0), (0.0, 0.0)]

The processing function can be used to reorganize the results:

>>> processing_fn(result_batch)
(1.0, 0.0, 1.0, 0.0)

Measurements that accept both observables and wires so that e.g. qml.counts, qml.probs and qml.sample can also be used. When initialized using only wires, these measurements are interpreted as measuring with respect to the observable qml.Z(wires[0])@qml.Z(wires[1])@...@qml.Z(wires[len(wires)-1])

measurements = [
    qml.expval(qml.X(0)),
    qml.probs(wires=[1]),
    qml.probs(wires=[0, 1])
]
tape = qml.tape.QuantumScript(measurements=measurements)
tapes, processing_fn = qml.transforms.split_non_commuting(tape)

This results in two tapes, each with commuting measurements:

>>> [t.measurements for t in tapes]
[[expval(X(0)), probs(wires=[1])], [probs(wires=[0, 1])]]