Skip to main contentIBM Quantum Documentation Preview
This is a preview build of IBM Quantum® documentation. Refer to quantum.cloud.ibm.com/docs for the official documentation.

Readout error mitigation for the Sampler primitive using M3

Estimated QPU usage: 1 minute (tested on IBM ibm_kingston)


Background

Unlike the Estimator primitive, the Sampler primitive does not have built-in support for error mitigation. Several of the methods supported by the Estimator are specifically designed for expectation values, and hence are not applicable to the Sampler primitive. However, readout error mitigation is a highly effective method that is also applicable to the Sampler primitive. This tutorial explains how to use the M3 Qiskit addon to mitigate readout error for the Sampler primitive.

The M3 Qiskit addon implements an efficient method for readout error mitigation.

What is readout error?

Immediately before measurement, the state of a qubit register is described by a superposition of computational basis states, or by a density matrix. Measurement of the qubit register into a classical bit register then proceeds in two steps. First the quantum measurement proper is performed. This means that the state of the qubit register is projected onto a single basis state that is characterized by a string of 11s and 00s. The second step consists of reading the bitstring characterizing this basis state and writing it into classical computer memory. We call this step readout. It turns out that readout error is more important than error due to to incorrectly collapsing the state. This makes sense when you recall that readout requires detecting a microscopic quantum state and amplifying it to the macroscopic realm. A readout resonator is coupled to the (transmon) qubit, thereby experiencing a very small frequency shift. A microwave pulse is then bounced off of the resonator, in turn experiencing small changes in its characteristics. The reflected pulse is then amplified and analyzed. This is a delicate process subject to a host of errors.

The important point is that, while both quantum measurement and readout are subject to error, the latter incurs the dominant error, called readout error. In the following we be concerned only with readout error.

Theoretical background

If the sampled bitstring (stored in classical memory) differs from the bitstring characterizing the projected quantum state, we say that a readout error has occurred. These errors are observed to be random and uncorrelated from sample to sample. It has proven useful to model readout error as a noisy classical channel. That is, for every pair of bitstrings ii and jj, there is fixed probability that a true value of jj will be incorrectly read as ii.

More precisely, for every pair of bitstrings (i,j)(i, j), there is a (conditional) probability Mi,j{M}_{i,j} that ii is read, given that the true value is jj. That is,

Mi,j=Pr(readout value is itrue value is j) for i,j(0,...,2n1),(1) {M}_{i,j} = \Pr(\text{readout value is } i | \text{true value is } j) \text{ for } i,j \in (0,...,2^n - 1), \tag{1}

where nn is the number of bits in the readout register. For concreteness, we assume that ii is a decimal integer whose binary representation is the bitstring that labels the computational basis states. We call the 2n×2n2^n \times 2^n matrix M{M} the assignment matrix. For fixed true value jj, summing the probability over all noisy outcomes ii must give one. That is

i=02n1Mi,j=1 for all j \sum_{i=0}^{2^n - 1} {M}_{i,j} = 1 \text{ for all } j

A matrix with no negative entries that satisfies (1) is called left-stochastic. A left-stochastic matrix is also called column-stochastic because each of its columns sums to 11. We experimentally determine approximate values for each element Mi,j{M}_{i,j} by repeatedly preparing each basis state j|j \rangle and then computing the frequencies of occurrence of sampled bitstrings.

If an experiment involves estimating a probability distribution over output bitstrings by repeated sampling, then we can use M{M} to mitigate readout error at the level of the distribution. The first step is to repeat a fixed circuit of interest many times, creating a histogram of sampled bitstrings. The normalized histogram is the measured probability distribution over the 2n2^n possible bitstrings, which we denote by p~R2n{\tilde{p}} \in \mathbb{R}^{2^n}. The (estimated) probability p~i{{\tilde{p}}}_i of sampling bitstring ii is equal to the sum over all true bitstrings jj, each weighted by the probability that it is mistaken for ii. This statement in matrix form is

p~=Mp,(2) {\tilde{p}} = {M} {\vec{p}}, \tag{2}

where p{\vec{p}} is the true distribution. In words, the readout error has the effect of multiplying the ideal distribution over bitstrings p{\vec{p}} by the assignment matrix M{M} to produce the observed distribution p~{\tilde{p}}. We have measured p~{\tilde{p}} and M{M}, but have no direct access to p{\vec{p}}. In principle, we will obtain the true distribution of bitstrings for our circuit by solving equation (2) for p{\vec{p}} numerically.

Before we move on, it's worth noting some important features of this naive approach.

  • In practice, equation (2) is not solved by inverting M{M}. Linear algebra routines in software libraries employ methods that are more stable, accurate, and efficient.
  • When estimating M{M}, we assumed that only readout errors occurred. In particular, we assume there were no state preparation and quantum measurement errors— or at least that they were otherwise mitigated. To the extent that this is a good assumption, M{M} really represents only readout error. But when we use M{M} to correct a measured distribution over bitstrings, we make no such assumption. In fact, we expect an interesting circuit to introduce noise, for instance, gate errors. The "true" distribution still includes effects from any errors that are not otherwise mitigated.

