qml.cut_circuit_mc

cut_circuit_mc(tape: pennylane.tape.tape.QuantumTape, classical_processing_fn: Optional[callable] = None, auto_cutter: Union[bool, Callable] = False, max_depth: int = 1, shots: Optional[int] = None, device_wires: Optional[pennylane.wires.Wires] = None, **kwargs)Tuple[Tuple[pennylane.tape.tape.QuantumTape], Callable][source]

Cut up a circuit containing sample measurements into smaller fragments using a Monte Carlo method.

Following the approach of Peng et al., strategic placement of WireCut operations can allow a quantum circuit to be split into disconnected circuit fragments. A circuit containing sample measurements can be cut and processed using Monte Carlo (MC) methods. This transform employs MC methods to allow for sampled measurement outcomes to be recombined to full bitstrings and, if a classical processing function is supplied, an expectation value will be evaluated.

Parameters
  • tape (QuantumTape) – the tape of the full circuit to be cut

  • classical_processing_fn (callable) – A classical postprocessing function to be applied to the reconstructed bitstrings. The expected input is a bitstring; a flat array of length wires, and the output should be a single number within the interval \([-1, 1]\). If not supplied, the transform will output samples.

  • auto_cutter (Union[bool, Callable]) – Toggle for enabling automatic cutting with the default kahypar_cut() partition method. Can also pass a graph partitioning function that takes an input graph and returns a list of edges to be cut based on a given set of constraints and objective. The default kahypar_cut() function requires KaHyPar to be installed using pip install kahypar for Linux and Mac users or visiting the instructions here to compile from source for Windows users.

  • max_depth (int) – The maximum depth used to expand the circuit while searching for wire cuts. Only applicable when transforming a QNode.

  • shots (int) – Number of shots. When transforming a QNode, this argument is set by the device’s shots value or at QNode call time (if provided). Required when transforming a tape.

  • device_wires (Wires) – Wires of the device that the cut circuits are to be run on. When transforming a QNode, this argument is optional and will be set to the QNode’s device wires. Required when transforming a tape.

  • kwargs – Additional keyword arguments to be passed to a callable auto_cutter argument. For the default KaHyPar cutter, please refer to the docstring of functions find_and_place_cuts() and kahypar_cut() for the available arguments.

Returns

Function which accepts the same arguments as the QNode. When called, this function will sample from the partitioned circuit fragments and combine the results using a Monte Carlo method.

Return type

Callable

Example

The following \(3\)-qubit circuit contains a WireCut operation and a sample() measurement. When decorated with @qml.cut_circuit_mc, we can cut the circuit into two \(2\)-qubit fragments:

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

@qml.cut_circuit_mc
@qml.qnode(dev)
def circuit(x):
    qml.RX(0.89, wires=0)
    qml.RY(0.5, wires=1)
    qml.RX(1.3, wires=2)

    qml.CNOT(wires=[0, 1])
    qml.WireCut(wires=1)
    qml.CNOT(wires=[1, 2])

    qml.RX(x, wires=0)
    qml.RY(0.7, wires=1)
    qml.RX(2.3, wires=2)
    return qml.sample(wires=[0, 2])

we can then execute the circuit as usual by calling the QNode:

>>> x = 0.3
>>> circuit(x)
tensor([[1, 1],
        [0, 1],
        [0, 1],
        ...,
        [0, 1],
        [0, 1],
        [0, 1]], requires_grad=True)

Furthermore, the number of shots can be temporarily altered when calling the qnode:

>>> results = circuit(x, shots=123)
>>> results.shape
(123, 2)

Alternatively, if the optimal wire-cut placement is unknown for an arbitrary circuit, the auto_cutter option can be enabled to make attempts in finding such a optimal cut. The following examples shows this capability on the same circuit as above but with the WireCut removed:

@qml.cut_circuit_mc(auto_cutter=True)
@qml.qnode(dev)
def circuit(x):
    qml.RX(0.89, wires=0)
    qml.RY(0.5, wires=1)
    qml.RX(1.3, wires=2)

    qml.CNOT(wires=[0, 1])
    qml.CNOT(wires=[1, 2])

    qml.RX(x, wires=0)
    qml.RY(0.7, wires=1)
    qml.RX(2.3, wires=2)
    return qml.sample(wires=[0, 2])
