Saltar al contenido principal
Este proyecto está en fase beta. ¿Encontraste un error? Abre un issue en GitHub. ¿Tienes una pregunta o una idea? Pregunta en Discussions — ¡nos encantaría tu opinión!
¿Cómo es la calidad de la traducción en esta página?

Entradas y salidas de Samplex

Introducción​

Samplex es el tipo central de la biblioteca samplomatic. Un samplex representa una distribución de probabilidad paramétrica sobre los parámetros de algún circuito plantilla, con otros campos de tipo array para usar al posprocesar los datos recogidos de la ejecución del circuito plantilla enlazado. Su interfaz central es el método sample(), que extrae muestras de esta distribución para producir una colección de arrays.

Esta guía describe cómo usar la interfaz sample(), cómo consultar y especificar las entradas requeridas y cómo inspeccionar las salidas esperadas. Los arrays que se suministran a sample() y que este devuelve tienen tipado estricto. Es decir, para una instancia de samplex, sus nombres, tipos y formas son fijos y se pueden consultar antes de realizar el muestreo. Las entradas y salidas presentes en un samplex dependen de muchos factores. Por ejemplo, de si hay twirling de mediciones y de si se requiere inyección de ruido. Por este motivo, en general no se espera que dos instancias de samplex tengan las mismas entradas y salidas.

Configuración​

Primero, construye un circuito con boxes y compílalo en un par samplex y plantilla. Estos se usarán en los ejemplos siguientes. Ten en cuenta que, al agrupar manualmente el circuito en boxes, esta guía usa 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 muestra qué papel desempeña cada uno de ellos en las entradas y salidas del samplex. Consulta la guía del transpilador para obtener información sobre cómo agrupar circuitos en boxes de forma automática.

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

../_images/88f6576d7e2fd76bfb52d5133c6bc07e908a28941c6538f5251ac508cfb202f7.png

A continuación, llama a build() sobre el circuito con boxes para construir un par plantilla y samplex. Verás cómo cada uno de los boxes se convierte en un “sándwich” de barreras que incluye fragmentos rz-sx-rz-sx-rz para implementar el dressing.

template, samplex = build(circuit)

template.draw("mpl", fold=100)

../_images/ec8456d0d0a5928c5d056184cbd1842dcf7aefdc7cdba428f62092b27646d601.png

Representa gráficamente el DAG del samplex y pasa el cursor sobre los nodos para ver lo siguiente:

  • Todos los nodos de muestreo (estrellas) se encargan de generar aleatorizaciones para el twirling, de muestrear modelos de ruido o de inyectar cambios de base.

  • Todos los nodos de recolección (pajaritas) se encargan de generar los segmentos de salida, que son inversiones de medición o ángulos de parámetros.

  • Todos los nodos intermedios (círculos) se encargan de distintos tipos de manipulación de datos.

samplex.draw()

Consultar las entradas requeridas y las salidas esperadas​

La forma más sencilla de ver las entradas requeridas y las salidas esperadas es imprimir el objeto Samplex. Los elementos de tipo array tienen el formato '{name}' <{type}[{shape}...]>, y las entradas requeridas preceden a las 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.

Las inputs() y 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 enumera 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 imprime 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

Enlazar valores de entrada y muestrear​

Para ejecutar sample(), el samplex necesita valores para al menos aquellas inputs() que están marcadas como requeridas. Estos valores se enlazan primero a una interfaz de entrada para que esta pueda realizar toda la comprobación de tipos necesaria (o la coerción de tipos en algunos casos) y el análisis de formas. Si algo es incorrecto en los tipos o las formas, o si falta un valor requerido, se genera un error detallado.

Enlazar la entrada​

En el siguiente ejemplo, se pasan al método sample() los valores de entrada requeridos. Luego se usa para extraer tres aleatorizaciones del samplex. Los enlaces requeridos de los dressings son los mapas de Pauli Lindblad para la inyección de ruido y el array de cambio de base. Además, se requieren valores para los cuatro parámetros del circuito original con boxes, que en este caso se establecen en np.linspace(0, 1, 4). El samplex compone estos parámetros en los dressings, por lo que se convierten en la práctica 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.
>)

Las entradas se pueden enlazar a la interfaz mediante varias llamadas a bind() o mediante asignación directa de elementos. A pesar del formato de diccionario anidado que bind() admite por comodidad, es un objeto de mapeo plano y admite toda la sintaxis habitual de los mapeos. Una vez que se han encontrado todas las entradas requeridas, fully_bound pasa a ser 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, son objetos de mapeo sobre los datos que se les han enlazado. Esto significa que puedes obtener, por ejemplo, los valores de los parámetros de la plantilla de la siguiente manera. En este caso, tiene la forma (3, 48), ya que se solicitaron tres aleatorizaciones y el circuito 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]

