Vai al contenuto principale
Questo progetto è in fase beta. Hai trovato un bug? Apri una issue su GitHub. Hai una domanda o un'idea? Chiedi su GitHub Discussions — apprezziamo il tuo feedback!
Com'è la qualità della traduzione su questa pagina?

Input e output di Samplex

Introduzione​

Samplex è il tipo centrale della libreria samplomatic. Un samplex rappresenta una distribuzione di probabilità parametrica sui parametri di un circuito template, con altri campi di tipo array da usare per il post-processing dei dati raccolti dall'esecuzione del circuito template associato. La sua interfaccia centrale è il metodo sample(), che estrae da questa distribuzione per produrre un insieme di array.

Questa guida descrive come usare l'interfaccia sample(), come interrogare e specificare gli input richiesti e come ispezionare gli output attesi. Gli array forniti a sample() e restituiti da esso sono fortemente tipizzati. Ciò significa che, per un'istanza di samplex, i loro nomi, tipi e forme sono fissi e interrogabili prima del campionamento. Gli input e gli output presenti in un samplex dipendono da molti fattori, ad esempio dalla presenza del twirling di misura e dalla necessità di iniezione del rumore. Per questo motivo, in generale non ci si aspetta che due istanze di samplex abbiano gli stessi input e output.

Configurazione​

Per prima cosa, costruisci un circuito suddiviso in box e compilalo in una coppia samplex e template. Verranno usati negli esempi che seguono. Nota che, quando si suddivide manualmente il circuito in box, questa guida usa i nomi alpha, beta, ref1, ref2, mod_ref1, mod_ref2, mod_ref3 e conclude. Inoltre, il circuito ha quattro oggetti Parameter. Questa guida mostra il ruolo di ciascuno di essi negli input e output del samplex. Consulta la guida al transpiler per informazioni sulla suddivisione automatica dei circuiti in box.

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

Successivamente, chiama build() sul circuito suddiviso in box per costruire una coppia template e samplex. Vedrai come ciascun box viene trasformato in un "sandwich" di barrier che include frammenti rz-sx-rz-sx-rz per implementare il dressing.

template, samplex = build(circuit)

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

../_images/ec8456d0d0a5928c5d056184cbd1842dcf7aefdc7cdba428f62092b27646d601.png

Disegna il DAG del samplex e passa il mouse sui nodi per vedere quanto segue:

  • Tutti i nodi di campionamento (stelle) sono responsabili di generare randomizzazioni per il twirling, di campionare da modelli di rumore o di iniettare cambi di base.

  • Tutti i nodi di raccolta (farfalle) sono responsabili di produrre le slice di output, ovvero i flip di misura o gli angoli dei parametri.

  • Tutti i nodi intermedi (cerchi) sono responsabili di vari tipi di manipolazione dei dati.

samplex.draw()

Interrogare gli input richiesti e gli output attesi​

Il modo più semplice per vedere gli input richiesti e gli output attesi è stampare l'oggetto Samplex. Gli elementi array sono formattati come '{name}' <{type}[{shape}...]> e gli input richiesti precedono quelli opzionali.

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.

Gli inputs() e gli outputs() del samplex, così come informazioni più dettagliate come nomi, forme, tipi e descrizioni, possono anche essere interrogati in modo programmatico. Il tipo restituito da entrambi i metodi è un TensorInterface. Ad esempio, il codice seguente elenca tutte le specifiche dell'interfaccia di input:

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'esempio successivo stampa alcune informazioni sugli specificatori di output i cui nomi contengono la stringa "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

Associare i valori di input e campionare​

Per eseguire sample(), il samplex ha bisogno di valori per almeno quegli inputs() contrassegnati come richiesti. Questi valori vengono prima associati a un'interfaccia di input, in modo che possa eseguire tutti i controlli di tipo necessari (o la coercizione di tipo in alcuni casi) e l'analisi delle forme. Se qualcosa non va nei tipi o nelle forme, o se manca un valore richiesto, viene sollevato un errore dettagliato.

Associare l'input​

Nell'esempio seguente, al metodo sample() vengono passati i valori di input richiesti. Viene poi usato per estrarre tre randomizzazioni dal samplex. Le associazioni richieste dai dressing sono le mappe di Pauli Lindblad per l'iniezione del rumore e l'array di cambio di base. Inoltre, sono richiesti i valori per i quattro parametri del circuito originale suddiviso in box, che in questo caso è impostato su np.linspace(0, 1, 4). Il samplex compone questi parametri nei dressing, per cui diventano di fatto modifiche ai valori dei parametri di output 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.
>)

Gli input possono essere associati all'interfaccia tramite più chiamate a bind() o tramite assegnazione diretta degli elementi. Nonostante il formato a dizionario annidato che bind() ammette per comodità, si tratta di un oggetto di mappatura piatto e supporta tutta la consueta sintassi delle mappature. Una volta trovati tutti gli input richiesti, fully_bound diventa vero.

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

Tutti gli oggetti TensorInterface, inclusa la sottoclasse SamplexOutput, mappano oggetti sui dati che sono stati loro associati. Ciò significa che puoi ottenere, ad esempio, i valori dei parametri del template come segue. In questo caso ha forma (3, 48), poiché sono state richieste tre randomizzazioni e il circuito template ha 48 parametri.

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]

Specificare l'input come dizionario​

