양자 근사 최적화 알고리즘
사용 시간 예상: Heron r3 프로세서 기준 22분 (참고: 이는 예상치이며 실제 실행 시간은 다를 수 있습니다.)
배경
이 튜토리얼은 Qiskit 패턴의 맥락 안에서 양자 근사 최적화 알고리즘(Quantum Approximate Optimization Algorithm, QAOA) — 하이브리드(양자-고전) 반복 방법 — 을 구현하는 방법을 보여줍니다. 먼저 작은 그래프에 대한 최대 컷(Maximum-Cut, Max-Cut) 문제를 풀어보고, 이후 유틸리티 규모로 실행하는 방법을 배웁니다. 이 튜토리얼의 모든 하드웨어 실행은 무료로 이용 가능한 Open Plan의 시간 제한 내에서 완료됩니다.
Max-Cut 문제는 클러스터링, 네트워크 과학, 통계 물리학 등 다양한 분야에 응용되는 어려운 최적화 문제(NP-hard 문제)입니다. 이 튜토리얼에서는 엣지로 연결된 노드로 구성된 그래프를 고려하며, 노드를 두 집합으로 나누어 해당 컷을 가로지르는 엣지의 수가 최대화되도록 분할하는 것을 목표로 합니다.

요구 사항
이 튜토리얼을 시작하기 전에 다음이 설치되어 있는지 확인하세요:
- Qiskit SDK v1.0 이상 (시각화 지원 포함)
- Qiskit Runtime v0.22 이상 (
pip install qiskit-ibm-runtime)
또한 IBM Quantum Platform의 인스턴스에 접근 권한이 필요합니다. 이 튜토리얼은 세션을 사용하는 워크로드를 실행하며, 세션은 Premium Plan에서만 사용할 수 있으므로 Open Plan에서는 실행할 수 없습니다.
설정
# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-ibm-runtime rustworkx scipy
import matplotlib
import matplotlib.pyplot as plt
import rustworkx as rx
from rustworkx.visualization import mpl_draw as draw_graph
import numpy as np
from scipy.optimize import minimize
from collections import defaultdict
from typing import Sequence
from qiskit.quantum_info import SparsePauliOp
from qiskit.circuit.library import QAOAAnsatz
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime import Session, EstimatorV2 as Estimator
from qiskit_ibm_runtime import SamplerV2 as Sampler
Part I. 소규모 QAOA
이 튜토리얼의 첫 번째 부분에서는 소규모 Max-Cut 문제를 통해 양자 컴퓨터를 이용하여 최적화 문제를 푸는 단계를 설명합니다.
이 문제를 양자 알고리즘에 매핑하기 전에, Max-Cut 문제가 어떻게 고전적인 조합 최적화 문제가 되는지 먼저 함수 의 최소화를 고려하여 이해해 보겠습니다.
여기서 입력 는 그래프의 각 노드에 해당하는 성분을 가진 벡터입니다. 그리고 각 성분을 또는 로 제한합니다(각각 컷에 포함되거나 포함되지 않음을 나타냄). 이 소규모 예시에서는 개의 노드를 가진 그래프를 사용합니다.
노드 쌍 에 대한 함수를 작성하여 해당 엣지 가 컷 안에 있는지 나타낼 수 있습니다. 예를 들어, 함수 는 또는 중 하나만 1일 때(즉, 엣지가 컷 안에 있을 때)만 1이고, 그 외에는 0입니다. 컷에 포함된 엣지를 최대화하는 문제는 다음과 같이 표현할 수 있습니다.
이를 최소화 형태로 다시 쓰면
이 경우 의 최솟값은 컷이 가로지르는 엣지의 수가 최대일 때입니다. 보면 알 수 있듯이 아직 양자 컴퓨팅과 관련된 내용은 없습니다. 이 문제를 양자 컴퓨터가 이해할 수 있는 형태로 재정의해야 합니다.
개의 노드를 가진 그래프를 생성하여 문제를 초기화합니다.
n = 5
graph = rx.PyGraph()
graph.add_nodes_from(np.arange(0, n, 1))
edge_list = [
(0, 1, 1.0),
(0, 2, 1.0),
(0, 4, 1.0),
(1, 2, 1.0),
(2, 3, 1.0),
(3, 4, 1.0),
]
graph.add_edges_from(edge_list)
draw_graph(graph, node_size=600, with_labels=True)
Step 1: 고전적 입력을 양자 문제로 매핑하기
패턴의 첫 번째 단계는 고전적인 문제(그래프)를 양자 Circuit과 연산자로 매핑하는 것입니다. 이를 위해 세 가지 주요 단계를 거칩니다:
- 일련의 수학적 재정식화를 통해 이 문제를 QUBO(Quadratic Unconstrained Binary Optimization) 표기법으로 표현합니다.
- 최적화 문제를 Hamiltonian으로 재작성하여 기저 상태가 비용 함수를 최소화하는 해에 해당하도록 합니다.
- 양자 어닐링과 유사한 과정을 통해 이 Hamiltonian의 기저 상태를 준비할 양자 Circuit을 생성합니다.
참고: QAOA 방법론에서는 궁극적으로 하이브리드 알고리즘의 비용 함수를 나타내는 연산자(Hamiltonian)와 문제에 대한 후보 해를 나타내는 양자 상태의 매개변수화된 Circuit(Ansatz)이 필요합니다. 이 후보 상태들에서 샘플링하고 비용 함수를 사용하여 평가할 수 있습니다.
그래프 → 최적화 문제
매핑의 첫 번째 단계는 표기법 변환입니다. 다음은 문제를 QUBO 표기법으로 표현한 것입 니다:
여기서 는 실수 값으로 이루어진 행렬이고, 은 그래프의 노드 수에 해당하며, 는 위에서 소개한 이진 변수 벡터이고, 는 벡터 의 전치를 나타냅니다.
Maximize
-2*x_0*x_1 - 2*x_0*x_2 - 2*x_0*x_4 - 2*x_1*x_2 - 2*x_2*x_3 - 2*x_3*x_4 + 3*x_0
+ 2*x_1 + 3*x_2 + 2*x_3 + 2*x_4
Subject to
No constraints
Binary variables (5)
x_0 x_1 x_2 x_3 x_4
최적화 문제 → Hamiltonian
QUBO 문제를 Hamiltonian(시스템의 에너지를 나타내는 행렬)으로 재정식화할 수 있습니다:
QAOA 문제에서 Hamiltonian으로의 재정식화 단계
QAOA 문제를 이런 방식으로 재 작성하는 방법을 보여주기 위해, 먼저 이진 변수 를 새로운 변수 로 다음과 같이 대체합니다.
여기서 가 이면 는 이어야 함을 알 수 있습니다. 최적화 문제()에서 를 로 치환하면 동치인 표현식을 얻을 수 있습니다.
이제 로 정의하고, 앞의 계수와 상수 항을 제거하면 동일한 최적화 문제의 두 가지 동치 표현식에 도달합니다.
여기서 는 에 의존합니다. 를 얻기 위해 최적화에 역할을 하지 않는 1/4 인수와 의 상수 오프셋을 제거했음을 참고하세요.
이제 문제의 양자 공식화를 위해 변수를 Pauli 행렬, 즉 다음과 같은 행렬로 승격시킵니다.
위의 최적화 문제에 이 행렬들을 대입하면 다음 Hamiltonian을 얻습니다.
또한, 행렬은 양자 컴퓨터의 계산 공간, 즉 크기 의 힐베르트 공간에 내장되어 있음을 기억하세요. 따라서 와 같은 항은 힐베르트 공간에 내장된 텐서곱 로 이해해야 합니다. 예를 들어, 다섯 개의 결정 변수를 가진 문제에서 항은 를 의미하며, 여기서 는 항등 행렬입니다.
이 Hamiltonian을 비용 함수 Hamiltonian이라고 합니다. 이 Hamiltonian은 기저 상태가 비용 함수 를 최소화하는 해에 해당하는 특성을 가집니다. 따라서 최적화 문제를 풀기 위해 이제 양자 컴퓨터에서 의 기저 상태(또는 기저 상태와 높은 겹침을 갖는 상태)를 준비해야 합니다. 그런 다음 이 상태에서 샘플링하면 높은 확률로 의 해를 얻을 수 있습니다. 이제 Max-Cut 문제에 대한 Hamiltonian 를 고려해 보겠습니다. 그래프의 각 꼭짓점을 또는 상태의 Qubit에 연결하며, 값은 꼭짓점이 속한 집합을 나타냅니다. 문제의 목표는 이고 이거나 그 반대인 엣지 의 수를 최대화하는 것입니다. 각 Qubit에 연산자를 연결하여,
엣지 는 의 고유값을 가질 때 컷에 속합니다. 다시 말해, 과 에 연결된 Qubit가 서로 다릅니다. 마찬가지로, 는 의 고유값을 가질 때 컷에 속하지 않습니다. 각 꼭짓점에 연결된 Qubit의 정확한 상태보다는 엣지를 가로질러 같은지 다른지만 중요합니다. Max-Cut 문제는 다음 Hamiltonian의 고유값이 최소화되도록 꼭짓점의 Qubit 할당을 찾는 것을 요구합니다.
즉, Max-Cut 문제에서 모든 에 대해 입니다. 의 값은 엣지의 가중치를 나타냅니다. 이 튜토리얼에서는 비가중 그래프를 고려하므로 모든 에 대해 입니다.
def build_max_cut_paulis(
graph: rx.PyGraph,
) -> list[tuple[str, list[int], float]]:
"""Convert the graph to Pauli list.
This function does the inverse of `build_max_cut_graph`
"""
pauli_list = []
for edge in list(graph.edge_list()):
weight = graph.get_edge_data(edge[0], edge[1])
pauli_list.append(("ZZ", [edge[0], edge[1]], weight))
return pauli_list
max_cut_paulis = build_max_cut_paulis(graph)
cost_hamiltonian = SparsePauliOp.from_sparse_list(max_cut_paulis, n)
print("Cost Function Hamiltonian:", cost_hamiltonian)
Cost Function Hamiltonian: SparsePauliOp(['IIIZZ', 'IIZIZ', 'ZIIIZ', 'IIZZI', 'IZZII', 'ZZIII'],
coeffs=[1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j])
Hamiltonian → 양자 Circuit
Hamiltonian 는 문제의 양자적 정의를 담고 있습니다. 이제 양자 컴퓨터에서 좋은 해를 샘플링하는 데 도움이 되는 양자 Circuit을 생성할 수 있습니다. QAOA는 양자 어닐링에서 영감을 받아 양자 Circuit에서 교대하는 연산자 레이어를 적용합니다.
기본 아이디어는 알려진 시스템의 기저 상태 에서 시작하여, 관심 있는 비용 연산자의 기저 상태로 시스템을 유도하는 것입니다. 이는 각도 와 를 가진 연산자 와 를 적용하여 이루어집니다.
생성하는 양자 Circuit은 와 로 매개변수화되어 있으므로, 다양한 와 값을 시도하고 결과 상태에서 샘플링할 수 있습니다.
이 경우 두 개의 매개변수 과 을 포함하는 하나의 QAOA 레이어 예시를 시도해 보겠습니다.
circuit = QAOAAnsatz(cost_operator=cost_hamiltonian, reps=2)
circuit.measure_all()
circuit.draw("mpl")
circuit.parameters
ParameterView([ParameterVectorElement(β[0]), ParameterVectorElement(β[1]), ParameterVectorElement(γ[0]), ParameterVectorElement(γ[1])])
Step 2: 양자 하드웨어 실행을 위한 문제 최적화
위의 Circuit은 양자 알고리즘을 이해하는 데 유용한 여러 추상화를 포함하고 있지만, 실제 하 드웨어에서는 실행할 수 없습니다. QPU에서 실행하려면 Circuit이 트랜스파일(transpilation) 또는 Circuit 최적화 단계를 구성하는 일련의 연산을 거쳐야 합니다.
Qiskit 라이브러리는 다양한 Circuit 변환을 지원하는 여러 트랜스파일 패스를 제공합니다. Circuit이 목적에 맞게 최적화되어 있는지 확인해야 합니다.
트랜스파일에는 다음과 같은 여러 단계가 포함될 수 있습니다:
- Circuit 내 Qubit(예: 결정 변수)을 장치의 물리적 Qubit에 초기 매핑하는 단계.
- 양자 Circuit의 명령어를 Backend가 이해하는 하드웨어 네이티브 명령어로 **언롤링(Unrolling)**하는 단계.
- Circuit에서 상호작용하는 Qubit을 인접한 물리적 Qubit으로 **라우팅(Routing)**하는 단계.
- 동적 디커플링(dynamical decoupling)으로 단일 Qubit Gate를 추가하여 노이즈를 억제하는 오류 억제 단계.
트랜스파일에 관한 자세한 내용은 문서를 참고하세요.
아래 코드는 추상적인 Circuit을 Qiskit IBM Runtime 서비스를 통해 클라우드에서 접근 가능한 장치 중 하나에서 실행할 수 있는 형식으로 변환하고 최적화합니다.
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=127
)
print(backend)
# Create pass manager for transpilation
pm = generate_preset_pass_manager(optimization_level=3, backend=backend)
candidate_circuit = pm.run(circuit)
candidate_circuit.draw("mpl", fold=False, idle_wires=False)
<IBMBackend('test_heron_pok_1')>

