Pular para o conteúdo principal

Retropropagação de operador (OBP) para estimativa de valores esperados

Estimativa de uso: 4 minutos em um processador Heron r3 (NOTA: Esta é apenas uma estimativa. Seu tempo de execução pode variar.)

Resultados de aprendizagem

Ao concluir este tutorial, os usuários deverão compreender:

  • Como usar o qiskit-addon-obp para reduzir a profundidade do circuito quântico ao custo de um maior número de execuções de circuito
  • Como usar o qiskit-addon-utils para construir Hamiltonianos XYZ e seus circuitos de evolução temporal

Pré-requisitos

Sugerimos que os usuários estejam familiarizados com os seguintes tópicos antes de seguir este tutorial:

  • Usar a primitiva Estimator para calcular valores esperados de um observável

Contexto

A retropropagação de operador é uma técnica que envolve absorver operações do final de um circuito quântico no observável medido, geralmente reduzindo a profundidade do circuito ao custo de termos adicionais no observável. O objetivo é retropropagar o máximo possível do circuito sem permitir que o observável cresça demais. Uma implementação baseada em Qiskit está disponível no addon OBP do Qiskit. Leia a documentação correspondente para mais informações.

Considere um circuito de exemplo para o qual um observável O=PcPPO = \sum_P c_P P deve ser medido, onde PP são Paulis e cPc_P são coeficientes. Vamos denotar o circuito como um único unitário UU, que pode ser logicamente particionado em U=UCUQU = U_C U_Q como mostrado na figura abaixo.

Circuit diagram showing Uq followed by Uc

A retropropagação de operador absorve o unitário UCU_C no observável evoluindo-o como O=UCOUC=PcPUCPUCO' = U_C^{\dagger}OU_C = \sum_P c_P U_C^{\dagger}PU_C. Em outras palavras, parte da computação é realizada classicamente através da evolução do observável de OO para OO'. O problema original agora pode ser reformulado como medir o observável OO' para o novo circuito de menor profundidade cujo unitário é UQU_Q.

O unitário UCU_C é representado como um número de fatias UC=USUS1...U2U1U_C = U_S U_{S-1}...U_2U_1. Existem múltiplas maneiras de definir uma fatia. Por exemplo, no circuito de exemplo acima, cada camada de RzzR_{zz} e cada camada de portas RxR_x pode ser considerada como uma fatia individual. A retropropagação envolve o cálculo de O=Πs=1SPcPUsPUsO' = \Pi_{s=1}^S \sum_P c_P U_s^{\dagger} P U_s classicamente. Cada fatia UsU_s pode ser representada como Us=exp(iθsPs2)U_s = exp(\frac{-i\theta_s P_s}{2}), onde PsP_s é um Pauli de nn-qubits e θs\theta_s é um escalar. É fácil verificar que

UsPUs=Pif [P,Ps]=0,U_s^{\dagger} P U_s = P \qquad \text{if} ~[P,P_s] = 0, UsPUs=cos(θs)P+isin(θs)PsPif {P,Ps}=0U_s^{\dagger} P U_s = \qquad cos(\theta_s)P + i sin(\theta_s)P_sP \qquad \text{if} ~\{P,P_s\} = 0

No exemplo acima, se {P,Ps}=0\{P,P_s\} = 0, então precisamos executar dois circuitos quânticos, em vez de um, para calcular o valor esperado. Portanto, a retropropagação pode aumentar o número de termos no observável, levando a um maior número de execuções de circuito. Uma maneira de permitir uma retropropagação mais profunda no circuito, enquanto evita que o operador cresça demais, é truncar termos com coeficientes pequenos, em vez de adicioná-los ao operador. Por exemplo, no exemplo acima, pode-se escolher truncar o termo envolvendo PsPP_sP desde que θs\theta_s seja suficientemente pequeno. Truncar termos pode resultar em menos circuitos quânticos para executar, mas fazer isso resulta em algum erro no cálculo final do valor esperado proporcional à magnitude dos coeficientes dos termos truncados.

Requisitos

