Skip to main content
IBM Quantum Platform

Wire cutting for expectation values estimation

Usage estimate: 22 seconds on a Heron processor (NOTE: This is an estimate only. Your runtime might vary.)


Learning outcomes

After going through this tutorial, users should understand:

  • How to use qiskit-addon-cutting to partition a large circuit into smaller subcircuits, thereby reducing the effect of noise

Prerequisites

We suggest that users are familiar with the following topic before going through this tutorial:

  • Using the Sampler primitive, which is used in this workflow

Background

Circuit-knitting is an umbrella term that encapsulates various methods of partitioning a circuit in multiple smaller subcircuits involving fewer gates or qubits. Each of the subcircuits can be executed independently, and the final result is obtained by some classical post-processing over the outcome of each subcircuit. This technique is accessible in the Circuit cutting Qiskit addon; see the documentation along with other introductory material for a detailed explanation of the technique.

This tutorial focuses on a method called wire cutting, where the circuit is partitioned along the wire [1], [2]. Note that partitioning is simple in classical circuits since the outcome at the point of partition can be determined deterministically, and is either 0 or 1. However, the state of the qubit at the point of the cut is, in general, a mixed state. Therefore, each subcircuit needs to be measured multiple times in different bases (usually a tomographically complete basis, such as the Pauli basis [3], [4]) and correspondingly prepared in its eigenstate. The figure below (courtesy: [7]) shows an example of wire cutting for a four-qubit GHZ state into three subcircuits. Here MjM_j denotes a set of bases (usually Pauli X, Y and Z), and PiP_i denotes a set of eigenstates (usually 0|0\rangle, 1|1\rangle, +|+\rangle and +i|+i\rangle).

wc-1.png wc-2.png

Since each subcircuit has fewer qubits and gates, they are expected to be less amenable to noise. This tutorial shows an example where this method can be used to effectively suppress the noise in the system.


Requirements

Before starting this tutorial, be sure you have the following installed:

  • Qiskit SDK v2.0 or later, with visualization support
  • Qiskit Runtime v0.22 or later ( pip install qiskit-ibm-runtime )
  • Circuit cutting Qiskit addon v0.10.0 or later (pip install qiskit-addon-cutting)
  • Qiskit addon utils 0.3 or later (pip install qiskit-addon-utils)
  • Qiskit Aer (pip install qiskit-aer )

Setup

import numpy as np
import matplotlib.pyplot as plt

from qiskit.circuit import Parameter, ParameterVector, QuantumCircuit
from qiskit.quantum_info import PauliList, SparsePauliOp
from qiskit.transpiler import generate_preset_pass_manager
from qiskit_aer import AerSimulator
from qiskit.result import sampled_expectation_value

from qiskit_addon_cutting.instructions import CutWire
from qiskit_addon_cutting import (
    cut_wires,
    expand_observables,
    partition_problem,
    generate_cutting_experiments,
    reconstruct_expectation_values,
)

from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime import SamplerV2, Batch

Small-scale simulator example

This tutorial implements a Qiskit pattern to simulate a one-dimensional (1D) Many-Body Localization (MBL) circuit. The MBL circuit is a hardware-efficient circuit and is parameterized by two parameters θ\theta and ϕ\vec{\phi}. When θ\theta is set to 00 and the initial state is prepared in 0|0\rangle for all the qubits, the ideal expectation value of Zi\langle Z_i \rangle is +1+1 for every qubit site ii irrespective of the values of ϕ\vec{\phi}. More details on this circuit are available in this article.

Note that in a noiseless simulator, the expectation value obtained with and without circuit cutting will be same.

Step 1: Map classical inputs to a quantum problem

Construct the 1D MBL circuit

First, we present a function for constructing the 1D MBL circuit.

class MBLChainCircuit(QuantumCircuit):
    def __init__(
        self, num_qubits: int, depth: int, use_cut: bool = False
    ) -> None:
        super().__init__(
            num_qubits, name=f"MBLChainCircuit<{num_qubits}, {depth}>"
        )
        evolution = MBLChainEvolution(num_qubits, depth, use_cut)
        self.compose(evolution, inplace=True)


