Przejdź do głównej treści

Poprawa wartości oczekiwanych: pochłanianie propagowanego szumu (PNA)

W tym samouczku nauczysz się, jak korzystać z najnowszych narzędzi ekosystemu Qiskit, aby zaimplementować w pełni konfigurowalny przepływ pracy z mitygacją błędów. Przedstawimy technikę PNA i użyjemy jej do mitygacji błędów bramek. Skorzystamy też z TREX do mitygacji błędów odczytu oraz z selekcji post-selekcji, aby mitygować błędy nieuwzględnione w nauczonym modelu szumu.

Zarys

  • Krótki przegląd PNA
  • Tworzenie obwodu kwantowego opartego na metodzie Trottera i odpowiadającego obserwowalnego. Transpilacja do Backend i dodanie pomiarów post-selekcji.
  • Użycie samplomatic do twirling warstw bramek 2-Qubitowych i pomiarów. Znajdowanie unikalnych warstw 2Q w celu zmniejszenia kosztu uczenia szumu.
  • Użycie NoiseLearnerV3 do nauczenia modelu błędów wpływającego na bramki 2Q i pomiary.
  • Użycie qiskit-addon-pna do wygenerowania obserwowalnego mitygującego szum.
  • Użycie prymitywu qiskit-ibm-runtime.Executor do wygenerowania surowych próbek QPU odzwierciedlających każdy strzał dla każdej losowej randomizacji twirling i zmierzonej bazy.
  • Użycie qiskit-addon-utils do post-przetwarzania danych w mitygowaną wartość oczekiwaną.

Co to jest pochłanianie propagowanego szumu (PNA)?

Technika mitygacji błędów bramek polegająca na propagowaniu obserwowalnego przez odwrotny kanał szumu wpływający na bramki 2-Qubitowe, co daje w efekcie obserwowalny mitygujący szum. Bramki 2Q w eksperymencie, który chcemy uruchomić, będą dotknięte znacznym szumem. Zaszumiony eksperyment Jeśli nauczymy się modelu szumu, możemy zastosować jego odwrotność i anulować szum. Eksperyment z mitygacją szumu Zamiast implementować odwrotny kanał szumu poprzez jego próbkowanie na QPU (jak w PEC), możemy zaimplementować go klasycznie w mierzonym obserwowalnym, korzystając z propagacji Pauliego. Skutkuje to bardziej złożonym obserwowalnym, którego zmierzenie ma efekt mitygowania nauczonych błędów bramek. Przegląd PNA

Generowanie lustrzanego obwodu Trottera i obserwowalnego

W tym eksperymencie zbadamy dynamikę czasową modelu kicked Ising na 30 węzłach, ułożonego na jednowymiarowym łańcuchu spinowym. Rozważany Hamiltonian ma postać:

H=Ji,jZiZj+hiXiH = -J\sum\limits_{\langle i,j \rangle} Z_iZ_j + h\sum\limits_iX_i,

gdzie J>0J>0 opisuje sprzężenie sąsiednich spinów, i<ji<j, a globalne poprzeczne pole hh jest ustawione na π8\frac{\pi}{8}. Im dalej hh jest od kąta Clifforda (tj. θ=nπ2,nZ\theta=n\frac{\pi}{2}, n \in \mathbb{Z}), tym trudniej propagować generatory anty-szumu przez obwód.

Jako obserwowalny wybierzemy średnią magnetyzację na węzeł: 1Ni=1Nzi\frac{1}{N} \sum_{i=1}^{N} \langle z_i \rangle, gdzie NN to liczba węzłów.

# 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 obwodu kwantowego

Następnie wybierzemy łańcuch Qubitów na ibm_kingston, które wykazują niskie wskaźniki błędów, i transpilujemy obwód do 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 obwodu kwantowego

Twirl warstw bramek 2-qubitowych i pomiarów oraz znajdowanie unikalnych warstw