Step 3: Qiskit 프리미티브를 사용하여 실행
QAOA 워크플로에서 최적의 QAOA 파라미터는 반복적인 최적화 루프를 통해 찾으며, 이 루프는 일련의 Circuit 평가를 실행하고 고전적인 최적화기를 사용하여 최적의 및 파라미터를 찾습니다. 이 실행 루프는 다음 단계를 통해 수행됩니다:
- 초기 파라미터 정의
- 최적화 루프와 Circuit 샘플링에 사용할 프리미티브를 포함하는 새
Session인스턴스화 - 최적 파라미터 집합이 발견되면, 사후 처리 단계에서 사용할 최종 분포를 얻기 위해 Circuit을 마지막으로 한 번 더 실행
초기 파라미터로 Circuit 정의
임의로 선택된 파라미터로 시작합니다.
initial_gamma = np.pi
initial_beta = np.pi / 2
init_params = [initial_beta, initial_beta, initial_gamma, initial_gamma]
Backend 및 실행 프리미티브 정의
Qiskit Runtime 프리미티브를 사용하여 IBM® Backend와 상호작용합니다. 두 가지 프리미티브는 Sampler와 Estimator이며, 프리미티브의 선택은 양자 컴퓨터에서 실행하려는 측정 유형에 따라 달라집니다. 의 최소화를 위해서는 비용 함수의 측정이 단순히 의 기댓값이므로 Estimator를 사용합니다.
실행
프리미티브는 양자 장치에서 워크로드를 예약하기 위한 다양한 실행 모드를 제공하며, QAOA 워크플로는 Session 내에서 반복적으로 실행됩니다.

