Wire cutting for expectation values estimation
דף זה טרם תורגם. התוכן מוצג באנגלית.
Usage estimate: one minute on an Eagle processor (NOTE: This is an estimate only. Your runtime might vary.)
Background
Circuit-knitting is an umbrella term which encapsulates various methods of partitioning a circuit in multiple smaller subcircuits involving fewer gates and/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, a detailed explanation of the technique is given in the docs along with other introductory material.
This notebook deals with 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 basis (usually a tomographically complete set of basis such as the Pauli basis [3], [4] and correspondingly prepared in its eigenstate. The Figure below (courtesy: PhD Thesis, Ritajit Majumdar) shows an example of wire cutting for a 4-qubit GHZ state into three subcircuits. Here denote a set of basis (usually Pauli X, Y and Z) and denote a set of eigenstates (usually , , and ).
Since each subcircuit has fewer qubits and/or gates, they are expected to be less amenable to noise. This notebook 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.9.0 or later (
pip install qiskit-addon-cutting)
We shall consider a Many Body Localization (MBL) circuit for this notebook. The MBL circuit is a hardware-efficient circuit and is parameterized by two parameters and . When is set to and the initial state is prepared in for all the qubits, the ideal expectation value of is for every qubit site irrespective of the values of . You can check more details on MBL circuits in this paper.
Setup
# Added by doQumentation — installs packages not in the Binder environment
%pip install -q qiskit-addon-cutting
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.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
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)
Part I. Small scale example
Step 1: Map classical inputs to a quantum problem
Initially we build a template circuit without any specific parameter values. We also provide placeholders, called CutWire, to annotate the position of cuts. For the small scale example we consider a 10-qubit MBL circuit.
num_qubits = 10
depth = 2
mbl = MBLChainCircuit(num_qubits, depth)
mbl.draw("mpl", fold=-1)
Recall that we aim to find the expectation value of the observable when . We shall put some random values for the parameter .
phis = list(np.random.rand(mbl.num_parameters - 1))
theta = [0]
params = theta + phis
params
[0,
0.2376615174332788,
0.28244289857682414,
0.019248960591717768,
0.46140600996102477,
0.31408025180068433,
0.718184005135733,
0.991153920182475,
0.09289485768301442,
0.8857848280067783,
0.6177529765767047]
Now we annotate the circuit for cutting by inserting proper CutWire to create two roughly equal cuts. We set use_cut=True in the function, and allow it to annotate after qubits, being the number of qubits in the original circuit.
mbl_cut = MBLChainCircuit(num_qubits, depth, use_cut=True)
mbl_cut.assign_parameters(params, inplace=True)
mbl_cut.draw("mpl", fold=-1)
Step 2: Optimize problem for quantum hardware execution
Next we cut the circuit into two smaller subcircuits. For this example, we stick to only 2 subcircuits. For this, we use the Qiskit Addon: Circuit Cutting.
Cut the circuit into smaller subcircuits
Cutting the wire at a point increases the qubit count by one. Apart from the original qubit, there is now an extra qubit as a placeholder to the circuit after cutting. The following image gives a representation:
This Addon uses the function cut_wires to account for the extra qubits arising due to cutting.
mbl_move = cut_wires(mbl_cut)
Create and expand the observables
Now we construct the observable . Since the ideal outcome of for each is , the ideal outcome of is also .
observable = PauliList(
["I" * i + "Z" + "I" * (num_qubits - i - 1) for i in range(num_qubits)]
)
observable
PauliList(['ZIIIIIIIII', 'IZIIIIIIII', 'IIZIIIIIII', 'IIIZIIIIII',
'IIIIZIIIII', 'IIIIIZIIII', 'IIIIIIZIII', 'IIIIIIIZII',
'IIIIIIIIZI', 'IIIIIIIIIZ'])
However, note that the number of qubits in the circuit has increased after inserting the virtual 2-qubit Move operations after cutting. Therefore, we need to expand the observables as well by inserting identities to assert to the current circuit.
new_obs = expand_observables(observable, mbl, mbl_move)
new_obs
PauliList(['ZIIIIIIIIII', 'IZIIIIIIIII', 'IIZIIIIIIII', 'IIIZIIIIIII',
'IIIIZIIIIII', 'IIIIIIZIIII', 'IIIIIIIZIII', 'IIIIIIIIZII',
'IIIIIIIIIZI', 'IIIIIIIIIIZ'])
Note that each observable has now expanded to accommodate seven qubits, as in the circuit with Move operation, instead of the original 6 qubits. Next, partition the circuit into two subcircuits.
partitioned_problem = partition_problem(circuit=mbl_move, observables=new_obs)
Let us visualize the subcircuits
subcircuits = partitioned_problem.subcircuits
subcircuits[0].draw("mpl", fold=-1)
subcircuits[1].draw("mpl", fold=-1)
The observables have been partitioned as well to fit the subcircuits
subobservables = partitioned_problem.subobservables
subobservables
{0: PauliList(['IIIIII', 'IIIIII', 'IIIIII', 'IIIIII', 'IIIIII', 'IZIIII',
'IIZIII', 'IIIZII', 'IIIIZI', 'IIIIIZ']),
1: PauliList(['ZIIII', 'IZIII', 'IIZII', 'IIIZI', 'IIIIZ', 'IIIII', 'IIIII',
'IIIII', 'IIIII', 'IIIII'])}
Note that each subcircuit leads to a number of samples. The reconstruction takes into account the outcome of each of these samples. Each of these samples is termed a subexperiment.
Extending the observable using the Move operation requires a PauliList data structure. We can also create the observable in the more generic SparsePauliOp data structure which will be useful later during reconstruction of the subexperiments.
M_z = SparsePauliOp(
["I" * i + "Z" + "I" * (num_qubits - i - 1) for i in range(num_qubits)],
coeffs=[1 / num_qubits] * num_qubits,
)
M_z
SparsePauliOp(['ZIIIIIIIII', 'IZIIIIIIII', 'IIZIIIIIII', 'IIIZIIIIII', 'IIIIZIIIII', 'IIIIIZIIII', 'IIIIIIZIII', 'IIIIIIIZII', 'IIIIIIIIZI', 'IIIIIIIIIZ'],
coeffs=[0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j,
0.1+0.j, 0.1+0.j])
subexperiments, coefficients = generate_cutting_experiments(
circuits=subcircuits,
observables=subobservables,
num_samples=np.inf,
)
Let us see two examples where the cut qubits are measured in two different basis. First, it is measured in normal Z basis, and next it is measured in X basis.
subexperiments[0][6].draw("mpl", fold=-1)
subexperiments[0][2].draw("mpl", fold=-1)
Transpile each subexperiment
Currently we need to transpile our circuits before submitting them for execution. Therefore, we shall transpile each circuit in the subexperiments first.
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=127
)
Now we need to transpile each of the circuits in the subexperiments. For that we first create a pass manager, and then use it to transpile each of the circuits.
pm = generate_preset_pass_manager(optimization_level=2, backend=backend)
isa_subexperiments = {
label: pm.run(partition_subexpts)
for label, partition_subexpts in subexperiments.items()
}
isa_subexperiments[0][0].draw("mpl", fold=-1, idle_wires=False)
Step 3: Execute using Qiskit primitives
Now we shall execute each circuit in subexperiment. Qiskit-addon-cutting uses SamplerV2 to execute the subexperiments.
with Batch(backend=backend) as batch:
sampler = SamplerV2(mode=batch)
jobs = {
label: sampler.run(subsystem_subexpts, shots=2**12)
for label, subsystem_subexpts in isa_subexperiments.items()
}
Step 4: Post-process and return result in desired classical format
Once the circuits have been executed, we now need to retrieve the results and reconstruct the expectation value for the uncut circuit and the original observable.
# 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
0.9674376845359803
Cross verify
Let us now execute the circuit without cutting and check the outcome there. Note that for execution of the uncut circuit we can directly use EstimatorV2 for calculating the expectation values. But we shall use the same Primitive throughout. So we shall use SamplerV2 to get the probability distribution and calculate the expectation value using the sampled_expectation_value function.
First we need to transpile the uncut mbl circuit.
sampler = SamplerV2(mode=backend)
if mbl.num_clbits == 0:
mbl.measure_all()
isa_mbl = pm.run(mbl)
Next we construct the pub and run the uncut circuit.
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)
uncut_expval
0.9498046875000001
We note that the expectation value obtained via wire cutting is closer to the ideal value of than the uncut one. Let us now scale up the size of the problem.
Part II. Scale it up!
Previously, we showed the results for a 10-qubit MBL circuit. Next, we show that the improvement in expectation value is also obtained for larger circuits. To show that, we repeat the process for a 60-qubit MBL circuit.
Step 1: Map classical inputs to a quantum problem
num_qubits = 60
depth = 2
mbl = MBLChainCircuit(num_qubits, depth)
We create a random set of values for
phis = list(np.random.rand(mbl.num_parameters - 1))
theta = [0]
params = theta + phis
Next we construct the cut circuit
mbl_cut = MBLChainCircuit(num_qubits, depth, use_cut=True)
mbl_cut.assign_parameters(params, inplace=True)
mbl_cut.draw("mpl", fold=-1)
Step 2: Optimize problem for quantum hardware execution
As shown for the small scale example, we partition the circuit and the observable for the cutting experiments.
mbl_move = cut_wires(mbl_cut)
# Define observable
observable = PauliList(
["I" * i + "Z" + "I" * (num_qubits - i - 1) for i in range(num_qubits)]
)
new_obs = expand_observables(observable, mbl, mbl_move)
# Partition the circuit into subcircuits
partitioned_problem = partition_problem(circuit=mbl_move, observables=new_obs)
# Get subcircuits
subcircuits = partitioned_problem.subcircuits
subobservables = partitioned_problem.subobservables
We also create a SparsePauliOp object for the observable with proper co-efficients.
M_z = SparsePauliOp(
["I" * i + "Z" + "I" * (num_qubits - i - 1) for i in range(num_qubits)],
coeffs=[1 / num_qubits] * num_qubits,
)
Next we generate the subexperiments and transpile each circuit in the subexperiment.
subexperiments, coefficients = generate_cutting_experiments(
circuits=subcircuits,
observables=subobservables,
num_samples=np.inf,
)
isa_subexperiments = {
label: pm.run(partition_subexpts)
for label, partition_subexpts in subexperiments.items()
}