class MBLChainEvolution(QuantumCircuit):
    def __init__(self, num_qubits: int, depth: int, use_cut) -> None:
        super().__init__(
            num_qubits, name=f"MBLChainEvolution<{num_qubits}, {depth}>"
        )

        theta = Parameter("θ")
        phis = ParameterVector("φ", num_qubits)

        for layer in range(depth):
            layer_parity = layer % 2
            # print("layer parity", layer_parity)
            for qubit in range(layer_parity, num_qubits - 1, 2):
                # print(qubit)
                self.cz(qubit, qubit + 1)
                self.u(theta, 0, np.pi, qubit)
                self.u(theta, 0, np.pi, qubit + 1)
                if (
                    use_cut
                    and layer_parity == 0
                    and (
                        qubit == num_qubits // 2 - 1
                        or qubit == num_qubits // 2
                    )
                ):
                    self.append(CutWire(), [num_qubits // 2])
                if use_cut and layer < depth - 1 and layer_parity == 1:
                    if qubit == num_qubits // 2:
                        self.append(CutWire(), [qubit])
            for qubit in range(num_qubits):
                self.p(phis[qubit], qubit)
num_qubits = 10
depth = 2
mbl = MBLChainCircuit(num_qubits, depth)
mbl.draw("mpl", fold=-1)

Output:

Output of the previous code cell

We calculate the average expectation value O=1niZiO = \frac{1}{n} \sum_i Z_i over all qubits for θ=0\theta = 0. Since the ideal expectation value of Zi=1\langle Z_i \rangle = 1 \forall ii, the ideal expectation value of OO is also 11. The parameters ϕ\phi are selected randomly.

np.random.seed(42)
phis = list(np.random.rand(mbl.num_parameters - 1))
theta = [0]
params = theta + phis

The circuit needs to be annotated by inserting CutWire in the desired locations to partition it. For this tutorial, we opt for equal partition. The MBL circuit is designed so that setting use_cut=True in the function inserts the annotation properly after n2\frac{n}{2} qubits, nn being the number of qubits in the original circuit. We also assigned the randomly generated parameters to the circuit.

mbl_cut = MBLChainCircuit(num_qubits, depth, use_cut=True)
mbl_cut.assign_parameters(params, inplace=True)
mbl_cut.draw("mpl", fold=-1)

Output:

Output of the previous code cell

Step 2: Optimize problem for quantum hardware execution

Cut the circuit into smaller subcircuits

Now we partition the circuit into two smaller subcircuits using qiskit-addon-cutting. qiskit-addon-cutting appends a virtual Move gate to split the wire cut location by appropriately adjusting the number of qubits. Now we create the circuit with this virtual gate. Since there is one wire cut, the number of associated qubits will be increased by 1.

mbl_move = cut_wires(mbl_cut)
mbl_move.draw("mpl", fold=-1)

Output:

Output of the previous code cell

Construct and expand the observable

The observable, as defined before, will be the average of ZZ on each qubit. However, upon inserting the virtual Move gate, the effective number of qubits in the circuit increases. The observable must also be expanded accordingly to account for this change in the number of qubits. Note the observable always acts trivially (as in II) on the extra qubit added for the virtual Move gate.

observable = PauliList(
    ["I" * i + "Z" + "I" * (num_qubits - i - 1) for i in range(num_qubits)]
)
observable

Output:

PauliList(['ZIIIIIIIII', 'IZIIIIIIII', 'IIZIIIIIII', 'IIIZIIIIII',
           'IIIIZIIIII', 'IIIIIZIIII', 'IIIIIIZIII', 'IIIIIIIZII',
           'IIIIIIIIZI', 'IIIIIIIIIZ'])
new_obs = expand_observables(observable, mbl, mbl_move)
new_obs

Output:

PauliList(['ZIIIIIIIIII', 'IZIIIIIIIII', 'IIZIIIIIIII', 'IIIZIIIIIII',
           'IIIIZIIIIII', 'IIIIIIZIIII', 'IIIIIIIZIII', 'IIIIIIIIZII',
           'IIIIIIIIIZI', 'IIIIIIIIIIZ'])

Now the circuit can be partitioned along the Move gate and we obtain the subcircuits, as well as the subobservable, which is the portion of the original observable associated with each subcircuit.

partitioned_problem = partition_problem(circuit=mbl_move, observables=new_obs)
subcircuits = partitioned_problem.subcircuits
subobservables = partitioned_problem.subobservables

Here we visualize the two subcircuits:

subcircuits[0].draw("mpl", fold=-1)

Output:

Output of the previous code cell
subcircuits[1].draw("mpl", fold=-1)

Output:

Output of the previous code cell

Expanding the observable using the Move operation requires a PauliList data structure. To reconstruct the expectation value of the original circuit, we require the observable in the SparsePauliOp format.

M_z = SparsePauliOp(
    ["I" * i + "Z" + "I" * (num_qubits - i - 1) for i in range(num_qubits)],
    coeffs=[1 / num_qubits] * num_qubits,
)

As discussed before, for each cut the upstream circuit must be measured in a Pauli basis, and the downstream circuit must be prepared in the eigenstate of the basis. The function generate_cutting_experiments creates all of these necessary circuits and the coefficients associated with each circuit required for reconstruction. Find more detail in this paper.

subexperiments, coefficients = generate_cutting_experiments(
    circuits=subcircuits,
    observables=subobservables,
    num_samples=np.inf,
)

Transpile the circuits onto the backend

For the first example involving only simulation, we transpile the circuit into the basis gate set of the backend:

service = QiskitRuntimeService()
backend = service.least_busy(
    operational=True, simulator=False, min_num_qubits=133
)

print(backend)

Output:

<IBMBackend('ibm_fez')>

Step 3: Execute using Qiskit primitives

Now, execute each subexperiment:

pm_basis = generate_preset_pass_manager(
    optimization_level=2, basis_gates=backend.configuration().basis_gates
)
basis_subexperiments = {
    label: pm_basis.run(partition_subexpts)
    for label, partition_subexpts in subexperiments.items()
}
sampler = SamplerV2(mode=AerSimulator())
jobs = {
    label: sampler.run(subsystem_subexpts, shots=2**12)
    for label, subsystem_subexpts in basis_subexperiments.items()
}

Step 4: Post-process and return result in desired classical format

Now we retrieve the result of each subexperiment run and reconstruct the expectation value of the uncut circuit:

# Retrieve results
results = {label: job.result() for label, job in jobs.items()}
reconstructed_expval_terms = reconstruct_expectation_values(
    results,
    coefficients,
    subobservables,
)
reconstructed_expval = np.dot(reconstructed_expval_terms, M_z.coeffs).real
reconstructed_expval

Output:

np.float64(0.9953821063041687)
methods = [
    "Uncut",
    "Wire cut",
]
values = [
    1,
    reconstructed_expval,
]  # since the ideal expectation value in noiseless simulation is +1

ax = plt.gca()
plt.bar(methods, values, color="#a56eff", width=0.4, edgecolor="#8a3ffc")
ax.set_ylabel(r"$M_Z$", fontsize=12)

Output:

Text(0, 0.5, '$M_Z$')
Output of the previous code cell

Large-scale hardware example

Now we demonstrate wire cutting for a 60-qubit MBL circuit. The uncut, as well as the cut circuits, will be executed on IBM Quantum® hardware:

num_qubits = 60
depth = 2

# construct the circuit
mbl = MBLChainCircuit(num_qubits, depth)

# create parameters
phis = list(np.random.rand(mbl.num_parameters - 1))
theta = [0]
params = theta + phis

# construct the cut circuit
mbl_cut = MBLChainCircuit(num_qubits, depth, use_cut=True)
mbl_cut.assign_parameters(params, inplace=True)
mbl_move = cut_wires(mbl_cut)

# Define observable and expand to account for the wire cut
observable = PauliList(
    ["I" * i + "Z" + "I" * (num_qubits - i - 1) for i in range(num_qubits)]
)
new_obs = expand_observables(observable, mbl, mbl_move)

# Construct a SparsePauliOp version of the observable for later use in reconstruction
M_z = SparsePauliOp(
    ["I" * i + "Z" + "I" * (num_qubits - i - 1) for i in range(num_qubits)],
    coeffs=[1 / num_qubits] * num_qubits,
)

# Partition the circuit and get subcircuits and subobservables
partitioned_problem = partition_problem(circuit=mbl_move, observables=new_obs)
subcircuits = partitioned_problem.subcircuits
subobservables = partitioned_problem.subobservables

# Obtain subexperiments and coefficients
subexperiments, coefficients = generate_cutting_experiments(
    circuits=subcircuits,
    observables=subobservables,
    num_samples=np.inf,
)

# Transpile the subexperiments to the backend
pm = generate_preset_pass_manager(optimization_level=2, backend=backend)
isa_subexperiments = {
    label: pm.run(partition_subexpts)
    for label, partition_subexpts in subexperiments.items()
}

# Execute the subexperiments and retrieve results
with Batch(backend=backend) as batch:
    sampler = SamplerV2(mode=batch)
    sampler.options.environment.job_tags = ["TUT_WC"]
    jobs = {
        label: sampler.run(subsystem_subexpts, shots=2**12)
        for label, subsystem_subexpts in isa_subexperiments.items()
    }
results = {label: job.result() for label, job in jobs.items()}

# Reconstruct the expectation value of the original observable
reconstructed_expval_terms = reconstruct_expectation_values(
    results,
    coefficients,
    subobservables,
)
reconstructed_expval = np.dot(reconstructed_expval_terms, M_z.coeffs).real

# Compute the uncut circuit to obtain the noisy expectation value for comparison
sampler = SamplerV2(mode=backend)
sampler.options.environment.job_tags = ["TUT_WC"]

if mbl.num_clbits == 0:
    mbl.measure_all()
isa_mbl = pm.run(mbl)

pub = (isa_mbl, params)
uncut_job = sampler.run([pub])

uncut_counts = uncut_job.result()[0].data.meas.get_counts()
uncut_expval = sampled_expectation_value(uncut_counts, M_z)

# visualize the results
ax = plt.gca()
methods = ["uncut", "cut"]
values = [uncut_expval, reconstructed_expval]

plt.bar(methods, values, color="#a56eff", width=0.4, edgecolor="#8a3ffc")
plt.axhline(y=1, color="k", linestyle="--")
plt.text(0.3, 0.95, "Exact result")
plt.show()

Output:

Output of the previous code cell
uncut_expval

Output:

0.9202473958333336

Next steps

Recommendations

If you found this work interesting, you might be interested in the following material:


References

[1] Peng, T., Harrow, A. W., Ozols, M., & Wu, X. (2020). Simulating large quantum circuits on a small quantum computer. Physical review letters, 125(15), 150504.

[2] Tang, W., Tomesh, T., Suchara, M., Larson, J., & Martonosi, M. (2021, April). Cutqc: using small quantum computers for large quantum circuit evaluations. In Proceedings of the 26th ACM International conference on architectural support for programming languages and operating systems (pp. 473-486).

[3] Perlin, M. A., Saleem, Z. H., Suchara, M., & Osborn, J. C. (2021). Quantum circuit cutting with maximum-likelihood tomography. npj Quantum Information, 7(1), 64.

[4] Majumdar, R., & Wood, C. J. (2022). Error mitigated quantum circuit cutting. arXiv preprint arXiv:2211.13431.

[5] Khare, T., Majumdar, R., Sangle, R., Ray, A., Seshadri, P. V., & Simmhan, Y. (2023). Parallelizing Quantum-Classical Workloads: Profiling the Impact of Splitting Techniques. In 2023 IEEE International Conference on Quantum Computing and Engineering (QCE) (Vol. 1, pp. 990-1000). IEEE.

[6] Bhoumik, D., Majumdar, R., Saha, A., & Sur-Kolay, S. (2023). Distributed Scheduling of Quantum Circuits with Noise and Time Optimization. arXiv preprint arXiv:2309.06005.

[7] Majumdar, R. (2024). Efficient Reduction of Resources and Noise in Discrete Quantum Computing Circuits (Doctoral dissertation, Indian Statistical Institute - Kolkata). https://www.proquest.com/openview/b481def90b1cc80e6b58a77c99e8385c/1?pq-origsite=gscholar&cbl=2026366&diss=y

Was this page helpful?
Report a bug, typo, or request content on GitHub.