Lewati ke konten utama

Meningkatkan Nilai Ekspektasi: Propagated Noise Absorption (PNA)

Dalam tutorial ini, kita akan belajar cara memanfaatkan alat-alat terbaru dalam ekosistem Qiskit untuk mengimplementasikan alur kerja mitigasi error yang sepenuhnya dapat dikustomisasi. Kita akan memperkenalkan teknik PNA dan menggunakannya untuk memitigasi kesalahan gate. Kita juga akan menggunakan TREX untuk memitigasi kesalahan readout dan post-selection untuk memitigasi kesalahan yang tidak tertangkap dalam model noise yang dipelajari.

Garis Besar

  • Memberikan gambaran singkat tentang PNA
  • Membuat Circuit Trotter kuantum dan observable. Mentranspile ke backend dan menyertakan pengukuran post-selection.
  • Menggunakan samplomatic untuk men-twirl lapisan gate 2Q dan pengukuran. Menemukan lapisan 2Q unik untuk mengurangi biaya pembelajaran noise.
  • Menggunakan NoiseLearnerV3 untuk mempelajari model error yang memengaruhi gate 2Q dan pengukuran.
  • Menggunakan qiskit-addon-pna untuk menghasilkan observable yang memitigasi noise
  • Menggunakan primitif qiskit-ibm-runtime.Executor untuk menghasilkan sampel QPU mentah yang mencerminkan setiap shot untuk setiap randomisasi twirling dan basis yang diukur
  • Menggunakan qiskit-addon-utils untuk memproses data menjadi nilai ekspektasi yang telah dimitigasi.

Apa itu propagated noise absorption (PNA)?​

Teknik untuk memitigasi kesalahan gate dengan mempropagasi observable melalui saluran noise invers yang memengaruhi gate 2-qubit, menghasilkan observable yang memitigasi noise. Gate 2Q dalam eksperimen yang ingin kita jalankan akan dipengaruhi oleh noise yang cukup besar. Eksperimen berisik Jika kita mempelajari model noise, kita dapat menerapkan inversnya dan membatalkan noise tersebut. Eksperimen yang dimitigasi noisenya Alih-alih mengimplementasikan saluran noise invers dengan mengambil sampelnya di QPU seperti pada PEC, kita dapat mengimplementasikannya secara klasikal dalam observable yang diukur menggunakan propagasi Pauli. Ini menghasilkan observable yang lebih kompleks yang, ketika diukur, memiliki efek memitigasi noise gate yang dipelajari. Gambaran umum PNA

Buat Circuit Trotter yang dicerminkan dan observable​

Untuk eksperimen ini, kita akan mempelajari dinamika waktu model kicked Ising 30-site pada rantai spin 1D. Hamiltonian yang dipertimbangkan adalah:

H=βˆ’Jβˆ‘βŸ¨i,j⟩ZiZj+hβˆ‘iXiH = -J\sum\limits_{\langle i,j \rangle} Z_iZ_j + h\sum\limits_iX_i,

di mana J>0J>0 mendeskripsikan kopling spin tetangga terdekat, i<ji<j, dan medan transversal global, hh, diatur ke Ο€8\frac{\pi}{8}. Semakin jauh hh dari sudut Clifford (yaitu ΞΈ=nΟ€2,n∈Z\theta=n\frac{\pi}{2}, n \in \mathbb{Z}), semakin sulit mempropagasi generator anti-noise melalui Circuit.

Untuk pilihan observable, kita akan mempertimbangkan magnetisasi rata-rata satu site, 1Nβˆ‘i=1N⟨zi⟩\frac{1}{N} \sum_{i=1}^{N} \langle z_i \rangle, di mana NN adalah jumlah site.

# Added by doQumentation β€” required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-addon-pna qiskit-addon-utils qiskit-ibm-runtime samplomatic
import numpy as np
from qiskit import QuantumCircuit
from qiskit.quantum_info import Pauli, SparsePauliOp

num_qubits = 30
num_trotter_steps = 10
rx_angle = np.pi / 8

# Avg single-site magnetization
id_pauli = Pauli("I" * num_qubits)
observable = SparsePauliOp([id_pauli.dot(Pauli("Z"), [i]) for i in range(num_qubits)]) / num_qubits

