Funciones de costo
En esta lección aprenderemos a evaluar una función de costo:
- Primero, conoceremos las primitivas de Qiskit Runtime
- Definiremos una función de costo . Esta es una función específica del problema que define el objetivo que el optimizador debe minimizar (o maximizar)
- Definiremos una estrategia de medición con las primitivas de Qiskit Runtime para equilibrar velocidad y precisión
Primitivas
Todos los sistemas físicos, ya sean clásicos o cuánticos, pueden existir en diferentes estados. Por ejemplo, un automóvil en una carretera puede tener cierta masa, posición, velocidad o aceleración que caracterizan su estado. De manera similar, los sistemas cuánticos también pueden tener diferentes configuraciones o estados, pero se diferencian de los sistemas clásicos en la forma en que tratamos las mediciones y la evolución del estado. Esto da lugar a propiedades únicas como la superposición y el entrelazamiento, exclusivas de la mecánica cuántica. Así como podemos describir el estado de un automóvil usando propiedades físicas como la velocidad o la aceleración, también podemos describir el estado de un sistema cuántico usando observables, que son objetos matemáticos.
En mecánica cuántica, los estados se representan mediante vectores columna complejos normalizados, o kets (), y los observables son operadores lineales hermitianos () que actúan sobre los kets. Un vector propio () de un observable se conoce como eigenestado. Medir un observable para uno de sus eigenestados () nos dará el eigenvalor correspondiente () como resultado.
Si te preguntas cómo medir un sistema cuántico y qué puedes medir, Qiskit ofrece dos primitivas que pueden ayudarte:
Sampler: Dado un estado cuántico , esta primitiva obtiene la probabilidad de cada posible estado de la base computacional.Estimator: Dado un observable cuántico y un estado , esta primitiva calcula el valor esperado de .
La primitiva Sampler
La primitiva Sampler calcula la probabilidad de obtener cada posible estado de la base computacional, dado un circuito cuántico que prepara el estado . Calcula
donde es el número de qubits, y la representación entera de cualquier posible cadena binaria de salida (es decir, enteros en base ).
El Sampler de Qiskit Runtime ejecuta el circuito múltiples veces en un dispositivo cuántico, realiza mediciones en cada ejecución y reconstruye la distribución de probabilidad a partir de las cadenas de bits obtenidas. Cuantas más ejecuciones (o shots) realice, más precisos serán los resultados, pero esto requiere más tiempo y recursos cuánticos.
Sin embargo, dado que el número de posibles salidas crece exponencialmente con el número de qubits (es decir, ), el número de shots también deberá crecer exponencialmente para capturar una distribución de probabilidad densa. Por lo tanto, Sampler solo es eficiente para distribuciones de probabilidad dispersas; donde el estado objetivo debe poder expresarse como combinación lineal de los estados de la base computacional, con el número de términos creciendo a lo sumo polinomialmente con el número de qubits:
El Sampler también se puede configurar para recuperar probabilidades de una subsección del circuito, lo que representa un subconjunto del total de estados posibles.
La primitiva Estimator
La primitiva Estimator calcula el valor esperado de un observable para un estado cuántico ; donde las probabilidades del observable pueden expresarse como , siendo los eigenestados del observable . El valor esperado se define entonces como el promedio de todos los posibles resultados (es decir, los eigenvalores del observable) de una medición del estado , ponderado por las probabilidades correspondientes:
Sin embargo, calcular el valor esperado de un observable no siempre es posible, ya que a menudo no conocemos su eigenbasis. El Estimator de Qiskit Runtime utiliza un proceso algebraico complejo para estimar el valor esperado en un dispositivo cuántico real, descomponiendo el observable en una combinación de otros observables cuya eigenbasis sí conocemos.
En términos más simples, Estimator descompone cualquier observable que no sabe cómo medir en observables más simples y medibles llamados operadores de Pauli.
Cualquier operador puede expresarse como combinación de operadores de Pauli.
tal que
donde es el número de qubits, para (es decir, enteros en base ), y .
Tras realizar esta descomposición, Estimator deriva un nuevo circuito para cada observable (a partir del circuito original), con el fin de diagonalizar efectivamente el observable de Pauli en la base computacional y medirlo. Podemos medir fácilmente los observables de Pauli porque conocemos de antemano, lo cual no ocurre en general con otros observables.
Para cada , el Estimator ejecuta el circuito correspondiente en un dispositivo cuántico múltiples veces, mide el estado de salida en la base computacional y calcula la probabilidad de obtener cada posible salida . Luego busca el eigenvalor de correspondiente a cada salida , multiplica por y suma todos los resultados para obtener el valor esperado del observable para el estado dado .
Dado que calcular el valor esperado de Paulis es poco práctico (es decir, crece exponencialmente), Estimator solo puede ser eficiente cuando una gran cantidad de son cero (es decir, descomposición de Pauli dispersa en lugar de densa). Formalmente decimos que, para que este cálculo sea eficientemente resoluble, el número de términos no nulos debe crecer a lo sumo polinomialmente con el número de qubits :
El lector puede notar la suposición implícita de que el muestreo de probabilidades también debe ser eficiente, como se explicó para Sampler, lo que significa
Ejemplo guiado para calcular valores esperados
Supongamos el estado de un qubit , y el observable
con el siguiente valor esperado teórico
Como no sabemos cómo medir este observable, no podemos calcular su valor esperado directamente y necesitamos reexpresarlo como . Se puede demostrar que esto da el mismo resultado notando que y .
Veamos cómo calcular y directamente. Como y no conmutan (es decir, no comparten la misma eigenbasis), no pueden medirse simultáneamente, por lo que necesitamos los circuitos auxiliares:
# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-aer qiskit-ibm-runtime rustworkx
from qiskit import QuantumCircuit
from qiskit.quantum_info import SparsePauliOp
# The following code will work for any other initial single-qubit state and observable
original_circuit = QuantumCircuit(1)
original_circuit.h(0)
H = SparsePauliOp(["X", "Z"], [2, -1])
aux_circuits = []
for pauli in H.paulis:
aux_circ = original_circuit.copy()
aux_circ.barrier()
if str(pauli) == "X":
aux_circ.h(0)
elif str(pauli) == "Y":
aux_circ.sdg(0)
aux_circ.h(0)
else:
aux_circ.id(0)
aux_circ.measure_all()
aux_circuits.append(aux_circ)
original_circuit.draw("mpl")
# Auxiliary circuit for X
aux_circuits[0].draw("mpl")
# Auxiliary circuit for Z
aux_circuits[1].draw("mpl")
Ahora podemos llevar a cabo el cálculo manualmente usando Sampler y verificar los resultados con Estimator:
from qiskit.primitives import StatevectorSampler, StatevectorEstimator
from qiskit.result import QuasiDistribution
import numpy as np
## SAMPLER
shots = 10000
sampler = StatevectorSampler()
job = sampler.run(aux_circuits, shots=shots)
# Run the sampler job and step through results
expvals = []
for index, pauli in enumerate(H.paulis):
data_pub = job.result()[index].data
bitstrings = data_pub.meas.get_bitstrings()
counts = data_pub.meas.get_counts()
quasi_dist = QuasiDistribution(
{outcome: freq / shots for outcome, freq in counts.items()}
)
# Use the probabilities and known eigenvalues of Pauli operators to estimate the expectation value.
val = 0
if str(pauli) == "X":
val += -1 * quasi_dist.get(1, 0)
val += 1 * quasi_dist.get(0, 0)
if str(pauli) == "Y":
val += -1 * quasi_dist.get(1, 0)
val += 1 * quasi_dist.get(0, 0)
if str(pauli) == "Z":
val += 1 * quasi_dist.get(0, 0)
val += -1 * quasi_dist.get(1, 0)
expvals.append(val)
# Print expectation values
print("Sampler results:")
for pauli, expval in zip(H.paulis, expvals):
print(f" >> Expected value of {str(pauli)}: {expval:.5f}")
total_expval = np.sum(H.coeffs * expvals).real
print(f" >> Total expected value: {total_expval:.5f}")
# Use estimator for comparison
observables = [
*H.paulis,
H,
] # Note: run for individual Paulis as well as full observable H
estimator = StatevectorEstimator()
job = estimator.run([(original_circuit, observables)])
estimator_expvals = job.result()[0].data.evs
# Print results
print("Estimator results:")
for obs, expval in zip(observables, estimator_expvals):
if obs is not H:
print(f" >> Expected value of {str(obs)}: {expval:.5f}")
else:
print(f" >> Total expected value: {expval:.5f}")
Sampler results:
>> Expected value of X: 1.00000
>> Expected value of Z: 0.00420
>> Total expected value: 1.99580
Estimator results:
>> Expected value of X: 1.00000
>> Expected value of Z: 0.00000
>> Total expected value: 2.00000
Rigor matemático (opcional)
Expresando con respecto a la base de eigenestados de , , se deduce:
Como no conocemos los eigenvalores ni los eigenestados del observable objetivo , primero necesitamos considerar su diagonalización. Dado que es Hermitiano, existe una transformación unitaria tal que donde es la matriz diagonal de eigenvalores, de modo que si , y .
Esto implica que el valor esperado puede reescribirse como:
Dado que si un sistema está en el estado la probabilidad de medir es , el valor esperado anterior puede expresarse como:
Es muy importante notar que las probabilidades se toman del estado en lugar de . Por eso la matriz es absolutamente necesaria. Quizás te preguntes cómo obtener la matriz y los eigenvalores . Si ya tuvieras los eigenvalores, no habría necesidad de usar un computador cuántico, ya que el objetivo de los algoritmos variacionales es precisamente encontrar esos eigenvalores de .
Afortunadamente, existe una solución: cualquier matriz puede escribirse como combinación lineal de productos tensoriales de matrices de Pauli e identidades, todas las cuales son hermitianas y unitarias con y conocidos. Esto es lo que el Estimator de Runtime hace internamente al descomponer cualquier objeto Operator en un SparsePauliOp.
Estos son los operadores que se pueden usar:
Así que reescribamos con respecto a los Paulis e identidades:
donde para (es decir, base ), y :
donde y , tal que:
Funciones de costo
En general, las funciones de costo se usan para describir el objetivo de un problema y qué tan bien está funcionando un estado de prueba con respecto a ese objetivo. Esta definición puede aplicarse a distintos contextos, como química, aprendizaje automático, finanzas, optimización, entre otros.
Consideremos un ejemplo sencillo: encontrar el estado base de un sistema. Nuestro objetivo es minimizar el valor esperado del observable que representa la energía (Hamiltoniano ):
Podemos usar el Estimator para evaluar el valor esperado y pasárselo a un optimizador para que lo minimice. Si la optimización tiene éxito, devolverá un conjunto de valores de parámetros óptimos , a partir de los cuales podremos construir el estado solución propuesto y calcular el valor esperado observado como .
Nota que solo podremos minimizar la función de costo para el conjunto limitado de estados que estamos considerando. Esto nos lleva a dos posibilidades distintas:
- Nuestro ansatz no define el estado solución en el espacio de búsqueda: En este caso, el optimizador nunca encontrará la solución y tendremos que experimentar con otros ansatzes que puedan representar nuestro espacio de búsqueda con mayor precisión.
- El optimizador es incapaz de encontrar una solución válida: La optimización puede definirse de forma global o local. Exploraremos qué significa esto en la sección posterior.
En definitiva, estaremos ejecutando un ciclo de optimización clásico pero apoyándonos en la evaluación de la función de costo por parte de una computadora cuántica. Desde esta perspectiva, se podría concebir la optimización como una tarea puramente clásica en la que llamamos a algún oráculo cuántico de caja negra cada vez que el optimizador necesita evaluar la función de costo.
def cost_func_vqe(params, circuit, hamiltonian, estimator):
"""Return estimate of energy from estimator
Parameters:
params (ndarray): Array of ansatz parameters
ansatz (QuantumCircuit): Parameterized ansatz circuit
hamiltonian (SparsePauliOp): Operator representation of Hamiltonian
estimator (Estimator): Estimator primitive instance
Returns:
float: Energy estimate
"""
pub = (circuit, hamiltonian, params)
cost = estimator.run([pub]).result()[0].data.evs
return cost
from qiskit.circuit.library import TwoLocal
observable = SparsePauliOp.from_list([("XX", 1), ("YY", -3)])
reference_circuit = QuantumCircuit(2)
reference_circuit.x(0)
variational_form = TwoLocal(
2,
rotation_blocks=["rz", "ry"],
entanglement_blocks="cx",
entanglement="linear",
reps=1,
)
ansatz = reference_circuit.compose(variational_form)
theta_list = (2 * np.pi * np.random.rand(1, 8)).tolist()
ansatz.decompose().draw("mpl")
Primero ejecutaremos esto con un simulador: el StatevectorEstimator. Esto es lo recomendable para depurar, pero enseguida repetiremos el cálculo en hardware cuántico real. Cada vez más, los problemas de interés ya no son simulables clásicamente sin instalaciones de supercomputación de vanguardia.
estimator = StatevectorEstimator()
cost = cost_func_vqe(theta_list, ansatz, observable, estimator)
print(cost)
[-0.58744589]
Ahora procederemos a ejecutar en una computadora cuántica real. Ten en cuenta los cambios de sintaxis. Los pasos relacionados con el pass_manager se analizarán con más detalle en el siguiente ejemplo. Un paso de especial importancia en los algoritmos variacionales es el uso de una sesión de Qiskit Runtime. Abrir una sesión te permite ejecutar múltiples iteraciones de un algoritmo variacional sin tener que esperar en una nueva cola cada vez que se actualizan los parámetros. Esto es importante si los tiempos de cola son largos y/o se necesitan muchas iteraciones. Solo los socios de la red IBM Quantum® pueden usar las sesiones de Runtime. Si no tienes acceso a sesiones, puedes reducir el número de iteraciones que envías a la vez y guardar los parámetros más recientes para usarlos en ejecuciones futuras. Si envías demasiadas iteraciones o encuentras tiempos de cola demasiado largos, es posible que obtengas el código de error 1217, que hace referencia a demoras largas entre envíos de trabajos.
# Estimated usage: < 1 min. Benchmarked at 7 seconds on an Eagle processor
# Load necessary packages:
from qiskit_ibm_runtime import (
QiskitRuntimeService,
Session,
EstimatorOptions,
EstimatorV2 as Estimator,
)
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
# Select the least busy backend:
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, min_num_qubits=ansatz.num_qubits, simulator=False
)
# Or get a specific backend:
# backend = service.backend("ibm_brisbane")
# Use a pass manager to transpile the circuit and observable for the specific backend being used:
pm = generate_preset_pass_manager(backend=backend, optimization_level=1)
isa_ansatz = pm.run(ansatz)
isa_observable = observable.apply_layout(layout=isa_ansatz.layout)
# Set estimator options
estimator_options = EstimatorOptions(resilience_level=1, default_shots=10_000)
# Open a Runtime session:
with Session(backend=backend) as session:
estimator = Estimator(mode=session, options=estimator_options)
cost = cost_func_vqe(theta_list, isa_ansatz, isa_observable, estimator)
session.close()
print(cost)
Ten en cuenta que los valores obtenidos en los dos cálculos anteriores son muy similares. Las técnicas para mejorar los resultados se analizarán más adelante.
Ejemplo de mapeo a sistemas no físicos
El problema de corte máximo (Max-Cut) es un problema de optimización combinatoria que consiste en dividir los vértices de un grafo en dos conjuntos disjuntos de manera que se maximice el número de aristas entre ambos conjuntos. Más formalmente, dado un grafo no dirigido , donde es el conjunto de vértices y es el conjunto de aristas, el problema Max-Cut pide dividir los vértices en dos subconjuntos disjuntos, y , de modo que se maximice el número de aristas con un extremo en y el otro en .
Podemos aplicar Max-Cut para resolver distintos problemas, como agrupamiento (clustering), diseño de redes, transiciones de fase, entre otros. Empezaremos creando un grafo del problema:
import rustworkx as rx
from rustworkx.visualization import mpl_draw
n = 4
G = rx.PyGraph()
G.add_nodes_from(range(n))
# The edge syntax is (start, end, weight)
edges = [(0, 1, 1.0), (0, 2, 1.0), (0, 3, 1.0), (1, 2, 1.0), (2, 3, 1.0)]
G.add_edges_from(edges)
mpl_draw(
G, pos=rx.shell_layout(G), with_labels=True, edge_labels=str, node_color="#1192E8"
)
Este problema puede expresarse como un problema de optimización binaria. Para cada nodo , donde es el número de nodos del grafo (en este caso ), consideraremos la variable binaria . Esta variable tendrá el valor si el nodo pertenece a uno de los grupos que llamaremos , y si pertenece al otro grupo, que llamaremos . También denotaremos como (elemento de la matriz de adyacencia ) el peso de la arista que va del nodo al nodo . Como el grafo es no dirigido, . Entonces podemos formular nuestro problema como la maximización de la siguiente función de costo:
Para resolver este problema con una computadora cuántica, expresaremos la función de costo como el valor esperado de un observable. Sin embargo, los observables que Qiskit admite de forma nativa consisten en operadores de Pauli, que tienen valores propios y en lugar de y . Por eso realizaremos el siguiente cambio de variable:
Donde . Podemos usar la matriz de adyacencia para acceder cómodamente a los pesos de todas las aristas. Esto nos servirá para obtener nuestra función de costo:
Esto implica que:
Entonces la nueva función de costo que queremos maximizar es:
Además, la tendencia natural de una computadora cuántica es encontrar mínimos (generalmente la energía más baja) en lugar de máximos, así que en vez de maximizar vamos a minimizar:
Ahora que tenemos una función de costo que minimizar cuyas variables pueden tomar los valores y , podemos establecer la siguiente analogía con la Pauli :
En otras palabras, la variable será equivalente a una puerta actuando sobre el qubit . Además:
Entonces el observable que vamos a considerar es:
al que tendremos que sumar el término independiente después:
El operador es una combinación lineal de términos con operadores Z en los nodos conectados por una arista (recuerda que el qubit 0 está más a la derecha): . Una vez construido el operador, el ansatz para el algoritmo QAOA puede construirse fácilmente usando el circuito QAOAAnsatz de la biblioteca de circuitos de Qiskit.
from qiskit.circuit.library import QAOAAnsatz
from qiskit.quantum_info import SparsePauliOp
hamiltonian = SparsePauliOp.from_list(
[("IIZZ", 1), ("IZIZ", 1), ("IZZI", 1), ("ZIIZ", 1), ("ZZII", 1)]
)
ansatz = QAOAAnsatz(hamiltonian, reps=2)
# Draw
ansatz.decompose(reps=3).draw("mpl")
# Sum the weights, and divide by 2
offset = -sum(edge[2] for edge in edges) / 2
print(f"""Offset: {offset}""")
Offset: -2.5
Con el Estimator de Runtime tomando directamente un Hamiltoniano y un ansatz parametrizado, y devolviendo la energía necesaria, la función de costo para una instancia de QAOA es bastante sencilla:
def cost_func(params, ansatz, hamiltonian, estimator):
"""Return estimate of energy from estimator
Parameters:
params (ndarray): Array of ansatz parameters
ansatz (QuantumCircuit): Parameterized ansatz circuit
hamiltonian (SparsePauliOp): Operator representation of Hamiltonian
estimator (Estimator): Estimator primitive instance
Returns:
float: Energy estimate
"""
pub = (ansatz, hamiltonian, params)
cost = estimator.run([pub]).result()[0].data.evs
# cost = estimator.run(ansatz, hamiltonian, parameter_values=params).result().values[0]
return cost
import numpy as np
x0 = 2 * np.pi * np.random.rand(ansatz.num_parameters)
estimator = StatevectorEstimator()
cost = cost_func_vqe(x0, ansatz, hamiltonian, estimator)
print(cost)
1.473098768180865
# Estimated usage: < 1 min, benchmarked at 6 seconds on ibm_osaka, 5-23-24
# Load some necessary packages:
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime import Session, EstimatorV2 as Estimator
# Select the least busy backend:
backend = service.least_busy(
operational=True, min_num_qubits=ansatz.num_qubits, simulator=False
)
# Or get a specific backend:
# backend = service.backend("ibm_brisbane")
# Use a pass manager to transpile the circuit and observable for the specific backend being used:
pm = generate_preset_pass_manager(backend=backend, optimization_level=1)
isa_ansatz = pm.run(ansatz)
isa_hamiltonian = hamiltonian.apply_layout(layout=isa_ansatz.layout)
# Set estimator options
estimator_options = EstimatorOptions(resilience_level=1, default_shots=10_000)
# Open a Runtime session:
with Session(backend=backend) as session:
estimator = Estimator(mode=session, options=estimator_options)
cost = cost_func_vqe(x0, isa_ansatz, isa_hamiltonian, estimator)
# Close session after done
session.close()
print(cost)
1.1120776913677988
Volveremos a este ejemplo en la sección de Aplicaciones para explorar cómo usar un optimizador para recorrer el espacio de búsqueda. En términos generales, esto incluye:
- Usar un optimizador para encontrar los parámetros óptimos
- Vincular los parámetros óptimos al ansatz para encontrar los valores propios
- Traducir los valores propios a nuestra definición del problema
Estrategia de medición: velocidad versus precisión
Como mencionamos, estamos usando una computadora cuántica ruidosa como un oráculo de caja negra, donde el ruido puede hacer que los valores recuperados sean no deterministas, lo que genera fluctuaciones aleatorias que, a su vez, dificultarán — o incluso impedirán por completo — la convergencia de ciertos optimizadores hacia una solución propuesta. Este es un problema general que debemos abordar a medida que exploramos progresivamente la utilidad cuántica y avanzamos hacia la ventaja cuántica:
Podemos usar las opciones de supresión y mitigación de errores de los Primitivos de Qiskit Runtime para hacer frente al ruido y maximizar la utilidad de las computadoras cuánticas actuales.
Supresión de errores
La supresión de errores hace referencia a las técnicas utilizadas para optimizar y transformar un circuito durante la compilación con el fin de minimizar los errores. Se trata de una técnica básica de manejo de errores que normalmente introduce cierto sobrecoste de preprocesamiento clásico en el tiempo de ejecución total. Ese sobrecoste incluye transpilar los circuitos para ejecutarlos en hardware cuántico mediante:
- Expresar el circuito usando las puertas nativas disponibles en el sistema cuántico
- Mapear los qubits virtuales a qubits físicos
- Añadir operaciones SWAP según los requisitos de conectividad
- Optimizar las puertas de 1 y 2 qubits
- Añadir desacoplamiento dinámico a los qubits inactivos para prevenir los efectos de la decoherencia.
Los Primitivos permiten usar técnicas de supresión de errores configurando la opción optimization_level y seleccionando opciones avanzadas de transpilación. En un curso posterior, profundizaremos en distintos métodos de construcción de circuitos para mejorar los resultados, pero en la mayoría de los casos recomendamos usar optimization_level=3.
Visualizaremos el valor de aumentar la optimización en el proceso de transpilación examinando un circuito de ejemplo con un comportamiento ideal sencillo.
from qiskit.circuit import Parameter, QuantumCircuit
from qiskit.quantum_info import SparsePauliOp
theta = Parameter("theta")
qc = QuantumCircuit(2)
qc.x(1)
qc.h(0)
qc.cp(theta, 0, 1)
qc.h(0)
observables = SparsePauliOp.from_list([("ZZ", 1)])
qc.draw("mpl")
El circuito anterior puede producir valores esperados sinusoidales del observable dado, siempre que insertemos fases que abarquen un intervalo apropiado, como .
## Setup phases
import numpy as np
phases = np.linspace(0, 2 * np.pi, 50)
# phases need to be expressed as a list of lists in order to work
individual_phases = [[phase] for phase in phases]
Podemos usar un simulador para demostrar la utilidad de una transpilación optimizada. Más adelante volveremos a usar hardware real para mostrar la utilidad de la mitigación de errores. Usaremos QiskitRuntimeService para obtener un backend real (en este caso, ibm_brisbane) y AerSimulator para simular ese backend, incluyendo su comportamiento de ruido.
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_aer import AerSimulator
# get a real backend from the runtime service
service = QiskitRuntimeService()
backend = service.backend("ibm_brisbane")
# generate a simulator that mimics the real quantum system with the latest calibration results
backend_sim = AerSimulator.from_backend(backend)
Ahora podemos usar un pass manager para transpilar el circuito al "conjunto de instrucciones de arquitectura" o ISA del backend. Este es un nuevo requisito en Qiskit Runtime: todos los circuitos enviados a un backend deben ajustarse a las restricciones del target del backend, lo que significa que deben estar escritos en términos de la ISA del backend, es decir, el conjunto de instrucciones que el dispositivo puede entender y ejecutar. Estas restricciones del target están definidas por factores como las puertas base nativas del dispositivo, la conectividad entre qubits y, cuando corresponde, las especificaciones de pulsos y otros tiempos de instrucción.
Ten en cuenta que en este caso lo haremos dos veces: una con optimization_level = 0 y otra con el nivel fijado en 3. En cada caso usaremos la primitiva Estimator para estimar los valores esperados del observable para distintos valores de fase.
# Import estimator and specify that we are using the simulated backend:
from qiskit_ibm_runtime import EstimatorV2 as Estimator
estimator = Estimator(mode=backend_sim)
circuit = qc
# Use a pass manager to transpile the circuit and observable for the backend being simulated.
# Start with no optimization:
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
pm = generate_preset_pass_manager(backend=backend_sim, optimization_level=0)
isa_circuit = pm.run(circuit)
isa_observables = observables.apply_layout(layout=isa_circuit.layout)
noisy_exp_values = []
pub = (isa_circuit, isa_observables, [individual_phases])
cost = estimator.run([pub]).result()[0].data.evs
noisy_exp_values = cost[0]
# Repeat above steps, but now with optimization = 3:
exp_values_with_opt_es = []
pm = generate_preset_pass_manager(backend=backend_sim, optimization_level=3)
isa_circuit = pm.run(circuit)
isa_observables = observables.apply_layout(layout=isa_circuit.layout)
pub = (isa_circuit, isa_observables, [individual_phases])
cost = estimator.run([pub]).result()[0].data.evs
exp_values_with_opt_es = cost[0]
Por último, podemos graficar los resultados. Vemos que la precisión del cálculo fue bastante buena incluso sin optimización, pero mejoró claramente al subir el nivel de optimización a 3. Ten en cuenta que en circuitos más profundos y complejos, la diferencia entre los niveles de optimización 0 y 3 probablemente será más significativa. Este es un circuito muy simple que sirve como modelo de juguete.
import matplotlib.pyplot as plt
plt.plot(phases, noisy_exp_values, "o", label="opt=0")
plt.plot(phases, exp_values_with_opt_es, "o", label="opt=3")
plt.plot(phases, 2 * np.sin(phases / 2) ** 2 - 1, label="ideal")
plt.ylabel("Expectation")
plt.legend()
plt.show()
Mitigación de errores
La mitigación de errores hace referencia a técnicas que permiten reducir los errores de un circuito modelando el ruido del dispositivo en el momento de la ejecución. Por lo general, esto implica una sobrecarga de preprocesamiento cuántico relacionada con el entrenamiento del modelo y una sobrecarga de posprocesamiento clásico para mitigar los errores en los resultados brutos a partir del modelo generado.
La opción resilience_level de las primitivas de Qiskit Runtime especifica el nivel de resiliencia frente a errores que se desea construir. Los niveles más altos generan resultados más precisos a costa de tiempos de procesamiento más largos, debidos a la mayor sobrecarga de muestreo cuántico. Los niveles de resiliencia se pueden usar para configurar el equilibrio entre coste y precisión al aplicar mitigación de errores a tu consulta de primitivas.
Al implementar cualquier técnica de mitigación de errores, esperamos que el sesgo de nuestros resultados se reduzca con respecto al sesgo previo sin mitigar. En algunos casos, el sesgo puede incluso desaparecer. Sin embargo, esto tiene un coste. A medida que reducimos el sesgo en nuestras cantidades estimadas, la variabilidad estadística aumenta (es decir, la varianza), lo cual podemos compensar incrementando aún más el número de shots por circuito en nuestro proceso de muestreo. Esto introduce una sobrecarga adicional más allá de la necesaria para reducir el sesgo, por lo que no se aplica por defecto. Podemos activar fácilmente este comportamiento ajustando el número de shots por circuito en options.executions.shots, como se muestra en el ejemplo a continuación.
En este curso exploraremos estos modelos de mitigación de errores a alto nivel para ilustrar la mitigación que pueden realizar las primitivas de Qiskit Runtime, sin necesidad de conocer todos los detalles de implementación.
Extinción de errores de lectura por twirling (T-REx)
La extinción de errores de lectura por twirling (T-REx) utiliza una técnica conocida como Pauli twirling para reducir el ruido introducido durante el proceso de medición cuántica. Esta técnica no asume ninguna forma específica de ruido, lo que la hace muy general y eficaz.
Flujo de trabajo general:
- Adquirir datos para el estado cero con bits aleatoriamente invertidos (Pauli X antes de la medición)
- Adquirir datos para el estado deseado (ruidoso) con bits aleatoriamente invertidos (Pauli X antes de la medición)
- Calcular la función especial para cada conjunto de datos y dividir.
Podemos configurar esto con options.resilience_level = 1, como se muestra en el ejemplo a continuación.
Extrapolación a ruido cero
La extrapolación a ruido cero (ZNE, por sus siglas en inglés) funciona amplificando primero el ruido en el circuito que prepara el estado cuántico deseado, obteniendo mediciones para varios niveles de ruido distintos y usando esas mediciones para inferir el resultado sin ruido.
Flujo de trabajo general:
- Amplificar el ruido del circuito para varios factores de ruido
- Ejecutar cada circuito con el ruido amplificado
- Extrapolar hasta el límite de ruido cero
Podemos configurar esto con options.resilience_level = 2. Podemos optimizarlo aún más explorando distintos valores de noise_factors, noise_amplifiers y extrapolators, pero eso está fuera del alcance de este curso. Te animamos a experimentar con estas opciones descritas aquí.
Cada método conlleva su propia sobrecarga asociada: un equilibrio entre el número de cómputos cuánticos necesarios (tiempo) y la precisión de los resultados:
Uso de las opciones de mitigación y supresión de Qiskit Runtime
A continuación se muestra cómo calcular un valor esperado usando mitigación y supresión de errores en Qiskit Runtime. Podemos aprovechar exactamente el mismo circuito y observable que antes, pero esta vez manteniendo el nivel de optimización fijo en 2 y ajustando la resiliencia o las técnicas de mitigación de errores que se utilizan. Este proceso de mitigación de errores ocurre múltiples veces a lo largo del bucle de optimización.
Realizamos esta parte en hardware real, ya que la mitigación de errores no está disponible en simuladores.
# Estimated usage: 8 minutes, benchmarked on an Eagle processor, 5-23-24
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime import (
Session,
EstimatorOptions,
EstimatorV2 as Estimator,
)
# We select the least busy backend
# Select the least busy backend
# backend = service.least_busy(
# operational=True, min_num_qubits=ansatz.num_qubits, simulator=False
# )
# Or use a specific backend
backend = service.backend("ibm_brisbane")
# Initialize some variables to save the results from different runs:
exp_values_with_em0_es = []
exp_values_with_em1_es = []
exp_values_with_em2_es = []
# Use a pass manager to optimize the circuit and observables for the backend chosen:
pm = generate_preset_pass_manager(backend=backend, optimization_level=2)
isa_circuit = pm.run(circuit)
isa_observables = observables.apply_layout(layout=isa_circuit.layout)
# Open a session and run with no error mitigation:
estimator_options = EstimatorOptions(resilience_level=0, default_shots=10_000)
with Session(backend=backend) as session:
estimator = Estimator(mode=session, options=estimator_options)
pub = (isa_circuit, isa_observables, [individual_phases])
cost = estimator.run([pub]).result()[0].data.evs
session.close()
exp_values_with_em0_es = cost[0]
# Open a session and run with resilience = 1:
estimator_options = EstimatorOptions(resilience_level=1, default_shots=10_000)
with Session(backend=backend) as session:
estimator = Estimator(mode=session, options=estimator_options)
pub = (isa_circuit, isa_observables, [individual_phases])
cost = estimator.run([pub]).result()[0].data.evs
session.close()
exp_values_with_em1_es = cost[0]
# Open a session and run with resilience = 2:
estimator_options = EstimatorOptions(resilience_level=2, default_shots=10_000)
with Session(backend=backend) as session:
estimator = Estimator(mode=session, options=estimator_options)
pub = (isa_circuit, isa_observables, [individual_phases])
cost = estimator.run([pub]).result()[0].data.evs
session.close()
exp_values_with_em2_es = cost[0]
Como antes, podemos graficar los valores esperados resultantes en función del ángulo de fase para los tres niveles de mitigación de errores utilizados. Con cierta dificultad, se puede apreciar que la mitigación de errores mejora ligeramente los resultados. De nuevo, este efecto es mucho más pronunciado en circuitos más profundos y complejos.
import matplotlib.pyplot as plt
plt.plot(phases, exp_values_with_em0_es, "o", label="unmitigated")
plt.plot(phases, exp_values_with_em1_es, "o", label="resil = 1")
plt.plot(phases, exp_values_with_em2_es, "o", label="resil = 2")
plt.plot(phases, 2 * np.sin(phases / 2) ** 2 - 1, label="ideal")
plt.ylabel("Expectation")
plt.legend()
plt.show()
Resumen
Con esta lección aprendiste a crear una función de coste:
- Crear una función de coste
- Cómo aprovechar las primitivas de Qiskit Runtime para mitigar y suprimir el ruido
- Cómo definir una estrategia de medición para optimizar velocidad versus precisión
Este es nuestro flujo de trabajo variacional de alto nivel:
Nuestra función de coste se ejecuta en cada iteración del bucle de optimización. La próxima lección explorará cómo el optimizador clásico usa la evaluación de nuestra función de coste para seleccionar nuevos parámetros.
import qiskit
import qiskit_ibm_runtime
print(qiskit.version.get_version_info())
print(qiskit_ibm_runtime.version.get_version_info())
1.1.0
0.23.0