최적화 매퍼(Optimization Mapper) Qiskit 애드온을 사용한 웜 스타트 QAOA
예상 사용 시간: Heron r3 에서 9분 (참고: 이는 단지 예상치일 뿐입니다.) (실행 시간은 다를 수 있습니다.)
학습 성과
- 다음 방법을 사용하여 최대 절단 문제를 양자 2차 무제약 이진 최적화(QUBO) 모델로 매핑하는 방법
qiskit-addon-opt-mapper - 시뮬레이터에서 표준 QAOA를 구현하고 실행하는 방법
- 2차 계획법(QP) 완화식을 계산하고 웜스타트 회로를 구축하여 WS-QAOA를 적용하는 방법
- 표준 QAOA와 WS-QAOA 간의 에너지 수렴도와 해의 품질을 비교하는 방법
전제조건
배경
양자 근사 최적화 알고리즘(QAOA)은 최대 절단(max-cut) 및 일반 QUBO 표현식과 같은 조합 최적화 문제를 해결하기 위해 고안된 양자-고전 하이브리드 알고리즘입니다. Qiskit에서 QAOA에 대한 기초적인 소개를 보려면 QAOA 튜토리얼 을, 보다 심화된 회로 구축 기법을 보려면 고급 QAOA 튜토리얼 을 참고하세요.
표준 QAOA에서는:
- 초기 상태는 균일한 중첩 상태 입니다.
- 변분 매개변수는 무작위로 초기화됩니다.
- 고전적인 최적화 알고리즘은 비용 함수를 최소화하는 매개변수를 찾습니다.
그러나 실제 문제 규모와 잡음이 많은 양자 하드웨어의 경우, 무작위 초기화는 수렴 속도 저하, 열악한 국소 최소점, 그리고 최적화 비용 증가를 초래할 수 있다.
웜 스타트 QAOA (WS-QAOA)는 고전적 최적화 이론을 양자 회로에 직접 반영함으로써 이 문제를 개선합니다. 이 튜토리얼은 Egger, Mareček, Woerner가 ‘Warm-starting quantum optimization’에서 소개한 방법을 따릅니다. 핵심 아이디어는 다음과 같습니다:
- 원래의 이진 문제의 연속 이완 문제( 대신 에 대한 2차 계획법)를 풀어야 합니다.
- -rotation angles 를 사용하여 이완된 해 를 사용자 정의 초기 상태로 인코딩함으로써, 큐비트 가 를 측정할 확률이 인 상태에서 시작되도록 한다.
- 표준 -믹서를, 웜 스타트(warm-start) 초기 상태를 기본 상태로 갖는 사용자 정의 믹서로 대체하여, 알고리즘이 고전적 해 근처에서 시작하고 그 주변 영역을 탐색할 수 있도록 합니다.
정규화 매개변수 는 도달 가능성 문제를 방지하기 위해 를 0과 1에서 벗어나게 제한합니다. 또는 에서 초기화된 큐비트는 비용 해밀토니안에 의해 이동될 수 없습니다. 에서 WS-QAOA는 정확히 표준 QAOA로 환원됩니다.
문제 모델링에는 패키지가 qiskit-addon-opt-mapper 사용되며, 이 패키지의 Maxcut 애플리케이션 클래스는 그래프로부터 직접 QUBO를 생성하고, 변환기 및 번역기는 생성된 문제를 양자 해밀토니안으로 매핑합니다.
요구사항
이 튜토리얼을 시작하기 전에 다음 항목이 설치되어 있는지 확인하십시오:
- Qiskit SDK v2.0 또는 그 이후 버전, 시각화 기능 지원
- Qiskit Runtime v0.43 또는 그 이후 (
pip install qiskit-ibm-runtime) - 최적화 매퍼 Qiskit 애드온 (
pip install qiskit-addon-opt-mapper) - SciPy (
pip install scipy) - NetworkX (
pip install networkx)
설정
이 튜토리얼에서 필요한 모든 라이브러리를 불러오고, 튜토리얼 전반에 걸쳐 사용되는 보조 함수를 정의합니다.
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_ising소규모 시뮬레이터 예시
이 글에서는 가중 그래프에 대한 작은 맥스-컷 문제를 예시로 들어 설명하겠습니다. Max-cut 문제: 변의 가중치가 인 그래프 가 주어졌을 때, 절단면을 가로지르는 변들의 총 가중치를 최대화하는 두 집합 와 으로 정점을 분할하는 방법을 구하시오.
QUBO 최소화 문제로 볼 때, 최대 절단(max-cut)은 다음과 같이 표현할 수 있습니다:
시뮬레이터에서 처리하기 용이하도록 4노드 그래프를 사용합니다.
1단계: 고전적 입력을 양자 문제에 매핑하기
qiskit-addon-opt-mapper우리는 그래프로부터 직접 QUBO 표현을 구축하는 의 응용 클래스를 Maxcut 사용하여 최대 절단 문제를 정의한다. 그런 다음 이를 QUBO로 변환하고, QAOA에 적합한 이징 해밀토니안(SparsePauliOp)으로 변환합니다. 또한 QUBO의 연속 이완 문제(이진 제약 조건 을 로 대체)를 해결하여 웜 스타트 초기점 을 구합니다.
# 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:
이 그래프에는 5개의 간선이 있습니다. 최적의 맥스컷(max-cut)은 노드들을 와 (또는 그 보집합)으로 분할하며, 5개의 간선 중 4개를 끊어내어 컷 값이 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
이 Maxcut 클래스는 QUBO 생성 과정을 감싸고 있으므로, max-cut 목적 함수를 직접 전개할 필요가 없습니다. 출력된 목표 함수에는 각 변수의 선형 계수(각 변수가 절단에 개별적으로 기여하는 정도)와 각 교차항의 2차 계수(인접한 두 노드를 같은 쪽에 배치할 때 발생하는 페널티)가 표시됩니다. 가 to_optimization_problem() 반환하는 기본 OptimizationProblem 객체는 이진, 정수, 연속 및 스핀 변수를 지원하며, 다음 단계에서 사용되는 변환기 및 번역기가 기대하는 것과 동일한 객체입니다.
# 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
이 to_ising 변환기는 을 SparsePauliOp 나타내는 와, offset 을 만족하는 스칼라 를 반환합니다. 모든 가중치가 1인 이 최대 절단(max-cut) 문제에서, 모든 큐비트에 대해 가 성립하며( 대입 후 그래프는 선형적으로 대칭적입니다), 각 변은 강도 인 결합을 기여합니다. 의 최소 고유값은 최대 절단에 해당합니다.
# 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
다중 시작 솔버는 (또는 그 보충 문제인 )를 찾아내며, 이것이 바로 실제 최적의 이진 해입니다. 이 문제의 경우, QP 이완 조건이 타이트하며, 연속 최적값이 정수 최적값과 일치하므로, 이완 조건을 통해 최상의 컷을 즉시 파악할 수 있습니다. 2단계에서 를 사용하여 정규화한 후, 이 해는 웜 스타트 초기 상태로 인코딩됩니다.
2단계: 양자 하드웨어 실행을 위해 문제 최적화하기
두 개의 QAOA 회로를 구축하고, QP 해를 바탕으로 웜 스타트 각도를 준비합니다.
표준 QAOA는 균일한 중첩 상태 를 초기 상태로 사용하며, 각 레이어마다 로 구현된 표준 혼합기 를 사용합니다.
[1] 에 소개된 웜 스타트 QAOA(WS-QAOA)는 큐비트당 두 가지 구조적 변경을 가합니다 :
- 초기 상태: 이며, 이므로, 가 관측될 확률은 이다.
- 사용자 정의 믹서: . 이 믹서의 기저 상태는 입니다. 이는 WS-QAOA가 자체 믹서의 기저 상태에서 시작됨을 의미하며, 이는 표준 QAOA가 ‘ ’ 및 ‘ ’ 믹서를 통해 충족하는 특성과 동일합니다.
p=1레이어에 대한 참고 사항: (단일 QAOA 레이어의 경우) 표준 QAOA는 삼각형이 포함된 그래프에서 최적 에너지의 약 49%로 분석적으로 제한됩니다(이 그래프에는 0-1-2 삼각형이 있습니다). 웜 스타트는 해에 대한 선행 지식을 초기 상태에 직접 반영함으로써 이러한 한계를 극복합니다.
# 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>)
클리핑 후, 는 가 되고, 는 가 됩니다. 그 결과로 발생하는 각도 라디안은 큐비트 0과 3을 쪽으로, 큐비트 1과 2를 쪽으로 강하게 회전시키며, 이는 최적 절단 구조를 초기 양자 상태에 직접 인코딩합니다.
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)qaoa_ansatz표준 접근 방식에 대해서는 를 생성하고, 비용 유니타리를 적용하며, 각 reps 레이어에 대해 기본 혼합기를 적용하는 방식으로 위임합니다. WS-QAOA의 경우, 큐비트별 믹서 가 파울리 연산의 합이 아닌 게이트 시퀀스로 표현되기 때문에 명시적인 build_ws_qaoa 헬퍼를 유지합니다. 이 apply_cost_unitary 헬퍼는 해밀토니안에서 직접 데이터를 SparsePauliOp 읽어오기 때문에, 수동으로 회로를 구성할 필요 없이 모든 QUBO 문제를 처리할 수 있습니다.
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):
두 회로 모두 동일한 구조를 따릅니다. 즉, 초기 상태 준비 층이 먼저 오고, 그 다음에는 비용-유니터리 층과 믹서-유니터리 층이 번갈아 배치됩니다. WS-QAOA 회로에서, 시작 부분의 게이트는 를 인코딩하며, 믹서는 각 를 공액인 – – 삼중항으로 대체합니다. 두 회로 간의 깊이 차이는 에 비례하여 선형적으로 증가하지만, 깊이가 얕을 때는 관리 가능한 수준을 유지합니다.
3단계: Qiskit primitives를 사용하여 실행하기
우리는 정확하고 잡음이 없는 시뮬레이션을 위해 를 사용합니다 StatevectorEstimator . SciPy 에 있는 COBYLA 최적화기를 사용하는 이 minimize 함수는 변분 루프를 구동하며, 각 반복 단계에서 추정기를 호출하여 주어진 매개변수 집합 에 대한 를 계산합니다.
이 두 알고리즘은 최적화 전에 각 알고리즘이 가지고 있는 정보를 반영하는 서로 다른 초기 매개변수를 사용합니다:
- 표준 QAOA: 에서 무작위 초기화 — 구조적 정보가 없으므로 적절한 방법입니다.
- WS-QAOA: , — 에 따르면, 비용 단위는 항등 연산이며, 따라서 가장 첫 번째 회로 평가에서는 웜 스타트 초기 상태에서 직접 샘플링합니다. 이를 통해 COBYLA는 기존 해법과 일치하는 강력한 출발점을 얻게 됩니다.
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
WS-QAOA의 정보 기반 시작점 덕분에 COBYLA는 웜 스타트(warm-start) 해법에 가까운 의미 있는 에너지 값에서 시작하는 반면, 표준 QAOA는 에너지 지형상의 사실상 무작위적인 지점에서 시작합니다. 이러한 초기 품질의 차이가 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
근사 비율은 로 정의된다. 인 최소화 문제의 경우, 이 비율이 1에 가까울수록 알고리즘이 더 낮은 에너지(더 나은 해)를 찾았음을 의미한다. 모든 기저 상태에 대한 무차별 대입 검색은 가 작을 때만 가능하며, 이는 기준 참조 값으로 사용됩니다.
4단계: 후처리를 수행하고 원하는 기존 형식으로 결과를 반환합니다
수렴 과정을 시각화하고, 비트스트링 해를 위한 최적화된 회로를 추출한 뒤, 해당 비트스트링을 다시 최대 절단 분할로 복호화하여 최종 결과를 정리합니다.
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:
수렴 그래프는 각 COBYLA 함수 평가 시점의 에너지 값을 보여줍니다. 에서 수행된 표준 QAOA는 이 그래프에서 최적 에너지의 약 49% 수준(삼각형이 포함된 그래프에서 QAOA의 이론적 최대치)으로 제한되며, 약 수준에서 수렴합니다. 반면, 최적 해에 근접한 지점에서 초기화된 WS-QAOA는 훨씬 적은 반복 횟수로 (정확한 최적값) 근처로 빠르게 수렴합니다. 이는 웜 스타트의 주요 장점을 보여줍니다. 즉, 동일한 회로 깊이에서 훨씬 더 우수한 해를 도출해 낸다는 것입니다.
# 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
에서 Sampler 반환된 비트 문자열은 가장 오른쪽 위치에 0 큐비트가 오도록 되어 있으므로, 이 문자열을 역순으로 배열하면 인덱스 가 변수 에 매핑됩니다. 컷 값은 분할을 가로지르는 가장자리의 총 가중치이며, 이는 최대 컷 문제가 극대화하고자 하는 값입니다. 컷 값 4는 사용 가능한 5개의 변 중 4개를 사용하며, 이는 이 그래프에서 이론적으로 가능한 최대값입니다.
# 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
이 그래프 시각화에서는 각 노드를 파티션 할당에 따라 색을 달리하여 표시합니다(파란색 = , 주황색 = ). 파티션을 가로지르는(서로 다른 색상의 노드를 연결하는) 간선들이 컷에 포함됩니다.
두 방법 모두 컷 값이 4인 비트스트링을 찾아내지만, 그 이유는 매우 다릅니다. 수렴 그래프와 샘플링된 비트열은 서로 다른 두 가지를 측정한다는 점에 유의해야 합니다:
- 수렴 플롯 은 전체 양자 상태의 평균 에너지 를 추적하며, 이는 중첩 상태에 포함된 모든 비트열에 대한 가중 평균값입니다. 표준 QAOA는 ~ 로 수렴하는데, 이는 최적값인 보다 훨씬 높은 수치로, 이 알고리즘의 양자 상태가 최적에 미치지 못하는 수많은 비트열에 분산되어 있으며 올바른 답을 포함하는 경우는 드물다는 것을 의미합니다.
- 샘플링된 비트스트링 은 해당 상태에서 추출된 단일 값입니다. 표준 QAOA는 여기서 운이 좋았습니다. 확산 상태에서도 최적 분할이 가장 빈번하게 샘플링된 결과로 나타났기 때문입니다. 더 어려운 문제나 오류가 잦은 하드웨어, 혹은 경쟁하는 후보 해법이 더 많은 경우에는 이러한 행운도 결국 다해 버립니다.
반면 WS-QAOA는 평균 에너지를 까지 수렴시키는데, 이는 이 알고리즘의 양자 상태가 최적 비트열에 집중되어 있음을 의미한다. 거의 모든 시도에 대해 올바른 답이 나오기 때문에, 이 해법은 우연이 아니라 확실하게 도출된 것입니다.
실질적인 결과는 이렇습니다. 이 작고 소음이 없는 시뮬레이터에서는 그 차이가 미미해 보일 수 있지만, 문제 규모가 커지거나 실제 하드웨어에서 실행할 경우, 평균 에너지가 최적치에 가까운 상태는 확산 분포에서 가끔씩만 올바른 답을 추출하는 상태보다 훨씬 더 견고합니다.
# 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
이 히스토그램은 수렴 그래프에서 단지 암시했던 내용을 수치적으로 나타낸 것입니다. 표준 QAOA의 확률은 여러 개의 차선책 절단값에 분산되어 있으므로, 단 한 번의 시도로 4라는 최적의 절단값을 추출할 확률은 전체 질량의 극히 일부에 불과합니다. WS-QAOA는 확률의 거의 전부를 최적 컷에 집중시키기 때문에, 거의 모든 시도에서 정답을 반환합니다. 이는 평균 에너지가 기저 상태 에너지에 수렴한 상태와, 단순히 광범위한 중첩 상태에 기저 상태가 우연히 포함된 상태를 구별하는 실질적인 특징입니다.
대규모 하드웨어 예시
1~4단계를 하나의 코드 블록으로 압축합니다
# 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
다음 단계
이 글이 흥미로웠다면, 다음 자료도 참고해 보시기 바랍니다:
- 더 높은 QAOA 층 : 값을 늘려
p, 회로 층이 늘어남에 따라 두 알고리즘이 어떻게 개선되는지, 그리고 낮은 깊이에서 나타난 WS-QAOA의 우위가 지속되는지 확인해 보십시오. - Qiskit 애드온 최적화 매퍼 : 문 서를 살펴보고 다양한 조합 문제를 모델링해 보거나, 연속 이완 문제에 대해 여러 가지 솔버를 사용해 보세요.
참조
[1] D. J. Egger, J. Mareček, 및 S. Woerner, “Warm-starting quantum optimization,” Quantum, 제5권, 479쪽, 2021. arXiv:2009.10095
[2] E. Farhi, J. Goldstone, S. Gutmann, “양자 근사 최적화 알고리즘,” 《 arXiv:1411.4028 》, 2014.