Antes de iniciar este tutorial, certifique-se de ter o seguinte instalado:

  • Qiskit SDK v2.0 ou posterior, com suporte a visualização
  • Qiskit Runtime v0.22 ou posterior (pip install qiskit-ibm-runtime)
  • OBP Qiskit addon 0.3 ou posterior (pip install qiskit-addon-obp)
  • Qiskit addon utils 0.3 ou posterior (pip install qiskit-addon-utils)

Configuração

# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-addon-obp qiskit-addon-utils qiskit-ibm-runtime rustworkx
import numpy as np
import matplotlib.pyplot as plt

from qiskit.primitives import StatevectorEstimator
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit.quantum_info import SparsePauliOp
from qiskit.transpiler import CouplingMap
from qiskit.synthesis import LieTrotter

from qiskit_addon_utils.problem_generators import generate_xyz_hamiltonian
from qiskit_addon_utils.problem_generators import (
generate_time_evolution_circuit,
)
from qiskit_addon_utils.slicing import slice_by_depth, combine_slices
from qiskit_addon_obp.utils.simplify import OperatorBudget
from qiskit_addon_obp import backpropagate
from qiskit_addon_obp.utils.truncating import setup_budget

from rustworkx.visualization import graphviz_draw

from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime import EstimatorV2, EstimatorOptions

Exemplo em simulador de pequena escala

Este tutorial implementa um padrão Qiskit para simular a dinâmica quântica de uma cadeia de spins de Heisenberg usando o addon OBP do Qiskit. Observe que em um simulador sem ruído, o valor esperado obtido com e sem retropropagação será o mesmo.

Etapa 1: Mapear entradas clássicas para um problema quântico

Mapear a evolução temporal de um modelo quântico de Heisenberg para um experimento quântico

Primeiro, usaremos a função generate_xyz_hamiltonian do qiskit-addon-utils para gerar um Hamiltoniano tipo Heisenberg em um determinado grafo de conectividade. Este grafo pode ser um rustworkx.PyGraph ou um CouplingMap. A seguir, usaremos um CouplingMap de cadeia linear de 10 qubits.

num_qubits = 10
layout = [(i - 1, i) for i in range(1, num_qubits)]

# Instantiate a CouplingMap object
coupling_map = CouplingMap(layout)
graphviz_draw(coupling_map.graph, method="circo")

Output of the previous code cell

Em seguida, geramos um operador de Pauli modelando um Hamiltoniano XYZ de Heisenberg:

H^XYZ=(j,k)E(Jxσjxσkx+Jyσjyσky+Jzσjzσkz)+jV(hxσjx+hyσjy+hzσjz),{\hat{\mathcal{H}}_{XYZ} = \sum_{(j,k)\in E} (J_{x} \sigma_j^{x} \sigma_{k}^{x} + J_{y} \sigma_j^{y} \sigma_{k}^{y} + J_{z} \sigma_j^{z} \sigma_{k}^{z}) + \sum_{j\in V} (h_{x} \sigma_j^{x} + h_{y} \sigma_j^{y} + h_{z} \sigma_j^{z}),}

onde G(V,E)G(V,E) é o grafo do mapa de acoplamento. Para este tutorial, utilizamos Jx,Jy,JzJ_x, J_y, J_z como π8,π4,π2\frac{\pi}{8}, \frac{\pi}{4}, \frac{\pi}{2}, respectivamente, e hx,hy,hzh_x, h_y, h_z como π3,π6,π9\frac{\pi}{3}, \frac{\pi}{6}, \frac{\pi}{9}, respectivamente.

