Aller au contenu principal

Premiers pas avec la découpe de circuits par découpe de portes

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 deux exemples concrets de découpe de portes avec le package qiskit-addon-cutting. Le premier exemple montre comment réduire la profondeur d'un circuit (le nombre d'instructions) en découpant des portes d'intrication entre des qubits non adjacents, qui auraient autrement introduit un surcoût de SWAP lors de la transpilation vers le matériel. Le second exemple explique comment utiliser la découpe de portes pour réduire la largeur d'un circuit (le nombre de qubits) en le décomposant en plusieurs circuits avec moins de qubits.

Les deux exemples utilisent l'ansatz efficient_su2 et reconstruisent la même observable.

Découpe de portes pour réduire la profondeur du circuit

Le workflow suivant réduit la profondeur d'un circuit en découpant des portes distantes, évitant ainsi une longue série de SWAP qui seraient sinon introduits.

Commence avec l'ansatz efficient_su2, avec un entanglement « circulaire » pour introduire des portes distantes.

# 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.circuit.library import efficient_su2
from qiskit.quantum_info import SparsePauliOp
from qiskit.transpiler import generate_preset_pass_manager
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 import (
cut_gates,
partition_problem,
generate_cutting_experiments,
reconstruct_expectation_values,
)

circuit = efficient_su2(num_qubits=4, entanglement="circular")
circuit.assign_parameters([0.4] * len(circuit.parameters), inplace=True)

observable = SparsePauliOp(["ZZII", "IZZI", "-IIZZ", "XIXI", "ZIZZ", "IXIX"])
print(f"Observable: {observable}")
circuit.draw("mpl", scale=0.8)
Observable: SparsePauliOp(['ZZII', 'IZZI', 'IIZZ', 'XIXI', 'ZIZZ', 'IXIX'],
coeffs=[ 1.+0.j, 1.+0.j, -1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j])

Output of the previous code cell

Chacune des portes CNOT entre les qubits q0q_0 et q3q_3 introduit deux SWAP après transpilation (en supposant que les qubits sont connectés en ligne droite). Pour éviter cette augmentation de profondeur, tu peux remplacer ces portes distantes par des objets TwoQubitQPDGate à l'aide de la méthode cut_gates(). Cette fonction retourne également une liste d'instances QPDBasis — une par décomposition.

# Find the indices of the distant gates
cut_indices = [
i
for i, instruction in enumerate(circuit.data)
if {circuit.find_bit(q)[0] for q in instruction.qubits} == {0, 3}
]

# Decompose distant CNOTs into TwoQubitQPDGate instances
qpd_circuit, bases = cut_gates(circuit, cut_indices)

qpd_circuit.draw("mpl", scale=0.8)

Output of the previous code cell

Maintenant que les instructions de découpe ont été ajoutées, les sous-expériences auront une profondeur inférieure après transpilation par rapport au circuit original. L'extrait de code ci-dessous génère les sous-expériences à l'aide de generate_cutting_experiments, qui prend en entrée le circuit et l'observable à reconstruire.

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.

Une fois les sous-expériences générées, tu peux les transpiler et utiliser la primitive Sampler pour échantillonner la distribution et reconstruire les valeurs d'espérance estimées. Le bloc de code suivant génère, transpile et exécute les sous-expériences, puis reconstruit les résultats et les compare à la valeur d'espérance exacte.

