Entrées et sorties de Samplex
Introduction
Samplex est le type central de la bibliothèque samplomatic. Un samplex représente une distribution de probabilité paramétrique sur les paramètres d'un circuit modèle, avec d'autres champs à valeurs de tableaux à utiliser lors du post-traitement des données collectées à l'exécution du circuit modèle lié. Son interface centrale est la méthode sample(), qui tire dans cette distribution pour produire une collection de tableaux.
Ce guide décrit comment utiliser l'interface sample(), comment interroger et spécifier les entrées requises, et comment inspecter les sorties attendues. Les tableaux fournis à sample() et renvoyés par cette méthode sont fortement typés. Autrement dit, pour une instance de samplex, leurs noms, types et formes sont fixés et peuvent être interrogés avant l'échantillonnage. Les entrées et sorties présentes dans un samplex dépendent de nombreux facteurs, par exemple la présence ou non d'un twirling de mesure et la nécessité ou non d'une injection de bruit. Pour cette raison, on ne s'attend en général pas à ce que deux instances de samplex aient les mêmes entrées et sorties.
Configuration
Commence par construire un circuit placé dans des boxes, puis construis-le en une paire samplex et modèle. Elles seront utilisées dans les exemples qui suivent. Note que, lors de la mise en boxes manuelle du circuit, ce guide utilise les noms alpha, beta, ref1, ref2, mod_ref1, mod_ref2, mod_ref3 et conclude. De plus, le circuit comporte quatre objets Parameter. Ce guide montre le rôle que joue chacun d'eux dans les entrées et sorties du samplex. Consulte le guide du transpileur pour savoir comment placer automatiquement les circuits dans des boxes.
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")
Ensuite, appelle build() sur le circuit placé dans des boxes pour construire une paire modèle et samplex. Tu verras comment chacune des boxes est transformée en un « sandwich » de barrières qui inclut des fragments rz-sx-rz-sx-rz pour mettre en œuvre l'habillage.
template, samplex = build(circuit)
template.draw("mpl", fold=100)
Trace le DAG du samplex et survole les nœuds pour voir ce qui suit :
-
Tous les nœuds d'échantillonnage (étoiles) sont chargés de générer des randomisations pour le twirling, d'échantillonner à partir de modèles de bruit ou d'injecter des changements de base.
-
Tous les nœuds de collecte (nœuds papillon) sont chargés de produire les tranches de sortie, qui sont des inversions de mesure ou des angles de paramètres.
-
Tous les nœuds intermédiaires (cercles) sont chargés de divers types de manipulation de données.
samplex.draw()
Interroger les entrées requises et les sorties attendues
Le moyen le plus simple de voir les entrées requises et les sorties attendues est d'afficher l'objet Samplex. Les éléments de tableau sont formatés comme '{name}' <{type}[{shape}...]>, et les entrées requises précèdent les entrées facultatives.
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.
Les inputs() et outputs() du samplex, ainsi que des informations plus détaillées comme leurs noms, formes, types et descriptions, peuvent aussi être interrogés par programme. Le type de retour de ces deux méthodes est un TensorInterface. Par exemple, le code suivant liste toutes les spécifications de l'interface d'entrée :
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)]
L'exemple suivant affiche des informations sur les spécificateurs de sortie dont le nom contient la chaîne "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
Lier les valeurs d'entrée et échantillonner
Pour sample(), le samplex a besoin de valeurs pour au moins les inputs() marquées comme requises. Ces valeurs sont d'abord liées à une interface d'entrée afin qu'elle puisse effectuer toute la vérification de types nécessaire (ou la coercition de types dans certains cas) et l'analyse des formes. Si quelque chose ne va pas avec les types ou les formes, ou si une valeur requise est manquante, une erreur détaillée est levée.
Lier l'entrée
Dans l'exemple suivant, la méthode sample() reçoit les valeurs d'entrée requises. Elle sert ensuite à tirer trois randomisations du samplex. Les liaisons requises issues des habillages sont les cartes de Pauli Lindblad pour l'injection de bruit et le tableau de changement de base. De plus, des valeurs pour les quatre paramètres du circuit d'origine placé dans des boxes sont requises ; ici, elles sont définies à np.linspace(0, 1, 4). Le samplex compose ces paramètres dans les habillages, ils deviennent donc en pratique des modifications des valeurs de paramètres de sortie du 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.
>)
Les entrées peuvent être liées à l'interface par plusieurs appels à bind() ou par affectation directe d'éléments. Malgré le format de dictionnaire imbriqué que bind() accepte par commodité, il s'agit d'un objet de correspondance plat qui prend en charge toute la syntaxe habituelle des correspondances. Une fois toutes les entrées requises trouvées, fully_bound devient vrai.
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
Tous les objets TensorInterface, y compris la sous-classe SamplexOutput, mettent en correspondance des objets avec les données qui leur ont été liées. Cela signifie que tu peux obtenir, par exemple, les valeurs des paramètres du modèle comme suit. Dans ce cas, elles ont la forme (3, 48) puisque trois randomisations ont été demandées et que le circuit modèle a 48 paramètres.
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]
Spécifier l'entrée sous forme de dictionnaire
Au lieu de fournir un TensorInterface entièrement lié, tu peux spécifier les entrées sous forme de dictionnaire standard, comme le montre la cellule ci-dessous. Dans ce cas, la méthode sample() lie en interne les éléments du dictionnaire à l'interface d'entrée du samplex pour valider que toutes les entrées requises sont présentes et 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)
Types d'entrée
Il existe actuellement deux types de valeurs d'interface :
-
À valeurs de tableau : elles ont une forme et un type de données. Les valeurs liées sont converties vers le type de données si possible. N'importe quelle dimension peut être une dimension libre.
-
Cartes de Pauli Lindblad : elles doivent être de type
qiskit.quantum_info.PauliLindbladMapet doivent agir sur le nombre prescrit de qubits. Le nombre de termes de la carte est une dimension 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)
Dimensions libres
La classe TensorInterface a une notion de dimensions libres, qui sont des tailles entières nommées dont les valeurs sont indéterminées tant que des données qui les contraignent n'ont pas été liées à l'interface. Toutes les dimensions libres de même nom doivent se résoudre à une taille cohérente lors de la liaison des données, sinon une erreur est levée. Par exemple, dans les outputs() de tout samplex, l'axe du tableau correspondant aux randomisations est un paramètre libre qui porte par convention le nom "num_randomizations". Lorsque sample() est appelé, il lie des valeurs aux sorties
print("All free dimensions:", outputs.free_dimensions)
print("Current constraints:", outputs.bound_dimensions)
All free dimensions: {'num_randomizations'}
Current constraints: {'num_randomizations': 3}
Un autre scénario courant de dimension libre est le partage de contraintes sur les termes entre les spécifications de cartes de Pauli Lindblad et leurs modificateurs. Une qiskit.quantum_info.PauliLindbladMap est une séquence ordonnée de termes, où chaque terme contient un taux en virgule flottante et une représentation de Pauli creuse. La fonction build() ajoute un spécificateur "pauli_lindblad_maps.<ref>" de ce type pour chaque annotation InjectNoise, et si elle contient un modifier_ref, un spécificateur "local_scales.<modifier_ref>" est également ajouté. Les deux partagent la dimension libre "num_terms_<ref>" qui indique combien de termes de Pauli Lindblad sont présents dans le modèle de bruit, car le second doit être apparié (zip) avec les taux du premier pour les mettre à l'échelle.
# 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}
Convention d'ordre des qubits
Les samplexes construits à partir de circuits placés dans des boxes par la fonction build() peuvent exiger des entrées à valeurs de tableau pour sample(), dans lesquelles un indice correspond aux qubits de la box d'un circuit. Cette section explique la convention d'ordre à utiliser pour de tels axes. En résumé : utilise l'ordre des qubits du circuit placé dans des boxes, restreint aux qubits de la box.
Par exemple, supposons que tu veuilles déterminer comment spécifier une carte de Pauli Lindblad pour "noise1". L'ordre des qubits dans la carte de bruit est alors relatif à ces 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>]
Autrement dit, si tu veux que <Qubit register=(4, "q0"), index=2> soit bruité avec un bruit X, tu dois définir une carte de Pauli Lindblad comme celle-ci, car il se trouve à l'indice 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)>
Il en va de même pour les changements de base. Si tu veux effectuer une rotation vers les états propres +1 des opérateurs X, Y et Z pour les qubits 0, 1 et 2 de circuit, respectivement, tu dois utiliser le tableau suivant, en te rappelant que la convention , , et est utilisée dans cette bibliothèque (et dans Qiskit).
samplex.inputs().bind(basis_changes={"conclude": (basis_change := [2, 3, 1])})
basis_change
[2, 3, 1]
Note que la convention d'ordre des qubits n'est pas l'ordre défini par l'un ou l'autre des éléments suivants :
-
box_instruction.qubits, oùbox_instructionest unqiskit.circuit.CircuitInstructiondont l'opération est unqiskit.circuit.BoxOp, car l'ordre de cette liste de qubits pourrait dépendre de l'ordre dans lequel les instructions ont été ajoutées au contexte de la box, ce qui serait une convention déroutante. Par exemple,with box(): circuit.x([0, 1])etwith box(): circuit.x([1, 0])donneraient des ordres différents. -
box_instruction.operation.body.qubits, car ces qubits peuvent même ne pas apparaître dans le circuit externe. Les qubits à l'intérieur du corps même des instructionsBoxOp(ou de toute autre opération de flux de contrôle commeIfElseOp) n'ont de signification cohérente que dans cette portée. Ce serait une convention mal définie et difficile à utiliser en général.
Tests locaux avec le circuit modèle
Si un Samplex est construit à l'aide de la fonction build(), les valeurs des paramètres de sortie doivent être directement compatibles avec les paramètres du circuit modèle associé. Par exemple, tu peux lier au modèle les valeurs de l'une des randomisations pour l'inspecter localement.
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)
Cela te permet de vérifier que le samplex produit les échantillons attendus. Par exemple, le code suivant convertit le circuit lié en objet qiskit.quantum_info.Operator et le compose avec les Pauli d'inversion de bits pour vérifier visuellement que les trois randomisations sont logiquement équivalentes au circuit de 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()

De même, le code suivant passe les valeurs des paramètres de sortie et le modèle à un sampler pour simuler 10 000 shots de toutes les randomisations, et compare les valeurs moyennes obtenues (uniquement pour le registre alpha) aux valeurs moyennes d'une simulation du circuit de 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 ]