# Get a qubit operator describing the Heisenberg XYZ model
hamiltonian = generate_xyz_hamiltonian(
coupling_map,
coupling_constants=(np.pi / 8, np.pi / 4, np.pi / 2),
ext_magnetic_field=(np.pi / 3, np.pi / 6, np.pi / 9),
)
print(hamiltonian)
SparsePauliOp(['IIIIIIIXXI', 'IIIIIIIYYI', 'IIIIIIIZZI', 'IIIIIXXIII', 'IIIIIYYIII', 'IIIIIZZIII', 'IIIXXIIIII', 'IIIYYIIIII', 'IIIZZIIIII', 'IXXIIIIIII', 'IYYIIIIIII', 'IZZIIIIIII', 'IIIIIIIIXX', 'IIIIIIIIYY', 'IIIIIIIIZZ', 'IIIIIIXXII', 'IIIIIIYYII', 'IIIIIIZZII', 'IIIIXXIIII', 'IIIIYYIIII', 'IIIIZZIIII', 'IIXXIIIIII', 'IIYYIIIIII', 'IIZZIIIIII', 'XXIIIIIIII', 'YYIIIIIIII', 'ZZIIIIIIII', 'IIIIIIIIIX', 'IIIIIIIIIY', 'IIIIIIIIIZ', 'IIIIIIIIXI', 'IIIIIIIIYI', 'IIIIIIIIZI', 'IIIIIIIXII', 'IIIIIIIYII', 'IIIIIIIZII', 'IIIIIIXIII', 'IIIIIIYIII', 'IIIIIIZIII', 'IIIIIXIIII', 'IIIIIYIIII', 'IIIIIZIIII', 'IIIIXIIIII', 'IIIIYIIIII', 'IIIIZIIIII', 'IIIXIIIIII', 'IIIYIIIIII', 'IIIZIIIIII', 'IIXIIIIIII', 'IIYIIIIIII', 'IIZIIIIIII', 'IXIIIIIIII', 'IYIIIIIIII', 'IZIIIIIIII', 'XIIIIIIIII', 'YIIIIIIIII', 'ZIIIIIIIII'],
coeffs=[0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j,
0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j,
1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j,
0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j,
0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j,
1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j,
0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j, 1.04719755+0.j,
0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j,
0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j,
1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j,
0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j,
0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j,
1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j,
0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j,
0.34906585+0.j])

A partir do operador de qubit, podemos gerar um circuito quântico que modela sua evolução temporal. Utilizamos generate_time_evolution_circuit com decomposição de Lie Trotter para construir o circuito de evolução temporal.

circuit = generate_time_evolution_circuit(
hamiltonian,
time=0.2,
synthesis=LieTrotter(reps=2),
)
circuit.draw("mpl", style="iqp", fold=-1)

Output of the previous code cell

Etapa 2: Otimizar problema para execução em hardware quântico

Criar fatias de circuito para retropropagar

A função backpropagate retropropaga fatias inteiras de circuito por vez. Portanto, a escolha do fatiamento pode ter um impacto em quão bem a retropropagação funciona para um determinado problema. Aqui, agruparemos portas do mesmo tipo em fatias usando a função slice_by_depth.

Para uma discussão mais detalhada sobre fatiamento de circuito, confira este guia de instruções do pacote qiskit-addon-utils.

slices = slice_by_depth(circuit, max_slice_depth=1)
print(f"Separated the circuit into {len(slices)} slices.")
Separated the circuit into 18 slices.

Restringir o quanto o operador pode crescer durante a retropropagação

Durante a retropropagação, o número de termos no operador geralmente se aproximará rapidamente de 2L2^L, onde LL é o número de fatias. Quando dois termos no operador não comutam qubit a qubit, precisamos de circuitos separados para obter os valores esperados correspondentes a eles. Por exemplo, se temos um observável de dois qubits O=0.1XX+0.3IZ0.5IXO = 0.1 XX + 0.3 IZ - 0.5 IX, então como [XX,IX]=0[XX,IX] = 0, a medição em uma única base é suficiente para calcular os valores esperados para esses dois termos. No entanto, IZIZ anticomuta com os outros dois termos, então precisamos de uma medição de base separada para calcular o valor esperado de IZIZ. Em outras palavras, precisamos de dois circuitos em vez de um para calcular O\langle O \rangle. À medida que o número de termos no operador aumenta, há a possibilidade de que o número necessário de execuções de circuito também aumente.

O tamanho do operador pode ser limitado especificando o argumento operator_budget da função backpropagate, que aceita uma instância OperatorBudget.