# Implement Trotterized kicked-Ising model
circuit = QuantumCircuit(num_qubits)
for _step in range(num_trotter_steps):
circuit.rx(rx_angle, range(num_qubits))
for first_qubit in (1, 2):
for idx in range(first_qubit, num_qubits, 2):
# equivalent to Rzz(-pi/2):
circuit.sdg([idx - 1, idx])
circuit.cz(idx - 1, idx)
circuit.compose(circuit.inverse(), inplace=True)
circuit.measure_active()
circuit.draw("mpl", fold=-1)

Diagram Circuit kuantum

Selanjutnya, kita akan memilih rantai qubit di ibm_kingston yang melaporkan tingkat error rendah dan mentranspile Circuit ke backend.

from qiskit.transpiler import generate_preset_pass_manager
from qiskit_ibm_runtime import QiskitRuntimeService

backend_name = "ibm_kingston"
service = QiskitRuntimeService()
backend = service.backend(backend_name, use_fractional_gates=True)

# Use a chain of low-noise qubits
layout = [
44,
45,
46,
47,
57,
67,
68,
69,
78,
89,
88,
87,
97,
107,
106,
105,
117,
125,
126,
127,
128,
129,
118,
109,
110,
111,
98,
91,
92,
93,
]

pm = generate_preset_pass_manager(backend=backend, initial_layout=layout, optimization_level=0)
isa_circuit = pm.run(circuit)
isa_observable = observable.apply_layout(isa_circuit.layout)
isa_circuit.draw("mpl", fold=-1)
qiskit_runtime_service._discover_account:WARNING:2025-11-10 14:30:57,148: Loading account with the given token. A saved account will not be used.

Diagram Circuit kuantum

Twirl lapisan gate 2-qubit dan pengukuran serta temukan lapisan unik​

Di sini kita memastikan pass manager memberi anotasi pada box dengan anotasi Twirl dan InjectNoise, yang memungkinkan kita mempelajari noise yang akan memengaruhi Circuit kita dan mengaitkan noise tersebut dengan lapisan Circuit yang sesuai.

  • enable_gates/enable_measure: True: Jadikan box semua lapisan gate 2q dan pengukuran terminal. Gate satu qubit akan didress di kiri dalam box.
  • measure_annotations: all Sertakan anotasi Twirl dan ChangeBasis pada box pengukuran
  • twirling_strategy: active: Twirl semua qubit aktif di setiap box yang berisi gate entangling
  • inject_noise_targets: gates: Anotasi InjectNoise harus ditambahkan ke semua box beranotasi Twirl yang berisi gate entangling
  • inject_noise_strategy: uniform_modification: Semua lapisan noise harus diskalakan secara setara.
from samplomatic.transpiler import generate_boxing_pass_manager

# Box up circuit with Twirl and InjectNoise annotations
pm = generate_boxing_pass_manager(
enable_gates=True,
enable_measures=True,
measure_annotations="all",
twirling_strategy="active",
inject_noise_targets="gates",
inject_noise_strategy="uniform_modification",
remove_barriers=True,
)
boxed_circuit = pm.run(isa_circuit)
draw_circ = QuantumCircuit(boxed_circuit.num_qubits)
draw_circ.append(boxed_circuit.data[0], qargs=boxed_circuit.data[0].qubits)
draw_circ.append(boxed_circuit.data[1], qargs=boxed_circuit.data[1].qubits)
draw_circ.draw("mpl", fold=-1, scale=0.3, idle_wires=False)

Diagram Circuit kuantum

Buat Circuit template dan samplex, tentukan cara Circuit akan diambil sampelnya​

Di sini kita juga menambahkan pengukuran spectator dan post-selection, yang diperlukan untuk melakukan post-selection pada sampel yang dikeluarkan dari Executor.

import samplomatic
from qiskit.transpiler import PassManager
from qiskit_addon_utils.noise_management.post_selection.transpiler.passes import (
AddPostSelectionMeasures,
AddSpectatorMeasures,
)

# Build template circuit and samplex for later use with the "Executor"
template_circuit, samplex = samplomatic.build(boxed_circuit)

# Add post-selection instructions to the template circuit
post_selection_pm = PassManager(
[
AddSpectatorMeasures(backend.coupling_map),
AddPostSelectionMeasures(x_pulse_type="rx"),
]
)
template_circuit = post_selection_pm.run(template_circuit)
draw_circ = template_circuit.copy_empty_like()
draw_circ.data = template_circuit.data[:324]
draw_circ.draw("mpl", fold=-1, scale=0.3, idle_wires=False)