>>> results = circuit(x, shots=123)
>>> results.shape
(123, 2)

Manually placing WireCut operations and decorating the QNode with the cut_circuit_mc() batch transform is the suggested entrypoint into sampling-based circuit cutting using the Monte Carlo method. However, advanced users also have the option to work directly with a QuantumTape and manipulate the tape to perform circuit cutting using the below functionality:

tape_to_graph(tape)

Converts a quantum tape to a directed multigraph.

find_and_place_cuts(graph[, cut_method, …])

Automatically finds and places optimal WireCut nodes into a given tape-converted graph using a customizable graph partitioning function.

replace_wire_cut_nodes(graph)

Replace each WireCut node in the graph with a MeasureNode and PrepareNode.

fragment_graph(graph)

Fragments a graph into a collection of subgraphs as well as returning the communication (quotient) graph.

graph_to_tape(graph)

Converts a directed multigraph to the corresponding QuantumTape.

expand_fragment_tapes_mc(tapes, …)

Expands fragment tapes into a sequence of random configurations of the contained pairs of MeasureNode and PrepareNode operations.

qcut_processing_fn_sample(results, …)

Function to postprocess samples for the cut_circuit_mc() transform.

qcut_processing_fn_mc(results, …)

Function to postprocess samples for the cut_circuit_mc() transform.

The following shows how these elementary steps are combined as part of the cut_circuit_mc() transform.

Consider the circuit below:

np.random.seed(42)

with qml.tape.QuantumTape() as tape:
    qml.Hadamard(wires=0)
    qml.CNOT(wires=[0, 1])
    qml.PauliX(wires=1)
    qml.WireCut(wires=1)
    qml.CNOT(wires=[1, 2])
    qml.sample(wires=[0, 1, 2])
>>> print(tape.draw())
0: ──H─╭●───────────┤ ╭Sample
1: ────╰X──X──//─╭●─┤ ├Sample
2: ──────────────╰X─┤ ╰Sample

To cut the circuit, we first convert it to its graph representation:

>>> graph = qml.transforms.qcut.tape_to_graph(tape)

If, however, the optimal location of the WireCut is unknown, we can use find_and_place_cuts() to make attempts in automatically finding such a cut given the device constraints. Using the same circuit as above but with the WireCut removed, a slightly different cut with identical cost can be discovered and placed into the circuit with automatic cutting:

with qml.tape.QuantumTape() as uncut_tape:
    qml.Hadamard(wires=0)
    qml.CNOT(wires=[0, 1])
    qml.PauliX(wires=1)
    qml.CNOT(wires=[1, 2])
    qml.sample(wires=[0, 1, 2])
>>> cut_graph = qml.transforms.qcut.find_and_place_cuts(
...     graph=qml.transforms.qcut.tape_to_graph(uncut_tape),
...     cut_strategy=qml.transforms.qcut.CutStrategy(max_free_wires=2),
... )
>>> print(qml.transforms.qcut.graph_to_tape(cut_graph).draw())
 0: ──H─╭●───────────┤  Sample[|1⟩⟨1|]
 1: ────╰X──//──X─╭●─┤  Sample[|1⟩⟨1|]
 2: ──────────────╰X─┤  Sample[|1⟩⟨1|]

Our next step, using the original manual cut placement, is to remove the WireCut nodes in the graph and replace with MeasureNode and PrepareNode pairs.

>>> qml.transforms.qcut.replace_wire_cut_nodes(graph)

The MeasureNode and PrepareNode pairs are placeholder operations that allow us to cut the circuit graph and then randomly select measurement and preparation configurations at cut locations. First, the fragment_graph() function pulls apart the graph into disconnected components as well as returning the communication_graph detailing the connectivity between the components.

>>> fragments, communication_graph = qml.transforms.qcut.fragment_graph(graph)

We now convert the fragments back to QuantumTape objects

>>> fragment_tapes = [qml.transforms.qcut.graph_to_tape(f) for f in fragments]

The circuit fragments can now be visualized:

>>> print(fragment_tapes[0].draw())
0: ──H─╭●─────────────────┤  Sample[|1⟩⟨1|]
1: ────╰X──X──MeasureNode─┤
>>> print(fragment_tapes[1].draw())
1: ──PrepareNode─╭●─┤  Sample[|1⟩⟨1|]
2: ──────────────╰X─┤  Sample[|1⟩⟨1|]