This method, while useful in some circumstances, suffers from a few limitations.

The space and time resources needed to estimate M{M} grow exponentially in nn:

  • The estimation of M{M} and p~{\tilde{p}} is subject to statistical error due to finite sampling. This noise can be made as small as desired at the cost of more shots (up to the timescale of drifting hardware parameters that result in systematic errors in M{M}.) However, if no assumptions are made on the bitstrings observed when performing mitigation, the number of shots required to estimate M{M} grows at least exponentially in nn.
  • M{M} is a 2n×2n2^n \times 2^n matrix. When n>10n>10, the amount of memory required to store M{M} is greater than the memory available in a powerful laptop.

Further limitations are:

  • The recovered distribution p{\vec{p}} may have one or more negative probabilities (while still summing to one). One solution is to minimize Mpp~2||{M} {\vec{p}} - {\tilde{p}}||^2 subject to the constraint that each entry in p{\vec{p}} be non-negative. However, the runtime of such a method is orders of magnitude longer than directly solving equation (2).
  • This mitigation procedure works on the level of a probability distribution over bitstrings. In particular, it cannot correct an error in an individual observed bitstring.

Qiskit M3 addon: Scaling to longer bitstrings

The M3 method is an alternative to direct solution of equation (2) for mitigating readout error in the experimental distribution over bitstrings. While the direct method is limited to bitstrings no longer than about 1010 bits, M3 can handle much longer bitstrings. The two important features of M3 are

  • Correlations in readout error of order three and higher among collections of bits are assumed to be negligible and are ignored. In principle, at the cost of more shots, one could estimate higher correlations as well.
  • Rather than constructing M{M} explicitly, we use a much smaller effective matrix that records probabilities only for bitstrings collected when constructing p~{\tilde{p}}.

At a high level, the procedure works as follows.

First, we construct building blocks from which we can construct a simplified, effective, description of M{M}. Then, we repeatedly run the circuit of interest and collect bitstrings which we use to construct both p~{\tilde{p}} and, with the help of the building blocks, an effective M{M}.

More precisely,

  • Single-qubit assignment matrices are estimated for each qubit. To do this, we repeatedly prepare the qubit register in the all-zero state 0...0|0 ... 0 \rangle and then in the all-one state 1...1|1 ... 1 \rangle, and record the probability for each qubit that it is read incorrectly.

  • Correlations of order three and higher are assumed to be negligible and are ignored.

    Instead we construct a number nn of 2×22 \times 2 single qubit assignment matrices, and a number n(n1)/2n(n-1)/2 of 4×44 \times 4 two-qubit assignment matrices. These one- and two-qubit assignment matrices are stored for later use.

  • After repeatedly sampling a circuit to construct p~{\tilde{p}}, we construct an effective approximation to M{M} using only bitstrings that are sampled when constructing p~{\tilde{p}}. This effective matrix is built using the one- and two-qubit matrices described in the previous item. The linear dimension of this matrix is at most of the order of the number of shots used in constructing p~{\tilde{p}}, which is much smaller than the dimension 2n2^n of the full assignment matrix M{M} .

For technical details on M3, you can refer to Scalable Mitigation of Measurement Errors on Quantum Computers.

Application of M3 to a quantum algorithm

We'll apply M3's readout mitigation to the hidden shift problem. The hidden shift problem, and closely related problems such as the hidden subgroup problem, were originally conceived in a fault-tolerant setting (more precisely, before fault-tolerant QPUs were proved to be possible!). But they are studied with available processors as well. An example of algorithmic exponential speedup obtained for a variant of the hidden shift problem obtained on 127-qubit IBM QPU's can be found in this paper (arXiv version).

In the following, all arithmetic is Boolean. That is, for a,bZ2={0,1}a, b \in \mathbb{Z}_2 = \{0, 1\}, addition, a+ba + b is the logical XOR function. Furthermore, multiplication a×ba \times b (or aba b) is the logical AND function. For x,y{0,1}nx, y \in \{0, 1\}^n, x+yx + y is defined by bitwise application of XOR. The dot product :Z2nZ2\cdot: {\mathbb{Z}_2^n} \rightarrow \mathbb{Z}_2 is defined by xy=ixiyix \cdot y = \sum_i x_i y_i.

Hadamard operator and Fourier transform

In implementing quantum algorithms, it is very common to use the Hadamard operator as a Fourier transform. The computational basis states are sometimes called classical states. They stand in a one-to-one relation to the classical bitstrings. The nn-qubit Hadamard operator on classical states can be viewed as a Fourier transform on the Boolean hypercube:

Hn=12nx,yZ2n(1)xyyx.H^{\otimes n} = \frac{1}{\sqrt{2^n}} \sum_{x,y \in {\mathbb{Z}_2^n}} (-1)^{x \cdot y} {|{y}\rangle}{\langle{x}|}.

Consider a state s{|{s}\rangle} corresponding to fixed bitstring ss. Applying HnH^{\otimes n}, and using xs=δx,s{\langle {x}|{s}\rangle} = \delta_{x,s}, we see that the Fourier transform of s{|{s}\rangle} can be written

Hns=12nyZ2n(1)syy. H^{\otimes n} {|{s}\rangle} = \frac{1}{\sqrt{2^n}} \sum_{y \in {\mathbb{Z}_2^n}} (-1)^{s \cdot y} {|{y}\rangle}.

The Hadamard is its own inverse, that is, HnHn=(HH)n=InH^{\otimes n} H^{\otimes n} = (H H)^{\otimes n} = I^{\otimes n}. Thus, the inverse Fourier transform is the same operator, HnH^{\otimes n}. Explicitly, we have,

s=HnHns=Hn12nyZ2n(1)syy. {|{s}\rangle} = H^{\otimes n} H^{\otimes n} {|{s}\rangle} = H^{\otimes n} \frac{1}{\sqrt{2^n}} \sum_{y \in {\mathbb{Z}_2^n}} (-1)^{s \cdot y} {|{y}\rangle}.

The hidden shift problem

We consider a simple example of a hidden shift problem. The problem is to identify a constant shift in the input to a function. The function we consider is the dot product. It is the simplest member of a large class of functions that admit a quantum speedup for the hidden shift problem via techniques similar to those presented below.

Let x,yZ2mx,y \in {\mathbb{Z}_2^m} be bitstrings of length mm. We define f:Z2m×Z2m{1,1}{f}: {\mathbb{Z}_2^m} \times {\mathbb{Z}_2^m} \rightarrow \{-1,1\} by

f(x,y)=(1)xy. {f}(x, y) = (-1)^{x \cdot y}.

Let a,bZ2ma,b \in {\mathbb{Z}_2^m} be fixed bitstrings of length mm. We furthermore define g:Z2m×Z2m{1,1}g: {\mathbb{Z}_2^m} \times {\mathbb{Z}_2^m} \rightarrow \{-1,1\} by

g(x,y)=f(x+a,y+b)=(1)(x+a)(y+b), g(x, y) = {f}(x+a, y+b) = (-1)^{(x+a) \cdot (y+b)},

where aa and bb are (hidden) parameters. We are given two black boxes, one implementing ff, and the other gg. We suppose that we know that they compute the functions defined above, except that we know neither aa nor bb. The game is to determine the hidden bitstrings (shifts) aa and bb by making queries to ff and gg. It's clear that if we play the game classically, we need O(2m)O(2m) queries to determine aa and bb. For example, we can query gg with all pairs of strings that combined have exactly one element set to 11. On each query, we learn one element of either aa or bb. However, we will see that, if the black boxes are implemented as quantum circuits, we can determine aa and bb with a single query to each of ff and gg.

In the context of algorithmic complexity, a black box is called an oracle. In addition to being opaque, an oracle has the property that it consumes the input and produces the output instantly, adding nothing to the complexity budget of the algorithm in which it is embedded. In fact, in the case at hand, the oracles implementing ff and gg will be seen to be efficient.

Quantum circuits for ff and gg

We will need the following ingredients in order to implement ff and gg as quantum circuits.

For single qubit classical states x1,y1{|{x_1}\rangle}, {|{y_1}\rangle}, with x1,y1Z2x_1,y_1 \in \mathbb{Z}_2, the controlled ZZ gate CZ{CZ} may be written

CZx1y1x1=(1)x1y1x1x1y1.{CZ} {|{x_1}\rangle}{|{y_1}\rangle}{x_1} = (-1)^{x_1 y_1} {|{x_1}\rangle}{x_1}{|{y_1}\rangle}.

We will operate with mm CZ gates, one on (x1,y1)(x_1, y_1), and one on (x2,y2)(x_2, y_2), and so on, through (xm,ym)(x_m, y_m). We call this operator CZx,y{CZ}_{x,y}.

Uf=CZx,yU_f = {CZ}_{x,y} is a quantum version of f=f(x,y){f} = {f}(x,y):