Diagram Circuit kuantum

Pelajari noise​

Sebelum kita menjalankan eksperimen, kita pelajari model noise yang memengaruhi gate entangling dan pengukuran dalam Circuit. Memiliki model noise yang akurat sangat penting untuk memitigasi error secara efektif. Mempelajari noise tepat sebelum mengeksekusi eksperimen memberikan peluang terbaik agar model noise secara akurat menggambarkan noise aktual yang memengaruhi gate selama eksekusi.

Sebelum kita mempelajari noise, kita perlu menemukan lapisan 2-qubit unik dalam Circuit kita, sehingga kita dapat meminimalkan jumlah shot yang diperlukan untuk mempelajari noise untuk seluruh Circuit. Kita menggunakan find_unique_box_instructions dari samplomatic untuk mendapatkan lapisan unik dari Circuit yang di-box, termasuk lapisan pengukuran. Inilah lapisan yang kita oper ke noise learner.

Setelah kita mengetahui lapisannya, kita dapat mempelajari noise. Ada beberapa parameter yang perlu dipertimbangkan:

  • num_randomizations: Jumlah Circuit acak yang digunakan per konfigurasi Circuit pembelajaran
  • shots_per_randomization: Total jumlah shot yang digunakan per Circuit pembelajaran acak
  • layer_pair_depths: Kedalaman Circuit (diukur dalam jumlah pasangan) yang digunakan dalam eksperimen pembelajaran.
  • post_selection: Kita akan menggunakan post-selection berbasis edge selama pembelajaran menggunakan gate rx untuk mengimplementasikan pulsa pasca-pengukuran
from qiskit_ibm_runtime.noise_learner_v3.noise_learner_v3 import NoiseLearnerV3
from qiskit_ibm_runtime.options import NoiseLearnerV3Options
from samplomatic.utils import find_unique_box_instructions

# Load noise learner data from a shared job
load_saved_nl_result = True

# Noise learning parameters
num_randomizations_nl = 64
shots_per_randomization_nl = 128
strategy = "edge"
enable_postsel = True
x_pulse_type = "rx"

# Find the unique instructions (layers) from boxed-up circuit
unique_2q_layers_and_meas = find_unique_box_instructions(
boxed_circuit, normalize_annotations=None, undress_boxes=True
)

noise_learner_params = {
"num_randomizations": num_randomizations_nl,
"shots_per_randomization": shots_per_randomization_nl,
"layer_pair_depths": [1, 2, 4, 8, 12, 16, 24, 32, 40, 48],
"post_selection": {
"enable": enable_postsel,
"strategy": strategy,
"x_pulse_type": x_pulse_type,
},
"experimental": {},
}
# set the options
noise_learner_options = NoiseLearnerV3Options(**noise_learner_params)

# run the noise learner job
noise_learner = NoiseLearnerV3(backend, noise_learner_options)
noise_learner_job = noise_learner.run(unique_2q_layers_and_meas)
noise_learner_result = noise_learner_job.result()

nl_metadata = noise_learner_params | {"layout": layout}
import matplotlib.pyplot as plt