Para controlar a quantidade de recursos extras (número de execuções de circuito e, portanto, o tempo de QPU necessário) alocados, restringimos o número máximo de grupos de Pauli comutantes qubit a qubit que o observável retropropagado pode ter. Aqui especificamos que a retropropagação deve parar quando o número de grupos de Pauli comutantes qubit a qubit no operador crescer além de oito.

op_budget = OperatorBudget(max_qwc_groups=8)

Retropropagar fatias do circuito

Primeiro especificamos o observável como MZ=1Ni=1NZiM_Z = \frac{1}{N} \sum_{i=1}^N \langle Z_i \rangle, sendo NN o número de qubits. Retropropagaremos fatias do circuito de evolução temporal até que os termos no observável não possam mais ser combinados em oito ou menos grupos de Pauli comutantes qubit a qubit.

observable = SparsePauliOp.from_sparse_list(
[("Z", [i], 1 / num_qubits) for i in range(num_qubits)],
num_qubits=num_qubits,
)
observable
SparsePauliOp(['IIIIIIIIIZ', 'IIIIIIIIZI', 'IIIIIIIZII', 'IIIIIIZIII', 'IIIIIZIIII', 'IIIIZIIIII', 'IIIZIIIIII', 'IIZIIIIIII', 'IZIIIIIIII', 'ZIIIIIIIII'],
coeffs=[0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j,
0.1+0.j, 0.1+0.j])

Abaixo você verá que retropropagamos seis fatias, e os termos foram combinados em seis e não oito grupos. Isso implica que retropropagar mais uma fatia faria com que o número de grupos de Pauli excedesse oito. Podemos verificar que este é o caso inspecionando os metadados retornados. Observe também que nesta porção a transformação do circuito é exata. Isto é, nenhum termo do novo observável OO' foi truncado. O circuito retropropagado e o operador retropropagado fornecem exatamente o mesmo resultado que o circuito e operador originais.

# Backpropagate slices onto the observable
bp_obs, remaining_slices, metadata = backpropagate(
observable, slices, operator_budget=op_budget
)
# Recombine the slices remaining after backpropagation
bp_circuit = combine_slices(remaining_slices)

print(f"Backpropagated {metadata.num_backpropagated_slices} slices.")
print(
f"New observable has {len(bp_obs.paulis)} terms, which can be combined into "
f"{len(bp_obs.group_commuting(qubit_wise=True))} groups."
)
print(
f"Note that backpropagating one more slice would result in "
f"{metadata.backpropagation_history[-1].num_paulis[0]} terms "
f"across {metadata.backpropagation_history[-1].num_qwc_groups} groups."
)
print("The remaining circuit after backpropagation looks as follows:")
bp_circuit.draw("mpl", fold=-1, scale=0.6)
Backpropagated 6 slices.
New observable has 60 terms, which can be combined into 6 groups.
Note that backpropagating one more slice would result in 114 terms across 12 groups.
The remaining circuit after backpropagation looks as follows:

Output of the previous code cell

Para o exemplo de pequena escala em um simulador, não usaremos truncamento. Isso ocorre porque, na ausência de ruído, o circuito com e sem retropropagação leva ao mesmo resultado, e o truncamento piora o resultado devido à aproximação adicionada.

Transpilar os circuitos para o conjunto de portas base

Agora transpilamos tanto o circuito original quanto o circuito retropropagado para a porta base do backend. Não precisamos transpilar no backend real, pois vamos executar em um simulador para a instância pequena.

service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=133
)
print(backend)
<IBMBackend('ibm_kingston')>
pm_basis = generate_preset_pass_manager(
optimization_level=3, basis_gates=backend.configuration().basis_gates
)
isa_circuit = pm_basis.run(circuit)
isa_bp_circuit = pm_basis.run(bp_circuit)

Etapa 3: Executar usando primitivas Qiskit

Primeiro, criamos dois Primitive Unified Blocs (PUBs) correspondentes ao circuito original e ao circuito retropropagado. Em seguida, executamos os PUBs em um Estimator ideal para obter os valores esperados.

pubs = [(isa_circuit, observable), (isa_bp_circuit, bp_obs)]
rng = np.random.default_rng()
estimator = StatevectorEstimator(seed=rng)
job = estimator.run(pubs)