# Generate the subexperiments and sampling coefficients
subexperiments, coefficients = generate_cutting_experiments(
circuits=qpd_circuit, observables=observable.paulis, 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 = pass_manager.run(subexperiments)

# Set up the Qiskit Runtime Sampler primitive, submit the subexperiments, and retrieve the results
sampler = SamplerV2(backend)
job = sampler.run(isa_subexperiments, shots=4096 * 3)
results = job.result()

# Reconstruct the results
reconstructed_expval_terms = reconstruct_expectation_values(
results,
coefficients,
observable.paulis,
)

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

estimator = EstimatorV2()
exact_expval = (
estimator.run([(circuit, observable, [0.4] * len(circuit.parameters))])
.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: 0.49812826
Exact expectation value: 0.50497603
Error in estimation: -0.00684778
Relative error in estimation: -0.0135606
Note sur les coefficients de l'observable

Pour reconstruire fidèlement la valeur d'espérance, les coefficients de l'observable originale (qui diffèrent des coefficients renvoyés par generate_cutting_experiments()) doivent être appliqués à la sortie de la reconstruction, car cette information est perdue lors de la génération des expériences de découpe ou de l'expansion de l'observable.

En général, ces coefficients peuvent être appliqués via numpy.dot() comme illustré ci-dessus.

Découpe de portes pour réduire la largeur du circuit

Cette section montre comment utiliser la découpe de portes pour réduire la largeur d'un circuit. Commence avec le même ansatz efficient_su2, mais avec un entanglement « linéaire ».

qc = efficient_su2(4, entanglement="linear", reps=2)
qc.assign_parameters([0.4] * len(qc.parameters), inplace=True)

observable = SparsePauliOp(["ZZII", "IZZI", "-IIZZ", "XIXI", "ZIZZ", "IXIX"])
print(f"Observable: {observable}")

qc.draw("mpl", scale=0.8)
Observable: SparsePauliOp(['ZZII', 'IZZI', 'IIZZ', 'XIXI', 'ZIZZ', 'IXIX'],
coeffs=[ 1.+0.j, 1.+0.j, -1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j])

Output of the previous code cell

Génère ensuite les sous-circuits et les sous-observables à exécuter à l'aide de la fonction partition_problem(). Cette fonction prend en entrée le circuit, l'observable et un schéma de partitionnement optionnel, et retourne les circuits et observables découpés sous forme de dictionnaire.

Le partitionnement est défini par une chaîne d'étiquettes de la forme "AABB", où chaque étiquette correspond au qubit du même index dans l'argument circuit. Les qubits partageant une même étiquette de partition sont regroupés, et toutes les portes non locales qui s'étendent sur plusieurs partitions seront découpées.

Note

L'argument observables de partition_problem est de type PauliList. Les coefficients et les phases des termes de l'observable sont ignorés lors de la décomposition du problème et de l'exécution des sous-expériences. Ils peuvent être réappliqués lors de la reconstruction de la valeur d'espérance.

partitioned_problem = partition_problem(
circuit=qc, partition_labels="AABB", observables=observable.paulis
)
subcircuits = partitioned_problem.subcircuits
subobservables = partitioned_problem.subobservables
bases = partitioned_problem.bases

print(f"Sampling overhead: {np.prod([basis.overhead for basis in bases])}")
print(f"Subobservables: {subobservables}")
subcircuits["A"].draw("mpl", scale=0.8)
Sampling overhead: 81.0
Subobservables: {'A': PauliList(['II', 'ZI', 'ZZ', 'XI', 'ZZ', 'IX']), 'B': PauliList(['ZZ', 'IZ', 'II', 'XI', 'ZI', 'IX'])}

Output of the previous code cell

subcircuits["B"].draw("mpl", scale=0.8)

Output of the previous code cell

L'étape suivante consiste à utiliser les sous-circuits et les sous-observables pour générer les sous-expériences à exécuter sur un QPU, à l'aide de la méthode generate_cutting_experiments.

Pour estimer la valeur d'espérance du circuit complet, de nombreuses 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 QPUs. Le nombre d'échantillons à tirer de cette distribution est contrôlé par l'argument num_samples.

Le bloc de code suivant génère les sous-expériences et les exécute à l'aide de la primitive Sampler sur un simulateur local. (Pour les exécuter sur un QPU, remplace le backend par la ressource QPU de ton choix.)

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=4096 * 3)
for label, subsystem_subexpts in isa_subexperiments.items()
}

# Retrieve results
results = {label: job.result() for label, job in jobs.items()}

Enfin, la valeur d'espérance du circuit complet est 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.

# Get expectation values for each observable term
reconstructed_expval_terms = reconstruct_expectation_values(
results,
coefficients,
subobservables,
)

# Reconstruct final expectation value
reconstructed_expval = np.dot(reconstructed_expval_terms, observable.coeffs)

estimator = EstimatorV2()
exact_expval = (
estimator.run([(qc, observable, [0.4] * len(qc.parameters))])
.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: 0.53571896
Exact expectation value: 0.56254612
Error in estimation: -0.02682716
Relative error in estimation: -0.04768882

Étapes suivantes