Entradas y salidas de Samplex
Introducción
Samplex es el tipo principal de la biblioteca Samplomatic. Un «samplex» representa una distribución de probabilidad paramétrica sobre los parámetros de un circuito modelo, con otros campos de valores matriciales que se utilizan en el posprocesamiento de los datos recopilados tras la ejecución del circuito modelo asociado. Su interfaz principal es el sample() método que extrae elementos de esta distribución para generar una colección de matrices.
En esta guía se describe cómo utilizar la sample() interfaz, cómo consultar y especificar los datos de entrada necesarios, y cómo comprobar los resultados esperados. Los vectores que se pasan a y se devuelven de sample() son de tipado fuerte. Es decir, en el caso de un samplex, sus nombres, tipos y formas son fijos y se pueden consultar antes de realizar el muestreo. Las entradas y salidas de un samplex dependen de muchos factores. Por ejemplo, si se produce un giro de la medición y si es necesario inyectar ruido. Por este motivo, en general, no cabe esperar que dos instancias de Samplex tengan las mismas entradas y salidas.
Configuración
En primer lugar, construye un circuito en caja e intégralo en un par formado por un samplex y una plantilla. Se utilizarán en los ejemplos que vienen a continuación. Ten en cuenta que, al encajonar manualmente el circuito, esta guía utiliza los nombres alpha, beta, ref1, ref2, mod_ref1, mod_ref2, mod_ref3, y conclude. Además, el circuito tiene cuatro objetos Parameter . Esta guía explica cómo cada uno de ellos interviene en las entradas y salidas del Samplex. Consulta la guía del transpilador para obtener información sobre cómo agrupar circuitos automáticamente.
import matplotlib.pyplot as plt
import numpy as np
from qiskit.circuit import ClassicalRegister, Parameter, QuantumCircuit, QuantumRegister
from qiskit.quantum_info import Operator, Pauli, PauliLindbladMap
from samplomatic import ChangeBasis, InjectNoise, Twirl, build
# our circuit has two classical registers named alpha and beta
circuit = QuantumCircuit(
QuantumRegister(4), alpha := ClassicalRegister(3, "alpha"), beta := ClassicalRegister(1, "beta")
)
# the first box is only twirled
with circuit.box([Twirl()]):
for idx in range(4):
circuit.rx(Parameter(f"a{idx}"), idx)
circuit.cz(0, 1)
circuit.cz(1, 2)
# the second box is twirled, and has noise injected
with circuit.box([Twirl(), InjectNoise(ref="ref1", modifier_ref="mod_ref1")]):
circuit.h(1)
circuit.cz(1, 2)
# the third box is twirled, and has different noise injected
with circuit.box([Twirl(), InjectNoise(ref="ref2", modifier_ref="mod_ref2")]):
circuit.rx(0.1, range(4))
circuit.cz(0, 1)
circuit.cz(1, 2)
# the fourth box is the same as the second, but with a different modifer ref
with circuit.box([Twirl(), InjectNoise(ref="ref1", modifier_ref="mod_ref3")]):
circuit.h(1)
circuit.cz(1, 2)
circuit.barrier()
# the final two boxes twirl, and add a basis change in one case
with circuit.box([Twirl(), ChangeBasis(ref="conclude")]):
circuit.measure(range(3), alpha)
with circuit.box([Twirl()]):
circuit.measure([3], beta)
circuit.draw("mpl")
A continuación, utiliza el build() circuito enmarcado para crear una plantilla y un par de samplex. Verás cómo cada una de las cajas se convierte en un «sándwich» de barrera que incluye rz-sx-rz-sx-rz fragmentos para realizar el vendaje.
template, samplex = build(circuit)
template.draw("mpl", fold=100)
Representa gráficamente el DAG de Samplex y pasa el cursor por encima de los nodos para ver lo siguiente:
- Todos los nodos de muestreo (estrellas) se encargan de generar aleatorizaciones para el «twirling», de realizar muestreos a partir de modelos de ruido o de introducir cambios en la base.
- Todos los nodos de recopilación (pajaritas) se encargan de generar los cortes de salida, que son inversiones de medición o ángulos de parámetro.
- Todos los nodos intermedios (círculos) se encargan de diversos tipos de manipulación de datos.
samplex.draw()Indica los datos de entrada necesarios y los resultados esperados
La forma más sencilla de ver los datos de entrada necesarios y los resultados esperados es imprimir el objeto Samplex . Los elementos de la matriz se formatean como '{name}' <{type}[{shape}...]>, y los datos obligatorios preceden a los opcionales.
print(samplex)Samplex(<58 nodes>)
Inputs:
- 'basis_changes.conclude' <uint8[3]>: Basis changing gates, in the symplectic ordering
I=0, Z=1, X=2, and Y=3.
- 'parameter_values' <float64[4]>: Input parameter values to use during sampling.
- 'pauli_lindblad_maps.ref1' <PauliLindbladMap>: A PauliLindblad map acting on 2 qubits,
with 'num_terms_ref1' terms.
- 'pauli_lindblad_maps.ref2' <PauliLindbladMap>: A PauliLindblad map acting on 4 qubits,
with 'num_terms_ref2' terms.
- 'local_scales.mod_ref1' <float64['num_terms_ref1']>: (Optional) An array of factors by
which to scale individual rates of a Pauli Lindblad map, where the order should match the
corresponding list of terms.
- 'local_scales.mod_ref2' <float64['num_terms_ref2']>: (Optional) An array of factors by
which to scale individual rates of a Pauli Lindblad map, where the order should match the
corresponding list of terms.
- 'local_scales.mod_ref3' <float64['num_terms_ref1']>: (Optional) An array of factors by
which to scale individual rates of a Pauli Lindblad map, where the order should match the
corresponding list of terms.
- 'noise_scales.mod_ref1' <float64[]>: (Optional) A scalar factor by which to scale rates
of a Pauli Lindblad map.
- 'noise_scales.mod_ref2' <float64[]>: (Optional) A scalar factor by which to scale rates
of a Pauli Lindblad map.
- 'noise_scales.mod_ref3' <float64[]>: (Optional) A scalar factor by which to scale rates
of a Pauli Lindblad map.
Outputs:
* 'measurement_flips.alpha' <bool['num_randomizations', 1, 3]>: Bit-flip corrections
for measurement twirling.
* 'measurement_flips.beta' <bool['num_randomizations', 1, 1]>: Bit-flip corrections for
measurement twirling.
* 'parameter_values' <float32['num_randomizations', 48]>: Parameter values valid for an
associated template circuit.
* 'pauli_signs' <bool['num_randomizations', 3]>: Signs from sampled Pauli Lindblad
maps, where boolean values represent the parity of the number of non-trivial factors in the
sampled error that arise from negative rates. In other words, in order to implement basic
PEC, the sign used to correct expectation values should be ``(-1)**bool_value``. The order
matches the iteration order of boxes in the original circuit with noise injection
annotations.
Los y inputs() outputs() del samplex, así como información más detallada como sus nombres, formas, tipos y descripciones, también se pueden consultar mediante programación. El tipo de retorno de ambos métodos es un TensorInterface. Por ejemplo, el siguiente código muestra todas las especificaciones de la interfaz de entrada:
samplex.inputs().specs[TensorSpecification('basis_changes.conclude', (3,), dtype('uint8'), 'Basis changing gates, in the symplectic ordering I=0, Z=1, X=2, and Y=3.'),
TensorSpecification('local_scales.mod_ref1', ('num_terms_ref1',), dtype('float64'), 'An array of factors by which to scale individual rates of a Pauli Lindblad map, where the order should match the corresponding list of terms.', optional=True),
TensorSpecification('local_scales.mod_ref2', ('num_terms_ref2',), dtype('float64'), 'An array of factors by which to scale individual rates of a Pauli Lindblad map, where the order should match the corresponding list of terms.', optional=True),
TensorSpecification('local_scales.mod_ref3', ('num_terms_ref1',), dtype('float64'), 'An array of factors by which to scale individual rates of a Pauli Lindblad map, where the order should match the corresponding list of terms.', optional=True),
TensorSpecification('noise_scales.mod_ref1', (), dtype('float64'), 'A scalar factor by which to scale rates of a Pauli Lindblad map.', optional=True),
TensorSpecification('noise_scales.mod_ref2', (), dtype('float64'), 'A scalar factor by which to scale rates of a Pauli Lindblad map.', optional=True),
TensorSpecification('noise_scales.mod_ref3', (), dtype('float64'), 'A scalar factor by which to scale rates of a Pauli Lindblad map.', optional=True),
TensorSpecification('parameter_values', (4,), dtype('float64'), 'Input parameter values to use during sampling.'),
PauliLindbladMapSpecification('pauli_lindblad_maps.ref1', num_qubits=2, num_terms=num_terms_ref1),
PauliLindbladMapSpecification('pauli_lindblad_maps.ref2', num_qubits=4, num_terms=num_terms_ref2)]
El siguiente ejemplo muestra información sobre aquellos especificadores de salida cuyos nombres contienen la cadena "flips".
for spec in samplex.outputs().get_specs("flips"):
print(spec.name, spec.shape, spec.dtype)measurement_flips.alpha ('num_randomizations', 1, 3) bool
measurement_flips.beta ('num_randomizations', 1, 1) bool
Asignar valores de entrada y realizar una muestra
Para que el samplex funcione sample(), se necesitan valores al menos para aquellos campos inputs() que están marcados como obligatorios. En primer lugar, estos valores se asocian a una interfaz de entrada para que esta pueda realizar todas las comprobaciones de tipo necesarias (o la conversión de tipos, en algunos casos) y el análisis de la estructura. Si hay algún error en los tipos o las formas, o si falta algún valor obligatorio, se genera un mensaje de error detallado.
Asignar entrada
En el siguiente ejemplo, se pasan al método sample() los valores de entrada necesarios. A continuación, se utiliza para extraer tres aleatorizaciones del samplex. Los enlaces necesarios de los apariamientos son los mapas de Pauli-Lindblad para la inyección de ruido y la matriz de cambio de base. Además, se necesitan los valores de los cuatro parámetros del circuito en recuadro original, que en este caso está configurado en np.linspace(0, 1, 4). El samplex integra estos parámetros en los «dressings», por lo que, en la práctica, se convierten en modificaciones de los valores de los parámetros de salida del samplex.
inputs = samplex.inputs().bind(
# bind() concatenates nested dicts with a '.' so that we can
# set 'pauli_lindblad_maps.noise0/1' as follows. note that the
# names 'ref1' and 'ref2' derive from the names in the InjectNoise
# annotation.
pauli_lindblad_maps={
"ref1": PauliLindbladMap.identity(2),
"ref2": PauliLindbladMap.identity(4),
},
# likewise, we can set all basis changes as follows, where
# the name 'conclude' comes from our basis change annotation
basis_changes={"conclude": [0, 1, 2]},
# we must provide values for all parameters in the original circuit
parameter_values=np.linspace(0, 1, 4),
)
outputs = samplex.sample(inputs, num_randomizations=3)
print(outputs)SamplexOutput(<
- Dimension constraints: num_randomizations=3
* 'measurement_flips.alpha' <bool['num_randomizations', 1, 3]>: Bit-flip corrections for
measurement twirling.
* 'measurement_flips.beta' <bool['num_randomizations', 1, 1]>: Bit-flip corrections for
measurement twirling.
* 'parameter_values' <float32['num_randomizations', 48]>: Parameter values valid for an
associated template circuit.
* 'pauli_signs' <bool['num_randomizations', 3]>: Signs from sampled Pauli Lindblad maps,
where boolean values represent the parity of the number of non-trivial factors in the sampled
error that arise from negative rates. In other words, in order to implement basic PEC, the
sign used to correct expectation values should be ``(-1)**bool_value``. The order matches the
iteration order of boxes in the original circuit with noise injection annotations.
>)
Los datos de entrada se pueden vincular a la interfaz mediante varias llamadas a o bind() mediante la asignación directa de elementos. A pesar de que el formato de diccionario anidado bind() resulta muy práctico, se trata de un objeto de mapeo plano y es compatible con toda la sintaxis habitual de mapeo. Una vez que se han encontrado todas las entradas necesarias, se fully_bound convierte en verdadero.
inputs = (
samplex.inputs()
.bind(
pauli_lindblad_maps={
"ref1": PauliLindbladMap.identity(2),
"ref2": PauliLindbladMap.identity(4),
}
)
.bind(parameter_values=np.linspace(0, 1, 4))
)
# once the final requirement is bound, fully_bound becomes True
print("Fully bound?", inputs.fully_bound)
inputs["basis_changes.conclude"] = [0, 2, 3]
print("Fully bound?", inputs.fully_bound)
# setting optional values does not affect whether the interface is fully bound
inputs["noise_scales.mod_ref1"] = 3
print("Fully bound?", inputs.fully_bound)Fully bound? False
Fully bound? True
Fully bound? True
Todos los objetos TensorInterface , incluida la subclase SamplexOutput , asocian los objetos a los datos que se les han vinculado. Esto significa que puedes obtener, por ejemplo, los valores de los parámetros de la plantilla de la siguiente manera. En este caso, tiene forma, ya (3, 48) que se solicitaron tres aleatorizaciones y el circuito de plantilla tiene 48 parámetros.
print("(num_randomizations, num_template_params) =", outputs["parameter_values"].shape)
print(outputs["parameter_values"][0, :])(num_randomizations, num_template_params) = (3, 48)
[ 0.0000000e+00 0.0000000e+00 0.0000000e+00 1.5707964e+00
3.3333334e-01 1.5707964e+00 -1.5707964e+00 2.4749260e+00
-1.5707964e+00 1.5707964e+00 1.0000000e+00 1.5707964e+00
0.0000000e+00 1.5707964e+00 0.0000000e+00 3.1415927e+00
0.0000000e+00 0.0000000e+00 1.5707964e+00 3.0415926e+00
-1.5707964e+00 -1.5707964e+00 3.0415926e+00 -1.5707964e+00
-1.5707964e+00 1.0000000e-01 1.5707964e+00 1.5707964e+00
1.0000000e-01 1.5707964e+00 3.1415927e+00 1.5707964e+00
0.0000000e+00 0.0000000e+00 0.0000000e+00 0.0000000e+00
0.0000000e+00 3.1415927e+00 0.0000000e+00 3.1415927e+00
0.0000000e+00 0.0000000e+00 3.1415927e+00 1.5707964e+00
0.0000000e+00 4.4408921e-16 3.1415927e+00 0.0000000e+00]
Especifica la entrada como un diccionario
En lugar de proporcionar un objeto totalmente vinculado TensorInterface, puedes especificar los datos de entrada como un diccionario estándar, tal y como se muestra en la celda siguiente. En este caso, el método sample() vincula internamente los elementos del diccionario a la interfaz de entrada del samplex para comprobar que todas las entradas necesarias estén presentes y sean compatibles.
inputs = {
"pauli_lindblad_maps": {
"ref1": PauliLindbladMap.identity(2),
"ref2": PauliLindbladMap.identity(4),
},
"basis_changes": {"conclude": [0, 1, 2]},
"parameter_values": np.linspace(0, 1, 4),
}
outputs = samplex.sample(inputs, num_randomizations=3)Tipos de entrada
Actualmente, existen dos tipos de valores de interfaz:
- Con valores de matriz: tienen una forma y un tipo de datos. Los valores vinculados se convierten al tipo de datos correspondiente, si es posible. Cualquier dimensión puede ser una dimensión libre.
- Mapeos de Pauli-Lindblad: deben ser de tipo
qiskit.quantum_info.PauliLindbladMapy deben actuar sobre el número de qubits establecido. El número de términos del mapa es una dimensión libre.
# up to precision loss, these are equivalent via automatic type coersion of array inputs
samplex.inputs()["parameter_values"] = [1, 2, 8, 9]
samplex.inputs()["parameter_values"] = np.array([1, 2, 8, 9], dtype=np.float16)Dimensiones libres
La clase TensorInterface cuenta con el concepto de «dimensiones libres», que son tamaños enteros cuyos valores permanecen indeterminados hasta que se hayan asociado datos a la interfaz que los restringe. Todas las dimensiones libres con el mismo nombre deben tener un tamaño coherente al vincular los datos; de lo contrario, se producirá un error. Por ejemplo, en el outputs() de cualquier samplex, el eje de la matriz correspondiente a las aleatorizaciones es un parámetro libre que, "num_randomizations" por convención, recibe el nombre de. Cuando sample() se invoca, asigna valores a las salidas
print("All free dimensions:", outputs.free_dimensions)
print("Current constraints:", outputs.bound_dimensions)All free dimensions: {'num_randomizations'}
Current constraints: {'num_randomizations': 3}
Otro caso habitual de «dimensión libre» es el de compartir restricciones de términos entre las especificaciones del mapa de Pauli-Lindblad y sus modificadores. A qiskit.quantum_info.PauliLindbladMap es una secuencia ordenada de términos, en la que cada término contiene una tasa de coma flotante y una representación de Pauli dispersa. La función build() añade un especificador "pauli_lindblad_maps.<ref>" para este tipo en cada InjectNoise anotación y, si contiene un modifier_ref, también se añade un "local_scales.<modifier_ref>" especificador. Ambos comparten la dimensión libre "num_terms_<ref>" que especifica cuántos términos de Pauli-Lindblad hay en el modelo de ruido, ya que este último debe ajustarse a las tasas del primero para escalarlas.
# both the PauliLindbladMap and the local scales imply 2 terms, so that the free dimension
# 'num_terms_noise2' is satisfied
inputs = samplex.inputs().bind(
pauli_lindblad_maps={"ref2": PauliLindbladMap.from_list([("XXYZ", 0.1), ("IIXX", 0.2)])},
local_scales={"mod_ref2": [1, 2]},
)
print("All free dimensions:", inputs.free_dimensions)
print("Current constraints:", inputs.bound_dimensions)All free dimensions: {'num_terms_ref1', 'num_terms_ref2'}
Current constraints: {'num_terms_ref2': 2}
Convención de ordenación de qubits
Los samplexes construidos a partir de circuitos en recuadros mediante la función build() pueden requerir entradas con valores de matriz para sample(), donde un índice se corresponde con los qubits de un recuadro del circuito. En esta sección se explica la convención de ordenación que debe utilizarse para dichos ejes. En resumen: utiliza el orden de los qubits del circuito enmarcado, limitándote a los qubits de ese cuadro.
Por ejemplo, supongamos que quieres averiguar cómo especificar un mapa de Pauli-Lindblad para "noise1". Entonces, el orden de los qubits en el mapa de ruido se establece con respecto a estos qubits:
box_of_interest = circuit[1]
qubit_ordering_convention = [qubit for qubit in circuit.qubits if qubit in box_of_interest.qubits]
qubit_ordering_convention[<Qubit register=(4, "q0"), index=1>, <Qubit register=(4, "q0"), index=2>]
Es decir, si quieres <Qubit register=(4, "q0"), index=2> que haya ruido X , entonces deberías definir un mapa de Pauli-Lindblad como este, ya que se encuentra en el índice 1 de qubit_ordering_convention.
samplex.inputs().bind(
pauli_lindblad_maps={
"ref1": (noise1 := PauliLindbladMap.from_sparse_list([("X", [1], 0.12)], num_qubits=2))
}
)
noise1<PauliLindbladMap with 1 term on 2 qubits: (0.12)L(X_1)>
Lo mismo ocurre con los cambios de base. Si deseas rotar hacia los estados propios +1 de los operadores X, Y y Z para los qubits 0, 1 y 2 de circuit, respectivamente, debes utilizar la siguiente matriz, teniendo en cuenta que en esta biblioteca (y en Qiskit) se utiliza la convención , , y .
samplex.inputs().bind(basis_changes={"conclude": (basis_change := [2, 3, 1])})
basis_change[2, 3, 1]
Ten en cuenta que la convención de ordenación de los qubits no es el orden definido por ninguna de las siguientes opciones:
box_instruction.qubits, dondebox_instructiones unqiskit.circuit.CircuitInstructioncuya operación es unqiskit.circuit.BoxOp, ya que el orden de esta lista de qubits podría depender del orden en que se añadieron las instrucciones al contexto de la caja, lo que sería una convención confusa. Por ejemplo, ywith box(): circuit.x([0, 1])with box(): circuit.x([1, 0])darían lugar a ordenaciones diferentes.box_instruction.operation.body.qubitsporque es posible que estos qubits ni siquiera aparezcan en el circuito exterior. Solo se garantiza que los qubits que se encuentran dentro del cuerpo de las instruccionesBoxOp(o de cualquier otra operación de flujo de control, comoIfElseOp) tengan un significado coherente dentro de ese ámbito. Esta sería una convención poco clara y, en general, difícil de aplicar.
Pruebas locales con el circuito de referencia
Si Samplex se crea un utilizando la función build() , los valores de los parámetros de salida deben ser directamente compatibles con los parámetros del circuito de plantilla asociado. Por ejemplo, puedes asignar valores para una de las aleatorizaciones a la plantilla para su revisión local.
inputs = samplex.inputs().bind(
pauli_lindblad_maps={
"ref1": PauliLindbladMap.identity(2),
"ref2": PauliLindbladMap.identity(4),
},
basis_changes={"conclude": [0, 0, 0]},
parameter_values=np.linspace(0, 1, 4),
)
outputs = samplex.sample(inputs, num_randomizations=3)
bound_template = template.assign_parameters(outputs["parameter_values"][2])
bound_template.draw("mpl", fold=1000)
Esto te permite comprobar que el «samplex» está generando las muestras esperadas. Por ejemplo, el siguiente código convierte el circuito vinculado en un objeto qiskit.quantum_info.Operator y lo combina con los Paulis de inversión de bits para comprobar visualmente que las tres aleatorizaciones son lógicamente equivalentes al circuito base.
from samplomatic.utils import unbox
# Operator requires that we unpack all of the boxes first
unboxed = unbox(circuit).assign_parameters(inputs["parameter_values"])
# We want to plot unitary matrix amplitudes, so we need to remove the non-unitary measurements
unboxed.remove_final_measurements()
unboxed_unitary = Operator(unboxed)
# Plot magnitudes of the unitary matrix represenation of the circuit
plt.subplot(1, 4, 1)
plt.imshow(np.abs(unboxed_unitary))
plt.title("Base Circuit")
# For each randomization we sampled, plot the unitary matrix representation
for idx in range(3):
bound_template = template.assign_parameters(outputs["parameter_values"][idx])
bound_template.remove_final_measurements()
# The example does measurement twirling by compiling random bitflip gates before the
# measurements, so we need to undo these at the operator level for each randomization.
# We do this by casting them to Paulis and composing with the bound circuit's unitary
alpha_flips = Pauli(([0, 0, 0], outputs["measurement_flips.alpha"][idx, 0]))
beta_flips = Pauli(([0], outputs["measurement_flips.beta"][idx, 0]))
flips = beta_flips ^ alpha_flips
bound_unitary = Operator(bound_template) & flips
# Plot magnitudes of the unitary matrix represenation of the circuit
plt.subplot(1, 4, idx + 2)
plt.imshow(np.abs(bound_unitary))
plt.title(f"Randomization {idx}")
plt.tight_layout()
Del mismo modo, el siguiente código pasa los valores de los parámetros de salida y la plantilla a un muestreador para simular 10 000 muestras de todas las aleatorizaciones, y compara los valores esperados resultantes (solo del registro alfa) con los valores esperados de una simulación del circuito base.
from qiskit.primitives import StatevectorEstimator as Estimator
from qiskit.primitives import StatevectorSampler as Sampler
from qiskit.primitives.containers import BitArray
# simulate some expectation values direction from the base circuit
estimator_job = Estimator().run([(unboxed, ["IZII", "IIZI", "IIIZ"])])
evs = estimator_job.result()[0].data["evs"]
# get the sampler data from each randomization, and do bitflip correction
sampler_job = Sampler().run([(template, outputs["parameter_values"])], shots=10_000)
alpha_data = sampler_job.result()[0].data["alpha"]
alpha_data ^= BitArray.from_bool_array(outputs["measurement_flips.alpha"], "little")
# compare the results
print(" EVs from base circuit:", evs)
print("EVs from randomizations:", alpha_data.expectation_values(["ZII", "IZI", "IIZ"])) EVs from base circuit: [0.7819611 0.93553883 0.99500417]
EVs from randomizations: [0.789 0.9366 0.995 ]