SciPy 최소화 루틴에 Sampler 기반 비용 함수를 연결하여 최적 파라미터를 찾을 수 있습니다.
def cost_func_estimator(params, ansatz, hamiltonian, estimator):
# transform the observable defined on virtual qubits to
# an observable defined on all physical qubits
isa_hamiltonian = hamiltonian.apply_layout(ansatz.layout)
pub = (ansatz, isa_hamiltonian, params)
job = estimator.run([pub])
results = job.result()[0]
cost = results.data.evs
objective_func_vals.append(cost)
return cost
objective_func_vals = [] # Global variable
with Session(backend=backend) as session:
# If using qiskit-ibm-runtime<0.24.0, change `mode=` to `session=`
estimator = Estimator(mode=session)
estimator.options.default_shots = 1000
# Set simple error suppression/mitigation options
estimator.options.dynamical_decoupling.enable = True
estimator.options.dynamical_decoupling.sequence_type = "XY4"
estimator.options.twirling.enable_gates = True
estimator.options.twirling.num_randomizations = "auto"
result = minimize(
cost_func_estimator,
init_params,
args=(candidate_circuit, cost_hamiltonian, estimator),
method="COBYLA",
tol=1e-2,
)
print(result)
message: Return from COBYLA because the trust region radius reaches its lower bound.
success: True
status: 0
fun: -1.6295230263157894
x: [ 1.530e+00 1.439e+00 4.071e+00 4.434e+00]
nfev: 26
maxcv: 0.0
최적화기가 비용을 줄이고 Circuit에 더 나은 파라미터를 찾는 데 성공했습니다.
plt.figure(figsize=(12, 6))
plt.plot(objective_func_vals)
plt.xlabel("Iteration")
plt.ylabel("Cost")
plt.show()
Circuit에 대한 최적 파라미터를 찾은 후, 이 파라미터를 할당하고 최적화된 파라미터로 얻은 최종 분포를 샘플링할 수 있습니다. 이 단계에서는 Sampler 프리미티브를 사용해야 합니다. 비트스트링 측정의 확률 분포가 그래프의 최적 컷에 해당하기 때문입니다.
참고: 이는 컴퓨터에서 양자 상태 를 준비한 후 측정하는 것을 의미합니다. 측정은 상태를 단일 계산 기저 상태(예: 010101110000...)로 붕괴시키며, 이는 초기 최적화 문제( 또는 )의 후보 해 에 해당합니다.
optimized_circuit = candidate_circuit.assign_parameters(result.x)
optimized_circuit.draw("mpl", fold=False, idle_wires=False)