Etapa 4: Pós-processar e retornar resultado no formato clássico desejado

Agora obtemos os valores esperados dos circuitos original e retropropagado.

primitive_result = job.result()
circuit_expval = primitive_result[0].data.evs.item()
bp_circuit_expval = primitive_result[1].data.evs.item()
methods = [
"No backpropagation",
"Backpropagation",
]
values = [circuit_expval, bp_circuit_expval]

ax = plt.gca()
plt.bar(methods, values, color="#a56eff", width=0.4, edgecolor="#8a3ffc")
ax.set_ylim([0.6, 0.92])
ax.set_ylabel(r"$M_Z$", fontsize=12)
Text(0, 0.5, '$M_Z$')

Output of the previous code cell

Como esperado, os dois valores esperados concordam. Como estamos executando em um simulador de vetor de estado sem ruído, a retropropagação é uma transformação exata do par circuito-observável, portanto os fluxos de trabalho original e retropropagado devem produzir o mesmo valor de MZM_Z. O benefício da retropropagação só se torna aparente em hardware com ruído, onde o circuito retropropagado mais curto acumula menos erro, conforme ilustrado no exemplo de hardware em grande escala abaixo.

Exemplo de hardware em grande escala

Ao desenvolver um experimento, é útil começar com um circuito pequeno para facilitar visualizações e simulações. Agora analisamos a retropropagação de operador para um Hamiltoniano de Heisenberg de 50 qubits com o mesmo conjunto de valores para os parâmetros JJ e hh e o mesmo observável MZM_Z, mas para quatro passos de Trotter. O valor esperado ideal nessa escala não pode ser calculado por força bruta, então usamos uma rede de tensores e obtemos o valor esperado ideal como 0,89\simeq 0,89.

Juntamente com a retropropagação, neste exemplo em grande escala, também introduzimos a retropropagação com truncamento. Idealmente, queremos retropropagar o máximo possível para reduzir a profundidade do circuito efetivo. No entanto, isso frequentemente leva a um grande número de termos não comutativos no observável atualizado, aumentando o overhead quântico. Portanto, podemos eliminar termos do observável com coeficientes pequenos usando uma prática chamada truncamento. Embora o truncamento permita mais propagação ao reduzir o número de termos no observável atualizado, também introduz alguma aproximação. Portanto, é necessário restringir o truncamento dentro de certos limites para que o erro de aproximação não supere a redução de ruído obtida com uma retropropagação mais profunda.

Para restringir a quantidade de truncamento, alocamos um orçamento de erro para cada fatia, bem como o orçamento de erro total ao longo de todo o circuito retropropagado usando a função setup_budget. Isso garante que o truncamento seja controlado para cada fatia e para o circuito inteiro. Consulte também este guia para outras formas de alocar o orçamento.

num_qubits = 50
layout = [(i - 1, i) for i in range(1, num_qubits)]

# Instantiate a CouplingMap object
coupling_map = CouplingMap(layout)

hamiltonian = generate_xyz_hamiltonian(
coupling_map,
coupling_constants=(np.pi / 8, np.pi / 4, np.pi / 2),
ext_magnetic_field=(np.pi / 3, np.pi / 6, np.pi / 9),
)

# Generate a time evolution circuit for the Hamiltonian
circuit = generate_time_evolution_circuit(
hamiltonian,
time=0.2,
synthesis=LieTrotter(reps=4),
)

# Define the observable to measure
observable = SparsePauliOp.from_sparse_list(
[("Z", [i], 1 / num_qubits) for i in range(num_qubits)],
num_qubits,
)

slices = slice_by_depth(circuit, max_slice_depth=1)

# Define the maximum number of qwc groups allowed in the
# backpropagated observable,
# and the truncation error budget
op_budget = OperatorBudget(max_qwc_groups=15)
truncation_error_budget = setup_budget(
max_error_total=0.03, max_error_per_slice=0.005
)

# First backpropagation without truncation
bp_obs, remaining_slices, metadata = backpropagate(
observable, slices, operator_budget=op_budget
)
bp_circuit = combine_slices(remaining_slices)

