QAOA à démarrage à chaud avec le module complémentaire « Optimization Mapper » de Qiskit
Durée d'utilisation estimée : 9 minutes sur un Heron r3 (REMARQUE : il s'agit uniquement d'une estimation. (La durée d'exécution peut varier.)
Acquis d'apprentissage
À l'issue de ce tutoriel, vous devriez être en mesure de comprendre les éléments suivants :
- Comment transposer un problème de « max-cut » en une formulation quantique d'optimisation binaire quadratique sans contraintes (QUBO) à l'aide de
qiskit-addon-opt-mapper - Comment implémenter et exécuter le QAOA standard sur un simulateur
- Comment appliquer la méthode WS-QAOA en calculant la relaxation du programme quadratique (QP) et en concevant le circuit de « warm-start »
- Comment comparer la convergence énergétique et la qualité des solutions entre le QAOA standard et le WS-QAOA
Prérequis
Nous vous recommandons de vous familiariser avec les sujets suivants :
Arrière-plan
L'algorithme d'optimisation quantique approximative (QAOA) est un algorithme hybride quantique-classique conçu pour résoudre des problèmes d'optimisation combinatoire tels que le « max-cut » et les formulations QUBO générales. Pour une introduction de base au QAOA dans Qiskit, consultez le tutoriel sur le QAOA; pour découvrir des techniques plus avancées de conception de circuits, consultez le tutoriel avancé sur le QAOA.
Dans le QAOA standard :
- L'état initial est la superposition uniforme .
- Les paramètres variationnels sont initialisés de manière aléatoire.
- Un optimiseur classique recherche les paramètres qui minimisent la fonction de coût.
Cependant, pour des problèmes de taille réaliste et du matériel quantique sujet au bruit, une initialisation aléatoire peut entraîner une convergence lente, des minima locaux défavorables et un coût d'optimisation accru.
La méthode QAOA à démarrage à chaud (WS-QAOA) améliore ce résultat en intégrant directement les principes de l'optimisation classique dans le circuit quantique. Ce tutoriel suit les méthodes présentées par Egger, Mareček et Woerner dans leur article intitulé « Warm-starting quantum optimization ». L'idée principale est la suivante :
- Résoudre une relaxation continue du problème binaire d'origine (un programme quadratique sur au lieu de ).
- Encoder la solution « relaxed » en un état initial personnalisé à l'aide de -rotation angles , de sorte que le qubit se trouve initialement dans un état dont la probabilité de mesure est de .
- Remplacer le mélangeur standard par un mélangeur personnalisé dont l'état de base correspond à l'état initial de « démarrage à chaud », ce qui garantit que l'algorithme démarre près de la solution classique et puisse explorer son voisinage.
Un paramètre de régularisation limite en dehors de l'intervalle [0, 1] afin d'éviter les problèmes d'accessibilité; les qubits initialisés à ou ne peuvent pas être déplacés par l'hamiltonien de coût. Lorsque , WS-QAOA se réduit exactement à la version standard de QAOA.
La modélisation du problème s'appuie sur le qiskit-addon-opt-mapper package, dont Maxcut la classe d'application construit directement le QUBO à partir d'un graphe, et dont les convertisseurs et traducteurs associent le problème obtenu à des hamiltoniens quantiques.
Exigences
Avant de commencer ce tutoriel, assurez-vous d'avoir installé les éléments suivants :
- Qiskit SDK v2.0 ou version ultérieure, avec prise en charge de la visualisation
- Qiskit Runtime v0.43 ou version ultérieure (
pip install qiskit-ibm-runtime) - Module complémentaire « Optimization Mapper » pour Qiskit (
pip install qiskit-addon-opt-mapper) - SciPy (
pip install scipy) - NetworkX (
pip install networkx)
Configuration
Importez toutes les bibliothèques nécessaires et définissez les fonctions d'aide utilisées tout au long de ce tutoriel.
import numpy as np
import matplotlib.pyplot as plt
import networkx as nx
from scipy.optimize import minimize
from qiskit.circuit import QuantumCircuit, ParameterVector
from qiskit.circuit.library import qaoa_ansatz
from qiskit.quantum_info import Statevector
from qiskit.primitives import StatevectorEstimator, StatevectorSampler
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_ibm_runtime import (
QiskitRuntimeService,
Session,
EstimatorOptions,
EstimatorV2 as Estimator,
SamplerV2 as Sampler,
)
from qiskit_addon_opt_mapper.applications import Maxcut
from qiskit_addon_opt_mapper.converters import OptimizationProblemToQubo
from qiskit_addon_opt_mapper.translators import to_isingExemple de simulateur à petite échelle
Nous prendrons comme exemple concret un petit problème de « max-cut » sur un graphe pondéré. Max-cut pose la question suivante : étant donné un graphe avec des poids d'arêtes , trouver une partition des sommets en deux ensembles et qui maximise le poids total des arêtes traversant la coupure.
En tant que problème de minimisation QUBO, le problème du « max-cut » peut s'écrire comme suit :
Pour faciliter les calculs sur un simulateur, nous travaillons avec un graphe à quatre nœuds.
Étape 1 : Mettre en correspondance les entrées classiques avec un problème quantique
Nous définissons le problème de la coupe maximale à l'aide de la Maxcut classe d'application de qiskit-addon-opt-mapper, qui permet de construire la formulation QUBO directement à partir d'un graphe. Nous le convertissons ensuite en un QUBO et le traduisons en un hamiltonien d'Ising (SparsePauliOp) adapté à la QAOA. Nous résolvons également la relaxation continue du QUBO — en remplaçant la contrainte binaire par — afin d'obtenir le point de départ « warm-start » .
# Define a 4-node weighted graph for the max-cut problem
n_nodes = 4
edges = [(0, 1, 1.0), (0, 2, 1.0), (1, 2, 1.0), (1, 3, 1.0), (2, 3, 1.0)]
G = nx.Graph()
G.add_nodes_from(range(n_nodes))
G.add_weighted_edges_from(edges)
pos = nx.spring_layout(G, seed=42)
edge_labels = {(u, v): d["weight"] for u, v, d in G.edges(data=True)}
fig, ax = plt.subplots(figsize=(4, 3))
nx.draw(G, pos, with_labels=True, node_color="lightblue", ax=ax)
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, ax=ax)
ax.set_title("Max-Cut graph")
plt.tight_layout()
plt.show()Output:
Le graphe comporte cinq arêtes. La partition « max-cut » optimale répartit les nœuds entre et (ou son complément), coupant ainsi quatre des cinq arêtes, ce qui donne une valeur de coupure égale à 4.
# Build the max-cut problem directly from the NetworkX graph using the
# Maxcut application class. Internally it constructs the QUBO
# minimize -sum_{(i,j) in E} w_ij * (x_i + x_j - 2*x_i*x_j)
# (each edge contributes -w to the linear terms and +2w to the quadratic
# term), so we get the same OptimizationProblem without the boilerplate.
maxcut = Maxcut(G)
prob = maxcut.to_optimization_problem()
print(prob.prettyprint())Output:
Problem name: Max-cut
Maximize
-2*x_0*x_1 - 2*x_0*x_2 - 2*x_1*x_2 - 2*x_1*x_3 - 2*x_2*x_3 + 2*x_0 + 3*x_1
+ 3*x_2 + 2*x_3
Subject to
No constraints
Binary variables (4)
x_0 x_1 x_2 x_3
Cette Maxcut classe encapsule la construction QUBO, ce qui nous évite d'avoir à développer manuellement l'objectif « max-cut ». L'objectif imprimé indique le coefficient linéaire de chaque variable (sa contribution individuelle à la découpe) et le coefficient quadratique de chaque terme croisé (la pénalité infligée lorsque deux nœuds adjacents se trouvent du même côté). La valeur sous-jacente OptimizationProblem renvoyée par to_optimization_problem() prend en charge les variables binaires, entières, continues et de type « spin », et correspond à l'objet attendu par les convertisseurs et les traducteurs utilisés à l'étape suivante.
# Convert the OptimizationProblem to a QUBO, then translate to an Ising Hamiltonian
#
# The substitution x_i = (1 - z_i)/2 maps binary variables to spin operators,
# yielding a Hamiltonian H_C = sum_i h_i Z_i + sum_{i<j} J_ij Z_i Z_j + constant.
# QAOA minimizes <H_C> to find the ground state, which encodes the optimal cut.
converter = OptimizationProblemToQubo()
qubo = converter.convert(prob)
cost_operator, offset = to_ising(qubo)
n_qubits = cost_operator.num_qubits
print(f"Cost Hamiltonian H_C ({n_qubits} qubits):")
print(cost_operator)
print(f"\nOffset (constant shift): {offset}")
print(" QUBO value = Ising energy + offset")Output:
Cost Hamiltonian H_C (4 qubits):
SparsePauliOp(['IIZZ', 'IZIZ', 'IZZI', 'ZIZI', 'ZZII'],
coeffs=[0.5+0.j, 0.5+0.j, 0.5+0.j, 0.5+0.j, 0.5+0.j])
Offset (constant shift): -2.5
QUBO value = Ising energy + offset
Le to_ising traducteur renvoie un SparsePauliOp représentant et un scalaire offset tel que . Pour ce problème de coupe maximale avec des poids unitaires, pour tous les qubits (le graphe est symétrique en termes linéaires après la substitution ), et chaque arête apporte un couplage de type « » d’intensité . La valeur propre minimale de correspond à la coupe maximale.
# Solve the continuous (QP) relaxation to obtain the warm-start point c*
#
# The QP relaxation replaces the binary constraint x_i in {0,1} with x_i in [0,1]
# and minimizes the same quadratic objective. Its solution c*_i gives the
# probability that variable i should be 1 according to the classical relaxation.
#
# The max-cut QUBO has a non-convex quadratic matrix (negative eigenvalues),
# so the relaxed problem has multiple local minima. A naive single start from
# [0.5,...,0.5] converges to the symmetric saddle point c* = [0.5,...,0.5],
# which carries no useful structural information about the problem.
# Multi-start optimization is used to reliably find the global minimum.
Q = qubo.objective.quadratic.to_array(symmetric=True)
mu = qubo.objective.linear.to_array()
def qp_objective(x_cont):
"""Continuous relaxation of the QUBO objective."""
return x_cont @ Q @ x_cont + mu @ x_cont + qubo.objective.constant
bounds = [(0.0, 1.0)] * n_qubits
rng = np.random.default_rng(42)
best_val = np.inf
c_star = None
for _ in range(200):
x0 = rng.uniform(0.0, 1.0, n_qubits)
result = minimize(qp_objective, x0, method="L-BFGS-B", bounds=bounds)
if result.fun < best_val:
best_val = result.fun
c_star = result.x
print(f"QP relaxation solution c* = {np.round(c_star, 4)}")
print(f"QP objective value = {best_val:.4f}")Output:
QP relaxation solution c* = [1. 0. 0. 1.]
QP objective value = -4.0000
Le solveur multi-démarrage trouve (ou son complément ), qui correspond à la solution binaire optimale réelle. Pour ce problème, la relaxation QP est stricte : le minimum continu coïncide avec l'optimum entier, ce qui signifie que la relaxation identifie immédiatement la meilleure coupe. Après régularisation à l'aide de la méthode « » à l'étape 2, cette solution sera intégrée à l'état initial de « warm-start ».
Étape 2 : Optimiser le problème en vue de son exécution sur un matériel quantique
Nous construisons deux circuits QAOA et déterminons les angles de démarrage à chaud à partir de la solution QP.
Le modèle QAOA standard utilise la superposition uniforme comme état initial et le mélangeur standard -mixer , implémenté sous la forme par couche.
La méthode QAOA à démarrage à chaud (WS-QAOA) décrite dans [1] apporte deux modifications structurelles par qubit :
- État initial : avec ; la probabilité de mesurer est donc égale à .
- Mélangeur personnalisé : « », dont l'état fondamental est « ». Cela signifie que le WS-QAOA se trouve dans l'état fondamental de son propre mélangeur, une propriété qu'il partage avec le QAOA standard, qui utilise l' et le mélangeur « ».
p=1Remarque sur les couches : pour une seule couche QAOA, l'algorithme QAOA standard est analytiquement limité à environ 49 % de l'énergie optimale sur les graphes contenant des triangles (ce graphe comporte le triangle 0-1-2). Le « warm start » contourne cette limitation en intégrant directement les connaissances préalables sur la solution dans l'état initial.
# Number of QAOA layers (each layer = one cost unitary + one mixer unitary)
p = 1
# Regularization: clip c* to [epsilon, 1-epsilon] so no qubit is initialized
# in |0> or |1>, which would freeze it under the cost Hamiltonian.
epsilon = 0.25
c_clipped = np.clip(c_star, epsilon, 1 - epsilon)
thetas = 2 * np.arcsin(np.sqrt(c_clipped))
print(f"Continuous relaxation c* = {np.round(c_star, 4)}")
print(f"After regularization = {np.round(c_clipped, 4)}")
print(f"Warm-start angles theta = {np.round(thetas, 4)} radians")
print()
print("Angle interpretation:")
print(" theta = 0 <-> c* = 0 (qubit points toward |0>)")
print(
" theta = pi/2 <-> c* = 0.5 (qubit in equal superposition, like |+>)"
)
print(" theta = pi <-> c* = 1 (qubit points toward |1>)")Output:
Continuous relaxation c* = [1. 0. 0. 1.]
After regularization = [0.75 0.25 0.25 0.75]
Warm-start angles theta = [2.0944 1.0472 1.0472 2.0944] radians
Angle interpretation:
theta = 0 <-> c* = 0 (qubit points toward |0>)
theta = pi/2 <-> c* = 0.5 (qubit in equal superposition, like |+>)
theta = pi <-> c* = 1 (qubit points toward |1>)
Après découpage, devient et devient . Les angles résultants radians font pivoter fortement les qubits 0 et 3 vers et les qubits 1 et 2 vers , ce qui code directement la structure de la coupe optimale dans l'état quantique initial.
def apply_cost_unitary(qc, cost_op, gamma):
"""Apply exp(-i * gamma * H_C) to the circuit.
Each Pauli term in H_C contributes a rotation gate:
- Single-Z term h_i * Z_i -> RZ(2 * gamma * h_i) on qubit i
- Two-Z term J_ij * Z_i Z_j -> CNOT, RZ(2 * gamma * J_ij), CNOT
"""
for pauli_term, coeff in zip(cost_op.paulis, cost_op.coeffs):
indices = [
j for j, q in enumerate(pauli_term.to_label()[::-1]) if q == "Z"
]
if len(indices) == 1:
qc.rz(2 * gamma * coeff.real, indices[0])
elif len(indices) == 2:
qc.cx(indices[0], indices[1])
qc.rz(2 * gamma * coeff.real, indices[1])
qc.cx(indices[0], indices[1])
def build_ws_qaoa(cost_op, n_layers, n_qubits, thetas):
"""WS-QAOA: warm-start initial state + custom per-qubit mixer.
Per Egger et al. (2021) Eq. (1)-(2):
Initial state per qubit i: R_Y(theta_i) |0>
Mixer gate per qubit i: R_Y(theta_i) R_Z(-2*beta) R_Y(-theta_i)
"""
gammas = ParameterVector("γ", n_layers)
betas = ParameterVector("β", n_layers)
qc = QuantumCircuit(n_qubits)
for i, theta in enumerate(thetas):
qc.ry(theta, i) # warm-start initial state
for k in range(n_layers):
apply_cost_unitary(qc, cost_op, gammas[k])
for i, theta in enumerate(thetas):
qc.ry(theta, i)
qc.rz(-2 * betas[k], i)
qc.ry(-theta, i)
return qc, gammas, betas
# Standard QAOA via the Qiskit built-in helper:
# qaoa_ansatz prepares |+>^n, then alternates exp(-i*gamma*H_C) with the
# default X-mixer for `reps` layers. The returned circuit exposes the
# variational parameters via std_qc.parameters.
std_qc = qaoa_ansatz(cost_operator, reps=p)
# WS-QAOA: keep the custom builder. The per-qubit mixer
# R_Y(theta_i) R_Z(-2*beta) R_Y(-theta_i) is implemented as an explicit gate
# sequence rather than as a SparsePauliOp, so we construct the circuit
# directly to stay close to the Egger et al. (2021) formulation.
ws_qc, ws_gammas, ws_betas = build_ws_qaoa(cost_operator, p, n_qubits, thetas)Pour l'approche standard, nous renvoyons à qaoa_ansatz, qui construit l' e, applique l'opérateur unitaire de coût et utilise le mélangeur « » par défaut pour chacune des reps couches. Pour WS-QAOA, nous conservons l'aide explicite build_ws_qaoa car l' du mélangeur par qubit est exprimée sous la forme d'une séquence de portes plutôt que sous la forme d'une somme de Paulis. Cet apply_cost_unitary outil lit directement à partir de SparsePauliOp l'hamiltonien; il permet donc de traiter n'importe quel problème QUBO sans avoir à construire manuellement de circuit.
print("Standard QAOA circuit (p=1):")
std_qc.draw("mpl", fold=-1)Output:
Standard QAOA circuit (p=1):
print("\nWS-QAOA circuit (p=1):")
ws_qc.draw("mpl", fold=-1)Output:
WS-QAOA circuit (p=1):
Les deux circuits suivent la même structure : une couche initiale de préparation d'état, suivie d' e alternance de couches « cost-unitary » et « mixer-unitary ». Dans le circuit WS-QAOA, les portes d’ouverture codent , et le mélangeur remplace chaque par un triplet conjugué – – . La différence de profondeur entre les deux circuits augmente de manière linéaire avec l' e, mais reste gérable à faible profondeur.
Étape 3 : Exécutez la commande à l'aide d' Qiskit primitives
Nous utilisons StatevectorEstimator pour une simulation exacte et sans bruit. La minimize fonction disponible sur SciPy, qui utilise l'optimiseur COBYLA, pilote la boucle variationnelle en appelant l'estimateur à chaque itération afin d'évaluer pour un ensemble de paramètres donné .
Les deux algorithmes utilisent des paramètres initiaux différents qui reflètent les connaissances dont chacun dispose avant l'optimisation :
- QAOA standard : initialisation aléatoire dans — ce qui est approprié puisqu’aucune information structurelle n’est disponible.
- WS-QAOA : , — sur , le coût unitaire correspond à l'identité; ainsi, la toute première évaluation du circuit effectue un échantillonnage direct à partir de l'état initial de « warm-start ». Cela donne à COBYLA un signal de départ solide, en phase avec la solution classique.
estimator = StatevectorEstimator()
def make_cost_fn(circuit, param_order, cost_op, estimator, history):
"""Return a scalar cost function compatible with scipy.optimize.minimize."""
def cost_fn(params):
bound = circuit.assign_parameters(dict(zip(param_order, params)))
job = estimator.run([(bound, cost_op)])
energy = job.result()[0].data.evs.real
history.append(energy)
return energy
return cost_fn
# Standard QAOA: random initialization
np.random.seed(42)
std_param_order = list(std_qc.parameters)
std_params0 = np.random.uniform(0, np.pi, len(std_param_order))
std_history = []
std_result = minimize(
make_cost_fn(
std_qc, std_param_order, cost_operator, estimator, std_history
),
std_params0,
method="COBYLA",
options={"maxiter": 300, "rhobeg": 0.5},
)
print(f"Standard QAOA optimal energy : {std_result.fun:.4f}")
print(f" optimal params: {std_result.x.round(4)}")
print(f" optimizer calls: {len(std_history)}")
# WS-QAOA: informed initialization
ws_params0 = np.concatenate([np.zeros(p), np.full(p, np.pi / 4)])
ws_history = []
ws_param_order = list(ws_gammas) + list(ws_betas)
ws_result = minimize(
make_cost_fn(ws_qc, ws_param_order, cost_operator, estimator, ws_history),
ws_params0,
method="COBYLA",
options={"maxiter": 300, "rhobeg": 0.5},
)
print(f"\nWS-QAOA optimal energy : {ws_result.fun:.4f}")
print(
f" optimal params: gamma={ws_result.x[:p].round(4)}, beta={ws_result.x[p:].round(4)}"
)
print(f" optimizer calls: {len(ws_history)}")Output:
Standard QAOA optimal energy : -0.5859
optimal params: [0.6803 2.0533]
optimizer calls: 47
WS-QAOA optimal energy : -1.5000
optimal params: gamma=[-0.0001], beta=[1.5708]
optimizer calls: 42
Grâce à la base de départ éclairée de WS-QAOA, COBYLA commence avec une valeur d'énergie significative proche de la solution de « démarrage à chaud », tandis que la méthode QAOA standard part d'un point essentiellement aléatoire sur le paysage énergétique. Cette différence de niveau de départ est le principal facteur à l'origine de l'écart de convergence observable à l'étape 4.
# Compute the exact optimal energy by brute-force over all 2^n bitstrings
all_energies = [
Statevector.from_label(format(k, f"0{n_qubits}b"))
.expectation_value(cost_operator)
.real
for k in range(2**n_qubits)
]
optimal_energy = min(all_energies)
print(f"Exact optimal energy : {optimal_energy:.4f}")
print(f"Standard QAOA approx. ratio : {std_result.fun / optimal_energy:.4f}")
print(f"WS-QAOA approx. ratio : {ws_result.fun / optimal_energy:.4f}")Output:
Exact optimal energy : -1.5000
Standard QAOA approx. ratio : 0.3906
WS-QAOA approx. ratio : 1.0000
Le rapport d'approximation est défini comme suit : . Pour les problèmes de minimisation où , un rapport proche de 1 signifie que l'algorithme a trouvé une énergie plus faible (une meilleure solution). La recherche par force brute sur l'ensemble des états de base d' s n'est réalisable que pour les petites s et sert de référence de référence.
Étape 4 : Traitement ultérieur et restitution du résultat dans le format classique souhaité
Nous visualisons la convergence, échantillonnons les circuits optimisés pour obtenir des solutions sous forme de chaînes de bits, décodons ces chaînes de bits pour les reconvertir en partitions de coupe maximale, puis résumons les résultats finaux.
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(std_history, label="Standard QAOA", alpha=0.85)
ax.plot(ws_history, label="WS-QAOA", alpha=0.85)
ax.axhline(
optimal_energy,
color="k",
linestyle="--",
label=f"Exact optimal ({optimal_energy:.2f})",
)
ax.set_xlabel("Optimizer call")
ax.set_ylabel(r"$\langle H_C \rangle$")
ax.set_title("Convergence: Standard QAOA vs. WS-QAOA")
ax.legend()
plt.tight_layout()
plt.show()Output:
Le graphique de convergence montre l' d'énergie à chaque évaluation de la fonction COBYLA. Le QAOA standard, disponible sur , se limite à environ 49 % de l’énergie optimale sur ce graphe (le maximum théorique pour un QAOA de type « » sur des graphes comportant des triangles), et se stabilise autour de . Le WS-QAOA, initialisé près de la solution optimale, converge rapidement vers (l’optimum exact) avec beaucoup moins d’itérations. Cela illustre l'avantage majeur du « warm start » : à profondeur de circuit égale, il permet d'obtenir une solution nettement meilleure.
# Sample the optimized circuits to recover the most probable bitstring solutions
sampler = StatevectorSampler()
shots = 1024
def get_best_bitstring(circuit, param_order, optimal_params, sampler, shots):
bound = circuit.assign_parameters(dict(zip(param_order, optimal_params)))
bound.measure_all()
job = sampler.run([bound], shots=shots)
counts = job.result()[0].data.meas.get_counts()
return max(counts, key=counts.get), counts
def evaluate_cut(bitstring, G):
"""Compute the Max-Cut value for a bitstring node assignment."""
x = [int(b) for b in bitstring]
cut_val = sum(
w for u, v, w in G.edges.data("weight", default=1) if x[u] != x[v]
)
set0 = [i for i, b in enumerate(bitstring) if b == "0"]
set1 = [i for i, b in enumerate(bitstring) if b == "1"]
return cut_val, set0, set1
# Qiskit bitstring ordering: rightmost character = qubit 0
def decode_bitstring(bs):
return bs[::-1]
std_best, std_counts = get_best_bitstring(
std_qc, std_param_order, std_result.x, sampler, shots
)
ws_best, ws_counts = get_best_bitstring(
ws_qc, ws_param_order, ws_result.x, sampler, shots
)
std_cut, std_s0, std_s1 = evaluate_cut(decode_bitstring(std_best), G)
ws_cut, ws_s0, ws_s1 = evaluate_cut(decode_bitstring(ws_best), G)
print(f"Standard QAOA most-probable bitstring : {std_best}")
print(f" Partition: S={std_s0}, S̄={std_s1} | cut value = {std_cut}")
print()
print(f"WS-QAOA most-probable bitstring : {ws_best}")
print(f" Partition: S={ws_s0}, S̄={ws_s1} | cut value = {ws_cut}")Output:
Standard QAOA most-probable bitstring : 0110
Partition: S=[0, 3], S̄=[1, 2] | cut value = 4.0
WS-QAOA most-probable bitstring : 0110
Partition: S=[0, 3], S̄=[1, 2] | cut value = 4.0
Les chaînes de bits provenant de Sampler sont renvoyées avec le qubit 0 à l'extrême droite; ainsi, en inversant la chaîne, on associe l'index à la variable . La valeur de la coupure correspond au poids total des arêtes qui traversent la partition, ce que le problème de la coupure maximale cherche à maximiser. Une valeur de découpage égale à 4 utilise quatre des cinq arêtes disponibles, ce qui correspond au maximum théorique pour ce graphe.
# Visualize the WS-QAOA solution on the graph
fig, axes = plt.subplots(1, 2, figsize=(8, 3))
for ax, s0, s1, cut, title in [
(axes[0], std_s0, std_s1, std_cut, f"Standard QAOA (cut = {std_cut})"),
(axes[1], ws_s0, ws_s1, ws_cut, f"WS-QAOA (cut = {ws_cut})"),
]:
colors = ["skyblue" if i in s0 else "salmon" for i in G.nodes()]
nx.draw(G, pos, with_labels=True, node_color=colors, ax=ax)
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, ax=ax)
ax.set_title(title)
plt.tight_layout()
plt.show()
# Summary
# to_ising offset: QUBO value = Ising energy + offset, so Max-Cut value = -(Ising energy + offset)
optimal_cut = -(optimal_energy + offset)
print("=== Summary ===")
print(
f"{'Method':<20} {'Ising energy':>14} {'Cut value':>12} {'Approx. ratio':>15}"
)
print("-" * 65)
print(
f"{'Standard QAOA':<20} {std_result.fun:>14.4f} {std_cut:>12} {std_result.fun/optimal_energy:>15.4f}"
)
print(
f"{'WS-QAOA':<20} {ws_result.fun:>14.4f} {ws_cut:>12} {ws_result.fun/optimal_energy:>15.4f}"
)
print(
f"{'Exact optimal':<20} {optimal_energy:>14.4f} {optimal_cut:>12.0f} {'1.0000':>15}"
)Output:
=== Summary ===
Method Ising energy Cut value Approx. ratio
-----------------------------------------------------------------
Standard QAOA -0.5859 4.0 0.3906
WS-QAOA -1.5000 4.0 1.0000
Exact optimal -1.5000 4 1.0000
Dans la visualisation graphique, chaque nœud est coloré en fonction de la partition à laquelle il appartient (bleu = , orange = ). Les arêtes qui traversent la partition (reliant des nœuds de couleurs différentes) sont celles qui sont prises en compte dans la coupure.
Ces deux méthodes trouvent une chaîne binaire dont la valeur de coupure est 4, mais pour des raisons très différentes. Il est important de noter que le graphique de convergence et la chaîne binaire échantillonnée mesurent deux choses différentes :
- Le graphique de convergence suit l' énergétique moyenne de l'état quantique complet, qui correspond à une moyenne pondérée de toutes les chaînes de bits présentes dans la superposition. L'algorithme QAOA standard converge vers environ , ce qui est bien supérieur à l' optimale, ce qui signifie que son état quantique est réparti sur de nombreuses chaînes de bits sous-optimales et ne contient que rarement la bonne réponse.
- La chaîne de bits échantillonnée correspond à un seul tirage issu de cet état. La méthode QAOA standard a eu de la chance dans ce cas : la partition optimale s'est avérée être le résultat le plus fréquemment observé, même à partir d'un état diffus. Face à des problèmes plus complexes, à du matériel plus bruyant ou à un plus grand nombre de solutions candidates en concurrence, cette chance finit par s'épuiser.
WS-QAOA, en revanche, fait converger son énergie moyenne jusqu’à , ce qui signifie que son état quantique est concentré sur les chaînes de bits optimales. Presque chaque essai donne la bonne réponse; la solution est donc trouvée de manière fiable et non par hasard.
Conséquence pratique : sur ce petit simulateur silencieux, la différence peut sembler minime, mais pour des problèmes de plus grande envergure ou sur du matériel réel, un état dont l'énergie moyenne est proche de l'optimum est bien plus robuste qu'un état qui ne trouve que de manière occasionnelle la bonne réponse à partir d'une distribution diffuse.
# Compare the full probability distribution over cut values for both
# algorithms. The most-probable bitstring above only reveals the mode;
# this histogram exposes how much of the quantum state's probability mass
# lands on the optimal cut versus on suboptimal partitions.
def cut_value_distribution(counts, G, shots):
dist = {}
for bs, c in counts.items():
cut, _, _ = evaluate_cut(decode_bitstring(bs), G)
dist[cut] = dist.get(cut, 0.0) + c / shots
return dist
std_cut_dist = cut_value_distribution(std_counts, G, shots)
ws_cut_dist = cut_value_distribution(ws_counts, G, shots)
cut_values = sorted(set(std_cut_dist) | set(ws_cut_dist))
std_probs = [std_cut_dist.get(c, 0.0) for c in cut_values]
ws_probs = [ws_cut_dist.get(c, 0.0) for c in cut_values]
fig, ax = plt.subplots(figsize=(7, 4))
x = np.arange(len(cut_values))
width = 0.4
ax.bar(
x - width / 2, std_probs, width, label="Standard QAOA", color="steelblue"
)
ax.bar(x + width / 2, ws_probs, width, label="WS-QAOA", color="salmon")
ax.axvline(
cut_values.index(optimal_cut),
color="k",
linestyle="--",
alpha=0.4,
label=f"Optimal cut = {optimal_cut:g}",
)
ax.set_xticks(x)
ax.set_xticklabels([f"{c:g}" for c in cut_values])
ax.set_xlabel("Cut value")
ax.set_ylabel("Probability")
ax.set_title(f"Probability of measuring each cut value ({shots} shots)")
ax.legend()
plt.tight_layout()
plt.show()
print(
f"P(cut = {optimal_cut:g}) | Standard QAOA = "
f"{std_cut_dist.get(optimal_cut, 0):.4f} "
f"WS-QAOA = {ws_cut_dist.get(optimal_cut, 0):.4f}"
)Output:
P(cut = 4) | Standard QAOA = 0.4639 WS-QAOA = 1.0000
Cet histogramme quantifie ce que le graphique de convergence ne faisait que suggérer. Dans l'algorithme QAOA standard, la probabilité est répartie entre plusieurs valeurs de coupure sous-optimales; par conséquent, la probabilité d'obtenir une coupure optimale de quatre en un seul essai ne représente qu'une fraction de la masse totale. WS-QAOA concentre la quasi-totalité de sa probabilité sur la solution optimale, de sorte que presque chaque tentative donne la bonne réponse. C'est là la marque distinctive d'un état dont l'énergie moyenne a convergé vers l'énergie de l'état fondamental, par opposition à un état qui se trouve simplement inclure l'état fondamental dans une superposition large.
Exemple de matériel à grande échelle
Regrouper les étapes 1 à 4 en un seul bloc de code
# Selecting a backend using real hardware
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=127
)
print(f"Using backend: {backend.name}")Output:
Using backend: ibm_boston
# ── Step 1a: Build the 40-node Max-Cut problem ─────────────────────────────
# A 3-regular graph (every node has exactly 3 neighbors) is a standard QAOA
N_LARGE = 40
G_large = nx.random_regular_graph(d=3, n=N_LARGE, seed=0)
edges_large = list(G_large.edges())
print(f"Graph: {N_LARGE} nodes, {len(edges_large)} edges (3-regular)")
# Visualize the graph so it is clear what problem we are solving before any
# quantum work. Nodes in a circular layout; each edge contributes +1 to the
# cut value when its endpoints land in different partitions.
pos_large = nx.circular_layout(G_large)
fig, ax = plt.subplots(figsize=(6, 6))
nx.draw(
G_large,
pos_large,
with_labels=True,
node_color="lightblue",
node_size=400,
font_size=7,
ax=ax,
)
ax.set_title(f"40-node 3-regular Max-Cut graph ({len(edges_large)} edges)")
plt.tight_layout()
plt.show()
# Same Maxcut → OptimizationProblem → QUBO → Ising pipeline as the small example,
# applied to the 40-node graph.
prob_large = Maxcut(G_large).to_optimization_problem()
converter_large = OptimizationProblemToQubo()
qubo_large = converter_large.convert(prob_large)
cost_op_large, offset_large = to_ising(qubo_large)
n_qubits_large = cost_op_large.num_qubits
print(
f"Cost operator: {n_qubits_large} qubits, {len(cost_op_large)} Pauli terms"
)
# ── Step 1b: QP relaxation (multi-start L-BFGS-B) ─────────────────────────
# Same multi-start approach as the small example. At 40 qubits the relaxed
# landscape has many more local minima, so 200 random starts are essential
# to find a low-energy warm-start point.
Q_large = qubo_large.objective.quadratic.to_array(symmetric=True)
mu_large = qubo_large.objective.linear.to_array()
def qp_obj_large(x):
return x @ Q_large @ x + mu_large @ x + qubo_large.objective.constant
bounds_large = [(0.0, 1.0)] * n_qubits_large
rng_qp = np.random.default_rng(42)
best_val_large, c_star_large = np.inf, None
for _ in range(200):
x0 = rng_qp.uniform(0.0, 1.0, n_qubits_large)
res = minimize(qp_obj_large, x0, method="L-BFGS-B", bounds=bounds_large)
if res.fun < best_val_large:
best_val_large, c_star_large = res.fun, res.x
# Regularize and convert to rotation angles (same formula as small example)
epsilon_large = 0.25
c_clipped_large = np.clip(c_star_large, epsilon_large, 1 - epsilon_large)
thetas_large = 2 * np.arcsin(np.sqrt(c_clipped_large))
print(
f"c* range: [{c_star_large.min():.3f}, {c_star_large.max():.3f}] "
f"theta range: [{thetas_large.min():.3f}, {thetas_large.max():.3f}] rad"
)
# Plot the distribution of c* values to see how much structure the relaxation
# extracted. Values near 0/1 mean confident assignments; values near 0.5 mean
# the classical solver was uncertain and quantum exploration is most needed there.
fig, ax = plt.subplots(figsize=(6, 3))
ax.hist(c_star_large, bins=20, color="steelblue", edgecolor="white")
ax.axvline(0.5, color="k", linestyle="--", label="Uniform prior (std QAOA)")
ax.set_xlabel(r"$c^*_i$")
ax.set_ylabel("Count")
ax.set_title(r"Distribution of warm-start values $c^*_i$ (40-node graph)")
ax.legend()
plt.tight_layout()
plt.show()
# ── Step 1c: Build WS-QAOA circuit ─────────────────────────────────────────
# Reuse build_ws_qaoa from the small-scale section unchanged; the helper
# scales automatically with n_qubits and the cost operator size.
p_large = 1
ws_qc_large, ws_gammas_large, ws_betas_large = build_ws_qaoa(
cost_op_large, p_large, n_qubits_large, thetas_large
)
ws_qc_large.measure_all()
# ── Step 2: Transpile to hardware-native gates ──────────────────────────
# generate_preset_pass_manager compiles the abstract circuit to th
# gate set of the backend and inserts SWAP gates wherever the cost Hamiltonian
# couples qubits that are not directly connected on the processor.
pm = generate_preset_pass_manager(optimization_level=3, backend=backend)
ws_isa_large = pm.run(ws_qc_large)
ecr_count = ws_isa_large.count_ops().get("ecr", 0)
print(
f"\nTranspiled circuit: 2Q depth={ws_isa_large.depth(lambda x: x.operation.num_qubits == 2)}"
)
ws_isa_large.draw("mpl", fold=-1)Output:
Graph: 40 nodes, 60 edges (3-regular)
Cost operator: 40 qubits, 60 Pauli terms
c* range: [0.000, 1.000] theta range: [1.047, 2.094] rad
Transpiled circuit: 2Q depth=86
# ── Classical baseline via simulated annealing ────────────────────
# Run SA before any hardware calls to get a strong classical reference cut
# value. SA is fast (seconds), needs no solver license, and reliably finds
# near-optimal solutions on 40-node graphs. We use sa_cut as the denominator
# for the approximation ratio instead of the looser QP upper bound.
#
# At each step we flip a random node and accept the move if it improves the
# cut, or with probability exp(delta/T) otherwise. Temperature T decays
# geometrically, allowing uphill moves early on to escape local minima.
def simulated_annealing_maxcut(
G, seed=0, T0=2.0, T_min=1e-4, alpha=0.995, n_steps=100_000
):
rng_sa = np.random.default_rng(seed)
n = G.number_of_nodes()
x = rng_sa.integers(0, 2, n)
best_x = x.copy()
best_cut = sum(1 for u, v in G.edges() if x[u] != x[v])
T = T0
for _ in range(n_steps):
i = rng_sa.integers(0, n)
delta = sum((-1 if x[i] != x[nb] else 1) for nb in G.neighbors(i))
if delta > 0 or rng_sa.random() < np.exp(delta / T):
x[i] ^= 1
cut = sum(1 for u, v in G.edges() if x[u] != x[v])
if cut > best_cut:
best_cut, best_x = cut, x.copy()
T = max(T * alpha, T_min)
return best_x, best_cut
sa_solution, sa_cut = simulated_annealing_maxcut(G_large)
print(f"Simulated annealing cut value: {sa_cut} (classical reference)")
# ── Step 3: Execution on hardware ───────────────────────────
# A Session reserves the backend so the COBYLA iterations and final sampling
# run back-to-back without re-queuing between jobs — important when the
# optimizer submits many short jobs sequentially. All jobs are tagged with
# "TUT_WSQAOA" for traceability in the IBM Quantum dashboard.
#
# EstimatorV2 with resilience_level=1 enables twirled readout error extinction
# (TREX), which corrects systematic measurement bit-flip errors without extra
# circuit overhead. 4096 shots per call balances estimation noise vs. job time.
estimator_options = EstimatorOptions()
estimator_options.resilience_level = 1
estimator_options.default_shots = 4096
estimator_options.environment.job_tags = ["TUT_WSQAOA"]
# Align the cost observable with the physical qubit layout chosen by the transpiler
cost_op_isa = cost_op_large.apply_layout(ws_isa_large.layout)
ws_param_order_isa = list(ws_isa_large.parameters)
ws_history_hw = []
with Session(backend=backend) as session:
estimator_hw = Estimator(mode=session, options=estimator_options)
def hw_cost_fn(params):
bound = ws_isa_large.assign_parameters(
dict(zip(ws_param_order_isa, params))
)
energy = (
estimator_hw.run([(bound, cost_op_isa)]).result()[0].data.evs.real
)
ws_history_hw.append(float(energy))
print(
f" iter {len(ws_history_hw):>3d} <H_C> = {energy:.4f}", end="\r"
)
return float(energy)
# Warm-start initialization: gamma=0 means the cost unitary is the identity on
# the first call, so COBYLA immediately evaluates the warm-start state itself —
# a much better starting signal than a random point.
ws_params0_hw = np.concatenate(
[np.zeros(p_large), np.full(p_large, np.pi / 4)]
)
ws_result_hw = minimize(
hw_cost_fn,
ws_params0_hw,
method="COBYLA",
options={"maxiter": 150, "rhobeg": 0.3},
)
print(
f"\nOptimization complete: energy={ws_result_hw.fun:.4f}, "
f"iterations={len(ws_history_hw)}"
)
# ── Step 3b: Sample the optimized circuit ──────────────────────────────────
# Use 8192 shots for the final sample to get a reliable mode estimate.
sampler_hw = Sampler(
mode=session,
options={"environment": {"job_tags": ["TUT_WSQAOA"]}},
)
ws_bound_hw = ws_isa_large.assign_parameters(
dict(zip(ws_param_order_isa, ws_result_hw.x))
)
counts_hw = (
sampler_hw.run([ws_bound_hw], shots=8192)
.result()[0]
.data.meas.get_counts()
)
best_bs_hw = max(counts_hw, key=counts_hw.get)
best_count = counts_hw[best_bs_hw]
total_shots = sum(counts_hw.values())
# Decode: Qiskit returns bitstrings with qubit 0 at the rightmost position,
# so reversing the string maps character index i to variable x_i.
cut_val_hw, s0_hw, s1_hw = evaluate_cut(best_bs_hw[::-1], G_large)
# Compare against simulated annealing.
# A ratio >= 1.0 means WS-QAOA matched or beat the classical SA solution.
# A ratio close to 1.0 (e.g. > 0.95) shows the quantum result is competitive.
approx_ratio_hw = cut_val_hw / sa_cut
print(
f"Most-probable bitstring frequency: {best_count}/{total_shots} "
f"({100*best_count/total_shots:.1f}%)"
)
print(
f"WS-QAOA cut: {cut_val_hw} | SA cut: {sa_cut} "
f"| Approximation ratio vs SA: {approx_ratio_hw:.4f}"
)
# Visualize both solutions side-by-side on the graph.
# Blue = partition S, orange = partition S-bar.
# Edges crossing between colors are the ones counted in the cut.
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
for ax, assignment, cut, title in [
(
axes[0],
list(sa_solution),
sa_cut,
f"Simulated Annealing (cut={sa_cut})",
),
(
axes[1],
[int(b) for b in best_bs_hw[::-1]],
cut_val_hw,
f"WS-QAOA hardware (cut={cut_val_hw})",
),
]:
colors = [
"skyblue" if assignment[i] == 0 else "salmon" for i in G_large.nodes()
]
nx.draw(
G_large,
pos_large,
with_labels=True,
node_color=colors,
node_size=400,
font_size=7,
ax=ax,
)
ax.set_title(title)
plt.suptitle("Max-Cut partitions: SA vs WS-QAOA", fontsize=13)
plt.tight_layout()
plt.show()
# ── Step 4: Convergence plot and summary ──────────────────────────────────
# On real hardware the trace will be noisy (shot noise + gate errors), but the
# overall downward trend confirms that COBYLA is making progress despite noise.
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(ws_history_hw, color="tab:orange", label="WS-QAOA (hardware)")
ax.axhline(
ws_result_hw.fun,
color="tab:orange",
linestyle=":",
label=f"Final energy ({ws_result_hw.fun:.3f})",
)
ax.set_xlabel("Optimizer call")
ax.set_ylabel(r"$\langle H_C \rangle$")
ax.set_title(f"WS-QAOA convergence on {backend.name} (40 qubits, p=1)")
ax.legend()
plt.tight_layout()
plt.show()
print("\n=== Large Scale Summary ===")
print(f"{'Metric':<38} {'Value':>10}")
print("-" * 50)
print(f"{'Nodes / Edges':<38} {N_LARGE:>5} / {len(edges_large):<4}")
print(f"{'QAOA layers (p)':<38} {p_large:>10}")
print(f"{'Transpiled ECR gate count':<38} {ecr_count:>10}")
print(f"{'Transpiled circuit depth':<38} {ws_isa_large.depth():>10}")
print(f"{'Optimizer iterations':<38} {len(ws_history_hw):>10}")
print(f"{'WS-QAOA energy (hardware)':<38} {ws_result_hw.fun:>10.4f}")
print(f"{'Cut value':<38} {cut_val_hw:>10}")
print(f"{'Simulated annealing cut value':<38} {sa_cut:>10}")
print(f"{'Approximation ratio (vs SA)':<38} {approx_ratio_hw:>10.4f}")Output:
Simulated annealing cut value: 53 (classical reference)
iter 31 <H_C> = -12.4094
Optimization complete: energy=-13.0256, iterations=31
Most-probable bitstring frequency: 4/8192 (0.0%)
WS-QAOA cut: 53 | SA cut: 53 | Approximation ratio vs SA: 1.0000
=== Large Scale Summary ===
Metric Value
--------------------------------------------------
Nodes / Edges 40 / 60
QAOA layers (p) 1
Transpiled ECR gate count 0
Transpiled circuit depth 276
Optimizer iterations 31
WS-QAOA energy (hardware) -13.0256
Cut value 53
Simulated annealing cut value 53
Approximation ratio (vs SA) 1.0000
Etapes suivantes
Si ce travail vous a paru intéressant, les documents suivants pourraient vous intéresser :
- Couches QAOA supérieures : augmentez
ple nombre de couches pour observer comment les deux algorithmes s'améliorent à mesure que le nombre de couches du circuit augmente, et pour voir si l'avantage du WS-QAOA à faible profondeur persiste. - Module d'optimisation « Mapper » de Qiskit : consultez la documentation et essayez de modéliser différents problèmes combinatoires, ou d'utiliser différents solveurs pour la relaxation continue.
Références
[1] D. J. Egger, J. Mareček et S. Woerner, « Warm-starting quantum optimization », Quantum, vol. 5, p. 479, 2021. arXiv:2009.10095
[2] E. Farhi, J. Goldstone et S. Gutmann, « A quantum approximate optimization algorithm », arXiv:1411.4028, 2014.