Aller au contenu principal

Premiers pas avec la découpe de circuits par coupures de fil

Versions des packages

Le code de cette page a été développé avec les dépendances suivantes. Nous recommandons d'utiliser ces versions ou des versions plus récentes.

qiskit[all]~=2.3.0
qiskit-ibm-runtime~=0.43.1
qiskit-aer~=0.17
qiskit-addon-cutting~=0.10.0

Ce guide présente un exemple concret de coupures de fil avec le package qiskit-addon-cutting. Il explique comment reconstruire les valeurs d'espérance d'un circuit à sept qubits à l'aide de la technique de coupure de fil.

Une coupure de fil est représentée dans ce package par une instruction à deux qubits Move, définie comme une réinitialisation du second qubit sur lequel l'instruction agit, suivie d'un échange des deux qubits. Cette opération équivaut à transférer l'état du premier qubit vers le second, tout en rejetant simultanément l'état entrant du second qubit.

Le package est conçu pour être cohérent avec la façon dont les coupures de fil doivent être traitées sur des qubits physiques. Par exemple, une coupure de fil peut transférer l'état du qubit physique nn et le continuer sur le qubit physique mm après la coupure. On peut voir la « découpe d'instructions » comme un cadre unifié permettant de considérer à la fois les coupures de fil et de portes dans le même formalisme (puisqu'une coupure de fil n'est qu'une instruction Move coupée). Ce cadre permet également la réutilisation des qubits, ce qui est expliqué dans la section sur la découpe manuelle des fils.

L'instruction à un qubit CutWire offre une interface plus abstraite et simplifiée pour travailler avec les coupures de fil. Elle te permet d'indiquer à un niveau élevé où couper un fil dans le circuit, et l'addon de découpe de circuits insère automatiquement les instructions Move appropriées.

L'exemple suivant illustre la reconstruction de la valeur d'espérance après une coupure de fil. Tu vas créer un circuit avec plusieurs portes non locales et définir des observables à estimer.

# Added by doQumentation — required packages for this notebook
!pip install -q numpy qiskit qiskit-addon-cutting qiskit-aer qiskit-ibm-runtime
import numpy as np
from qiskit import QuantumCircuit
from qiskit.transpiler import generate_preset_pass_manager
from qiskit.quantum_info import SparsePauliOp
from qiskit_ibm_runtime.fake_provider import FakeManilaV2
from qiskit_ibm_runtime import SamplerV2, Batch
from qiskit_aer.primitives import EstimatorV2

from qiskit_addon_cutting.instructions import Move, CutWire
from qiskit_addon_cutting import (
partition_problem,
generate_cutting_experiments,
cut_wires,
expand_observables,
reconstruct_expectation_values,
)

qc_0 = QuantumCircuit(7)
for i in range(7):
qc_0.rx(np.pi / 4, i)
qc_0.cx(0, 3)
qc_0.cx(1, 3)
qc_0.cx(2, 3)
qc_0.cx(3, 4)
qc_0.cx(3, 5)
qc_0.cx(3, 6)
qc_0.cx(0, 3)
qc_0.cx(1, 3)
qc_0.cx(2, 3)

# Define observable
observable = SparsePauliOp(["ZIIIIII", "IIIZIII", "IIIIIIZ"])

# Draw circuit
qc_0.draw("mpl")

Output of the previous code cell

Couper les fils avec l'instruction haut niveau CutWire

Ensuite, effectue des coupures de fil avec l'instruction à un qubit CutWire sur le qubit q3q_3. Une fois les sous-expériences prêtes à être exécutées, utilise la fonction cut_wires() pour transformer les instructions CutWire en instructions Move sur des qubits nouvellement alloués.

qc_1 = QuantumCircuit(7)
for i in range(7):
qc_1.rx(np.pi / 4, i)
qc_1.cx(0, 3)
qc_1.cx(1, 3)
qc_1.cx(2, 3)
qc_1.append(CutWire(), [3])
qc_1.cx(3, 4)
qc_1.cx(3, 5)
qc_1.cx(3, 6)
qc_1.append(CutWire(), [3])
qc_1.cx(0, 3)
qc_1.cx(1, 3)
qc_1.cx(2, 3)

qc_1.draw("mpl")

Output of the previous code cell

Note sur l'expansion des observables

Lorsqu'un circuit est étendu par une ou plusieurs coupures de fil, l'observable doit être mis à jour pour tenir compte des qubits supplémentaires introduits. Le package qiskit-addon-cutting fournit une fonction utilitaire expand_observables(), qui prend en argument des objets PauliList ainsi que les circuits original et étendu, et renvoie un nouveau PauliList.

Ce PauliList renvoyé ne contiendra aucune information sur les coefficients de l'observable d'origine, mais ceux-ci peuvent être ignorés jusqu'à la reconstruction de la valeur d'espérance finale.

# Transform CutWire instructions to Move instructions
qc_2 = cut_wires(qc_1)

# Expand the observable to match the new circuit size
expanded_observable = expand_observables(observable.paulis, qc_0, qc_2)
print(f"Expanded Observable: {expanded_observable}")
qc_2.draw("mpl")
Expanded Observable: ['ZIIIIIIII', 'IIIZIIIII', 'IIIIIIIIZ']

Output of the previous code cell

Partitionner le circuit et l'observable

Le problème peut maintenant être décomposé en partitions. Cela se fait avec la fonction partition_problem(), qui accepte un ensemble optionnel d'étiquettes de partition pour spécifier comment séparer le circuit. Les qubits partageant une même étiquette de partition sont regroupés, et toutes les portes non locales couvrant plusieurs partitions sont coupées.

Si aucune étiquette de partition n'est fournie, le partitionnement est déterminé automatiquement en fonction de la connectivité du circuit. Lis la section suivante sur la découpe manuelle des fils pour plus d'informations sur l'utilisation des étiquettes de partition.

partitioned_problem = partition_problem(
circuit=qc_2,
observables=expanded_observable,
)
subcircuits = partitioned_problem.subcircuits
subobservables = partitioned_problem.subobservables
bases = partitioned_problem.bases

print(f"Subobservables to measure: \n{subobservables}\n")
print(f"Sampling overhead: {np.prod([basis.overhead for basis in bases])}")
subcircuits[0].draw("mpl")
Subobservables to measure:
{0: PauliList(['IIIII', 'ZIIII', 'IIIIZ']), 1: PauliList(['ZIII', 'IIII', 'IIII'])}

Sampling overhead: 256.0

Output of the previous code cell

subcircuits[1].draw("mpl")

Output of the previous code cell

Dans ce schéma de partitionnement, tu as coupé deux fils, ce qui entraîne un surcoût d'échantillonnage de 444^4.

Générer les sous-expériences à exécuter et post-traiter les résultats

Pour estimer la valeur d'espérance du circuit complet, plusieurs sous-expériences sont générées à partir de la distribution quasi-probabiliste conjointe des portes décomposées, puis exécutées sur un ou plusieurs QPU. La méthode generate_cutting_experiments réalise cela en prenant en argument les dictionnaires subcircuits et subobservables créés précédemment, ainsi que le nombre d'échantillons à tirer de la distribution.

Note sur le nombre d'échantillons

L'argument num_samples spécifie le nombre d'échantillons à tirer de la distribution quasi-probabiliste et détermine la précision des coefficients utilisés pour la reconstruction. Passer l'infini (np.inf) garantit que tous les coefficients sont calculés exactement. Consulte la documentation de l'API sur la génération des poids et la génération des expériences de découpe pour plus d'informations.

# Generate subexperiments
subexperiments, coefficients = generate_cutting_experiments(
circuits=subcircuits, observables=subobservables, num_samples=np.inf
)

# Set a backend to use and transpile the subexperiments
backend = FakeManilaV2()
pass_manager = generate_preset_pass_manager(
optimization_level=1, backend=backend
)
isa_subexperiments = {
label: pass_manager.run(partition_subexpts)
for label, partition_subexpts in subexperiments.items()
}

# Submit each partition's subexperiments to the Qiskit Runtime Sampler
# primitive, in a single batch so that the jobs will run back-to-back.
with Batch(backend=backend) as batch:
sampler = SamplerV2(mode=batch)
jobs = {
label: sampler.run(subsystem_subexpts, shots=2**12)
for label, subsystem_subexpts in isa_subexperiments.items()
}
# Retrieve results
results = {label: job.result() for label, job in jobs.items()}

Pour terminer, la valeur d'espérance du circuit complet peut être reconstruite à l'aide de la méthode reconstruct_expectation_values().

Le bloc de code ci-dessous reconstruit les résultats et les compare à la valeur d'espérance exacte.

reconstructed_expval_terms = reconstruct_expectation_values(
results,
coefficients,
subobservables,
)
# Apply the coefficients of the original observable
reconstructed_expval = np.dot(reconstructed_expval_terms, observable.coeffs)

# Compute the exact expectation value using the `qiskit_aer` package.
estimator = EstimatorV2()
exact_expval = estimator.run([(qc_0, observable)]).result()[0].data.evs
print(
f"Reconstructed expectation value: {np.real(np.round(reconstructed_expval, 8))}"
)
print(f"Exact expectation value: {np.round(exact_expval, 8)}")
print(
f"Error in estimation: {np.real(np.round(reconstructed_expval-exact_expval, 8))}"
)
print(
f"Relative error in estimation: {np.real(np.round((reconstructed_expval-exact_expval) / exact_expval, 8))}"
)
Reconstructed expectation value: 1.45965266
Exact expectation value: 1.59099026
Error in estimation: -0.1313376
Relative error in estimation: -0.08255085
Note sur les coefficients des observables

Pour reconstruire avec précision la valeur d'espérance, les coefficients de l'observable d'origine (qui diffèrent de la sortie de generate_cutting_experiments()) doivent être appliqués au résultat de la reconstruction, car cette information a été perdue lors de la génération des expériences de découpe ou lors de l'expansion de l'observable.

Ces coefficients peuvent généralement être appliqués via numpy.dot() comme illustré précédemment.

Couper les fils avec l'instruction bas niveau Move

L'une des limitations de l'instruction haut niveau CutWire est qu'elle ne permet pas la réutilisation des qubits. Si cela est souhaité pour une expérience de découpe, tu peux à la place placer manuellement des instructions Move. Cependant, comme l'instruction Move rejette l'état du qubit de destination, il est important que ce qubit ne partage aucun enchevêtrement avec le reste du système. Sinon, l'opération de réinitialisation provoquera un effondrement partiel de l'état du circuit après la coupure de fil.

Le bloc de code ci-dessous effectue une coupure de fil sur le qubit q3q_3 pour le même exemple de circuit que précédemment. La différence ici est que tu peux réutiliser un qubit en inversant l'opération Move là où la seconde coupure de fil a été effectuée (cela n'est cependant pas toujours possible et dépend du circuit à couper).

