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.

Real-Time Benchmarking for Qubit Selection

Usage estimate: 31min 30s on a Heron R2 processor (NOTE: This is an estimate only. Your runtime might vary.)

Background and Tutorial Overview

This tutorial shows how to run real-time characterization experiments and update backend properties to improve qubit selection when mapping a circuit to the physical qubits on a quantum processing unit (QPU).

The main goal is to demonstrate the benefits of running real-time characterization experiments before executing a quantum circuit. Specifically, we explain why selecting the best qubit layout matters and how the quality of a layout can directly influence circuit fidelity.

A qubit layout refers to the specific set of physical qubits on which a quantum circuit is mapped. All operations in the circuit are executed only on these qubits, meaning that the hardware performance of the chosen layout has a direct impact on the circuit’s overall fidelity.

You will learn how to:

  • Perform basic characterization experiments in Qiskit to determine the key properties of a QPU.

  • Update QPU error properties with real-time data and use them to transpile circuits, enabling the selection of the best-performing qubit layouts before execution on actual hardware.

To highlight the benefits of real-time QPU characterization, we will establish a correlation between predicted and measured hardware performance. This will be done by transpiling and running the same circuit across several randomly selected layouts and comparing their outcomes. As a case study, we focus on a modified Local Unitary Cluster Jastrow (LUCJ) circuit, which is commonly used as an ansatz to estimate the ground-state energy of correlated electronic systems.

Why does this matter?

A quantum processor’s properties can drift faster than the interval between reported QPU updates. One common cause is two-level-systems (TLS) interactions, which fluctuate on short timescales and can temporarily degrade qubit performance. When this happens, the qubit selection routines in Qiskit’s transpile stage may rely on outdated data, leading to suboptimal circuit mappings. Running real-time characterization experiments before execution helps capture the current hardware state and update backend properties, improving layout reliability and circuit performance.

Requirements

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

  • Qiskit SDK v2.0 or later, with visualization support ( pip install 'qiskit[visualization]' )

  • Qiskit Runtime v0.42 or later ( pip install qiskit-ibm-runtime )

  • Qiskit Experiments v0.12 or later ( pip install qiskit-experiments )

  • Python-based Simulations of Chemistry Framework v2.1 or later (pip install pyscf)

  • ffsim v0.0.58 or later ( pip install ffsim )

  • Qiskit Device Benchmarking v0.1 or later

    (git clone git@github.com:qiskit-community/qiskit-device-benchmarking.git

    cd qiskit-device-benchmarking

    pip install .)

Setup

import pyscf
import pyscf.cc
import pyscf.mcscf
from pyscf.data.elements import chemcore
 
import rustworkx as rx
from rustworkx import NoEdgeBetweenNodes, PyGraph
from typing import List, Tuple
import numpy as np
import pandas as pd
import ffsim
from datetime import datetime, timedelta
import random
from collections import Counter
import matplotlib.pyplot as plt
import re
 
from qiskit import QuantumCircuit, QuantumRegister
from qiskit.transpiler import CouplingMap
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit.transpiler.passmanager import PassManager
from qiskit.transpiler.passes import RemoveIdentityEquivalent
from qiskit.providers import BackendV2
from qiskit.result import marginal_counts as mcts
 
from qiskit_experiments.framework import BatchExperiment, ParallelExperiment
from qiskit_experiments.library import StandardRB
from qiskit_experiments.library.randomized_benchmarking import LayerFidelity
 
import qiskit_device_benchmarking.utilities.graph_utils as gu
import qiskit_device_benchmarking.utilities.layer_fidelity_utils as lfu
 
from qiskit_ibm_runtime import QiskitRuntimeService, Session
from qiskit_ibm_runtime import SamplerV2 as Sampler
from qiskit_ibm_runtime.models import BackendProperties
from qiskit_ibm_runtime.transpiler.passes.basis.fold_rzz_angle import (
    FoldRzzAngle,
)

Select a backend

Using the Qiskit IBM Runtime service, we select a QPU to run on.

There are some options we can define when choosing a backend, such as enabling fractional gates. Fractional gates are parameterized quantum gates that enable direct execution of arbitrary-angle rotations (within specific bounds), eliminating the need to decompose them into multiple basis gates. We are using them in this tutorial because they can significantly reduce both the depth and duration of quantum circuits, which helps with the total QPU time of the characetrization experiments described below, but note that they are not required to run characterization experiments in general. For more information on fractional gates, feel free to check out this tutorial.

service = QiskitRuntimeService()
backend = service.least_busy(
    operational=True, simulator=False, use_fractional_gates=True
)
device = backend.name
# print(device)

Defining real-time characterization experiments

Below is a list of common characterization experiments used to describe the properties of a QPU (please refer to the qiskit-experiments package for more details). These primarily target errors in single- and two-qubit control operations. While experiments such as T₁ and T₂ characterization are also common, we exclude them here to save QPU time. Moreover, the experiments shown below already capture some of their effects—for example, a qubit with a short T₁ relaxation time often exhibits higher control errors when the gate time is comparable to the relaxation time.

Readout

In a a state preparation and measurement (SPAM) experiment, also known as a readout experiment, qubits are initialized in a known state—either ∣0⟩ or ∣1⟩—and then measured. In real quantum devices, hardware imperfections can cause deviations in both the preparation and measurement processes.

To estimate these errors, we run two simple experiments for each qubit:

  • Ground-State Test: We measure the qubit without applying any gate. Ideally, it should be in the ∣0⟩ state. Any measurement of ∣1⟩ indicates an error.

  • Excited-State Test: We apply an X gate to flip the qubit to ∣1⟩, then measure it. If we instead get ∣0⟩, this also indicates an error.

# Create readout experiment on all qubits in parallel
num_qubits = backend.num_qubits
spam0 = QuantumCircuit(num_qubits, num_qubits)
spam0.measure_all()
spam1 = QuantumCircuit(num_qubits, num_qubits)
spam1.x(range(num_qubits))
spam1.measure_all()

Single qubit Randomized Benchmarking (1Q errors)

Randomized Benchmarking (RB) is a widely used technique to estimate gate error rates in quantum processors. In this case below we focus on 1Q errors. In randomized benchmarking, sequences of random Clifford gates are generated such that, in the absence of errors, they would ideally return qubits to their initial state. After execution, the probability of shots that returns to the initial state is used to estimate the Error Per Clifford (EPC).

# Create SQRB experiments in batches
G = backend.coupling_map.graph.to_undirected(multigraph=False)
sqrb_batches = gu.get_iso_qubit_list(G)
 
lengths = np.array([1, 50, 100, 500, 1000, 3000])  # Clifford lengths (x-axis)
num_samples = 6  # number of samples per clifford length (how many samplings are taken at each clifford length)
 
sqrb_exp_list = []
for batch in sqrb_batches:
    rb1q_exps = []
    for qubit in batch:
        rb1q_exp = StandardRB(
            physical_qubits=[int(qubit)],
            lengths=lengths,
            backend=backend,
            seed=42,
            num_samples=num_samples,
        )
        rb1q_exps.append(rb1q_exp)
    sqrb_exp_list.append(
        ParallelExperiment(rb1q_exps, backend=backend, flatten_results=True)
    )
sqrb_exp = BatchExperiment(
    sqrb_exp_list, backend=backend, flatten_results=True
)
sqrb_exp.set_experiment_options(separate_jobs=True)
samples_m = 3  # multiple of samples, determines number of circuits per job
sqrb_exp.experiment_options.max_circuits = samples_m * num_samples

Layer Fidelity (2Q errors)

IBM Quantum introduced Layer Fidelity — a new metric that evaluates a processor’s ability to run layered circuits across the entire device. One can extract 2Q errors from this experiment. Layer Fidelity extends randomized benchmarking by aggregating data over many layers of simultaneous two-qubit (2Q) gates, providing valuable insight into crosstalk and 2Q gate length limitations that often bottleneck large-scale quantum algorithms.

To capture these system-wide effects, Layer Fidelity is run on all edges (i.e., 2Q gates) in a device. This is implemented through the Layer Fidelity Grid method, which divides the QPU connectivity graph into two sets of 'qubit chains' that together capture all edges and then runs Layer Fidelity on each of them:

  • One set runs vertically across the device,

  • The other runs horizontally.

# Create Layer Fidelity experiment
 
# Get two qubit gate
if "ecr" in backend.configuration().basis_gates:
    twoq_gate = "ecr"
elif "cz" in backend.configuration().basis_gates:
    twoq_gate = "cz"
else:
    twoq_gate = "cx"
 
# Get one qubit basis gates
oneq_gates = []
for i in backend.configuration().basis_gates:
    # put in a case to handle rx and rzz
    if i.casefold() == "rx" or i.casefold() == "rzz":
        continue
    if i.casefold() != twoq_gate.casefold():
        oneq_gates.append(i)
 