Tutaj upewniamy się, że pass manager adnotuje bloki za pomocą adnotacji Twirl i InjectNoise, które pozwalają nam uczyć się szumu wpływającego na nasz Circuit i powiązać ten szum z odpowiadającą mu warstwą układu.

  • enable_gates/enable_measure: True: Grupuj wszystkie warstwy bramek 2q i końcowe pomiary w bloki. Bramki jednokubitowe zostaną umieszczone po lewej stronie wewnątrz bloków.
  • measure_annotations: all: Dołącz adnotacje Twirl i ChangeBasis do bloku pomiarowego.
  • twirling_strategy: active: Twirl wszystkich aktywnych Qubitów w każdym bloku zawierającym bramki splątujące.
  • inject_noise_targets: gates: Adnotacje InjectNoise powinny być dodawane do wszystkich bloków z adnotacją Twirl zawierających bramki splątujące.
  • inject_noise_strategy: uniform_modification: Wszystkie warstwy szumu powinny być skalowane jednakowo.
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)

Quantum circuit diagram

Generowanie szablonowego Circuit i samplex, definiowanie sposobu próbkowania Circuit

Tutaj dodajemy również pomiary spektatorów i pomiary post-selekcji, które są potrzebne do wykonania post-selekcji na próbkach zwróconych przez 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)

Quantum circuit diagram

Ucz się szumu

Zanim uruchomimy eksperymenty, uczymy się modelu szumu wpływającego na bramki splątujące i pomiary w obwodzie. Posiadanie dokładnego modelu szumu jest niezbędne do skutecznego łagodzenia błędów. Uczenie szumu tuż przed wykonaniem eksperymentów daje największą szansę na to, że model szumu wiernie opisze rzeczywisty szum wpływający na bramki podczas wykonania.

Zanim nauczymy się szumu, musimy znaleźć unikalne warstwy dwu-qubitowe w naszym obwodzie, aby zminimalizować liczbę strzałów potrzebnych do nauczenia szumu dla całego obwodu. Używamy find_unique_box_instructions z samplomatic, aby uzyskać unikalne warstwy z boxowanego obwodu, w tym warstwę pomiarową. To te warstwy przekazujemy do nauciciela szumu.

Gdy już znamy warstwy, możemy nauczyć się szumu. Jest kilka parametrów, które warto wziąć pod uwagę:

  • num_randomizations: Liczba losowych obwodów do użycia na konfigurację obwodu uczącego
  • shots_per_randomization: Całkowita liczba strzałów do użycia na losowy obwód uczący
  • layer_pair_depths: Głębokości obwodu (mierzone w liczbie par) do użycia w eksperymentach uczących.
  • post_selection: Podczas uczenia użyjemy post-selekcji opartej na krawędziach, korzystając z bramek rx do implementacji pulsów po pomiarze
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>

Plot output

Powiąż bloki obwodów z nauczonym szumem

Tutaj tworzymy odwzorowanie między identyfikatorami referencyjnymi InjectNoise każdego bloku a nauczonym modelem szumu (PauliLindbladMap) wpływającym na bramki splątujące w tym bloku.

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()

Propaguj obserwabli przez nauczony szum odwrotny, aby uzyskać obserwabli ograniczającą szum

Jak omówiono powyżej, odbywa się to w dwóch krokach. Najpierw propagujemy generator szumu odwrotnego na koniec obwodu. Następnie propagujemy obserwabli przez ten ewoluowany generator. Proces ten jest powtarzany dla każdego generatora szumu odwrotnego w obwodzie. W tej implementacji każdy generator w danej warstwie jest propagowany do końca obwodu równolegle. Dodatkowo, wieloprocesowość Pythona jest używana do równoległego wykonywania zarówno propagacji w przód generatorów szumu odwrotnego, jak i propagacji wstecznej obserwabli. Zapobiega to gromadzeniu się ewoluowanych generatorów w pamięci i maksymalizuje wykorzystanie zasobów obliczeniowych.