qc_1 = QuantumCircuit(8)
for i in [*range(4), *range(5, 8)]:
qc_1.rx(np.pi / 4, i)
qc_1.cx(0, 3)
qc_1.cx(1, 3)
qc_1.cx(2, 3)
qc_1.append(Move(), [3, 4])
qc_1.cx(4, 5)
qc_1.cx(4, 6)
qc_1.cx(4, 7)
qc_1.append(Move(), [4, 3])
qc_1.cx(0, 3)
qc_1.cx(1, 3)
qc_1.cx(2, 3)

# Expand observable
observable_expanded = SparsePauliOp(["ZIIIIIII", "IIIIZIII", "IIIIIIIZ"])
qc_1.draw("mpl")

Output of the previous code cell

Le circuit ci-dessus peut maintenant être partitionné et les expériences de découpe générées. Pour spécifier explicitement comment le circuit doit être partitionné, tu peux ajouter des étiquettes de partition à la fonction partition_problem(). Les qubits partageant une même étiquette de partition sont regroupés, et toutes les portes non locales couvrant plusieurs partitions sont coupées. Les clés du dictionnaire renvoyé par partition_problem() correspondront à celles spécifiées dans la chaîne d'étiquettes.

partitioned_problem = partition_problem(
circuit=qc_1,
partition_labels="AAAABBBB",
observables=observable_expanded.paulis,
)
subcircuits = partitioned_problem.subcircuits
subobservables = partitioned_problem.subobservables
bases = partitioned_problem.bases

print(f"Subobservables to measure: \n{subobservables}\n")
print(f"Sampling overhead: {np.prod([basis.overhead for basis in bases])}")
subcircuits["A"].draw("mpl")
Subobservables to measure:
{'A': PauliList(['IIII', 'ZIII', 'IIIZ']), 'B': PauliList(['ZIII', 'IIII', 'IIII'])}

Sampling overhead: 256.0

Output of the previous code cell

subcircuits["B"].draw("mpl")

Output of the previous code cell

Les expériences de découpe peuvent maintenant être générées et la valeur d'espérance reconstruite de la même manière que dans la section précédente.

Prochaines étapes

Recommandations