# If using qiskit-ibm-runtime<0.24.0, change `mode=` to `backend=`
sampler = Sampler(mode=backend)
sampler.options.default_shots = 10000
# Set simple error suppression/mitigation options
sampler.options.dynamical_decoupling.enable = True
sampler.options.dynamical_decoupling.sequence_type = "XY4"
sampler.options.twirling.enable_gates = True
sampler.options.twirling.num_randomizations = "auto"
pub = (optimized_circuit,)
job = sampler.run([pub], shots=int(1e4))
counts_int = job.result()[0].data.meas.get_int_counts()
counts_bin = job.result()[0].data.meas.get_counts()
shots = sum(counts_int.values())
final_distribution_int = {key: val / shots for key, val in counts_int.items()}
final_distribution_bin = {key: val / shots for key, val in counts_bin.items()}
print(final_distribution_int)
{28: 0.0328, 11: 0.0343, 2: 0.0296, 25: 0.0308, 16: 0.0303, 27: 0.0302, 13: 0.0323, 7: 0.0312, 4: 0.0296, 9: 0.0295, 26: 0.0321, 30: 0.031, 23: 0.0324, 31: 0.0303, 21: 0.0335, 15: 0.0317, 12: 0.0309, 29: 0.0297, 3: 0.0313, 5: 0.0312, 6: 0.0274, 10: 0.0329, 22: 0.0353, 0: 0.0315, 20: 0.0326, 8: 0.0322, 14: 0.0306, 17: 0.0295, 18: 0.0279, 1: 0.0325, 24: 0.0334, 19: 0.0295}
Step 4: 사후 처리 및 원하는 고전적 형식으로 결과 반환
사후 처리 단계는 샘플링 출력을 해석하여 원래 문제의 해를 반환합니다. 이 경우, 가장 높은 확률을 가진 비트스트링이 최적 컷을 결정하기 때문에 해당 비트스트링에 관심이 있습니다. 문제의 대칭성으로 인해 네 가지 가능한 해가 존재하며, 샘플링 과정은 이 중 하나를 약간 더 높은 확률로 반환합니다. 하지만 아래에 표시된 분포에서 네 개의 비트스트링이 나머지보다 확연히 더 높은 확 률을 보이는 것을 확인할 수 있습니다.
# auxiliary functions to sample most likely bitstring
def to_bitstring(integer, num_bits):
result = np.binary_repr(integer, width=num_bits)
return [int(digit) for digit in result]
keys = list(final_distribution_int.keys())
values = list(final_distribution_int.values())
most_likely = keys[np.argmax(np.abs(values))]
most_likely_bitstring = to_bitstring(most_likely, len(graph))
most_likely_bitstring.reverse()
print("Result bitstring:", most_likely_bitstring)
Result bitstring: [0, 1, 1, 0, 1]
matplotlib.rcParams.update({"font.size": 10})
final_bits = final_distribution_bin
values = np.abs(list(final_bits.values()))
top_4_values = sorted(values, reverse=True)[:4]
positions = []
for value in top_4_values:
positions.append(np.where(values == value)[0])
fig = plt.figure(figsize=(11, 6))
ax = fig.add_subplot(1, 1, 1)
plt.xticks(rotation=45)
plt.title("Result Distribution")
plt.xlabel("Bitstrings (reversed)")
plt.ylabel("Probability")
ax.bar(list(final_bits.keys()), list(final_bits.values()), color="tab:grey")
for p in positions:
ax.get_children()[int(p[0])].set_color("tab:purple")
plt.show()
최적 컷 시각화
최적 비트스트링으로부터 원래 그래프에서 이 컷을 시각화할 수 있습니다.
# auxiliary function to plot graphs
def plot_result(G, x):
colors = ["tab:grey" if i == 0 else "tab:purple" for i in x]
pos, _default_axes = rx.spring_layout(G), plt.axes(frameon=True)
rx.visualization.mpl_draw(
G, node_color=colors, node_size=100, alpha=0.8, pos=pos
)
plot_result(graph, most_likely_bitstring)
그리고 컷의 값을 계산합니다:
def evaluate_sample(x: Sequence[int], graph: rx.PyGraph) -> float:
assert len(x) == len(
list(graph.nodes())
), "The length of x must coincide with the number of nodes in the graph."
return sum(
x[u] * (1 - x[v]) + x[v] * (1 - x[u])
for u, v in list(graph.edge_list())
)
cut_value = evaluate_sample(most_likely_bitstring, graph)
print("The value of the cut is:", cut_value)
The value of the cut is: 5
Part II. 규모를 키워봅시다!
IBM Quantum® Platform을 통해 100개 이상의 Qubit을 갖춘 다양한 장치에 접근할 수 있습니다. 100개 노드로 구성된 가중 그래프에서 Max-Cut을 풀기 위한 장치를 선택하세요. 이것은 "유틸리티 규모"의 문제입니다. 워크플로를 구축하는 단계는 위와 동일하지만, 훨씬 더 큰 그래프를 다룹니다.
n = 100 # Number of nodes in graph
graph_100 = rx.PyGraph()
graph_100.add_nodes_from(np.arange(0, n, 1))
elist = []
for edge in backend.coupling_map:
if edge[0] < n and edge[1] < n:
elist.append((edge[0], edge[1], 1.0))
graph_100.add_edges_from(elist)
draw_graph(graph_100, node_size=200, with_labels=True, width=1)