Especificar la entrada como un diccionario​

En lugar de proporcionar un TensorInterface totalmente enlazado, puedes especificar las entradas como un diccionario estándar, como se muestra en la celda siguiente. En este caso, el método sample() enlaza internamente los elementos del diccionario a la interfaz de entrada del samplex para validar que todas las entradas requeridas 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, hay dos tipos de valores de interfaz:

  • De tipo array: Tienen una forma y un tipo de datos. Los valores enlazados se convierten al tipo de datos si es posible. Cualquier dimensión puede ser una dimensión libre.

  • Mapas de Pauli Lindblad: Deben ser del tipo qiskit.quantum_info.PauliLindbladMap y deben actuar sobre el número prescrito de qubits. 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 tiene la noción de dimensiones libres, que son tamaños enteros con nombre cuyos valores quedan indeterminados hasta que se enlazan a la interfaz datos que los restringen. Todas las dimensiones libres con el mismo nombre deben resolverse a un tamaño coherente al enlazar los datos; de lo contrario, se generará un error. Por ejemplo, en las outputs() de cualquier samplex, el eje del array correspondiente a las aleatorizaciones es un parámetro libre que, por convención, se llama "num_randomizations". Cuando se llama a sample(), este enlaza 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 escenario común de dimensión libre es compartir restricciones de términos entre las especificaciones de mapas de Pauli Lindblad y sus modificadores. Un qiskit.quantum_info.PauliLindbladMap es una secuencia ordenada de términos, donde 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 por cada anotación InjectNoise y, si esta contiene un modifier_ref, también se añade un especificador "local_scales.<modifier_ref>". Ambos comparten la dimensión libre "num_terms_<ref>", que especifica cuántos términos de Pauli Lindblad hay en el modelo de ruido, porque el segundo necesita emparejarse con 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 con boxes mediante la función build() pueden requerir entradas de tipo array para sample(), donde un índice corresponde a los qubits del box de un circuito. Esta sección explica la convención de ordenación que debe usarse para esos ejes. En resumen: usa el orden de qubits del circuito con boxes, restringido a los qubits del box.

Por ejemplo, supón que quieres averiguar cómo especificar un mapa de Pauli Lindblad para "noise1". Entonces el orden de los qubits en el mapa de ruido es 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 que <Qubit register=(4, "q0"), index=2> tenga ruido X, debes definir un mapa de Pauli Lindblad como este, porque 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 se aplica a los cambios de base. Si quieres rotar a los autoestados +1 de los operadores X, Y y Z para los qubits 0, 1 y 2 de circuit, respectivamente, debes usar el siguiente array, recordando que en esta biblioteca (y en Qiskit) se usa la convención I=0I=0, Z=1Z=1, X=2X=2 e Y=3Y=3.

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 qubits no es el orden definido por ninguno de los siguientes:

  • box_instruction.qubits, donde box_instruction es un qiskit.circuit.CircuitInstruction cuya operación es un qiskit.circuit.BoxOp, porque el orden de esta lista de qubits podría depender del orden en que se añadieron las instrucciones al contexto del box, lo que sería una convención confusa. Por ejemplo, with box(): circuit.x([0, 1]) y with box(): circuit.x([1, 0]) darían lugar a ordenaciones diferentes.

  • box_instruction.operation.body.qubits, porque es posible que estos qubits ni siquiera aparezcan en el circuito exterior. Los qubits dentro del cuerpo real de las instrucciones BoxOp (o de cualquier otra operación de flujo de control como IfElseOp) solo tienen garantizado un significado coherente dentro de ese ámbito. Esta sería una convención mal definida y difícil de usar en general.

Pruebas locales con el circuito plantilla​

Si un Samplex se construye usando la función build(), los valores de los parámetros de salida deben ser directamente compatibles con los parámetros del circuito plantilla asociado. Por ejemplo, puedes enlazar a la plantilla los valores de una de las aleatorizaciones para inspeccionarla localmente.

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)

../_images/1d82c46209f8648a2df572cfef8ca256f47952566d34386761ab942aa6e50afd.png

Esto te permite verificar que el samplex produce las muestras esperadas. Por ejemplo, el siguiente código convierte el circuito enlazado en un objeto qiskit.quantum_info.Operator y lo compone con los Paulis de inversión de bit para inspeccionar 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()

../_images/af1aa74f8a0e8abed673d4cd43ff8b810c44297d2f3a42fcfb49ade811e51c58.png

De manera similar, el siguiente código pasa los valores de los parámetros de salida y la plantilla a un sampler para simular 10 000 shots de todas las aleatorizaciones, y compara los valores esperados resultantes (solo del registro alpha) 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 ]