Uruchamiając PNA, zawsze musisz podać zaszumiony obwód i obserwabli. Jeśli twój zaszumiony obwód jest obwodem blokowym z adnotacjami InjectNoise, musisz podać odwzorowanie stworzone w powyższym kroku. Można też przekazać obwód nieblokowy zawierający instrukcje PauliLindbladError z qiskit-aer. W takim przypadku nie trzeba podawać refs_to_noise_models. Oprócz głównych danych wejściowych warto rozważyć:

  • max_err_terms: Liczba składników do zachowania w każdym generatorze szumu odwrotnego podczas propagacji w przód. Zezwolenie na większą wartość zazwyczaj zwiększa dokładność, jednak to zachowanie nie jest gwarantowane jako monotonicznie rosnące.
  • max_obs_terms: Liczba składników do zachowania w obserwabli ograniczającej szum, O~\tilde{O}, podczas propagacji wstecznej przez ewoluowany szum odwrotny. Większe wartości zazwyczaj zwiększają dokładność, ale nie jest to zagwarantowane w sposób monotonicznie rosnący.
  • num_processes: Liczba rdzeni przeznaczonych do procesu. Pamiętaj, że generatory są propagowane w przód i stosowane do obserwabli równolegle.
  • search_step: Krok propagacji wstecznej używa metody zachłannej do przybliżonego sprzężenia dwóch operatorów w bazie Pauliego. Metodę tę można przyspieszyć, zwiększając search_step. Więcej informacji znajdziesz w dokumentacji pauli-prop.
  • num_to_measure: Choć ta zmienna nie jest wejściem do generate_noise_mitigating_observable, używamy jej do kontrolowania liczby składników z O~\tilde{O}, które rzeczywiście chcemy zmierzyć. Tutaj zmierzymy tylko 30 czołowych składników, które odpowiadają oryginalnym składnikom naszej obserwabli. Składniki te zostały teraz przeskalowane w taki sposób, że ich pomiar ma efekt mitygacji nauczonych błędów bramkowych. Mimo że mierzymy tylko 30 składników z O~\tilde{O}, często nadal jest korzystne pozwolenie jej na duży wzrost, gdyż zwiększa to precyzję współczynników skalowania czołowych składników.
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

Przekształć bazy pomiarowe do postaci kanonicznej

Następnie znajdziemy minimalny zbiór baz do pomiaru, który w pełni pokrywa każdy term Pauliego w mierzonym obserwablu (wiele obserwablów można mierzyć jednocześnie, jeśli komutują one qubitowo). Ponieważ mierzymy wyłącznie termy naszego oryginalnego obserwabla, czyli sumę wszystkich jednoqubitowych operatorów Z, wystarczy jedna baza -- baza złożona ze wszystkich Z.

Oprócz wyznaczenia zbioru baz pomiarowych Pauliego musimy odwzorować te termy Pauliego na postać kanoniczną oczekiwaną przez prymityw Executor. Więcej informacji o kanonicznym porządku qubitów znajdziesz w dokumentacji 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
]

Określ sposób próbkowania w QuantumProgram

QuantumProgram to miejsce, w którym określasz sposób próbkowania eksperymentu:

  • template_circuit: Circuit zawierający wszystkie bramki niezbędne do realizacji wszystkich pożądanych losowych instancji (z losowań twirling, parametrów itp.).
  • samplex: Obiekt definiujący rozkład prawdopodobieństwa po wszystkich możliwych losowych instancjach Circuit, z którego pobierane są próbki.
  • samplex_arguments: Powiązania niezbędne do pełnego zdefiniowania samplskiego
    • basis_changes: Tutaj określamy zbiór baz do pomiaru, który pokrywa wszystkie termy Pauliego w mierzonym obserwablu.
    • noise_scales.ref: Ustawiamy skalę każdej warstwy szumu na 0.0, aby zapobiec wstrzykiwaniu dodatkowego szumu do próbek.
    • pauli_lindblad_maps: Wymagane, jeśli przekazano noise_scales. Odwzorowuje warstwy szumu na powiązany model szumu.
  • shape: Krotka kształtu rozszerzająca niejawny kształt zdefiniowany przez samplex_arguments. Osi wprowadzone przez to rozszerzenie wyliczają losowe instancje.
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),
)

Próbkuj Circuit za pomocą prototypu prymitywu Executor

Teraz, gdy zdefiniowaliśmy nasz QuantumProgram, uruchomienie eksperymentu jest proste. Wystarczy utworzyć obiekt Executor, podać mu Backend i uruchomić program.

from qiskit_ibm_runtime import Executor

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

Przetwórz próbki, aby obliczyć wartość oczekiwaną z korekcją błędów

Aby obliczyć wartość oczekiwaną z korekcją błędów, wykonamy następujące kroki:

  • Obliczymy współczynniki skalowania TREX na podstawie wyuczonego szumu wpływającego na pomiary.
  • Wygenerujemy maskę służącą do zachowania tylko próbek, które przeszły selekcję po pomiarze.
  • Użyjemy funkcji executor_expectation_values z qiskit-addon-utils, aby połączyć wszystkie dane w wartość oczekiwaną z korekcją błędów.
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