Additionally, we must remap the tape wires to match those available on our device.

>>> dev = qml.device("default.qubit", wires=2, shots=1)
>>> fragment_tapes = [qml.map_wires(t, dict(zip(t.wires, dev.wires))) for t in fragment_tapes]

Note that the number of shots on the device is set to \(1\) here since we will only require one execution per fragment configuration. In the following steps we introduce a shots value that will determine the number of fragment configurations. When using the cut_circuit_mc() decorator with a QNode, this shots value is automatically inferred from the provided device.

Next, each circuit fragment is randomly expanded over MeasureNode and PrepareNode configurations. For each pair, a measurement is sampled from the Pauli basis and a state preparation is sampled from the corresponding pair of eigenstates.

A settings array is also given which tracks the configuration pairs. Since each of the 4 measurements has 2 possible eigenvectors, all configurations can be uniquely identified by 8 values. The number of rows is determined by the number of cuts and the number of columns is determined by the number of shots.

>>> shots = 3
>>> configurations, settings = qml.transforms.qcut.expand_fragment_tapes_mc(
...     fragment_tapes, communication_graph, shots=shots
... )
>>> tapes = tuple(tape for c in configurations for tape in c)
>>> settings
tensor([[6, 3, 4]], requires_grad=True)

Each configuration is drawn below:

>>> for t in tapes:
...     print(qml.drawer.tape_text(t))
...     print("")
0: ──H─╭●────┤  Sample[|1⟩⟨1|]
1: ────╰X──X─┤  Sample[Z]

0: ──H─╭●────┤  Sample[|1⟩⟨1|]
1: ────╰X──X─┤  Sample[X]

0: ──H─╭●────┤  Sample[|1⟩⟨1|]
1: ────╰X──X─┤  Sample[Y]

0: ──I─╭●─┤  Sample[|1⟩⟨1|]
1: ────╰X─┤  Sample[|1⟩⟨1|]

0: ──X──S─╭●─┤  Sample[|1⟩⟨1|]
1: ───────╰X─┤  Sample[|1⟩⟨1|]

0: ──H─╭●─┤  Sample[|1⟩⟨1|]
1: ────╰X─┤  Sample[|1⟩⟨1|]

The last step is to execute the tapes and postprocess the results using qcut_processing_fn_sample(), which processes the results to approximate the original full circuit output bitstrings.

>>> results = qml.execute(tapes, dev, gradient_fn=None)
>>> qml.transforms.qcut.qcut_processing_fn_sample(
...     results,
...     communication_graph,
...     shots=shots,
... )
[array([[0., 0., 0.],
        [1., 0., 0.],
        [1., 0., 0.]])]

Alternatively, it is possible to calculate an expectation value if a classical processing function is provided that will accept the reconstructed circuit bitstrings and return a value in the interval \([-1, 1]\):

def fn(x):
    if x[0] == 0:
        return 1
    if x[0] == 1:
        return -1
>>> qml.transforms.qcut.qcut_processing_fn_mc(
...     results,
...     communication_graph,
...     settings,
...     shots,
...     fn
... )
array(-4.)

Using the Monte Carlo approach of [Peng et. al](https://arxiv.org/abs/1904.00102), the cut_circuit_mc transform also supports returning sample-based expectation values of observables that are diagonal in the computational basis, as shown below for a ZZ measurement on wires 0 and 2:

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

def observable(bitstring):
    return (-1) ** np.sum(bitstring)

@qml.cut_circuit_mc(classical_processing_fn=observable)
@qml.qnode(dev)
def circuit(x):
    qml.RX(0.89, wires=0)
    qml.RY(0.5, wires=1)
    qml.RX(1.3, wires=2)

    qml.CNOT(wires=[0, 1])
    qml.WireCut(wires=1)
    qml.CNOT(wires=[1, 2])

    qml.RX(x, wires=0)
    qml.RY(0.7, wires=1)
    qml.RX(2.3, wires=2)
    return qml.sample(wires=[0, 2])

We can now approximate the expectation value of the observable using

>>> circuit(x)
tensor(-0.776, requires_grad=True)