hw_rates_1q = []
hw_rates_2q = []
for nlr in noise_learner_result[:2]:
plm_list = nlr.to_pauli_lindblad_map().to_sparse_list()
hw_rates_1q += [rate for (pstr, qubits, rate) in plm_list if len(pstr) == 1]
hw_rates_2q += [rate for (pstr, qubits, rate) in plm_list if len(pstr) == 2]
hw_rates_1q = sorted(hw_rates_1q)
hw_rates_2q = sorted(hw_rates_2q)
median_1q = hw_rates_1q[len(hw_rates_1q) // 2]
median_2q = hw_rates_2q[len(hw_rates_2q) // 2]
fig, ax = plt.subplots(1, 1, figsize=(14, 5))
ax.scatter(
(hw_rates_1q),
[(i) / (len(hw_rates_1q) - 1) for i in range(len(hw_rates_1q))],
color="red",
label="1q rates",
)
ax.set_xscale("log")
ax.set_ylim(0, 1.1)
ax.vlines(median_1q, 0, 1, color="red")
ax.text(median_1q * 1.1, 0.1, f"{median_1q:.2e}")
ax.scatter(
(hw_rates_2q),
[(i) / (len(hw_rates_2q) - 1) for i in range(len(hw_rates_2q))],
color="blue",
label="2q rates",
)
ax.set_xscale("log")
ax.set_ylim(0, 1.1)
ax.vlines(median_2q, 0, 1, color="blue")
ax.text(median_2q * 1.1, 0.2, f"{median_2q:.2e}")
ax.set_title("Learned noise rates")
ax.set_xlabel("Noise rate")
ax.set_yticks([])
plt.legend()
<matplotlib.legend.Legend at 0x321dd63f0>

Keluaran plot

Hubungkan kotak sirkuit dengan noise yang dipelajari​

Di sini, kita membuat pemetaan antara ID referensi InjectNoise dari setiap kotak ke model noise yang dipelajari (PauliLindbladMap) yang memengaruhi gate entangling dalam kotak tersebut.

from samplomatic.annotations import InjectNoise
from samplomatic.utils import get_annotation

# map inject noise refs to pauli lindblad maps
refs_to_noise_models = {}
for instruction, result in zip(unique_2q_layers_and_meas, noise_learner_result, strict=False):
if inject_noise_annot := get_annotation(instruction.operation, InjectNoise):
refs_to_noise_models[inject_noise_annot.ref] = result.to_pauli_lindblad_map()

Propagasi observable melalui anti-noise yang dipelajari untuk mendapatkan observable peredam noise​

Seperti yang dibahas di atas, ini dilakukan dalam dua langkah. Pertama, kita mempropagasi generator anti-noise ke akhir sirkuit. Setelah itu, kita mempropagasi observable melalui generator yang telah berevolusi tersebut. Proses ini diulang untuk setiap generator anti-noise dalam sirkuit. Dalam implementasi ini, setiap generator dalam layer tertentu dipropagasi ke akhir sirkuit secara paralel. Selain itu, multiprocessing Python digunakan untuk melakukan forward-propagation anti-noise sekaligus back-propagation observable secara paralel. Ini mencegah penumpukan generator yang telah berevolusi di memori dan juga memaksimalkan penggunaan sumber daya komputasi.

Saat menjalankan PNA, kamu selalu perlu menyediakan sirkuit bernoisy dan observable. Jika sirkuit bernoisy-mu adalah sirkuit yang diberi kotak dengan anotasi InjectNoise, kamu perlu menyediakan pemetaan yang kita buat pada langkah di atas. Kita juga bisa memberikan sirkuit tanpa kotak yang berisi instruksi PauliLindbladError dari qiskit-aer. Dalam kasus itu, refs_to_noise_models tidak perlu disediakan. Selain input utama, pengguna perlu mempertimbangkan:

  • max_err_terms: Jumlah term yang disimpan dalam setiap generator anti-noise saat di-forward-propagate. Nilai yang lebih besar umumnya meningkatkan akurasi, tapi perilaku ini tidak dijamin monotonik.
  • max_obs_terms: Jumlah term yang disimpan dalam observable peredam noise, O~\tilde{O}, saat di-back-propagate melalui anti-noise yang telah berevolusi. Nilai yang lebih besar umumnya meningkatkan akurasi, tapi tidak dijamin monotonik.
  • num_processes: Jumlah core yang didedikasikan untuk proses ini. Ingat, generator di-forward-propagate dan diterapkan ke observable secara paralel.
  • search_step: Langkah back-propagation menggunakan metode greedy untuk mengkonjugasi dua operator secara perkiraan dalam basis Pauli. Metode ini bisa dipercepat dengan menaikkan search_step. Lihat dokumentasi pauli-prop untuk informasi lebih lanjut.
  • num_to_measure: Meski variabel ini bukan input dari generate_noise_mitigating_observable, kita gunakan untuk mengontrol berapa banyak term dari O~\tilde{O} yang benar-benar ingin diukur. Di sini kita hanya mengukur 30 term teratas, yaitu term-term asli dalam observable kita. Term-term tersebut telah di-rescale sehingga mengukurnya memiliki efek memitigasi noise gate yang dipelajari. Meski kita hanya mengukur 30 term dari O~\tilde{O}, sering kali tetap berguna untuk membiarkannya tumbuh besar, karena itu meningkatkan presisi faktor skala term-term terdepan.
from qiskit_addon_pna import generate_noise_mitigating_observable

# PNA parameters
num_processes = 8
max_err_terms = 10_000
max_obs_terms = 10_000
num_to_measure = num_qubits

obs_tilde_isa = generate_noise_mitigating_observable(
boxed_circuit,
isa_observable,
refs_to_noise_models,
max_err_terms=max_err_terms,
max_obs_terms=max_obs_terms,
num_processes=num_processes,
print_progress=True,
search_step=8,
)
p_2_v = {p: v for v, p in enumerate(layout)}
obs_tilde_virtual = SparsePauliOp.from_sparse_list(
[
(pstr, [p_2_v[p] for p in p_qubits], coeff)
for (pstr, p_qubits, coeff) in obs_tilde_isa.to_sparse_list()
],
num_qubits=num_qubits,
)
obs_tilde_virtual = obs_tilde_virtual[np.argsort(np.abs(obs_tilde_virtual.coeffs))[::-1]][
:num_to_measure
]
Finished! 13560 / 13560 generators propagated.
obs_tilde_isa = obs_tilde_isa[np.argsort(np.abs(obs_tilde_isa.coeffs))][::-1]
plt.xscale("log")
plt.yscale("log")
plt.title(r"$\tilde{O}$ coeff magnitudes")
plt.ylabel("Magnitude")
plt.xlabel("Pauli term index")
plt.plot(np.abs(obs_tilde_isa.coeffs), ".")
[<matplotlib.lines.Line2D at 0x16b69e840>]

Plot output

Ubah basis pengukuran ke bentuk kanonik​

Selanjutnya, kita akan menemukan set basis minimal untuk diukur sehingga kita bisa mencakup setiap term Pauli dalam observable yang diukur (banyak observable bisa diukur secara bersamaan jika mereka commute secara qubit-wise). Karena kita hanya mengukur term-term dalam observable asli kita, yang merupakan jumlah semua Pauli Z tunggal, hanya satu basis yang diperlukan -- basis all-Z.

Selain menemukan set basis pengukuran Pauli, kita perlu memetakan term-term Pauli ini ke bentuk kanonik yang diharapkan oleh primitive Executor. Untuk informasi lebih lanjut tentang urutan Qubit kanonik, kunjungi dokumentasi samplomatic.

from qiskit_addon_utils.exp_vals.measurement_bases import get_measurement_bases

meas_box = boxed_circuit.data[-1]
canonical_qubits = [
idx for idx, qubit in enumerate(boxed_circuit.qubits) if qubit in meas_box.qubits
]
c_2_p = {c: p for c, p in enumerate(canonical_qubits)} # canonical -> physical
p_2_v = {p: v for v, p in enumerate(layout)} # physical -> virtual
c_2_v = {c: p_2_v[p] for c, p in c_2_p.items()} # canonical -> virtual
meas_bases, bases_reverser = get_measurement_bases(obs_tilde_virtual)
meas_bases_canonical = [
np.array([base[c_2_v[c]] for c in range(num_qubits)], dtype=np.uint8) for base in meas_bases
]

Tentukan cara sampling dalam QuantumProgram​

QuantumProgram adalah tempat kita menentukan cara melakukan sampling eksperimen:

  • template_circuit: Sirkuit yang berisi semua gate yang diperlukan untuk mengimplementasikan semua randomisasi yang diinginkan (dari randomisasi twirling, parameter, dll.).
  • samplex: Objek yang mendefinisikan distribusi probabilitas atas semua kemungkinan randomisasi sirkuit untuk di-sampling.
  • samplex_arguments: Binding yang diperlukan untuk mendefinisikan samplex sepenuhnya
    • basis_changes: Di sinilah kita menentukan set basis untuk diukur yang akan mencakup semua term Pauli dalam observable yang diukur.
    • noise_scales.ref: Kita setel skala setiap layer noise ke 0.0 untuk mencegah noise tambahan disuntikkan ke sampel kita.
    • pauli_lindblad_maps: Diperlukan jika noise_scales diberikan. Ini hanya memetakan layer noise ke model noise terkait.
  • shape: Tuple shape untuk memperluas shape implisit yang didefinisikan oleh samplex_arguments. Sumbu non-trivial yang diperkenalkan oleh perluasan ini mengenumerasi randomisasi.
from qiskit_ibm_runtime import QuantumProgram

# Control the # of shots during execution
shots_per_randomization_exec = 64
num_randomizations_exec = 6144

# Zero out the noise to prevent noise from being injected during execution.
# We only added InjectNoise annotations so PNA could associate the noise
# to layers in the circuit
samplex_inputs = {f"noise_scales.{ref}": 0.0 for ref in refs_to_noise_models}
samplex_inputs |= {"pauli_lindblad_maps": refs_to_noise_models}

# Specify the bases to measure
bases_broadcastable = np.expand_dims(np.array(meas_bases_canonical), axis=1)
samplex_inputs |= {"basis_changes": {"basis0": bases_broadcastable}}

# Convert samplex_inputs into a dict to pass to QuantumProgram
samplex_arguments = samplex.inputs().make_broadcastable().bind(**samplex_inputs)

# Instantiate the QuantumProgram with the specified parameters
program = QuantumProgram(shots=shots_per_randomization_exec)
program.append(
circuit=template_circuit,
samplex=samplex,
samplex_arguments=samplex_arguments,
shape=(num_randomizations_exec),
)

Sample sirkuit menggunakan prototype primitive Executor​

Setelah kita mendefinisikan QuantumProgram, mengeksekusi eksperimen menjadi sangat mudah. Kita cukup menginstansiasi objek Executor, menyediakan backend, dan menjalankan program.

from qiskit_ibm_runtime import Executor

# Execute (sample) the circuit
executor = Executor(backend)
job_exec = executor.run(program)
exec_results = job_exec.result()

Post-process sampel untuk menghitung nilai ekspektasi yang telah dimitigasi errornya​

Untuk menghitung nilai ekspektasi yang telah dimitigasi errornya, kita akan:

  • Menghitung faktor skala TREX berdasarkan noise yang dipelajari yang memengaruhi pengukuran
  • Membuat mask untuk menyimpan hanya sampel yang telah di-post-select
  • Menggunakan fungsi executor_expectation_values dari qiskit-addon-utils untuk menggabungkan semua data menjadi nilai ekspektasi yang telah dimitigasi errornya.
from qiskit_addon_utils.exp_vals.expectation_values import executor_expectation_values
from qiskit_addon_utils.noise_management import trex_factors
from qiskit_addon_utils.noise_management.post_selection import PostSelector

# Computing the TREX factors
measurement_noise_map = noise_learner_result[2].to_pauli_lindblad_map()
trex_rescale_factors = trex_factors(measurement_noise_map, bases_reverser)

# Post-select the results
post_selector = PostSelector.from_circuit(
circuit=template_circuit, coupling_map=backend.coupling_map
)

# Compute the ps mask for filtering results
mask = post_selector.compute_mask(exec_results[0], strategy="edge")

# Compute expvals using post selected results
results = executor_expectation_values(
exec_results[0]["meas"],
bases_reverser,
meas_basis_axis=0,
avg_axis=1,
measurement_flips=exec_results[0]["measurement_flips.meas"],
pauli_signs=exec_results[0].get("pauli_signs", None),
postselect_mask=mask,
rescale_factors=trex_rescale_factors,
)
bases_reverser_unmit = {Pauli("Z" * num_qubits): [observable]}
args = [
(bases_reverser_unmit, None, None),
(bases_reverser, None, None),
(bases_reverser, None, trex_rescale_factors),
(bases_reverser, mask, None),
(bases_reverser, mask, trex_rescale_factors),
]

evs = []
for reverser, postsel_mask, factors in args:
# Compute expvals using post selected results
res_ps = executor_expectation_values(
exec_results[0]["meas"],
reverser,
meas_basis_axis=0,
avg_axis=1,
measurement_flips=exec_results[0]["measurement_flips.meas"],
pauli_signs=exec_results[0].get("pauli_signs", None),
postselect_mask=postsel_mask,
rescale_factors=factors,
)
res_ps = np.array(res_ps)
evs.append(res_ps[:, 0][0])

experiments = ["PNA", "PNA+TREX", "PNA+PS", "PNA+PS+TREX"]
colors = ["#d9d9d9", "#b0b0b0", "#7f7f7f", "#4c4c4c"]
plt.bar(experiments, evs[1:], color=colors)
plt.axhline(y=1, color="green", linestyle="--", linewidth=2, label="Ideal")
plt.axhline(y=evs[0], color="red", linestyle="--", linewidth=2, label="Unmitigated")
plt.ylabel("Expectation value", fontsize=14)

plt.title(r"30q Mirrored Ising, 10 Trotter steps, $\theta_{rx}=\frac{\pi}{8}$", fontsize=14)
plt.legend(loc="upper left", bbox_to_anchor=(1.05, 1), borderaxespad=0.0)
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()

Plot output