# First, get the grid chains, these are hard coded in the layer fidelity utilities module
grid_chains = lfu.get_grids(backend)
 
# There are two sets of chains that can be run in four disjoint experiments
coupling_map = backend.coupling_map
edges = list(backend.target[twoq_gate].keys())
layers = [[] for i in range(4)]
grid_chain_flt = [[], []]
for i in range(2):
    all_pairs = gu.path_to_edges(grid_chains[i], coupling_map)
    for j, pair_lst in enumerate(all_pairs):
        grid_chain_flt[i] += grid_chains[i][j]
        sub_pairs = [
            tuple(pair) if tuple(pair) in edges else tuple(pair)[::-1]
            for pair in pair_lst
        ]
        layers[2 * i] += sub_pairs[0::2]
        layers[2 * i + 1] += sub_pairs[1::2]
 
# LF for horizontally-trending chains
h_qubits = grid_chain_flt[0]
lf_h = LayerFidelity(
    physical_qubits=h_qubits,
    two_qubit_layers=[layers[0], layers[1]],
    lengths=[1, 10, 20, 30, 40, 60, 80, 100, 150, 200, 400],
    backend=backend,
    num_samples=12,
    seed=60,
    two_qubit_gate=twoq_gate,
    one_qubit_basis_gates=oneq_gates,
)
 
# LF for vertically-trending chains
v_qubits = grid_chain_flt[1]
lf_v = LayerFidelity(
    physical_qubits=v_qubits,
    two_qubit_layers=[layers[2], layers[3]],
    lengths=[1, 10, 20, 30, 40, 60, 80, 100, 150, 200, 400],
    backend=backend,
    num_samples=12,
    seed=60,
    two_qubit_gate=twoq_gate,
    one_qubit_basis_gates=oneq_gates,
)
 
# Set maximum number of circuits per job to avoid errors due to too large payload
lf_v.experiment_options.max_circuits = 144
lf_h.experiment_options.max_circuits = 144
 
batches_lf = [lf_v, lf_h]

QPU properties over time