Invece di fornire un TensorInterface completamente associato, puoi specificare gli input come un dizionario standard, come mostrato nella cella seguente. In questo caso il metodo sample() associa internamente gli elementi del dizionario all'interfaccia di input del samplex per verificare che tutti gli input richiesti siano presenti e compatibili.

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)

Tipi di input​

Attualmente esistono due tipi di valore per l'interfaccia:

  • Di tipo array: hanno una forma e un tipo di dati. I valori associati vengono convertiti nel tipo di dati, se possibile. Qualsiasi dimensione può essere una dimensione libera.

  • Mappe di Pauli Lindblad: devono essere di tipo qiskit.quantum_info.PauliLindbladMap e devono agire sul numero prescritto di qubit. Il numero di termini nella mappa è una dimensione libera.

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

Dimensioni libere​

La classe TensorInterface ha il concetto di dimensioni libere, ovvero dimensioni intere con nome i cui valori restano indeterminati finché non vengono associati all'interfaccia dei dati che li vincolano. Tutte le dimensioni libere con lo stesso nome devono risolversi in una dimensione coerente quando si associano i dati, altrimenti viene sollevato un errore. Ad esempio, negli outputs() di qualsiasi samplex, l'asse dell'array corrispondente alle randomizzazioni è un parametro libero che per convenzione ha il nome "num_randomizations". Quando viene chiamato sample(), associa i valori agli output

print("All free dimensions:", outputs.free_dimensions)
print("Current constraints:", outputs.bound_dimensions)
All free dimensions: {'num_randomizations'}
Current constraints: {'num_randomizations': 3}

Un altro scenario comune di dimensione libera è la condivisione dei vincoli sui termini tra le specifiche delle mappe di Pauli Lindblad e i loro modificatori. Una qiskit.quantum_info.PauliLindbladMap è una sequenza ordinata di termini, in cui ogni termine contiene un tasso in virgola mobile e una rappresentazione di Pauli sparsa. La funzione build() aggiunge uno specificatore "pauli_lindblad_maps.<ref>" per questo tipo per ogni annotazione InjectNoise e, se questa contiene un modifier_ref, viene aggiunto anche uno specificatore "local_scales.<modifier_ref>". Entrambi condividono la dimensione libera "num_terms_<ref>", che specifica quanti termini di Pauli Lindblad sono presenti nel modello di rumore, perché il secondo deve essere accoppiato (zip) ai tassi del primo per scalarli.

# 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}

Convenzione sull'ordinamento dei qubit​

I samplex costruiti da circuiti suddivisi in box tramite la funzione build() possono richiedere input di tipo array per sample(), in cui un indice corrisponde ai qubit nel box di un circuito. Questa sezione spiega la convenzione di ordinamento da usare per tali assi. In sintesi: usa l'ordine dei qubit del circuito suddiviso in box, limitato ai qubit del box.

Ad esempio, supponiamo di voler capire come specificare una mappa di Pauli Lindblad per "noise1". L'ordine dei qubit nella mappa di rumore è allora riferito a questi qubit:

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>]

Cioè, se vuoi che <Qubit register=(4, "q0"), index=2> sia affetto da rumore X, devi definire una mappa di Pauli Lindblad come questa, perché si trova all'indice 1 di 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 stesso vale per i cambi di base. Se vuoi ruotare negli autostati +1 degli operatori X, Y e Z rispettivamente per i qubit 0, 1 e 2 di circuit, devi usare l'array seguente, ricordando che in questa libreria (e in Qiskit) si usa la convenzione 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]

Nota che la convenzione di ordinamento dei qubit non è l'ordine definito da nessuno dei due seguenti:

  • box_instruction.qubits, dove box_instruction è una qiskit.circuit.CircuitInstruction la cui operazione è un qiskit.circuit.BoxOp, perché l'ordine di questo elenco di qubit potrebbe dipendere dall'ordine in cui le istruzioni sono state aggiunte al contesto del box, il che sarebbe una convenzione confusa. Ad esempio, with box(): circuit.x([0, 1]) e with box(): circuit.x([1, 0]) darebbero ordinamenti diversi.

  • box_instruction.operation.body.qubits, perché questi qubit potrebbero non comparire nemmeno nel circuito esterno. I qubit all'interno del corpo effettivo delle istruzioni BoxOp (o di qualsiasi altra operazione di flusso di controllo come IfElseOp) hanno un significato coerente solo all'interno di quello scope. Sarebbe una convenzione mal definita e in generale difficile da usare.

Test locale con il circuito template​

Se un Samplex viene costruito usando la funzione build(), i valori dei parametri di output dovrebbero essere direttamente compatibili con i parametri del circuito template associato. Ad esempio, puoi associare al template i valori di una delle randomizzazioni per un'ispezione locale.

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

Questo ti permette di verificare che il samplex produca i campioni attesi. Ad esempio, il codice seguente converte il circuito associato in un oggetto qiskit.quantum_info.Operator e lo compone con i Pauli di bitflip per verificare visivamente che tutte e tre le randomizzazioni siano logicamente equivalenti al circuito di 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

Analogamente, il codice seguente passa i valori dei parametri di output e il template a un sampler per simulare 10.000 shot di tutte le randomizzazioni e confronta i valori di aspettazione risultanti (solo del registro alpha) con i valori di aspettazione di una simulazione del circuito di 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 ]