Ufxy=CZx,yxy=(1)xyxy.%\CZ_{x,y} {|#1\rangle}{z} = U_f {|{x}\rangle}{|{y}\rangle} = {CZ}_{x,y} {|{x}\rangle}{|{y}\rangle} = (-1)^{x \cdot y} {|{x}\rangle}{|{y}\rangle}.

We also need to implement a bitstring shift. We denote the operator on the xx register Xa1XamX^{a_1}\cdots X^{a_m} by XaX_a and likewise on the yy register Xb=Xb1XbmX_b = X^{b_1}\cdots X^{b_m}. These operators apply XX wherever a single bit is 11, and the identity II wherever it is 00. Then we have

XaXbxy=x+ay+b. X_a X_b {|{x}\rangle}{|{y}\rangle} = {|{x+a}\rangle}{|{y+b}\rangle}.

The second black box gg is implemented by the unitary UgU_g, given by

Ug=XaXbCZx,yXaXb.%U_g {|{x}\rangle}{|{y}\rangle} = X_aX_b \CZ_{x,y} X_aX_b {|{x}\rangle}{|{y}\rangle}. U_g = X_aX_b {CZ}_{x,y} X_aX_b.

To see this, we apply the operators from right to left to the state xy{|{x}\rangle}{|{y}\rangle}. First

XaXbxy=x+ay+b. X_a X_b {|{x}\rangle}{|{y}\rangle} = {|{x+a}\rangle}{|{y+b}\rangle}.

Then,

CZx,yx+ay+b=(1)(x+a)(y+b)x+ay+b {CZ}_{x,y} {|{x+a}\rangle}{|{y+b}\rangle} = (-1)^{(x+a)\cdot (y+b)} {|{x+a}\rangle}{|{y+b}\rangle}

Finally,

XaXb(1)(x+a)(y+b)x+ay+b=(1)(x+a)(y+b)xy, X^a X^b (-1)^{(x+a)\cdot (y+b)} {|{x+a}\rangle}{|{y+b}\rangle} = (-1)^{(x+a)\cdot (y+b)} {|{x}\rangle}{|{y}\rangle},

which is indeed the quantum version of f(x+a,y+b)f(x+a, y+b).

The hidden shift algorithm

Now we put the pieces constructed above together to solve the hidden shift problem. We begin by applying Hadamards to the registers initialized to the all-zero state.

H2m=HmHm0m0m=122mx,yZ2m(1)xyxy.H^{\otimes 2m} = H^{\otimes m} \otimes H^{\otimes m} {{|{0}\rangle}^{\otimes m}}{{|{0}\rangle}^{\otimes m}} = \frac{1}{\sqrt{2^{2m}}} \sum_{x, y \in {\mathbb{Z}_2^m}} (-1)^{x \cdot y} {|{x}\rangle}{|{y}\rangle}.

Next, we query the oracle gg to arrive at

UgH2m0m0m=122mx,yZ2m(1)(x+a)(y+b)xyU_g H^{\otimes 2m} {{|{0}\rangle}^{\otimes m}}{{|{0}\rangle}^{\otimes m}} = \frac{1}{\sqrt{2^{2m}}} \sum_{x, y \in {\mathbb{Z}_2^m}} (-1)^{(x+a) \cdot (y+b)} {|{x}\rangle}{|{y}\rangle} 122mx,yZ2m(1)xy+xb+yaxy.\approx \frac{1}{\sqrt{2^{2m}}} \sum_{x, y \in {\mathbb{Z}_2^m}} (-1)^{x \cdot y + x \cdot b + y \cdot a} {|{x}\rangle}{|{y}\rangle}.

In the last line, we omitted the constant global phase factor (1)ab(-1)^{a \cdot b}, and denote equality up to a phase by \approx. Next, applying the oracle ff introduces another factor of (1)xy(-1)^{x \cdot y}, canceling the one already present. We then have,

UfUgH2m0m0m122mx,yZ2m(1)xb+yaxy.U_f U_g H^{\otimes 2m} {{|{0}\rangle}^{\otimes m}}{{|{0}\rangle}^{\otimes m}} \approx \frac{1}{\sqrt{2^{2m}}} \sum_{x, y \in {\mathbb{Z}_2^m}} (-1)^{x \cdot b + y \cdot a} {|{x}\rangle}{|{y}\rangle}.

The final step is to apply the inverse Fourier transform, H2m=HmHmH^{\otimes 2m} = H^{\otimes m} \otimes H^{\otimes m}, resulting in

H2mUfUgH2m0m0mba.H^{\otimes 2m} U_f U_g H^{\otimes 2m} {{|{0}\rangle}^{\otimes m}}{{|{0}\rangle}^{\otimes m}} \approx {|{b}\rangle}{|{a}\rangle}.

The circuit is finished. In the absence of noise, sampling the quantum registers will return the bitstrings b,ab, a with probability one.

The Boolean inner product is an example of the so-called bent functions. We will not define bent functions here but merely note that they "are maximally resistant against attacks that seek to exploit a dependence of the outputs on some linear subspace of the inputs." This quote is from the article Quantum algorithms for highly non-linear Boolean functions, which gives efficient hidden-shift algorithms for several classes of bent functions. The algorithm in this tutorial appears in Section 3.1 of the article.

In the more general case, the circuit for finding a hidden shift sZns \in \mathbb{Z}^n is

HnUf~HnUgHn0n=s. H^{\otimes n} U_{\tilde{f}} H^{\otimes n} U_g H^{\otimes n} {|{0}\rangle}^{\otimes n} = {|{s}\rangle}.

In the general case, ff and gg are functions of a single variable. Our example of the inner product has this form if we let f(x,y)f(z)f(x, y) \to f(z), with zz equal to the concatenation of xx and yy, and ss equal to the concatenation of aa and bb. The general case requires exactly two oracles: One oracle for gg and one for f~\tilde{f}, where the latter is a function known as the dual of the bent function ff. The inner product function has the self-dual property f~=f\tilde{f}=f.

In our circuit for the hidden shift on the inner product we omitted the middle layer of Hadamards that appears in the circuit for the general case. While in the general case this layer is necessary, we saved a bit of depth by omitting it, at the expense of a bit of post-processing because the output is ba{|{b}\rangle}{|{a}\rangle} instead of the desired ab{|{a}\rangle}{|{b}\rangle}.


Requirements

Before starting this tutorial, ensure that you have the following installed:

  • Qiskit SDK 2.1 or later with visualization support (pip install 'qiskit[visualization]')
  • Qiskit Runtime 0.41 or later (pip install qiskit-ibm-runtime)
  • M3 Qiskit addon 3.0 (pip install mthree)

Setup

from collections.abc import Iterator, Sequence
from random import Random
from qiskit.circuit import (
    CircuitInstruction,
    QuantumCircuit,
    QuantumRegister,
    Qubit,
)
from qiskit.circuit.library import CZGate, HGate, XGate
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_ibm_runtime import QiskitRuntimeService
import timeit
import matplotlib.pyplot as plt
from qiskit_ibm_runtime import SamplerV2 as Sampler
import mthree

Step 1: Map classical inputs to a quantum problem

First, we write the functions to implement the hidden shift problem as a QuantumCircuit.

def apply_hadamards(qubits: Sequence[Qubit]) -> Iterator[CircuitInstruction]:
    """Apply a Hadamard gate to every qubit."""
    for q in qubits:
        yield CircuitInstruction(HGate(), [q], [])
 
 
def apply_shift(
    qubits: Sequence[Qubit], shift: int
) -> Iterator[CircuitInstruction]:
    """Apply X gates where the bits of the shift are equal to 1."""
    for i, q in zip(range(shift.bit_length()), qubits):
        if shift >> i & 1:
            yield CircuitInstruction(XGate(), [q], [])
 
 
def oracle_f(qubits: Sequence[Qubit]) -> Iterator[CircuitInstruction]:
    """Apply the f oracle."""
    for i in range(0, len(qubits) - 1, 2):
        yield CircuitInstruction(CZGate(), [qubits[i], qubits[i + 1]])
 
 
def oracle_g(
    qubits: Sequence[Qubit], shift: int
) -> Iterator[CircuitInstruction]:
    """Apply the g oracle."""
    yield from apply_shift(qubits, shift)
    yield from oracle_f(qubits)
    yield from apply_shift(qubits, shift)
 
 
def determine_hidden_shift(
    qubits: Sequence[Qubit], shift: int
) -> Iterator[CircuitInstruction]:
    """Determine the hidden shift."""
    yield from apply_hadamards(qubits)
    yield from oracle_g(qubits, shift)
    # We omit this layer in exchange for post processing
    # yield from apply_hadamards(qubits)
    yield from oracle_f(qubits)
    yield from apply_hadamards(qubits)
 
 
def run_hidden_shift_circuit(n_qubits, rng):
    hidden_shift = rng.getrandbits(n_qubits)
 
    qubits = QuantumRegister(n_qubits, name="q")
    circuit = QuantumCircuit.from_instructions(
        determine_hidden_shift(qubits, hidden_shift), qubits=qubits
    )
    circuit.measure_all()
    # Format the hidden shift as a string.
    hidden_shift_string = format(hidden_shift, f"0{n_qubits}b")
    return (circuit, hidden_shift, hidden_shift_string)
 
 
def display_circuit(circuit):
    return circuit.remove_final_measurements(inplace=False).draw(
        "mpl", idle_wires=False, scale=0.5, fold=-1
    )

We'll start with a fairly small example:

n_qubits = 6
random_seed = 12345
rng = Random(random_seed)
circuit, hidden_shift, hidden_shift_string = run_hidden_shift_circuit(
    n_qubits, rng
)
 
print(f"Hidden shift string {hidden_shift_string}")
 
display_circuit(circuit)

Output:

Hidden shift string 011010
Output of the previous code cell

Step 2: Optimize circuits for quantum hardware execution

job_tags = [
    f"shift {hidden_shift_string}",
    f"n_qubits {n_qubits}",
    f"seed = {random_seed}",
]
job_tags

Output:

['shift 011010', 'n_qubits 6', 'seed = 12345']
# Uncomment this to run the circuits on a quantum computer on IBMCloud.
service = QiskitRuntimeService()
backend = service.backend("ibm_kingston")
 
# from qiskit_ibm_runtime.fake_provider import FakeMelbourneV2
# backend = FakeMelbourneV2()
# backend.refresh(service)
 
print(f"Using backend {backend.name}")
 
 
def get_isa_circuit(circuit, backend):
    pass_manager = generate_preset_pass_manager(
        optimization_level=3, backend=backend, seed_transpiler=1234
    )
    isa_circuit = pass_manager.run(circuit)
    return isa_circuit
 
 
isa_circuit = get_isa_circuit(circuit, backend)
display_circuit(isa_circuit)

Output:

Using backend ibm_kingston
Output of the previous code cell

Step 3: Execute circuits using Qiskit Primitives

# submit job for solving the hidden shift problem using the Sampler primitive
NUM_SHOTS = 50_000
 
 
def run_sampler(backend, isa_circuit, num_shots):
    sampler = Sampler(mode=backend)
    sampler.options.environment.job_tags
    pubs = [(isa_circuit, None, NUM_SHOTS)]
    job = sampler.run(pubs)
    return job
 
 
def setup_mthree_mitigation(isa_circuit, backend):
    # retrieve the final qubit mapping so mthree knows which qubits to calibrate
    qubit_mapping = mthree.utils.final_measurement_mapping(isa_circuit)
 
    # submit jobs for readout error calibration
    mit = mthree.M3Mitigation(backend)
    mit.cals_from_system(qubit_mapping, rep_delay=None)
 
    return mit, qubit_mapping
job = run_sampler(backend, isa_circuit, NUM_SHOTS)
mit, qubit_mapping = setup_mthree_mitigation(isa_circuit, backend)

Step 4: Post-process and return results in classical format

In the theoretical discussion above, we determined that for input abab, we expect output baba. An additional complication is that, in order to have a simpler (pre-transpiled) circuit, we inserted the required CZ gates between neighboring pairs of qubits. This amounts to interleaving the bitstrings aa and bb as a1b1a2b2a1 b1 a2 b2 \ldots. The output string baba will be interleaved in a similar way: b1a1b2a2b1 a1 b2 a2 \ldots. The function unscramble below transforms the output string from b1a1b2a2b1 a1 b2 a2 \ldots to a1b1a2b2a1 b1 a2 b2 \ldots so that the input and output strings may be compared directly.

# retrieve bitstring counts
def get_bitstring_counts(job):
    result = job.result()
    pub_result = result[0]
    counts = pub_result.data.meas.get_counts()
    return counts, pub_result
counts, pub_result = get_bitstring_counts(job)

The Hamming distance between two bit strings is the number of indices at which the bits differ.

def hamming_distance(s1, s2):
    weight = 0
    for c1, c2 in zip(s1, s2):
        (c1, c2) = (int(c1), int(c2))
        if (c1 == 1 and c2 == 1) or (c1 == 0 and c2 == 0):
            weight += 1
 
    return weight
# Replace string of form a1b1a2b2... with b1a1b2a1...
# That is, reverse order of successive pairs of bits.
def unscramble(bitstring):
    ps = [bitstring[i : i + 2][::-1] for i in range(0, len(bitstring), 2)]
    return "".join(ps)
 
 
def find_hidden_shift_bitstring(counts, hidden_shift_string):
    # convert counts to probabilities
    probs = {
        unscramble(bitstring): count / NUM_SHOTS
        for bitstring, count in counts.items()
    }
 
    # Retrieve the most probable bitstring.
    most_probable = max(probs, key=lambda x: probs[x])
 
    print(f"Expected hidden shift string: {hidden_shift_string}")
    if most_probable == hidden_shift_string:
        print("Most probable bitstring matches hidden shift 😊.")
    else:
        print("Most probable bitstring didn't match hidden shift ☹️.")
    print("Top 10 bitstrings and their probabilities:")
    display(
        {
            k: (v, hamming_distance(hidden_shift_string, k))
            for k, v in sorted(
                probs.items(), key=lambda x: x[1], reverse=True
            )[:10]
        }
    )
 
    return probs, most_probable
probs, most_probable = find_hidden_shift_bitstring(
    counts, hidden_shift_string
)

Output:

Expected hidden shift string: 011010
Most probable bitstring matches hidden shift 😊.
Top 10 bitstrings and their probabilities:
{'011010': (0.9743, 6),
 '001010': (0.00812, 5),
 '010010': (0.0063, 5),
 '011000': (0.00554, 5),
 '011011': (0.00492, 5),
 '011110': (0.00044, 5),
 '001000': (0.00012, 4),
 '010000': (8e-05, 4),
 '001011': (6e-05, 4),
 '000010': (6e-05, 4)}

Let's record the probability of the most probable bitstring before applying readout error mitigation with M3.

max_probability_before_M3 = probs[most_probable]
max_probability_before_M3

Output:

0.9743

Now we apply the readout correction learned by M3 to the counts. The function apply_corrections returns a quasi-probability distribution. This is a list of floats that sum to one. But some values might be negative.

def perform_mitigation(mit, counts, qubit_mapping):
    # mitigate readout error
    quasis = mit.apply_correction(counts, qubit_mapping)
 
    # print results
    most_probable_after_m3 = unscramble(max(quasis, key=lambda x: quasis[x]))
 
    is_hidden_shift_identified = most_probable_after_m3 == hidden_shift_string
    if is_hidden_shift_identified:
        print("Most probable bitstring matches hidden shift 😊.")
    else:
        print("Most probable bitstring didn't match hidden shift ☹️.")
    print("Top 10 bitstrings and their quasi-probabilities:")
    topten = {
        unscramble(k): f"{v:.2e}"
        for k, v in sorted(quasis.items(), key=lambda x: x[1], reverse=True)[
            :10
        ]
    }
    max_probability_after_M3 = float(topten[most_probable_after_m3])
    display(topten)
 
    return max_probability_after_M3, is_hidden_shift_identified
print(f"Expected hidden shift string: {hidden_shift_string}")
max_probability_after_M3, is_hidden_shift_identified = perform_mitigation(
    mit, counts, qubit_mapping
)

Output:

Expected hidden shift string: 011010
Most probable bitstring matches hidden shift 😊.
Top 10 bitstrings and their quasi-probabilities:
{'011010': '1.01e+00',
 '001010': '8.75e-04',
 '001000': '7.38e-05',
 '010000': '4.51e-05',
 '111000': '2.18e-05',
 '001011': '1.74e-05',
 '000010': '6.42e-06',
 '011001': '-7.18e-06',
 '011000': '-4.53e-04',
 '010010': '-1.28e-03'}

Comparing identifying the hidden shift string before and after applying M3 correction

def compare_before_and_after_M3(
    max_probability_before_M3,
    max_probability_after_M3,
    is_hidden_shift_identified,
):
    is_probability_improved = (
        max_probability_after_M3 > max_probability_before_M3
    )
    print(f"Most probable probability before M3: {max_probability_before_M3}")
    print(f"Most probable probability after M3: {max_probability_after_M3}")
    if is_hidden_shift_identified and is_probability_improved:
        print("Readout error mitigation effective! 😊")
    else:
        print("Readout error mitigation not effective. ☹️")
compare_before_and_after_M3(
    max_probability_before_M3,
    max_probability_after_M3,
    is_hidden_shift_identified,
)

Output:

Most probable probability before M3: 0.9743
Most probable probability after M3: 1.01
Readout error mitigation effective! 😊

Plot how CPU time required by M3 scales with shots

# Collect samples for numbers of shots varying from 5000 to 25000.
shots_range = range(5000, NUM_SHOTS + 1, 2500)
times = []
for shots in shots_range:
    print(f"Applying M3 correction to {shots} shots...")
    t0 = timeit.default_timer()
    _ = mit.apply_correction(
        pub_result.data.meas.slice_shots(range(shots)).get_counts(),
        qubit_mapping,
    )
    t1 = timeit.default_timer()
    print(f"\tDone in {t1 - t0} seconds.")
    times.append(t1 - t0)
 
fig, ax = plt.subplots()
ax.plot(shots_range, times, "o--")
ax.set_xlabel("Shots")
ax.set_ylabel("Time (s)")
ax.set_title("Time to apply M3 correction")

Output:

Applying M3 correction to 5000 shots...
	Done in 0.003321983851492405 seconds.
Applying M3 correction to 7500 shots...
	Done in 0.004425413906574249 seconds.
Applying M3 correction to 10000 shots...
	Done in 0.006366567220538855 seconds.
Applying M3 correction to 12500 shots...
	Done in 0.0071477219462394714 seconds.
Applying M3 correction to 15000 shots...
	Done in 0.00860048783943057 seconds.
Applying M3 correction to 17500 shots...
	Done in 0.010026784148067236 seconds.
Applying M3 correction to 20000 shots...
	Done in 0.011459112167358398 seconds.
Applying M3 correction to 22500 shots...
	Done in 0.012727141845971346 seconds.
Applying M3 correction to 25000 shots...
	Done in 0.01406092382967472 seconds.
Applying M3 correction to 27500 shots...
	Done in 0.01546052098274231 seconds.
Applying M3 correction to 30000 shots...
	Done in 0.016769016161561012 seconds.
Applying M3 correction to 32500 shots...
	Done in 0.019537431187927723 seconds.
Applying M3 correction to 35000 shots...
	Done in 0.019739801064133644 seconds.
Applying M3 correction to 37500 shots...
	Done in 0.021093040239065886 seconds.
Applying M3 correction to 40000 shots...
	Done in 0.022840639110654593 seconds.
Applying M3 correction to 42500 shots...
	Done in 0.023974396288394928 seconds.
Applying M3 correction to 45000 shots...
	Done in 0.026412792038172483 seconds.
Applying M3 correction to 47500 shots...
	Done in 0.026364430785179138 seconds.
Applying M3 correction to 50000 shots...
	Done in 0.02820305060595274 seconds.
Text(0.5, 1.0, 'Time to apply M3 correction')
Output of the previous code cell

Interpreting the plot

The plot above shows that the time required to apply M3 correction scales linearly in the number of shots.

Scaling up!

n_qubits = 80
rng = Random(12345)
circuit, hidden_shift, hidden_shift_string = run_hidden_shift_circuit(
    n_qubits, rng
)
 
print(f"Hidden shift string {hidden_shift_string}")

Output:

Hidden shift string 00000010100110101011101110010001010000110011101001101010101001111001100110000111
isa_circuit = get_isa_circuit(circuit, backend)
job = run_sampler(backend, isa_circuit, NUM_SHOTS)
mit, qubit_mapping = setup_mthree_mitigation(isa_circuit, backend)
counts, pub_result = get_bitstring_counts(job)
probs, most_probable = find_hidden_shift_bitstring(
    counts, hidden_shift_string
)

Output:

Expected hidden shift string: 00000010100110101011101110010001010000110011101001101010101001111001100110000111
Most probable bitstring matches hidden shift 😊.
Top 10 bitstrings and their probabilities:
{'00000010100110101011101110010001010000110011101001101010101001111001100110000111': (0.50402,
  80),
 '00000010100110101011101110010001010000110011100001101010101001111001100110000111': (0.0396,
  79),
 '00000010100110101011101110010001010000110011101001101010101001111001100100000111': (0.0323,
  79),
 '00000010100110101011101110010001010000110011101001101010101001101001100110000111': (0.01936,
  79),
 '00000010100110101011101110010011010000110011101001101010101001111001100110000111': (0.01432,
  79),
 '00000010100110101011101110010001010000110011101001101010101001011001100110000111': (0.0101,
  79),
 '00000010100110101011101110010001010000110011101001101010101001110001100110000111': (0.00924,
  79),
 '00000010100110101011101110010001010000010011101001101010101001111001100110000111': (0.00908,
  79),
 '00000010100110101011100110010001010000110011101001101010101001111001100110000111': (0.00888,
  79),
 '00000010100110101011101110010001010000110011101001100010101001111001100110000111': (0.0082,
  79)}

We see that the correct hidden shift string is found. Furthermore, the nine next-most-probably bitstrings are wrong in only one position.

Record the most probable probability

max_probability_before_M3 = probs[most_probable]
max_probability_before_M3

Output:

0.50402
print(f"Expected hidden shift string: {hidden_shift_string}")
max_probability_after_M3, is_hidden_shift_identified = perform_mitigation(
    mit, counts, qubit_mapping
)

Output:

Expected hidden shift string: 00000010100110101011101110010001010000110011101001101010101001111001100110000111
Most probable bitstring matches hidden shift 😊.
Top 10 bitstrings and their quasi-probabilities:
{'00000010100110101011101110010001010000110011101001101010101001111001100110000111': '9.85e-01',
 '00000010100110101011101110010001010000110011100001101010101001111001100110000111': '6.84e-03',
 '00000010100110101011100110010001010000110011101001101010101001111001100110000111': '3.87e-03',
 '00000010100110101011101110010011010000110011101001101010101001111001100110000111': '3.42e-03',
 '00000010100110101011101110010001010000110011101001101010101001111001100100000111': '3.30e-03',
 '00000010100110101011101110010001010000110011101001101010101001110001100110000111': '3.28e-03',
 '00000010100010101011101110010001010000110011101001101010101001111001100110000111': '2.62e-03',
 '00000010100110101011101110010001010000110011101001101010101001101001100110000111': '2.43e-03',
 '00000010100110101011101110010000010000110011101001101010101001111001100110000111': '1.73e-03',
 '00000010100110101011101110010001010000110011101001101010101001111001000110000111': '1.63e-03'}
compare_before_and_after_M3(
    max_probability_before_M3,
    max_probability_after_M3,
    is_hidden_shift_identified,
)

Output:

Most probable probability before M3: 0.54348
Most probable probability after M3: 0.99
Readout error mitigation effective! 😊

The results show that readout error was the dominant source of error and the M3 mitigation was effective.