# Now backpropagate with truncation, using the same operator budget and
# the defined truncation error budget
bp_obs_trunc, remaining_slices_trunc, metadata = backpropagate(
observable,
slices,
operator_budget=op_budget,
truncation_error_budget=truncation_error_budget,
)
bp_circuit_trunc = combine_slices(
remaining_slices_trunc, include_barriers=False
)

# Now we transpile the original circuit and the two backpropagated circuits,
# and apply the layout to the corresponding observables
pm = generate_preset_pass_manager(optimization_level=3, backend=backend)

isa_circuit = pm.run(circuit)
isa_bp_circuit = pm.run(bp_circuit)
isa_bp_circuit_trunc = pm.run(bp_circuit_trunc)

isa_observable = observable.apply_layout(isa_circuit.layout)
isa_bp_observable = bp_obs.apply_layout(isa_bp_circuit.layout)
isa_bp_observable_trunc = bp_obs_trunc.apply_layout(
isa_bp_circuit_trunc.layout
)

# Compare the 2-qubit depth of each transpiled circuit to see how much
# depth backpropagation saved
print(
f"2-qubit depth without backpropagation: "
f"{isa_circuit.depth(lambda x: x.operation.num_qubits == 2)}"
)
print(
f"2-qubit depth with backpropagation: "
f"{isa_bp_circuit.depth(lambda x: x.operation.num_qubits == 2)}"
)
print(
f"2-qubit depth with backpropagation and truncation: "
f"{isa_bp_circuit_trunc.depth(lambda x: x.operation.num_qubits == 2)}"
)

pubs = [
(isa_circuit, isa_observable),
(isa_bp_circuit, isa_bp_observable),
(isa_bp_circuit_trunc, isa_bp_observable_trunc),
]

# Now we instantiate the Estimator primitive for the hardware with
# ZNE and measurement error
# mitigation and compute the three circuits and observables
options = EstimatorOptions()
options.default_precision = 0.01
options.resilience_level = 2
options.resilience.zne.noise_factors = [1, 1.2, 1.4]
options.resilience.zne.extrapolator = ["linear"]
estimator = EstimatorV2(mode=backend, options=options)

estimator.options.environment.job_tags = ["TUT_OBP"]
job = estimator.run(pubs)

# Retrieve the results and the standard deviations
result_no_bp = job.result()[0].data.evs.item()
result_bp = job.result()[1].data.evs.item()
result_bp_trunc = job.result()[2].data.evs.item()

std_no_bp = job.result()[0].data.stds.item()
std_bp = job.result()[1].data.stds.item()
std_bp_trunc = job.result()[2].data.stds.item()
2-qubit depth without backpropagation: 24
2-qubit depth with backpropagation: 20
2-qubit depth with backpropagation and truncation: 18
print(f"Expectation value without backpropagation: {result_no_bp}")
print(f"Backpropagated expectation value: {result_bp}")
print(f"Backpropagated expectation value with truncation: {result_bp_trunc}")
Expectation value without backpropagation: 0.9543907942381811
Backpropagated expectation value: 0.9445337385406468
Backpropagated expectation value with truncation: 0.934050286970965
# Plot the results
methods = [
"No backpropagation",
"Backpropagation",
"Backpropagation w/ truncation",
]
values = [result_no_bp, result_bp, result_bp_trunc]
error_bars = [std_no_bp, std_bp, std_bp_trunc]

ax = plt.gca()
plt.bar(methods, values, color="#a56eff", width=0.4, edgecolor="#8a3ffc")
plt.errorbar(methods, values, yerr=error_bars, fmt="o", color="r", capsize=5)
plt.axhline(0.89)
ax.set_ylim([0.8, 0.98])
plt.text(0.25, 0.895, "Exact result")
ax.set_ylabel(r"$M_Z$", fontsize=12)
Text(0, 0.5, '$M_Z$')

Output of the previous code cell

Próximos passos

Se você achou este trabalho interessante, pode se interessar pelo seguinte material:

Recommendations