Looking at the reported QPU properties over time (we'll consider a single week below), we see how these can fluctuate on a scale of a single day. Small fluctuations can happen even in shorter timescales, mostly due to unexpected environment interactions. In this scenario, the reported properties will not accurately capture the current status of the QPU. Moreover, if a job is transpiled locally (using current reported properties) and submitted but executed only at a later time (minutes or days), it may run the risk of having used outdated properties for qubit selection in the transpilation step. This highlights the importance of having updated information about the QPU at execution time.

# Let's retrieve the properties over a certain time range
days = 7
errors_list = []
for day_idx in range(0, days):
    calibrations_time = datetime.now() - timedelta(days=day_idx)
    targer_hist = backend.target_history(datetime=calibrations_time)
 
    t1_dict, t2_dict = {}, {}
    for qubit in range(targer_hist.num_qubits):
        t1_dict[qubit] = targer_hist.qubit_properties[qubit].t1
        t2_dict[qubit] = targer_hist.qubit_properties[qubit].t2
 
    errors_dict = {
        "1q": targer_hist["sx"],
        "2q": targer_hist[twoq_gate],
        "spam": targer_hist["measure"],
    }
 
    errors_list.append(errors_dict)
# Let's plot these properties over time
fig, axs = plt.subplots(3, 1, figsize=(10, 20), sharex=False)
 
# Plot readout values
for qubit in range(targer_hist.num_qubits):
    spams = []
    for errors_dict in errors_list:
        spam_dict = errors_dict["spam"]
        spams.append(spam_dict[tuple([qubit])].error)
 
    axs[0].plot(spams)
 
axs[0].set_title("Readout Errors")
axs[0].set_ylabel("Error Rate")
axs[0].set_xlabel("Days")
 
# Plot 1Q Gate Errors
for qubit in range(targer_hist.num_qubits):
    oneq_gates = []
    for errors_dict in errors_list:
        oneq_gate_dict = errors_dict["1q"]
        oneq_gates.append(oneq_gate_dict[tuple([qubit])].error)
 
    axs[1].plot(oneq_gates)
 
axs[1].set_title("1Q Gate Errors")
axs[1].set_ylabel("Error Rate")
axs[1].set_xlabel("Days")
axs[1].set_ylim([0, 0.01])
 
# Plot 2Q Gate Errors
for pair in edges:
    twoq_gates = []
    for errors_dict in errors_list:
        twoq_gate_dict = errors_dict["2q"]
        twoq_gates.append(twoq_gate_dict[pair].error)
 
    axs[2].plot(twoq_gates)
 
axs[2].set_title("2Q Gate Errors")
axs[2].set_ylabel("Error Rate")
axs[2].set_xlabel("Days")
axs[2].set_ylim([0, 0.15])
 
plt.subplots_adjust(hspace=0.5)
plt.show()

Output:

<Figure size 1000x2000 with 3 Axes>

When in a workflow should characterization experiments be run?

Characterization experiments should be run before transpilation. Once the characterization experiments are completed, their results can be used to update the QPU error properties. These updates directly affect the transpilation step as they provide real-time data on hardware perfromamce. In practice, it is best to run all jobs as close in time as possible, which can be achieved by executing them within a single session.

The typical workflow is as follows:

  • Run characterization experiments.
  • Update QPU properties.
  • Transpile the circuit using the updated backend.
  • Execute the circuit on hardware.

Note: Before running the full workflow in a single session, ensure that each part of your code executes successfully on its own.


Step 1: Map classical inputs to a quantum problem

Let us put this to practice. We'll define a circuit of interest, in our case a modified LUCJ circuit, and transpile the circuit to randomly selected layouts. We'll then run QPU characterization experiments, along with the modified LUCJ circuit on the randomly selected layouts, and compare the correlation between QPU error properties and layout performance.

In Step 1, we begin by defining our circuit, a modified version of a LUCJ circuit. We'll do this for a N₂ molecule using the 6-31G basis set, which is a common choice in quantum chemistry for balancing accuracy and computational cost. From this specification, we can obtain the molecular properties needed for our circuit.

We then construct a modified LUCJ circuit, which is designed to simplify validation of results. This circuit:

  • Starts from the Hartree–Fock state.
  • Applies the LUCJ operator together with its inverse.

Because of the mirrored structure of the circuit, the final output state is still the Hartree-Fock state, allowing direct validation of measurement outcomes. To avoid excessive circuit depth, we remove the two adjacent orbital rotations in the middle of the circuit, and replace them with a barrier to prevent the transpiler from performing further gate cancellations. The resulting circuit has a similar structure to a single layer of a typical LUCJ circuit.

# Specify molecule properties
open_shell = False
spin_sq = 0
 
# Build N2 molecule
mol = pyscf.gto.Mole()
mol.build(
    atom=[["N", (0, 0, 0)], ["N", (1.0, 0, 0)]],
    basis="6-31g",
    symmetry="Dooh",
)
 
# Get the active space
ncore = chemcore(mol)
naorb = mol.nao_nr() - ncore
active_space = [p for p in range(ncore, mol.nao_nr())]
 
# Get molecular integrals
scf = pyscf.scf.RHF(mol).run()
num_orbitals = len(active_space)
n_electrons = int(sum(scf.mo_occ[active_space]))
num_elec_a = (n_electrons + mol.spin) // 2
num_elec_b = (n_electrons - mol.spin) // 2
cas = pyscf.mcscf.CASCI(scf, num_orbitals, (num_elec_a, num_elec_b))
mo = cas.sort_mo(active_space, base=0)
hcore, nuclear_repulsion_energy = cas.get_h1cas(mo)
eri = pyscf.ao2mo.restore(1, cas.get_h2cas(mo), num_orbitals)

Output:

converged SCF energy = -108.835236570774

Before constructing the modified LUCJ ansatz circuit, we first perform a CCSD calculation. The t1​ and t2 amplitudes from this calculation will be used to initialize the parameters of the ansatz.

We then use ffsim to create the circuit. Since our molecule has a closed-shell Hartree-Fock state, we use the spin-balanced variant of the UCJ ansatz, UCJOpSpinBalanced. We pass interaction pairs appropriate for a heavy-hex lattice qubit topology (see the background section on the LUCJ ansatz).

# Get CCSD t2 amplitudes for initializing the ansatz
ccsd = pyscf.cc.CCSD(
    scf, frozen=[i for i in range(mol.nao_nr()) if i not in active_space]
).run()
t1 = ccsd.t1
t2 = ccsd.t2
 
# Prepare UCJ operator
n_reps = 1
alpha_alpha_indices = [(p, p + 1) for p in range(num_orbitals - 1)]
alpha_beta_indices = [(p, p) for p in range(0, num_orbitals, 4)]
nelec = (num_elec_a, num_elec_b)
 
ucj_op = ffsim.UCJOpSpinBalanced.from_t_amplitudes(
    t2=t2,
    t1=t1,
    n_reps=n_reps,
    interaction_pairs=(alpha_alpha_indices, alpha_beta_indices),
)
orbital_rotation = ucj_op.orbital_rotations[0]
diag_coulomb_mat_aa, diag_coulomb_mat_ab = ucj_op.diag_coulomb_mats[0]
 
# Create an empty quantum circuit
qubits = QuantumRegister(2 * num_orbitals, name="q")
circuit = QuantumCircuit(qubits)
 
# Prepare Hartree-Fock state as the reference state and append it to the quantum circuit
circuit.append(ffsim.qiskit.PrepareHartreeFockJW(num_orbitals, nelec), qubits)
 
# Apply the UCJ operator to the reference state
circuit.append(
    ffsim.qiskit.OrbitalRotationJW(ucj_op.norb, orbital_rotation.T.conj()),
    qubits,
)
circuit.append(
    ffsim.qiskit.DiagCoulombEvolutionJW(
        ucj_op.norb,
        (diag_coulomb_mat_aa, diag_coulomb_mat_ab, diag_coulomb_mat_aa),
        -1.0,
    ),
    qubits,
)
 
# Add a barrier for transpilation
circuit.barrier()
 
# Apply the inverse UCJ operator
circuit.append(
    ffsim.qiskit.DiagCoulombEvolutionJW(
        ucj_op.norb,
        (diag_coulomb_mat_aa, diag_coulomb_mat_ab, diag_coulomb_mat_aa),
        1.0,
    ),
    qubits,
)
circuit.append(
    ffsim.qiskit.OrbitalRotationJW(ucj_op.norb, orbital_rotation), qubits
)
 
# Measure qubits
circuit.measure_all()

Output:

E(CCSD) = -109.0398256929733  E_corr = -0.20458912219883
# Let's draw the circuit
circuit.draw(fold=-1)

Output:

┌───────────────────┐┌──────────────┐┌───────────────────┐ ░ ┌───────────────────┐┌──────────────┐ ░ ┌─┐ , q_0: ┤0 ├┤0 ├┤0 ├─░─┤0 ├┤0 ├─░─┤M├───────────────────────────────────────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ └╥┘┌─┐ , q_1: ┤1 ├┤1 ├┤1 ├─░─┤1 ├┤1 ├─░──╫─┤M├────────────────────────────────────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ └╥┘┌─┐ , q_2: ┤2 ├┤2 ├┤2 ├─░─┤2 ├┤2 ├─░──╫──╫─┤M├─────────────────────────────────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ └╥┘┌─┐ , q_3: ┤3 ├┤3 ├┤3 ├─░─┤3 ├┤3 ├─░──╫──╫──╫─┤M├──────────────────────────────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ └╥┘┌─┐ , q_4: ┤4 ├┤4 ├┤4 ├─░─┤4 ├┤4 ├─░──╫──╫──╫──╫─┤M├───────────────────────────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ └╥┘┌─┐ , q_5: ┤5 ├┤5 ├┤5 ├─░─┤5 ├┤5 ├─░──╫──╫──╫──╫──╫─┤M├────────────────────────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_6: ┤6 ├┤6 ├┤6 ├─░─┤6 ├┤6 ├─░──╫──╫──╫──╫──╫──╫─┤M├─────────────────────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_7: ┤7 ├┤7 ├┤7 ├─░─┤7 ├┤7 ├─░──╫──╫──╫──╫──╫──╫──╫─┤M├──────────────────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_8: ┤8 ├┤8 ├┤8 ├─░─┤8 ├┤8 ├─░──╫──╫──╫──╫──╫──╫──╫──╫─┤M├───────────────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_9: ┤9 ├┤9 ├┤9 ├─░─┤9 ├┤9 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├────────────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_10: ┤10 ├┤10 ├┤10 ├─░─┤10 ├┤10 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├─────────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_11: ┤11 ├┤11 ├┤11 ├─░─┤11 ├┤11 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├──────────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_12: ┤12 ├┤12 ├┤12 ├─░─┤12 ├┤12 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├───────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_13: ┤13 ├┤13 ├┤13 ├─░─┤13 ├┤13 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├────────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_14: ┤14 ├┤14 ├┤14 ├─░─┤14 ├┤14 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├─────────────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_15: ┤15 ├┤15 ├┤15 ├─░─┤15 ├┤15 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├──────────────────────────────────────────────── , │ Hartree_fock_jw ││ Orb_rot_jw ││ Diag_coulomb_jw │ ░ │ Diag_coulomb_jw ││ Orb_rot_jw │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_16: ┤16 ├┤16 ├┤16 ├─░─┤16 ├┤16 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├───────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_17: ┤17 ├┤17 ├┤17 ├─░─┤17 ├┤17 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├────────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_18: ┤18 ├┤18 ├┤18 ├─░─┤18 ├┤18 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├─────────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_19: ┤19 ├┤19 ├┤19 ├─░─┤19 ├┤19 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├──────────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_20: ┤20 ├┤20 ├┤20 ├─░─┤20 ├┤20 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├───────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_21: ┤21 ├┤21 ├┤21 ├─░─┤21 ├┤21 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├────────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_22: ┤22 ├┤22 ├┤22 ├─░─┤22 ├┤22 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├─────────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_23: ┤23 ├┤23 ├┤23 ├─░─┤23 ├┤23 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├──────────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_24: ┤24 ├┤24 ├┤24 ├─░─┤24 ├┤24 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├───────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_25: ┤25 ├┤25 ├┤25 ├─░─┤25 ├┤25 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├────────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_26: ┤26 ├┤26 ├┤26 ├─░─┤26 ├┤26 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├─────────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_27: ┤27 ├┤27 ├┤27 ├─░─┤27 ├┤27 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├──────────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_28: ┤28 ├┤28 ├┤28 ├─░─┤28 ├┤28 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├───────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_29: ┤29 ├┤29 ├┤29 ├─░─┤29 ├┤29 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├────── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_30: ┤30 ├┤30 ├┤30 ├─░─┤30 ├┤30 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├─── , │ ││ ││ │ ░ │ ││ │ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘┌─┐ , q_31: ┤31 ├┤31 ├┤31 ├─░─┤31 ├┤31 ├─░──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫──╫─┤M├ , └───────────────────┘└──────────────┘└───────────────────┘ ░ └───────────────────┘└──────────────┘ ░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ └╥┘ ,meas: 32/══════════════════════════════════════════════════════════════════════════════════════════════════════╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩══╩═ , 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31

Step 2: Optimize problem for quantum hardware execution

In this step, we transpile the modified LUCJ circuit to match the connectivity of the target quantum processor.

  • First, we identify all possible qubit layouts on the device that follow the “zig-zag” pattern described in the Sample-based quantum diagonalization of a chemistry Hamiltonian tutorial (see the picture below). This layout is particularly well-suited for the LUCJ ansatz because it reduces the number of gates required to implement the circuit. We include code to automatically search for all such layouts.

  • From these, we randomly select N layouts to test. For each layout, we later incorporate the corresponding qubit and gate properties of the device, which allows us to predict performance using a cost function and compare it against the measured hardware outcomes.

  • For each chosen layout, we construct a staged pass manager using Qiskit’s generate_preset_pass_manager function. We set the pre_init stage of the pass manager to ffsim.qiskit.PRE_INIT to enable transpiler passes that can significantly reduce gate counts by:

    • Decomposing gates into orbital rotations.
    • Merging consecutive orbital rotations.
    • Using an optimized gate decomposition if an orbital rotation is applied to a computational basis state.
  • Finally, we run the pass manager on our circuit to obtain a hardware-optimized version of the modified LUCJ circuit.

image.png

Randomly select a subset of zig-zag layouts

def create_linear_chains(num_orbitals: int) -> PyGraph:
    """
    In zig-zag layout, there are two linear chains (with connecting qubits between
    the chains). This function creates those two linear chains: a rustworkx PyGraph
    with two disconnected linear chains. Each chain contains `num_orbitals` number
    of nodes, i.e., in the final graph there are `2 * num_orbitals` number of nodes.
 
    Args:
        num_orbitals (int): Number orbitals or nodes in each linear chain. They are
            also known as alpha-alpha interaction qubits.
 
    Returns:
        A rustworkx.PyGraph with two disconnected linear chains each with `num_orbitals`
            number of nodes.
    """
    G = rx.PyGraph()
 
    for n in range(num_orbitals):
        G.add_node(n)
 
    for n in range(num_orbitals - 1):
        G.add_edge(n, n + 1, None)
 
    for n in range(num_orbitals, 2 * num_orbitals):
        G.add_node(n)
 
    for n in range(num_orbitals, 2 * num_orbitals - 1):
        G.add_edge(n, n + 1, None)
 
    return G
 
 
def create_lucj_zigzag_layout(
    num_orbitals: int, backend_coupling_graph: PyGraph
) -> tuple[PyGraph, int]:
    """
    This function creates the complete zigzag graph that 'can be mapped' to a IBM QPU with
    heavy-hex connectivity (the zigzag must be an isomorphic sub-graph to the QPU/backend
    coupling graph for it to be mapped).
    The zigzag pattern includes both linear chains (alpha-alpha interactions) and connecting
    qubits between the linear chains (alpha-beta interactions).
 
    Args:
        num_orbitals (int): Number of orbitals, i.e., number of nodes in each alpha-alpha linear chain.
        backend_coupling_graph (PyGraph): Coupling map of the backend
 
    Returns:
        G_new (PyGraph): The graph with IBM backend compliant zigzag pattern.
        num_alpha_beta_qubits (int): Number of connecting qubits between the linear chains
            in the zigzag pattern.
    """
    isomorphic = False
    G = create_linear_chains(num_orbitals=num_orbitals)
 
    num_iters = num_orbitals
    while not isomorphic:
        G_new = G.copy()
        num_alpha_beta_qubits = 0
        for n in range(num_iters):
            if n % 4 == 0:
                new_node = 2 * num_orbitals + num_alpha_beta_qubits
                G_new.add_node(new_node)
                G_new.add_edge(n, new_node, None)
                G_new.add_edge(new_node, n + num_orbitals, None)
                num_alpha_beta_qubits = num_alpha_beta_qubits + 1
        isomorphic = rx.is_subgraph_isomorphic(backend_coupling_graph, G_new)
        num_iters -= 1
 
    return G_new, num_alpha_beta_qubits
 
 
def _make_backend_cmap_pygraph(backend: BackendV2) -> PyGraph:
    graph = backend.coupling_map.graph
    if not graph.is_symmetric():
        graph.make_symmetric()
    backend_coupling_graph = graph.to_undirected()
 
    edge_list = backend_coupling_graph.edge_list()
    removed_edge = []
    for edge in edge_list:
        if set(edge) in removed_edge:
            continue
        try:
            backend_coupling_graph.remove_edge(edge[0], edge[1])
            removed_edge.append(set(edge))
        except NoEdgeBetweenNodes:
            pass
 
    return backend_coupling_graph
 
 
def get_zigzag_layouts(
    num_orbitals: int,
    backend: BackendV2,
) -> List[List[int]]:
    """
    Function that generates a list of zigzag pattern layouts from the
    coupling map and physical qubits of a given backend.
 
    Args:
        num_orbitals (int): Number of orbitals.
        backend (BackendV2): A backend.
 
    Returns:
        A list of device compliant layouts (list[list[int]]) with zigzag pattern
    """
    backend_coupling_graph = _make_backend_cmap_pygraph(backend)
 
    G, num_aplha_beta_qubits = create_lucj_zigzag_layout(
        num_orbitals=num_orbitals,
        backend_coupling_graph=backend_coupling_graph,
    )
 
    # Instead of generating many isomorphic layouts, we can generate only one
    # and return that as later we will be using `VF2PostLayout` to find and score
    # many isomorphic layouts.
    isomorphic_mappings = rx.vf2_mapping(
        backend_coupling_graph, G, subgraph=True
    )
    isomorphic_mappings = list(isomorphic_mappings)
 
    edges = list(G.edge_list())
 
    layouts = []
    for mapping in isomorphic_mappings:
        initial_layout = [None] * (2 * num_orbitals + num_aplha_beta_qubits)
        for key, value in mapping.items():
            initial_layout[value] = key
        layouts.append(initial_layout[:-num_aplha_beta_qubits])
    return layouts
 
 
def get_edges(path: List[int], backend) -> List[Tuple[int]]:
    """
    Return a list of edges from a path of qubits.
 
    Args:
    - path: List of nodes (qubits)
    - backend: a backend
    Returns:
    - List of edges
    """
 
    coupling_map = CouplingMap(backend.configuration().coupling_map)
    G = coupling_map.graph
    edges = []
    prev_node = None
    # get all prossible edges from the nodes provided
    for node in path:
        if prev_node is not None:
            # check the edge is in G and in the coupling map
            if (
                G.has_edge(prev_node, node)
                and (prev_node, node) in coupling_map
            ):
                edges.append((prev_node, node))
            elif (
                G.has_edge(node, prev_node)
                and (node, prev_node) in coupling_map
            ):
                edges.append((node, prev_node))
        prev_node = node
    return edges
# find all possible zig-zag layouts
all_layouts = get_zigzag_layouts(num_orbitals, backend)
print("Number of possible zig zag layouts:", len(all_layouts))
 
# randomly select a subset of N layouts
N = 30 if len(all_layouts) >= 30 else len(all_layouts)
subsets = random.sample(all_layouts, N)
print("Number of randomly selected layouts:", len(subsets))

Output:

Number of possible zig zag layouts: 206
Number of randomly selected layouts: 30
# # Save randomly selected layouts locally (optional)
# today = date.today()
# print('Date:', today)
 
# subsets_dict = {}
# for i, v in enumerate(subsets):
#     subsets_dict[i] = v
 
# with open(f'{today}_{device}_{num_orbitals}_subsets.yaml', 'w') as file:
#     yaml.dump(subsets_dict, file)

Transpile layouts

The circuit defined on Step 1 contains a series of abstractions useful to think about quantum algorithms, but not possible to run on the hardware. To be able to run on a QPU, the circuit needs to undergo a series of operations that make up the transpilation or circuit optimization step. Transpilation may involves several steps, some of these are:

  • Initial mapping of the qubits in the circuit to physical qubits on the device.
  • Unrolling and optimization of the instructions in the quantum circuit to the hardware-native instructions that the backend understands.
  • Routing of any qubits in the circuit that interact by injecting SWAP gates (when needed) to make it compatible with the QPU's connectivity

More information about transpilation is available in our documentation.

transpiled_circuits = []
 
for i, layout in enumerate(subsets):
    # Transpile circuit on different layouts
    pass_manager = generate_preset_pass_manager(
        optimization_level=3,
        backend=backend,
        initial_layout=layout,
        seed_transpiler=42,
    )
    # Set the pre-initialization stage of the pass manager with passes suggested by ffsim
    pass_manager.pre_init = ffsim.qiskit.PRE_INIT
    pass_manager.post_init = PassManager([RemoveIdentityEquivalent()])
    pass_manager.post_optimization = PassManager(
        [FoldRzzAngle(), RemoveIdentityEquivalent(target=backend.target)]
    )
    transpiled = pass_manager.run(circuit)
    transpiled_circuits.append(transpiled)
    print("Counts for layout", i + 1, transpiled.count_ops())

Output:

Counts for layout 1 OrderedDict([('rz', 1566), ('rx', 1281), ('rzz', 714), ('x', 135), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 2 OrderedDict([('rz', 1566), ('rx', 1281), ('rzz', 714), ('x', 135), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 3 OrderedDict([('rz', 1545), ('rx', 1290), ('rzz', 714), ('x', 133), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 4 OrderedDict([('rz', 1545), ('rx', 1288), ('rzz', 714), ('x', 131), ('sx', 72), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 5 OrderedDict([('rz', 1566), ('rx', 1280), ('rzz', 714), ('x', 133), ('sx', 71), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 6 OrderedDict([('rz', 1553), ('rx', 1273), ('rzz', 714), ('x', 131), ('sx', 72), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 7 OrderedDict([('rz', 1554), ('rx', 1274), ('rzz', 714), ('x', 133), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 8 OrderedDict([('rz', 1567), ('rx', 1282), ('rzz', 714), ('x', 135), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 9 OrderedDict([('rz', 1555), ('rx', 1275), ('rzz', 714), ('x', 135), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 10 OrderedDict([('rz', 1545), ('rx', 1290), ('rzz', 714), ('x', 133), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 11 OrderedDict([('rz', 1566), ('rx', 1280), ('rzz', 714), ('x', 133), ('sx', 71), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 12 OrderedDict([('rz', 1567), ('rx', 1281), ('rzz', 714), ('x', 133), ('sx', 71), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 13 OrderedDict([('rz', 1546), ('rx', 1289), ('rzz', 714), ('x', 133), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 14 OrderedDict([('rz', 1555), ('rx', 1275), ('rzz', 714), ('x', 135), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 15 OrderedDict([('rz', 1546), ('rx', 1289), ('rzz', 714), ('x', 133), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 16 OrderedDict([('rz', 1546), ('rx', 1287), ('rzz', 714), ('x', 131), ('sx', 72), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 17 OrderedDict([('rz', 1554), ('rx', 1274), ('rzz', 714), ('x', 133), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 18 OrderedDict([('rz', 1566), ('rx', 1280), ('rzz', 714), ('x', 133), ('sx', 71), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 19 OrderedDict([('rz', 1546), ('rx', 1289), ('rzz', 714), ('x', 133), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 20 OrderedDict([('rz', 1567), ('rx', 1282), ('rzz', 714), ('x', 135), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 21 OrderedDict([('rz', 1554), ('rx', 1276), ('rzz', 714), ('x', 133), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 22 OrderedDict([('rz', 1546), ('rx', 1287), ('rzz', 714), ('x', 131), ('sx', 72), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 23 OrderedDict([('rz', 1546), ('rx', 1289), ('rzz', 714), ('x', 133), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 24 OrderedDict([('rz', 1554), ('rx', 1276), ('rzz', 714), ('x', 135), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 25 OrderedDict([('rz', 1554), ('rx', 1275), ('rzz', 714), ('x', 133), ('sx', 71), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 26 OrderedDict([('rz', 1553), ('rx', 1273), ('rzz', 714), ('x', 131), ('sx', 72), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 27 OrderedDict([('rz', 1545), ('rx', 1290), ('rzz', 714), ('x', 133), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 28 OrderedDict([('rz', 1554), ('rx', 1273), ('rzz', 714), ('x', 133), ('sx', 71), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 29 OrderedDict([('rz', 1553), ('rx', 1275), ('rzz', 714), ('x', 133), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])
Counts for layout 30 OrderedDict([('rz', 1554), ('rx', 1274), ('rzz', 714), ('x', 135), ('sx', 70), ('cz', 36), ('measure', 32), ('barrier', 2)])

Step 3: Execute using Qiskit primitives

With the circuit optimized for hardware execution, we are now ready to run it on the target device, along with the necessary characterization experiments, and collect measurement samples.

Both the characterization routines and the modified LUCJ circuit are executed within a single session to ensure they are run as close in time as possible, so that the characterization experiments capture the state of the hardware in real-time. This minimizes the effect of hardware drift and ensures that the results reflect the actual conditions under which the circuit is executed.

We also define the sampler options used for execution. These settings control parameters such as dynamical decoupling and number of shots, which may vary depending on the specific experiment being run (e.g., fewer shots for characterization routines, more for the final LUCJ circuit).

We begin by retrieving the characterization results and using them to build updated error maps for all qubits and gates in the device. These maps allow us to refresh the readout errors, as well as single-qubit and two-qubit gate errors. The updated information is stored in a separate object backend_updated, which can be used by the transpiler to find an optimal qubit layout.

Note: Please note that in a real application, characterization experiments should be done right before transpiling your circuit of interest, so the transpiler can use this information to select the best layout to run your circuit on. In this specific case, we have commented out the transpilation block inside of the session below because we already transpiled our circuit of interest on random layouts in Step 2 (which we needed to do in order to compare the perfomance of the circuit on different regions of the device).

# Helper functions to update QPU properties
 
# Make a readout error map
def get_readout_errors(num_qubits, readout_result):
    ro_error = {}
    cts_spam0 = readout_result[0].data.meas.get_counts()
    cts_spam1 = readout_result[1].data.meas.get_counts()
    spam_shots = readout_result[0].data.meas.num_shots
    for q in range(num_qubits):
        try:
            ro_error[q] = (
                1
                - (
                    (mcts(cts_spam0, [q])["0"] + mcts(cts_spam1, [q])["1"])
                    / 2
                )
                / spam_shots
            )
        except KeyError:
            ro_error[q] = 1 - ((mcts(cts_spam0, [q])["0"]) / 2) / spam_shots
    return ro_error
 
 
# Make a 1Q error map
def get_error_dict(job_results):
    values_map = {}
    for index, row in job_results.iterrows():
        if len(row.components) > 1:
            q1 = row.components[0].index
            q2 = row.components[1].index
            q = (q1, q2)
        else:
            q = row.components[0].index
        value = row.value.nominal_value
        values_map[q] = value
    return values_map
 
 
# Make a 2Q error map
def get_twoq_errors(backend, lf_result_h, lf_result_v):
    # Get two qubit gate
    if "ecr" in backend.configuration().basis_gates:
        twoq_gate = "ecr"
    elif "cz" in backend.configuration().basis_gates:
        twoq_gate = "cz"
    else:
        twoq_gate = "cx"
    lf_err_dict = lfu.make_error_dict(backend, twoq_gate)
    updated_err_dicts = []
    for i, lf_data in enumerate([lf_result_h, lf_result_v]):
        for j in range(2):
            updated_err_dicts.append(
                lfu.df_to_error_dict(lf_data, layers[2 * i + j])
            )
    lf_err_dict = lfu.update_error_dict(lf_err_dict, updated_err_dicts)
    return lf_err_dict
 
 
# Update readout errors
def update_readout_props(props, error_map):
    qubits = error_map.keys()
    for i in range(len(qubits)):
        for param in props["qubits"][i]:
            if param["name"] == "readout_error":
                param["value"] = error_map[i]
                break
    return props
 
 
# Update 1Q errors
def update_1q_props(props, error_map):
    for ix, data in enumerate(props["gates"]):
        # Determine if referenced gate is 2Q
        if len(data["qubits"]) > 1:
            # Get the gate type and hardware component acted on by the referenced gate
            x = re.search("^([a-zA-Z]+)([0-9]+_[0-9]+)", data["name"])
            gate_type, component = (
                x[1],
                (int(x[2].split("_")[0]), int(x[2].split("_")[1])),
            )
        # Determine that referenced gate is 1Q
        else:
            # Get the gate type and hardware component acted on by the referenced gate
            x = re.search("^([a-zA-Z]+)([0-9]+)", data["name"])
            gate_type, component = x[1], int(x[2])
 
        # Scan over gate parameters
        if gate_type == "sx" or gate_type == "x":
            for iy, parameter in enumerate(data["parameters"]):
                # Check if a parameter stores a gate error rate
                if parameter["name"] == "gate_error":
                    # Check if that parameter is in the characterized error rates
                    if (
                        f"EPG_{gate_type}" == "EPG_sx"
                        or f"EPG_{gate_type}" == "EPG_x"
                    ):
                        gate_error = error_map[component]
                        props["gates"][ix]["parameters"][iy]["value"] = (
                            gate_error
                        )
    return props
 
 
# Update layer fidelity 2Q errors
def update_lf_props(props, error_map):
    for ix, gate in enumerate(props["gates"]):
        if gate["gate"] in ["cz", "cx", "ecr"]:
            qubit_pair = gate["qubits"]
            pair_key = f"{qubit_pair[0]}_{qubit_pair[1]}"
            pair_key_reversed = f"{qubit_pair[1]}_{qubit_pair[0]}"
            if pair_key in error_map or pair_key_reversed in error_map:
                try:
                    gate_error = error_map[pair_key]
                except:
                    gate_error = error_map[pair_key_reversed]
                for iy, param in enumerate(gate["parameters"]):
                    if param["name"] == "gate_error":
                        props["gates"][ix]["parameters"][iy]["value"] = (
                            gate_error
                        )
                        break
    return props
# Run circuits
with Session(backend=backend) as session:
    # Run characterization experiments
    sampler = Sampler()
 
    # QPU usage ~8s
    sampler.options.default_shots = 10_000
    job_readout = sampler.run([spam0, spam1])
    print("Readout job id:", job_readout.job_id())
 
    # QPU usage 16s
    sampler.options.default_shots = 250
    job_sqrb = sqrb_exp.run(sampler=sampler)
    print("Single qubit RB job ids:", job_sqrb.job_ids)
 
    # QPU usage: ~15s each with the use of fractional gates, ~60s total
    sampler.options.default_shots = 250
    job_lf_vert = lf_v.run(sampler=sampler)
    job_lf_ho = lf_h.run(sampler=sampler)
    print("Vertical Layer Fidelity job ids:", job_lf_vert.job_ids)
    print("Horizontal Layer Fidelity job ids:", job_lf_ho.job_ids)
 
    # Retrieve characterization results
    readout_result = job_readout.result()
    sqrb_result_x = job_sqrb.analysis_results("EPG_x", dataframe=True)
    sqrb_result_sx = job_sqrb.analysis_results("EPG_sx", dataframe=True)
    lf_result_v = job_lf_vert.analysis_results(
        "ProcessFidelity", dataframe=True
    )
    lf_result_h = job_lf_ho.analysis_results(
        "ProcessFidelity", dataframe=True
    )
 
    # Create error maps from all characterization results
    ro_error = get_readout_errors(backend.num_qubits, readout_result)
    sqrb_err_x_dict = get_error_dict(sqrb_result_x)
    sqrb_err_sx_dict = get_error_dict(sqrb_result_sx)
    lf_err_dict = get_twoq_errors(backend, lf_result_h, lf_result_v)
 
    # Update QPU properties
    backend_updated = service.backend(device, use_fractional_gates=True)
    props_dict = backend_updated.properties().to_dict()
    props_dict = update_readout_props(props_dict, ro_error)
    props_dict = update_1q_props(props_dict, sqrb_err_x_dict)
    props_dict = update_1q_props(props_dict, sqrb_err_sx_dict)
    props_dict = update_lf_props(props_dict, lf_err_dict)
    props = BackendProperties.from_dict(props_dict)
    backend_updated._properties = props
 
    # # Transpile circuit using updated QPU properties to find the best qubit layout
    # pass_manager = generate_preset_pass_manager(optimization_level=3, backend=backend_updated)
    # pass_manager.pre_init = ffsim.qiskit.PRE_INIT
    # pass_manager.post_init = PassManager([RemoveIdentityEquivalent()])
    # pass_manager.post_optimization = PassManager(
    #     [
    #         FoldRzzAngle(), RemoveIdentityEquivalent(target=backend.target)
    #     ]
    # )
    # transpiled_circuits = pass_manager.run(circuit) # for some circuit of interest
 
    # Run your circuit (in this case the modified LUCJ circuit) and define sampler options to turn DD on/off
    sampler_options = {
        "dynamical_decoupling": {
            "enable": True,
            "sequence_type": "XpXm",
        }
    }
    sampler = Sampler(options=sampler_options)
    sampler.options.default_shots = 200_000
    job_sampler = sampler.run(transpiled_circuits)
    print("Job id for list of circuits:", job_sampler.job_id())

Output:

Readout job id: d5gkfqigim5s73ags9bg
Single qubit RB job ids: ['d5gkgqv67pic7384akk0', 'd5gkh4v67pic7384al10']
Vertical Layer Fidelity job ids: ['d5gkhsqgim5s73agsbm0', 'd5gki4nea9qs73913cm0']
Horizontal Layer Fidelity job ids: ['d5gkir7ea9qs73913deg', 'd5gkj3cpe0pc73al94t0']
/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py:134: RuntimeWarning: divide by zero encountered in scalar divide
  a_guess = (curve_data.y[0] - b_guess) / (alpha_guess ** curve_data.x[0])
/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/qiskit_experiments/library/randomized_benchmarking/layer_fidelity_analysis.py:128: RuntimeWarning: divide by zero encountered in scalar divide
  a_guess = (curve_data.y[0] - b_guess) / (alpha_guess ** curve_data.x[0])
/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/qiskit_experiments/library/randomized_benchmarking/layer_fidelity_analysis.py:128: RuntimeWarning: divide by zero encountered in scalar divide
  a_guess = (curve_data.y[0] - b_guess) / (alpha_guess ** curve_data.x[0])
qiskit_runtime_service.backends:WARNING:2026-01-09 14:46:30,626: Using instance: Solutions Demo premium fleet, plan: premium
Job id for list of circuits: d5glm6agim5s73agtkk0

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

Finally, we analyze the outcomes of the modified LUCJ circuit by counting the number of correct Hartree–Fock bitstrings returned. This provides a direct measure of how well the hardware reproduced the expected result.

By combining these two pieces of information—the backend properties and the percentage of correct bitstrings—we can study the correlation between predicted performance (from a score function using QPU properties) and the measured hardware performance.

Let us first take a look at what type of information can be extracted from the characterization experiments. Below is an example of an updated readout error dictionary that was extracted from the SPAM experiment. Similar information can be obtained for 1Q and 2Q errors.

# readout error dictionary
ro_error

Output:

{0: 0.012499999999999956,
 1: 0.018900000000000028,
 2: 0.004049999999999998,
 3: 0.02210000000000001,
 4: 0.005249999999999977,
 5: 0.014499999999999957,
 6: 0.008900000000000019,
 7: 0.023599999999999954,
 8: 0.005750000000000033,
 9: 0.010149999999999992,
 10: 0.008050000000000002,
 11: 0.016000000000000014,
 12: 0.02675000000000005,
 13: 0.019549999999999956,
 14: 0.01319999999999999,
 15: 0.017349999999999977,
 16: 0.005449999999999955,
 17: 0.006650000000000045,
 18: 0.008750000000000036,
 19: 0.007449999999999957,
 20: 0.00880000000000003,
 21: 0.006850000000000023,
 22: 0.005249999999999977,
 23: 0.004049999999999998,
 24: 0.008950000000000014,
 25: 0.01154999999999995,
 26: 0.009650000000000047,
 27: 0.009000000000000008,
 28: 0.006149999999999989,
 29: 0.011600000000000055,
 30: 0.006800000000000028,
 31: 0.014950000000000019,
 32: 0.030950000000000033,
 33: 0.016000000000000014,
 34: 0.01165000000000005,
 35: 0.013249999999999984,
 36: 0.006149999999999989,
 37: 0.00924999999999998,
 38: 0.007950000000000013,
 39: 0.016750000000000043,
 40: 0.01795000000000002,
 41: 0.06369999999999998,
 42: 0.01639999999999997,
 43: 0.1018,
 44: 0.006249999999999978,
 45: 0.03810000000000002,
 46: 0.006950000000000012,
 47: 0.02429999999999999,
 48: 0.005149999999999988,
 49: 0.016599999999999948,
 50: 0.004249999999999976,
 51: 0.04400000000000004,
 52: 0.010000000000000009,
 53: 0.041200000000000014,
 54: 0.005550000000000055,
 55: 0.05169999999999997,
 56: 0.011499999999999955,
 57: 0.007349999999999968,
 58: 0.006149999999999989,
 59: 0.00814999999999999,
 60: 0.030850000000000044,
 61: 0.038349999999999995,
 62: 0.02144999999999997,
 63: 0.03359999999999996,
 64: 0.00495000000000001,
 65: 0.0857,
 66: 0.008449999999999958,
 67: 0.05810000000000004,
 68: 0.005850000000000022,
 69: 0.00814999999999999,
 70: 0.004750000000000032,
 71: 0.010650000000000048,
 72: 0.48150000000000004,
 73: 0.014599999999999946,
 74: 0.007449999999999957,
 75: 0.005049999999999999,
 76: 0.034150000000000014,
 77: 0.009449999999999958,
 78: 0.013599999999999945,
 79: 0.008199999999999985,
 80: 0.005850000000000022,
 81: 0.01739999999999997,
 82: 0.004349999999999965,
 83: 0.08040000000000003,
 84: 0.01034999999999997,
 85: 0.014800000000000035,
 86: 0.007850000000000024,
 87: 0.042200000000000015,
 88: 0.009299999999999975,
 89: 0.01585000000000003,
 90: 0.005900000000000016,
 91: 0.021150000000000002,
 92: 0.005800000000000027,
 93: 0.023800000000000043,
 94: 0.00934999999999997,
 95: 0.01649999999999996,
 96: 0.005700000000000038,
 97: 0.00770000000000004,
 98: 0.007299999999999973,
 99: 0.0252,
 100: 0.010850000000000026,
 101: 0.05515000000000003,
 102: 0.017900000000000027,
 103: 0.00539999999999996,
 104: 0.02400000000000002,
 105: 0.015199999999999991,
 106: 0.008050000000000002,
 107: 0.01595000000000002,
 108: 0.005850000000000022,
 109: 0.009199999999999986,
 110: 0.003750000000000031,
 111: 0.009950000000000014,
 112: 0.010750000000000037,
 113: 0.06399999999999995,
 114: 0.006549999999999945,
 115: 0.00539999999999996,
 116: 0.009499999999999953,
 117: 0.017650000000000055,
 118: 0.007299999999999973,
 119: 0.01880000000000004,
 120: 0.008750000000000036,
 121: 0.01144999999999996,
 122: 0.01090000000000002,
 123: 0.006650000000000045,
 124: 0.04610000000000003,
 125: 0.010700000000000043,
 126: 0.007349999999999968,
 127: 0.0121,
 128: 0.01034999999999997,
 129: 0.018850000000000033,
 130: 0.003650000000000042,
 131: 0.08489999999999998,
 132: 0.0043999999999999595,
 133: 0.014549999999999952,
 134: 0.007099999999999995,
 135: 0.04849999999999999,
 136: 0.006950000000000012,
 137: 0.005049999999999999,
 138: 0.00660000000000005,
 139: 0.009499999999999953,
 140: 0.018100000000000005,
 141: 0.007449999999999957,
 142: 0.010549999999999948,
 143: 0.005299999999999971,
 144: 0.003950000000000009,
 145: 0.012800000000000034,
 146: 0.0044999999999999485,
 147: 0.005449999999999955,
 148: 0.006000000000000005,
 149: 0.043300000000000005,
 150: 0.004550000000000054,
 151: 0.010299999999999976,
 152: 0.006099999999999994,
 153: 0.00770000000000004,
 154: 0.00660000000000005,
 155: 0.00990000000000002}

These errors were succesfully updated to our backend_updated object, as can be seen below.

print("SPAM error for qubit 0:", backend.properties().readout_error(0))
print(
    "Updated SPAM error for qubit 0:",
    backend_updated.properties().readout_error(0),
)

Output:

SPAM error for qubit 0: 0.0113525390625
Updated SPAM error for qubit 0: 0.012499999999999956
# # Optionally, we can save these backend properties for future analysis
 
# props = backend.properties().to_dict()
# props_lf = backend_updated.properties().to_dict()
 
# today = date.today()
 
# try:
#     with open(f'{today}_{device}_properties.yaml', 'w') as file:
#         yaml.dump(props, file, sort_keys=False)
# except Exception as e:
#     print(f"An error occurred: {e}")
 
 
# try:
#     with open(f'{today}_{device}_properties_realtime_lf.yaml', 'w') as file:
#         yaml.dump(props_lf, file, sort_keys=False)
# except Exception as e:
#     print(f"An error occurred: {e}")

Find correlation between QPU properties and hardware perfomance for randomly selected layouts

We can now analyze the outcomes of the modified LUCJ circuit by counting the number of correct Hartree–Fock bitstrings returned. This provides a direct measure of how well the hardware reproduced the expected result.

By combining these two pieces of information—the updated QPU properties and the percentage of correct bitstrings—we can study the correlation between predicted performance (from a score function using backend properties) and the measured hardware performance on different qubit layouts.

To do this, we use the results from the modified LUCJ circuit:

  • First, we calculate the ratio of correct bitstrings that return to the initial Hartree–Fock state for each layout. This ratio serves as a measure of circuit fidelity on hardware.

  • Next, we assign a predicted score to each layout using a custom scoring function, similar to the one implemented in Qiskit for transpilation.

  • Finally, we compare the predicted score with the actual hardware results by plotting the score of each layout against its measured performance.

This comparison allows us to identify trends and correlations between the QPU error properties and observed circuit fidelity across different layouts.

We begin by retrieving the modified LUCJ results and computing the ratios of correct bitstrings.

results = job_sampler.result()
def get_correct_bitstrings(counts, hf_bitstring):
    """This function returns the number of bitstrings with the correct
    Hartree-Fock bitstring.
 
    Inputs:
        - counts (Dict): dictionary of bitstrings with frequency counts as values
        - hf_bitstring (str): string representing a Hartree-Fock state
    Output:
        - correct_Bs (int): number of bitstrings with the correct
                            Hartree-Fock bitstring
    """
    correct_bs = 0
    for bitstring, freq in counts.items():
        if bitstring == hf_bitstring:
            correct_bs += freq
    return correct_bs
# Hartree-Fock state bitstring
hf_bitstring = (
    "0" * (num_orbitals - num_elec_b)
    + "1" * num_elec_b
    + "0" * (num_orbitals - num_elec_a)
    + "1" * num_elec_a
)
 
print("Hartree-Fock bitstring", hf_bitstring)

Output:

Hartree-Fock bitstring 00000000000111110000000000011111
# Get ratios of Hartreefock states
ratios = []
for i, layout in enumerate(subsets):
    result = results[i]
    counts = result.data.meas.get_counts()
    correct_bs = get_correct_bitstrings(counts, hf_bitstring)
    shots = result.data.meas.num_shots
    ratio = correct_bs / shots
    ratios.append(ratio)
    print(f"{i+1} ratio of correct Hartree-Fock bitstrings:", ratio)

Output:

1 ratio of correct Hartree-Fock bitstrings: 0.0
2 ratio of correct Hartree-Fock bitstrings: 0.0
3 ratio of correct Hartree-Fock bitstrings: 0.00033
4 ratio of correct Hartree-Fock bitstrings: 0.0
5 ratio of correct Hartree-Fock bitstrings: 0.001875
6 ratio of correct Hartree-Fock bitstrings: 0.00528
7 ratio of correct Hartree-Fock bitstrings: 0.005235
8 ratio of correct Hartree-Fock bitstrings: 0.000285
9 ratio of correct Hartree-Fock bitstrings: 0.00265
10 ratio of correct Hartree-Fock bitstrings: 0.00346
11 ratio of correct Hartree-Fock bitstrings: 0.00025
12 ratio of correct Hartree-Fock bitstrings: 0.0019
13 ratio of correct Hartree-Fock bitstrings: 0.00299
14 ratio of correct Hartree-Fock bitstrings: 0.004335
15 ratio of correct Hartree-Fock bitstrings: 0.003195
16 ratio of correct Hartree-Fock bitstrings: 0.0
17 ratio of correct Hartree-Fock bitstrings: 0.00777
18 ratio of correct Hartree-Fock bitstrings: 0.000365
19 ratio of correct Hartree-Fock bitstrings: 0.004015
20 ratio of correct Hartree-Fock bitstrings: 0.001085
21 ratio of correct Hartree-Fock bitstrings: 5.5e-05
22 ratio of correct Hartree-Fock bitstrings: 0.0003
23 ratio of correct Hartree-Fock bitstrings: 0.000355
24 ratio of correct Hartree-Fock bitstrings: 0.003905
25 ratio of correct Hartree-Fock bitstrings: 0.000375
26 ratio of correct Hartree-Fock bitstrings: 0.006875
27 ratio of correct Hartree-Fock bitstrings: 0.0
28 ratio of correct Hartree-Fock bitstrings: 3.5e-05
29 ratio of correct Hartree-Fock bitstrings: 0.00011
30 ratio of correct Hartree-Fock bitstrings: 0.007115

Code for scoring layouts

The scoring function defined below is similar to the one implemented in Qiskit, but it can be adapted to any custom scoring logic.

The scoring function uses the single-qubit and two-qubit error rates associated with each qubit in the layout. For every element in the transpiled circuit, it raises the corresponding error-rate value to a power equal to the number of times that operation appears, estimating the overall circuit fidelity. This produces a score that can be used to compare the relative performance of different layouts.

def qiskit_cost(backend, circuit):
    # Get the counts of 1q and 2q for all qubits and edges in the circuit
    gate_counts = {}
    two_q_list = []
    one_q_list = []
    for item in circuit._data:
        if (
            item.operation.num_qubits == 2
            and item.operation.name != "barrier"
        ):
            q0 = circuit.find_bit(item.qubits[0]).index
            q1 = circuit.find_bit(item.qubits[1]).index
            if item.operation.name in ["cz", "rzz", "ecr" "cx"]:
                two_q_list.append((q0, q1))
        elif item.operation.name in [
            "rx",
            "x",
            "sx",
            "measure",
            "reset",
            "id",
        ]:
            q0 = circuit.find_bit(item.qubits[0]).index
            one_q_list.append(q0)
    gate_counts = {
        "twoq": (Counter(two_q_list)),
        "oneq": (Counter(one_q_list)),
    }
 
    # Get the average 1q error for all qubits in the circuit
    props = backend.properties()
    oneq_errors = {}
    oneq_gates = list(
        set(backend.configuration().basis_gates)
        - set(["cz", "rzz", "ecr", "cx"])
    )
    for q in one_q_list:
        ave_err = 0
        for gate in oneq_gates:
            ave_err += props.gate_error(gate, q)
        ave_err += props.readout_error(q)
        ave_err = ave_err / (
            len(oneq_errors) + 1
        )  # take average err of all 1q operations
        oneq_errors[q] = ave_err
 
    # Get the average 2q error for all edges in the circuit
    twoq_errors = {}
    twoq_gates = list(
        set(backend.configuration().basis_gates) - set(oneq_gates)
    )
    for pair in two_q_list:
        q0 = pair[0]
        q1 = pair[1]
        ave_err = 0
        for gate in twoq_gates:
            try:
                ave_err += props.gate_error(gate, [q0, q1])
            except:
                ave_err += props.gate_error(gate, [q1, q0])
        ave_err = ave_err / len(twoq_gates)
        twoq_errors[pair] = ave_err
 
    # Compute the product of fidelities from 1q and 2q errors
    fidelity = 1
    for k in gate_counts.keys():
        for indx, num_gates in gate_counts[k].items():
            if k == "twoq":
                err = twoq_errors[indx]
            elif k == "oneq":
                err = oneq_errors[indx]
            fidelity *= (1 - err) ** num_gates
    return fidelity
# Find the estimated cost of each layout
scores = []
for i, layout in enumerate(subsets):
    reported = qiskit_cost(backend, circuit=transpiled_circuits[i])
    lf = qiskit_cost(backend_updated, circuit=transpiled_circuits[i])
    scores.append(
        {
            "layout": i,
            "cost_reported": reported,
            "cost_lf": lf,
            "ratio": ratios[i],
        }
    )
scores = pd.DataFrame(scores)
 
scores = scores.sort_values(by="ratio", ascending=False).reset_index(
    drop=True
)

Plot the score-layout correlation

We now plot the correlation between the estimated layout score (based on reported and real-time QPU properties) and the actual hardware performance (measured as the ratio of bitstrings that return to the initial Hartree–Fock state).

As expected, there is a strong correlation: layouts with a higher predicted score generally correspond to higher observed performance, with a decaying trend in fidelity as the estimated score decreases. Both reported and real-time QPU properties capture this correlation well. This suggests that the way Qiskit reports qubit and gate fidelities is already accurate for most use cases.

# Function to normalize an array using Min-Max Scaling
def normalize(x):
    x = np.asarray(x)
    return (x - np.min(x)) / (np.max(x) - np.min(x))
 
 
# Get costs data
x = np.asarray(scores.index.to_list()) + 1
r = scores.ratio
repo = scores.cost_reported
lf = scores.cost_lf
 
# Normaliza data
repo = normalize(repo)
lf = normalize(lf)
 
# Create the figure and primary y-axis
fig, ax1 = plt.subplots(figsize=(12, 5))
 
# Create the secondary y-axis
ax2 = ax1.twinx()
 
# Plot the data
ax1.plot(x, repo, marker="o", linestyle="-", label="reported")
ax1.plot(x, lf, marker="o", linestyle="-", label="real-time", color="crimson")
ax2.plot(
    x,
    r,
    color="black",
    marker="x",
    linestyle="--",
    label="percetange of correct bitstrings",
)
 
# Set labels and titles
ax1.set_xlabel("Layout Index")
ax1.set_xticks(x)
ax1.set_ylabel("Layout Score")
ax2.set_ylabel("Ratio of valid HF states")
ax1.legend(loc="upper left", bbox_to_anchor=(1.15, 0.5))
ax2.legend(loc="lower left", bbox_to_anchor=(1.15, 0.5))
ax1.grid()
plt.xlabel("Layout number")
fig.tight_layout()
plt.title(
    "Modified LUCJ circuit: Correlation between layout score and hardware performance for different QPU properties"
)

Output:

Text(0.5, 1.0, 'Modified LUCJ circuit: Correlation between layout score and hardware performance for different QPU properties')
<Figure size 1200x500 with 2 Axes>

However, in some situations, real-time characterization can provide a clear advantage. For example, if a qubit is strongly interacting with a two-level system (TLS) during the experiment—but such intercation was not there when the last reported fidelity was uploaded—then the real-time QPU properties will capture this change, while the reported properties will not.

Another motivation for running characterization experiments before executing a circuit is to overwrite unrealistic error values. In Qiskit, some gate errors may be reported as 1 when randomized benchmarking fits fail. Although these values do not reflect the true performance of the gate, they can skew predictions. Characterization experiments can replace such placeholder values with updated estimates, providing a more realistic description of the hardware. More details about this behavior are discussed in the Appendix section.

Key Takeaways

We found a clear correlation between QPU error properties and hardware performance across different qubit layouts. A scoring function can be used to estimate circuit fidelity on specific device regions and predict which layouts will perform best—either through Qiskit’s built-in transpile function or a custom implementation.

Because these scoring functions rely on QPU error properties, having accurate and up-to-date data is essential. Qiskit generally provides reliable, recent property information, which is often sufficient for identifying promising layouts. However, for higher accuracy, it is recommended to run real-time characterization experiments as close in time as possible to the execution of the circuit of interest.

Real-time characterization helps capture fast hardware dynamics, such as fluctuations from two-level system (TLS) interactions, and can also overwrite inaccurate error values resulting from failed randomized benchmarking fits.

If you can allocate additional QPU time for these experiments, doing so is highly recommended—it can significantly improve circuit reliability and overall performance.


Appendix

In this section, we examine the specific case where single-qubit or two-qubit gate errors are reported as having a value of 1 in Qiskit. An error value of 1 corresponds to a gate fidelity of 1 – error = 0.

These values typically arise when randomized benchmarking (RB) fits fail for poorly performing gates. In such cases, the default error is set to 1. While the true error is likely close to 1, using this default value can lead to underestimating the actual hardware performance, depending on how the scoring function evaluates layouts during transpilation.

For example, if all two-qubit errors (such as cz or rzz) for a specific gate, say (0, 1), are reported with an error of 1, then the average two-qubit error for that edge is 1, which corresponds to a fidelity of 0. In the scoring function implemented in this tutorial, the total circuit fidelity is multiplied by this value, which forces the entire product to 0. As a result, the layout is automatically ruled out as extremely poor-performing, even though other gates in the same layout may still function well.

To illustrate this, take a look at the example below that extracts layouts with single- or two-qubit errors equal to 1.

valid_layouts = []
invalid_layouts = []
invalid_edges = []
invalid_qubits = []
for i, layout in enumerate(all_layouts):
    errors = []
    edges = get_edges(layout, backend)
    for edge in edges:
        error = backend.properties().gate_error(twoq_gate, edge)
        errors.append(error)
        if error == 1:
            invalid_edges.append(edge) if edge not in invalid_edges else None
    for qubit in layout:
        error = backend.properties().gate_error("sx", qubit)
        errors.append(error)
        if error == 1:
            invalid_qubits.append(
                qubit
            ) if qubit not in invalid_qubits else None
    if all(err < 1 for err in errors):
        valid_layouts.append(layout)
    else:
        invalid_layouts.append(layout)
 
print("Number of valid layouts (all errors < 1):", len(valid_layouts))
print(
    "Number of invalid layouts (one or more errors == 1):",
    len(invalid_layouts),
)
print("List of invalid 1Q gates:", invalid_qubits)
print("List of invalid 2Q gates:", invalid_edges)
if invalid_qubits:
    print("Quick example:")
    print(
        f"1Q error for {invalid_qubits[0]}:",
        backend.properties().gate_error("sx", invalid_qubits[0]),
    )
if invalid_edges:
    print(
        f"2Q error for {invalid_edges[0]}:",
        backend.properties().gate_error(twoq_gate, invalid_edges[0]),
    )

Output:

Number of valid layouts (all errors < 1): 158
Number of invalid layouts (one or more errors == 1): 48
List of invalid 1Q gates: [72]
List of invalid 2Q gates: [(27, 28), (71, 72), (33, 32), (28, 27), (32, 33), (72, 71)]
Quick example:
1Q error for 72: 1
2Q error for (27, 28): 1

The reported QPU error values are directly used during the transpilation step, where gate error properties guide the selection of the predicted highest-performing layouts for a quantum circuit.

When a layout includes one or more gates with a reported error value of 1, the effect on scoring can be severe. In some cases, this can force an entire layout score to 0, even if other gates in the layout perform reasonably well. By contrast, when using real-time QPU properties, the same layout often receives a nonzero score, reflecting a more realistic estimate of its true performance.

if invalid_layouts:
    pass_manager_invalid = generate_preset_pass_manager(
        optimization_level=3,
        backend=backend,
        initial_layout=invalid_layouts[8],
        seed_transpiler=42,
    )
 
    # Set the pre-initialization stage of the pass manager with passes suggested by ffsim
    pass_manager_invalid.pre_init = ffsim.qiskit.PRE_INIT
    pass_manager_invalid.post_init = PassManager([RemoveIdentityEquivalent()])
    pass_manager_invalid.post_optimization = PassManager(
        [FoldRzzAngle(), RemoveIdentityEquivalent(target=backend.target)]
    )
    transpiled_invalid = pass_manager_invalid.run(circuit)
 
    print(
        "Score from reported QPU properties:",
        qiskit_cost(backend, transpiled_invalid),
    )
    print(
        "Score from real-time QPU properties:",
        qiskit_cost(backend_updated, transpiled_invalid),
    )

Output:

Score from reported QPU properties: 0.0
Score from real-time QPU properties: 3.170245705431666e-17

This following case was observed on the backend ibm_fez at a past time. In this example, all layouts had one or more gates with reported errors equal to 1. This occurs when benchmarking fits fail and the error is defaulted to the maximum value. In this case, the real-time properties were much better at predicting actual hardware performance, which provides a strong reason for running characterization experiments.

image-2.png