Quantum Approximate Multi-Objective Optimization
예상 소요 시간: Heron r2 프로세서에서 10분 (참고: 이는 단지 예상치일 뿐입니다.) (실행 시간은 다를 수 있습니다.)
학습 성과
이 튜토리얼은 카디널리티 제약이 있는 포트폴리오 최적화 문제를 다룹니다. 정확히 개의 자산을 보유해야 한다는 제약 조건 하에서, 위험, 수익률 및 분산 투자 목표를 균형 있게 고려하여 최적의 포트폴리오 집합을 구하는 문제를 해결합니다.
이 튜토리얼을 마치면 다음 내용을 이해하게 될 것입니다:
- 낮은 위험, 높은 수익, 적절한 분산 투자라는 세 가지 상충되는 목표를 가진 포트폴리오 선정 문제를 양자 최적화 문제로 어떻게 표현할 수 있는가.
- 단일 QAOA 회로가 일련의 목표 가중치를 따라 스윕되면서, 최적의 절충 포트폴리오로 구성된 파레토 프론트를 어떻게 도출해 내는지.
- XY 믹서가 어떻게 “정확히 K개의 자산을 선택”하는 부분공간 내에서 검색을 유지하여, 페널티 항이 필요 없게 하고, 측정된 비트열을 사후 선택함으로써 실행 가능성을 보장하는지.
- Kotil 등( arXiv:2503.22797 )의 연구에 따라, 정확한 최적화가 불가능할 정도로 규모가 큰 경우 매트릭스-곱-상태 시뮬레이터를 사용하여 회로의 각도를 훈련하는 방법.
전제조건
다음 사항에 대해 미리 숙지해 두시는 것이 좋습니다:
배경
포트폴리오 매니저가 단 하나의 수치만을 최적화하는 경우는 거의 없다. 그들은 수익률은 높고, 위험(그 수익률의 분산)은 낮으며, 포트폴리오가 시장의 특정 부문에 과도하게 노출되지 않도록 종목이 다양한 섹터에 분산되기를 원합니다. 이러한 목표들은 서로 상충됩니다. 수익률이 가장 높은 자산은 대개 변동성도 가장 크며, 한 가지 유망한 분야에 집중하면 포트폴리오의 분산 효과가 떨어집니다.
단 하나뿐인 ‘최고의’ 포트폴리오는 없습니다. 대신 파레토 프론트가 존재합니다. 이는 한 목표를 개선하려면 다른 목표를 포기해야만 하는 포트폴리오들의 집합입니다. 우리의 목표는 그 전선을 명확히 파악하여 의사결정자가 자신이 선호하는 절충안을 선택할 수 있도록 하는 것입니다.
이 문제를 중에서 정확히 개의 자산을 선택하는 문제로 정의한다(각 자산은 포함되거나 제외되며, 자산당 1개의 큐비트가 할당된다). 세 가지 해밀토니안이 세 가지 목표를 나타냅니다. 우리는 이를 심플렉스 위에 위치한 가중치 와 결합하며(이들의 합은 1이 됨), QAOA 샘플러는 각 가중치 선택에 대해 적절한 포트폴리오를 반환합니다. 가중치를 일괄 조정하면 위험, 수익, 분산 투자의 상대적 중요도가 함께 조정되며, 표본으로 추출된 모든 포트폴리오의 합집합은 파레토 전선을 그립니다.
먼저, 무차별 대입 방식으로 검증할 수 있는 8개 자산으로 구성된 작은 예제를 통해 전체 워크플로를 살펴본 다음, 양자 하드웨어에 맞춰 조정된 40개 자산 규모의 인스턴스에 동일한 방법을 적용해 봅니다.
이 튜토리얼은 양자 우위를 보여주는 것보다는 워크플로우(매핑, 각도 학습, 제약 조건 하의 샘플링, 파레토 후처리)를 다루고 있습니다. 이 연구에서 사용한 40개 자산 규모에서는 균일 무작위 표본 추출이 QAOA 샘플러와 거의 비슷한 성능을 보이며, 우리는 그 비교 결과를 명확히 제시한다.
요구사항
이 튜토리얼을 시작하기 전에 다음 항목이 설치되어 있는지 확인하십시오:
- Qiskit SDK v2.0 또는 그 이후 버전으로, 시각화 기능을 지원함
- Qiskit Runtime v0.22 또는 그 이후 (
pip install qiskit-ibm-runtime) - Qiskit Aer (
pip install qiskit-aer) - Qiskit 애드온인 최적화 매퍼 (
pip install qiskit-addon-opt-mapper)와 QAOA 훈련 파이프라인은v0.1.0태그에 고정되어 있습니다:pip install "git+https://github.com/qiskit-community/[email protected]" moocore파레토 프론트 및 하이퍼볼륨 계산용 (pip install moocore)
설정
튜토리얼 전반에 걸쳐 사용되는 라이브러리를 불러오고, 재현성을 위해 난수 시드를 고정합니다.
import numpy as np
import matplotlib.pyplot as plt
from math import comb
from moocore import hypervolume, filter_dominated, is_nondominated
from qiskit import QuantumCircuit
from qiskit.circuit import ParameterVector
from qiskit.circuit.library import qaoa_ansatz
from qiskit.quantum_info import SparsePauliOp
from qiskit.transpiler import generate_preset_pass_manager
from qiskit_aer.primitives import SamplerV2 as AerSampler
from qiskit_addon_opt_mapper.problems import OptimizationProblem
from qaoa_training_pipeline.training import ScipyTrainer
from qaoa_training_pipeline.evaluation import (
StatevectorEvaluator,
MPSAerEvaluator,
)
np.random.seed(42)
sampler = AerSampler(seed=42) # local simulator for the small-scale example
print("Setup complete.")Output:
Setup complete.
소규모 시뮬레이터 예시
먼저 6개 부문에서 선정한 8개의 자산을 바탕으로, 그중 정확히 개를 선정합니다. 자산이 8개뿐이므로 유효한 포트폴리오는 개뿐이므로, 나중에 양자 계산 결과를 전수 탐색 결과를 통해 검증할 수 있습니다.
1단계: 고전적 입력을 양자 문제에 매핑하기
각 자산은 하나의 큐비트이며, 와 같은 비트열은 포트폴리오를 나타냅니다 10110010 ( 1s 는 우리가 보유한 자산들입니다). 세 가지 요소가 필요합니다. 바로 시장 데이터, 세 가지 객관적 해밀토니안, 그리고 자산이 정확히 인 포트폴리오만을 제안하는 회로입니다.
# --- Small-scale universe: 8 assets across 6 sectors ---
tickers = ["AAPL", "XOM", "JPM", "JNJ", "KO", "AMT", "AMZN", "SLB"]
sectors = [
"Tech",
"Energy",
"Finance",
"Health",
"Staples",
"REIT",
"Tech",
"Energy",
]
n_assets = len(tickers)
K = 4 # choose exactly K assets
n_obj = 3 # risk, return, diversification
# Annualized expected returns
mu = np.array([0.28, 0.12, 0.22, 0.05, 0.08, 0.10, 0.32, 0.15])
# Annualized covariance matrix (the "risk" model)
sigma = np.array(
[
[0.070, 0.010, 0.020, 0.008, 0.005, 0.012, 0.045, 0.011],
[0.010, 0.065, 0.015, 0.006, 0.004, 0.008, 0.009, 0.050],
[0.020, 0.015, 0.055, 0.010, 0.007, 0.015, 0.018, 0.014],
[0.008, 0.006, 0.010, 0.030, 0.012, 0.009, 0.007, 0.005],
[0.005, 0.004, 0.007, 0.012, 0.025, 0.006, 0.004, 0.003],
[0.012, 0.008, 0.015, 0.009, 0.006, 0.045, 0.011, 0.007],
[0.045, 0.009, 0.018, 0.007, 0.004, 0.011, 0.085, 0.010],
[0.011, 0.050, 0.014, 0.005, 0.003, 0.007, 0.010, 0.072],
]
)
# Diversification score: number of cross-sector pairs in the portfolio.
# D[i,j] = 0.5 when assets i and j are in different sectors, so x^T D x counts
# the cross-sector pairs. More cross-sector pairs = better diversified.
D = np.array(
[
[1.0 if sectors[i] != sectors[j] else 0.0 for j in range(n_assets)]
for i in range(n_assets)
]
)
np.fill_diagonal(D, 0.0)
D = D / 2
print(f"{n_assets} assets, choose K={K}, {n_obj} objectives")
for t, s, m in zip(tickers, sectors, mu):
print(f" {t:5s} ({s:8s}) expected return {m:5.0%}")Output:
8 assets, choose K=4, 3 objectives
AAPL (Tech ) expected return 28%
XOM (Energy ) expected return 12%
JPM (Finance ) expected return 22%
JNJ (Health ) expected return 5%
KO (Staples ) expected return 8%
AMT (REIT ) expected return 10%
AMZN (Tech ) expected return 32%
SLB (Energy ) expected return 15%
# Each objective becomes a Hamiltonian whose lowest-energy bitstrings are the
# best portfolios for that objective. The opt-mapper turns a plain
# min/max problem over binary variables into the equivalent Ising operator.
def build_risk_hamiltonian(sigma, n):
"""Minimize portfolio variance x^T sigma x (quadratic -> ZZ terms)."""
prob = OptimizationProblem("risk")
prob.binary_var_list(n)
prob.minimize(quadratic=sigma)
op, _ = prob.to_ising()
return op.simplify()
def build_return_hamiltonian(mu, n):
"""Maximize expected return mu . x (linear -> Z terms)."""
prob = OptimizationProblem("return")
prob.binary_var_list(n)
prob.maximize(linear=mu)
op, _ = prob.to_ising()
return op.simplify()
def build_diversity_hamiltonian(D, n):
"""Maximize cross-sector pairs x^T D x (quadratic -> ZZ terms)."""
prob = OptimizationProblem("diversity")
prob.binary_var_list(n)
prob.maximize(quadratic=D)
op, _ = prob.to_ising()
return op.simplify()
H_risk = build_risk_hamiltonian(sigma, n_assets)
H_return = build_return_hamiltonian(mu, n_assets)
H_diversity = build_diversity_hamiltonian(D, n_assets)
cost_ops = [H_risk, H_return, H_diversity]
for name, op in zip(["risk", "return", "diversity"], cost_ops):
print(f"H_{name:10s}: {op.size} Pauli terms")Output:
H_risk : 36 Pauli terms
H_return : 8 Pauli terms
H_diversity : 34 Pauli terms
벌칙 없이 “정확히 K개의 자산”을 강제 적용합니다. 흔히 쓰이는 방법은, 부적절한 크기의 포트폴리오에 페널티를 부과하되 모든 큐비트를 다른 모든 큐비트와 연결하는 페널티 항을 추가하는 것인데, 이로 인해 트랜스파일레이션 후 회로가 훨씬 더 복잡해집니다. 대신 우리는 XY 믹서를 사용하는데, 이 믹서는 QAOA 상태를 항상 동일한 해밍 가중치를 가진 비트열 간에만 이동시킵니다. 개의 자산이 이미 선택된 상태에서 시작하면, 회로가 탐색하는 모든 포트폴리오에도 정확히 개의 자산이 포함됩니다. 이 제약 조건은 페널티 항을 통해 강제되는 것이 아니라 회로 구조 자체에 내재되어 있습니다.
시작 상태를 저렴한 비용으로 준비합니다. 각 큐비트를 회전시켜 확률 로 “on” 상태가 되도록 합니다. -자산 결과로 제한할 경우, 이는 이상적인 등가중(Dicke) 상태를 재현하므로, 정확히 개의 1을 포함하는 측정된 비트열만 유지하면 됩니다. 이를 ‘사후 선별(post-selection) ’이라고 합니다.
def xy_mixer(n):
"""Line XY mixer: couples neighboring qubits with XX+YY. Conserves the number
of selected assets (Hamming weight), so cardinality is preserved automatically.
Using a line (not a full ring) keeps the circuit shallow and hardware-friendly."""
terms = [
(pauli, [i, i + 1], 1) for i in range(n - 1) for pauli in ("XX", "YY")
]
return SparsePauliOp.from_sparse_list(terms, n)
def product_init(n, k):
"""Cheap initial state: each qubit rotated so P(selected) = k/n. Zero two-qubit
gates. Post-selecting its weight-k outcomes reproduces the ideal Dicke state."""
qc = QuantumCircuit(n)
theta = 2 * np.arcsin(np.sqrt(k / n))
for q in range(n):
qc.ry(theta, q)
return qc
# Combine the three objectives with weights c (bound later, at sampling time).
p_layers = 1
c = ParameterVector("c", n_obj)
# Negate the objective so the sampling phase separator matches the sign the angles
# were trained under (the trainer maximizes the negated sum); binding below keeps +gamma.
combined_cost_op = sum(
-c[k] * H_k for k, H_k in enumerate(cost_ops)
).simplify()
ansatz = qaoa_ansatz(
combined_cost_op,
reps=p_layers,
initial_state=product_init(n_assets, K),
mixer_operator=xy_mixer(n_assets),
)
ansatz.measure_all()
betas = [p for p in ansatz.parameters if p.name.startswith("β")]
gammas = [p for p in ansatz.parameters if p.name.startswith("γ")]
print(f"Qubits: {ansatz.num_qubits} | QAOA layers: {p_layers}")
print(
f"Tunable angles: {len(betas)} beta + {len(gammas)} gamma, plus {n_obj} objective weights"
)Output:
Qubits: 8 | QAOA layers: 1
Tunable angles: 1 beta + 1 gamma, plus 3 objective weights
2단계: 양자 하드웨어 실행을 위해 문제를 최적화한다
실행에 앞서, 추상 회로는 하드웨어 전용 게이트로 변환됩니다. 이 정도 작은 규모에서는 비용만 살펴봅니다. 회로의 깊이가 얼마나 되는지, 그리고 2-큐비트 게이트를 몇 개나 사용하는지 말이죠 (2-큐비트 게이트는 실제 장치에서 잡음의 주요 원인입니다.)
# Bind dummy angle values so we can transpile and measure the circuit's size.
dummy = {p: 0.1 for p in ansatz.parameters}
test_pm = generate_preset_pass_manager(optimization_level=1)
test_qc = test_pm.run(ansatz.assign_parameters(dummy))
print(f"Circuit depth : {test_qc.depth()}")
print(
f"Two-qubit gate depth : {test_qc.depth(lambda x: len(x.qubits) > 1)}"
)
print(f"Two-qubit gate count : {test_qc.num_nonlocal_gates()}")Output:
Circuit depth : 25
Two-qubit gate depth : 23
Two-qubit gate count : 42
3단계: Qiskit primitives를 사용하여 실행하기
두 단계. 먼저, 정확한 상태 벡터 시뮬레이터를 사용하여 동일한 목적 함수 가중치를 적용해 QAOA 각도를 한 번 학습시킴으로써, 적절한 값을 구합니다. 그런 다음 심플렉스 전체에 걸쳐 여러 가중치 벡터를 순차적으로 적용하고, 각 지점에서 회로를 표본 추출하여 후보 포트폴리오를 수집합니다. 스윕 간에는 객관적 가중치만 변경되고(훈련된 각도는 변경되지 않으므로), 모든 가중치 벡터는 단일 배치 작업으로 제출됩니다.
# Train the angles with equal objective weights.
# The trainer maximizes energy, so we negate the (to-be-minimized) objective sum.
training_op = sum(-1.0 / n_obj * H_k for H_k in cost_ops).simplify()
# Linear-ramp initialization (Sack and Serbyn, arXiv:2101.05742)
dt = 0.75
grid = np.arange(1, p_layers + 1) - 0.5
init_params = np.concatenate((1 - grid * dt / p_layers, grid * dt / p_layers))
trainer = ScipyTrainer(
StatevectorEvaluator(), minimize_args={"options": {"maxiter": 300}}
)
print("Training QAOA angles (exact statevector)...")
result_train = trainer.train(
cost_op=training_op,
mixer=xy_mixer(n_assets),
initial_state=product_init(n_assets, K),
params0=init_params,
)
opt = result_train["optimized_params"]
opt_betas, opt_gammas = opt[:p_layers], opt[p_layers:]
print(f"Trained beta : {opt_betas}")
print(f"Trained gamma: {opt_gammas}")Output:
Training QAOA angles (exact statevector)...
Trained beta : [3.329186967386619]
Trained gamma: [3.4449804324291033]
def random_uniform_simplex(n_samples, n_obj=3):
"""n_samples weight vectors spread uniformly over the (n_obj-1)-simplex."""
s = np.zeros((n_samples, n_obj + 1))
s[:, 1:-1] = np.random.rand(n_samples, n_obj - 1)
s[:, -1] = 1
s = np.sort(s, axis=1)
return np.diff(s, axis=1)
# Bind the trained angles, leaving the objective weights c free for the sweep.
param_map = {betas[i]: opt_betas[i] for i in range(p_layers)}
param_map.update({gammas[i]: opt_gammas[i] for i in range(p_layers)})
ansatz_bound = ansatz.assign_parameters(param_map)
n_samples, shots = 200, 500
c_vecs = random_uniform_simplex(n_samples, n_obj)
print(f"Sampling {n_samples} weight vectors x {shots} shots...")
result = sampler.run([(ansatz_bound, c_vecs)], shots=shots).result()
# Collect every distinct bitstring seen across all weight vectors.
all_bitstrings = set()
for s in range(n_samples):
for bs in result[0].data.meas.get_counts(s):
# get_counts is little-endian; reverse so bit i = asset i
all_bitstrings.add(bs.replace(" ", "")[::-1])
print(f"Distinct portfolios sampled: {len(all_bitstrings)}")Output:
Sampling 200 weight vectors x 500 shots...
Distinct portfolios sampled: 256
4단계: 후처리를 수행하고 원하는 기존 형식으로 결과를 반환합니다
우리는 실행 가능한 포트폴리오만 선별하고(선별 단계 이후의 자산 중 정확히 개), 각 포트폴리오에 대해 세 가지 목표 모두에 대한 점수를 매긴 다음, 파레토 프론트, 즉 모든 목표에서 동시에 더 나은 성과를 보이는 포트폴리오가 없는 포트폴리오들을 추출합니다. 하이퍼볼륨은 전선이 얼마나 넓은 객관적 공간을 장악하고 있는지를 요약한 단일 수치로, 수치가 클수록 더 좋은 것이다.
단 256개의 비트스트링에 대해 10만 번의 시도를 수행하는 이 실행에서는 70개의 유효한 포트폴리오를 모두 확인하게 되므로, 이 규모에서는 QAOA가 최적을 찾았다는 증거라기보다는 파이프라인이 올바르게 연결되었는지 확인하는 사실상의 무차별 대입 검사라고 볼 수 있다.
def evaluate_portfolio(bitstring, sigma, mu, D):
"""Score one portfolio on all three objectives (all framed as 'bigger is better')."""
x = np.array([int(b) for b in bitstring])
# negative risk, return, diversification (cross-sector pairs)
return np.array([-(x @ sigma @ x), x @ mu, x @ D @ x])
# Post-select feasible portfolios, then score them.
feasible = [bs for bs in all_bitstrings if bs.count("1") == K]
fis = np.array([evaluate_portfolio(bs, sigma, mu, D) for bs in feasible])
pareto_front = filter_dominated(fis, maximise=True)
ref_point = fis.min(axis=0)
qmoo_hv = hypervolume(fis, ref=ref_point, maximise=True)
print(
f"Feasible portfolios found : {len(feasible)} of {comb(n_assets, K)} possible"
)
print(f"Pareto-front portfolios : {len(pareto_front)}")
print(f"Hypervolume : {qmoo_hv:.4f}")Output:
Feasible portfolios found : 70 of 70 possible
Pareto-front portfolios : 26
Hypervolume : 0.2487
fig = plt.figure(figsize=(8, 6))
ax = fig.add_subplot(111, projection="3d")
ax.scatter(
fis[:, 0],
fis[:, 1],
fis[:, 2],
c="lightgray",
s=12,
label="All feasible portfolios",
)
ax.scatter(
pareto_front[:, 0],
pareto_front[:, 1],
pareto_front[:, 2],
c="steelblue",
s=45,
label="Pareto front",
)
ax.set_xlabel("Negative risk")
ax.set_ylabel("Return")
ax.set_zlabel("Diversification")
ax.set_title("Risk / return / diversification Pareto front (8 assets)")
ax.legend()
plt.tight_layout()
plt.show()Output:
대규모 하드웨어 예시
이제 40개의 자산 (8개 섹터 × 5)에 대해 동일한 워크플로를 적용하고, ‘ ’를 선택합니다. 40개의 큐비트는 정확한 시뮬레이션이 불가능할 정도로 규모가 너무 큽니다( -진폭 상태 벡터). 따라서 8개 자산에서와 같은 방식으로 각도를 최적화할 수 없으며, 밀집 회로는 현재 하드웨어로는 처리하기에 너무 복잡합니다. , M인 유효 포트폴리오는 여전히 그 수가 적어 전통적인 방법으로 열거할 수 있으며, 이를 마지막에 정확한 기준점으로 사용합니다. 몇 가지 사항이 변경되지만, 이 메서드의 나머지 부분은 그대로 유지됩니다 :
- 정확한 상태 벡터가 아닌 행렬-곱-상태(MPS) 시뮬레이터를 사용하여 각도를 훈련시키세요. (Kotil 등)의 연구에 따르면, 목표 가중치를 동일한 값으로 고정하고, MPS 시뮬레이터에서 단일 β, γ를 최적화한 뒤, 이를 스윕의 모든 가중치 벡터에 재사용합니다. (우리는 달리는 속도에 맞춰 훈련합니다 — 작은 각도에서 큰 각도로의 전환은 없습니다.)
- 위험 모델을 하드웨어에 맞게 간소화하십시오. 전체 공분산은 780개의 모든 자산 쌍을 서로 연결합니다. 우리는 중요도 기반 QAP 절단 기법을 사용하여 가장 강력하고 경로 비용이 가장 저렴한 결합만 유지하고, 다이버시티 항을 위해 각 섹터를 라이트 링으로 결합합니다. 이를 통해 회로를 하드웨어에 적합한 크기로 유지하면서도 목표의 실용성을 확보할 수 있습니다.
- 코스를 평탄하게 유지하고 정직하게 점수를 매기세요. 라우팅은 확률적이므로, 여러 시드를 사용하여 트랜스파일링한 뒤 가장 얕은 트랜스파일링 결과를 선택합니다(이 과정에서 양자 시간이 소모되지 않습니다). 포트폴리오는 항상 실제의 완전한 평가 기준에 따라 채점 됩니다. 스파시피케이션은 회로의 형태만을 결정할 뿐, 포트폴리오를 평가하는 방식에는 영향을 미치지 않습니다.
각 자산의 결합 중 크기만 기준으로 가장 큰 것들만 유지하면, 회로는 스파스해지지만 배선하기에는 여전히 까다로워질 수 있습니다. 반면 QAP 축약 기법은 칩 상에서 크기도 크고 물리적으로도 가까운 커플링을 그대로 유지하므로, 동일한 게이트 할당량으로 더 얕고 하드웨어 구현에 더 유리한 회로를 구현할 수 있습니다.
1단계: 입력 데이터 매핑 (하드웨어에 최적화하기 위해 스파스화됨)
import csv
import urllib.request
# Download the committed market-data snapshot from the repo.
# --- 40-asset universe: 8 GICS sectors x 5 tickers (real market data) ---
# Load the committed market-data snapshot (real annualized returns and covariance).
# Values are stored at the precision used to train the shipped QAOA angles
# (mu: 3 dp, sigma: 4 dp), so the pre-trained parameters in instances/ stay exactly valid.
url = "https://raw.githubusercontent.com/Qiskit/documentation/main/datasets/tutorials/qmoo/market_data.csv"
urllib.request.urlretrieve(url, "market_data.csv")
with open("market_data.csv", newline="") as _f:
_rows = list(csv.reader(_f))
# Covariance column order
_tickers_csv = _rows[0][2:]
# Asset tickers
tickers_40 = [r[0] for r in _rows[1:]]
# Annualized expected returns
mu_40 = np.array([float(r[1]) for r in _rows[1:]])
# Covariance (risk model)
sigma_40 = np.array([[float(v) for v in r[2:]] for r in _rows[1:]])
sectors_40 = [
"Tech",
"Tech",
"Tech",
"Tech",
"Tech",
"Energy",
"Energy",
"Energy",
"Energy",
"Energy",
"Finance",
"Finance",
"Finance",
"Finance",
"Finance",
"Health",
"Health",
"Health",
"Health",
"Health",
"Staples",
"Staples",
"Staples",
"Staples",
"Staples",
"Industrials",
"Industrials",
"Industrials",
"Industrials",
"Industrials",
"Utilities",
"Utilities",
"Utilities",
"Utilities",
"Utilities",
"REIT",
"REIT",
"REIT",
"REIT",
"REIT",
]
n_assets_40 = len(tickers_40)
K_40 = 6 # choose exactly K assets
sector_names_40 = list(dict.fromkeys(sectors_40))
sect_idx_40 = np.array([sector_names_40.index(s) for s in sectors_40])
# True cross-sector diversification matrix (used for scoring)
D_40 = np.array(
[
[
0.5 if sectors_40[i] != sectors_40[j] else 0.0
for j in range(n_assets_40)
]
for i in range(n_assets_40)
]
)
np.fill_diagonal(D_40, 0.0)
print(
f"{n_assets_40} assets, {len(sector_names_40)} sectors, choose K={K_40}"
)Output:
40 assets, 8 sectors, choose K=6
# Sparsify the covariance so the risk circuit fits on hardware. A small diagonal
# shift (added after truncation) keeps the risk model positive semidefinite; at fixed
# K it adds the same constant to every portfolio, so it never changes the ranking.
# Importance-aware QAP truncation (Kotil et al. style): place the qubits on a line
# and use a Quadratic Assignment Problem to choose the layout that keeps the
# strongest covariance couplings within routing distance k of the swap network,
# then drop the rest. Unlike a fixed top-k cap, it keeps couplings that are both
# large AND cheap to route.
from scipy.optimize import quadratic_assignment as qap
from qiskit.transpiler.passes.routing.commuting_2q_gate_routing import (
SwapStrategy,
)
# Truncation level: larger k keeps more couplings (deeper circuit)
k_truncate = 2
_dist = np.array(
SwapStrategy.from_line(list(range(n_assets_40))).distance_matrix
)
def qap_truncate(Q, k):
w = np.abs(Q.copy())
np.fill_diagonal(w, 0.0)
mask = (_dist <= k).astype(float)
# Seed the QAP solver explicitly (by default it draws from NumPy's global
# RNG, which SciPy is deprecating) so the truncation is reproducible.
perm = qap(-w, mask, options={"rng": np.random.default_rng(42)}).col_ind
keep = mask[np.ix_(perm, perm)]
Qt = Q * keep
np.fill_diagonal(Qt, np.diag(Q))
return Qt
sigma_sparse = qap_truncate(sigma_40, k_truncate)
print(
f"QAP truncation (k={k_truncate}): risk edges kept = "
f"{(np.count_nonzero(sigma_sparse) - n_assets_40) // 2}"
)
ridge = max(0.0, -np.linalg.eigvalsh(sigma_sparse)[0]) + 1e-6
sigma_sparse = sigma_sparse + ridge * np.eye(n_assets_40)
# Diversity: couple each sector's assets in a ring (sparse stand-in for the
# same-sector pair count). Scoring still uses the true cross-sector matrix D_40.
def build_same_sector_hamiltonian(D_same, n):
prob = OptimizationProblem("diversity_sparse")
prob.binary_var_list(n)
prob.minimize(quadratic=D_same)
op, _ = prob.to_ising()
return op.simplify()
D_ring = np.zeros((n_assets_40, n_assets_40))
for s in set(sect_idx_40):
members = np.where(sect_idx_40 == s)[0]
for k in range(len(members)):
i, j = members[k], members[(k + 1) % len(members)]
D_ring[i, j] = D_ring[j, i] = 0.5
H_risk_40 = build_risk_hamiltonian(sigma_sparse, n_assets_40)
H_return_40 = build_return_hamiltonian(mu_40, n_assets_40)
H_diversity_40 = build_same_sector_hamiltonian(D_ring, n_assets_40)
cost_ops_40 = [H_risk_40, H_return_40, H_diversity_40]
n_zz = sum(
1 for p in sum(cost_ops_40).simplify().paulis if str(p).count("Z") == 2
)
print(
f"Cost-layer interactions: {n_zz} (dense would be {n_assets_40*(n_assets_40-1)//2})"
)Output:
QAP truncation (k=2): risk edges kept = 78
Cost-layer interactions: 102 (dense would be 780)
2~3단계: 각도를 훈련시킨 다음, 하드웨어 작업을 빌드하고 제출합니다
40 큐비트는 각도를 정확히 최적화하기에는 너무 많기 때문에, 목표 함수 가중치를 동일하게 설정하여 행렬곱 상태 시뮬레이터에서 모델을 하나 훈련시킨 후, 이 모델을 전체 스윕 과정에서 재사용합니다. 아래 셀은 파일에서 사전 학습된 값을 불러옵니다. 훈련된 비용 계층의 각도가 작기 때문에 ( ), 회로는 급격한 투영보다는 완만한 바이어스를 적용합니다.
import json
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2
# Pre-trained angles loaded from a file (training is slow; QDC pattern).
# Set load_params_file = False to retrain in-notebook.
load_params_file = True
params_url = "https://raw.githubusercontent.com/Qiskit/documentation/main/datasets/tutorials/qmoo/qaoa_params.json"
params_path = "qaoa_params.json"
if load_params_file:
urllib.request.urlretrieve(params_url, params_path)
qaoa_params = json.load(open(params_path))
p_layers_hw = qaoa_params["p_layers"]
opt_betas_40, opt_gammas_40 = qaoa_params["betas"], qaoa_params["gammas"]
else:
# Same workflow as the small-scale example: qaoa_training_pipeline's MPSAerEvaluator
# evaluates the QAOA energy on Aer's MPS simulator and supports the XY mixer.
# As in the small-scale example the trainer maximizes energy, so we negate the
# (to-be-minimized) objective sum; the 1/n_obj scaling matches the equal-weight
# point of the sweep, so the trained gamma transfers directly to the weighted circuits.
p_layers_hw = 1
training_op_40 = sum(-1.0 / n_obj * H_k for H_k in cost_ops_40).simplify()
dt = 0.75
grid = np.arange(1, p_layers_hw + 1) - 0.5
init_params_40 = np.concatenate(
(1 - grid * dt / p_layers_hw, grid * dt / p_layers_hw)
)
trainer_40 = ScipyTrainer(
MPSAerEvaluator({"matrix_product_state_max_bond_dimension": 24}),
minimize_args={"options": {"maxiter": 80}},
)
print("Training QAOA angles (MPS simulator)...")
result_train_40 = trainer_40.train(
cost_op=training_op_40,
mixer=xy_mixer(n_assets_40),
initial_state=product_init(n_assets_40, K_40),
params0=init_params_40,
)
opt_40 = result_train_40["optimized_params"]
opt_betas_40, opt_gammas_40 = (
list(opt_40[:p_layers_hw]),
list(opt_40[p_layers_hw:]),
)
import os
os.makedirs(os.path.dirname(params_path) or ".", exist_ok=True)
json.dump(
{
"p_layers": p_layers_hw,
"betas": opt_betas_40,
"gammas": opt_gammas_40,
},
open(params_path, "w"),
indent=2,
)
print(
f"Trained angles saved to {params_path} (set load_params_file=True to reuse)."
)
c40 = ParameterVector("c", n_obj)
# Negated to match the trained angles' sign, as in the small-scale cell; binding keeps +gamma.
combined_cost_op_40 = sum(
-c40[k] * H_k for k, H_k in enumerate(cost_ops_40)
).simplify()
qc_40 = qaoa_ansatz(
combined_cost_op_40,
reps=p_layers_hw,
initial_state=product_init(n_assets_40, K_40),
mixer_operator=xy_mixer(n_assets_40),
)
qc_40.measure_all()
b40 = [p for p in qc_40.parameters if p.name.startswith("β")]
g40 = [p for p in qc_40.parameters if p.name.startswith("γ")]
pmap = {b40[i]: opt_betas_40[i] for i in range(p_layers_hw)}
pmap.update({g40[i]: opt_gammas_40[i] for i in range(p_layers_hw)})
ansatz_qc_40 = qc_40.assign_parameters(pmap)
service = QiskitRuntimeService()
# only use Heron devices
backend = service.least_busy(min_num_qubits=156)
# SABRE routing is stochastic: different seeds give different depths. Transpilation
# is classical (it costs no QPU time), so we transpile many seeds and keep only the
# shallowest circuit -- a free reduction in two-qubit depth before anything is sent
# to hardware. Only this single best circuit is ever executed.
n_seeds = 24
best = None
depths = []
for seed in range(n_seeds):
pm = generate_preset_pass_manager(
optimization_level=3, backend=backend, seed_transpiler=seed
)
qc = pm.run(ansatz_qc_40)
d2 = qc.depth(lambda x: len(x.qubits) > 1)
depths.append(d2)
if best is None or d2 < best[0]:
best = (d2, seed, qc)
isa_qc = best[2]
sd = sorted(depths)
print(
f"Backend: {backend.name} | {n_seeds} seeds | two-qubit depth "
f"best/median/worst = {sd[0]}/{sd[len(sd)//2]}/{sd[-1]} (best seed {best[1]})"
)
print(
f"Selected circuit -> two-qubit gates: {isa_qc.num_nonlocal_gates()}, "
f"two-qubit depth: {isa_qc.depth(lambda x: len(x.qubits) > 1)}"
)Output:
Backend: ibm_kingston | 24 seeds | two-qubit depth best/median/worst = 220/261/300 (best seed 22)
Selected circuit -> two-qubit gates: 787, two-qubit depth: 220
# Submit one batched job (job mode; a single batch needs no Session).
# Extra shots: noise lowers the post-selection yield
n_samples_40, shots_40 = 24, 1500
c_vecs_40 = random_uniform_simplex(n_samples_40, n_obj)
sampler_hw = SamplerV2(mode=backend)
# Tag hardware jobs for tracking
sampler_hw.options.environment.job_tags = ["TUT_QAMOO"]
# Seconds; guard against runaway jobs
sampler_hw.options.max_execution_time = 600
# The QAOA angles are already bound; only the objective weights c remain free.
# Assign each weight vector to get one concrete circuit per point on the simplex.
bound_circuits_40 = [
isa_qc.assign_parameters({c40[k]: cv[k] for k in range(n_obj)})
for cv in c_vecs_40
]
job_hw = sampler_hw.run([(qc,) for qc in bound_circuits_40], shots=shots_40)
print(
f"Submitted to {backend.name}: job id {job_hw.job_id()} ({len(bound_circuits_40)} circuits)"
)Output:
Submitted to ibm_kingston: job id darcaalvr3kc73einokg (24 circuits)
4단계: 파레토 프론트로 후처리를 수행하고 최적 포트폴리오를 도출한다
result_hw = job_hw.result()
# Post-select feasible portfolios (exactly K assets), score on the TRUE objectives.
feasible_40 = set()
for s in range(n_samples_40):
for bs in result_hw[s].data.meas.get_counts():
# get_counts is little-endian; reverse so bit i = asset i
bs = bs.replace(" ", "")[::-1]
if bs.count("1") == K_40:
feasible_40.add(bs)
def score_40(P):
"""Score portfolios given as an (n, K) array of asset indices.
Returns an (n, 3) array of [negative risk, return, diversification], all framed
as 'bigger is better'. This is the single definition of the true objectives,
used for the hardware samples, the exact enumeration, and the random baseline.
Looping over the K x K index pairs keeps memory O(n) even for millions of rows.
"""
risk = np.zeros(len(P))
div = np.zeros(len(P))
for a in range(P.shape[1]):
for b in range(P.shape[1]):
risk += sigma_40[P[:, a], P[:, b]]
div += D_40[P[:, a], P[:, b]]
return np.column_stack([-risk, mu_40[P].sum(1), div])
def evaluate_40(bs):
"""Score one bitstring (bit i = asset i) with score_40."""
idx = np.flatnonzero([b == "1" for b in bs])
return score_40(idx[None, :])[0]
fis_40 = np.array([evaluate_40(bs) for bs in feasible_40])
pareto_40 = filter_dominated(fis_40, maximise=True)
print(f"Feasible portfolios collected : {len(feasible_40)}")
print(f"Pareto-front portfolios : {len(pareto_40)}")Output:
Feasible portfolios collected : 2359
Pareto-front portfolios : 20
# --- Honest benchmark: QAOA and random vs the EXACT Pareto front ---
# 40 choose 6 = 3,838,380 feasible portfolios -- few enough to enumerate exactly, score
# every one on the TRUE objectives, and get the exact Pareto front. That front is an
# absolute ceiling, and its feasible nadir is a FIXED hypervolume reference point, so the
# numbers are comparable across runs instead of depending on what happened to be sampled.
import itertools
# Stream the combinations straight into an (n, K) index array, without first
# building millions of Python tuples.
combos = np.fromiter(
itertools.chain.from_iterable(
itertools.combinations(range(n_assets_40), K_40)
),
dtype=np.int16,
).reshape(-1, K_40)
fis_exact = score_40(combos)
front_exact = filter_dominated(fis_exact, maximise=True)
# Fixed reference = worst value of each objective over ALL feasible portfolios (the nadir).
ref_fixed = fis_exact.min(axis=0)
# The hypervolume of a point set equals the hypervolume of its front.
hv_ceiling = hypervolume(front_exact, ref=ref_fixed, maximise=True)
hv_qaoa = hypervolume(fis_40, ref=ref_fixed, maximise=True)
def random_feasible_hv(n_draw, seed):
"""Hypervolume of n_draw uniformly-random feasible portfolios, same fixed reference."""
rng = np.random.default_rng(seed)
picks = set()
while len(picks) < n_draw:
picks.add(tuple(sorted(rng.choice(n_assets_40, K_40, replace=False))))
P = np.array(list(picks))
return hypervolume(score_40(P), ref=ref_fixed, maximise=True)
hv_rand = np.array(
[random_feasible_hv(len(feasible_40), seed) for seed in range(20)]
)
print(f"Exact Pareto front : {len(front_exact)} portfolios")
print(f"Hypervolume ceiling (optimum) : {hv_ceiling:.3f}")
print(
f"QAOA (hardware) : {100 * hv_qaoa / hv_ceiling:5.1f}% of optimum"
)
print(
f"Random ({len(feasible_40)} draws, 20 seeds) : "
f"{100 * hv_rand.mean() / hv_ceiling:5.1f}% +/- {100 * hv_rand.std() / hv_ceiling:.1f}% of optimum"
)Output:
Exact Pareto front : 61 portfolios
Hypervolume ceiling (optimum) : 64.211
QAOA (hardware) : 85.5% of optimum
Random (2359 draws, 20 seeds) : 84.4% +/- 1.3% of optimum
이 정도의 문제 규모에서는 QAOA의 성능이 균일 무작위 표본 추출과 거의 비슷한 수준입니다. 두 방법 모두 정확한 최적해의 하이퍼볼륨 중 상당 부분을 회복하며, 어느 쪽이 더 우월하다고 단정할 수는 없다. 이 결과는 노이즈가 많은 하드웨어 환경에서, 비용 연산자가 크게 잘린 단일 얕은 QAOA 층에서 예상되는 바입니다. 이 예시의 가치는 양자 가속도가 아니라, 종단 간 다목적 워크플로우(매핑, 각도 훈련, 제약 조건이 적용된 샘플링, 파레토 사후 처리)에 있습니다. 이 격차를 최적치에 가깝게 줄이려면 회로 깊이를 늘리거나(QAOA 층을 더 추가하거나), 절단 방식을 더 완만하게 하거나, 노이즈가 더 적은 하드웨어를 사용해야 할 것입니다.
# The best trade-offs found by the sampler: no other sampled portfolio beats these
# on every objective. They approximate the exact front computed above; a
# decision-maker picks the trade-off they prefer.
bs_list = list(feasible_40)
# Boolean mask over fis_40; keep_weakly=True also keeps portfolios whose
# objective values tie with a front point (what the strict filter would drop).
mask = is_nondominated(fis_40, maximise=True, keep_weakly=True)
front_bs = [b for b, m in zip(bs_list, mask) if m]
front_f = fis_40[mask]
order = np.argsort(-front_f[:, 1]) # show a span sorted by return
print(
f"{mask.sum()} non-dominated sampled portfolios. A representative span:\n"
)
print(
f"{'tickers held':40s} {'risk':>7s} {'return':>7s} {'cross-sector':>12s}"
)
for idx in order[:: max(1, len(order) // 12)]:
held = [tickers_40[i] for i, b in enumerate(front_bs[idx]) if b == "1"]
print(
f"{', '.join(held):40s} {-front_f[idx,0]:7.3f} {front_f[idx,1]:7.2f} {int(front_f[idx,2]):12d}"
)
fig = plt.figure(figsize=(8, 6))
ax = fig.add_subplot(111, projection="3d")
ax.scatter(
fis_40[:, 0],
fis_40[:, 1],
fis_40[:, 2],
c="lightgray",
s=8,
label="Sampled portfolios",
)
ax.scatter(
pareto_40[:, 0],
pareto_40[:, 1],
pareto_40[:, 2],
c="tomato",
marker="D",
s=40,
label="Pareto front",
)
ax.set_xlabel("Negative risk")
ax.set_ylabel("Return")
ax.set_zlabel("Diversification")
ax.set_title("40-asset Pareto front (quantum hardware)")
ax.legend()
plt.tight_layout()
plt.show()Output:
20 non-dominated sampled portfolios. A representative span:
tickers held risk return cross-sector
AAPL, NVDA, XOM, GS, BLK, CAT 1.715 2.22 13
NVDA, GS, MS, PFE, CAT, PLD 1.772 2.20 14
NVDA, MS, PFE, WMT, CAT, DUK 1.040 2.14 15
NVDA, CVX, GS, JNJ, KO, WMT 0.737 2.01 14
NVDA, COP, GS, JNJ, WMT, EQIX 1.014 1.98 15
NVDA, ABT, WMT, CAT, AEP, EQIX 0.913 1.92 15
NVDA, CVX, WMT, RTX, D, EQIX 0.835 1.82 15
NVDA, XOM, GS, JNJ, DUK, EQIX 0.763 1.80 15
AAPL, NVDA, JNJ, KO, RTX, SO 0.621 1.71 14
NVDA, CVX, BLK, JNJ, RTX, AEP 0.719 1.71 15
NVDA, JNJ, KO, COST, RTX, AEP 0.545 1.69 14
NVDA, CVX, ABT, WMT, HON, AEP 0.702 1.43 15
AAPL, GS, JNJ, PEP, AEP, EQIX 0.645 1.34 15
MSFT, XOM, BLK, JNJ, CAT, SO 0.617 1.34 15
MSFT, KO, WMT, RTX, SO, SPG 0.526 1.29 14
MSFT, XOM, BLK, JNJ, WMT, DUK 0.483 1.25 15
MSFT, CVX, JNJ, RTX, DUK, D 0.482 1.02 14
MSFT, XOM, JNJ, KO, HON, EQIX 0.479 0.93 15
MSFT, JNJ, PG, KO, RTX, DUK 0.423 0.86 14
MSFT, XOM, JNJ, PEP, DUK, AMT 0.476 0.57 15
다음 단계
이 튜토리얼이 흥미로웠다면, 다음 내용을 참고해 보세요:
- 다운로드한 시장 데이터(
market_data.csv)를 실제 가격 시계열에서 도출한 귀하의 수익률 및 공분산 추정치로 대체하십시오. - QAOA 레이어 수를 늘리거나, 12~16개의 어셋으로 훈련한 후 해당 각도를 전이시켜 하드웨어 성능을 최적치에 더 가깝게 끌어올리십시오.
- Kotil 등, ‘양자 근사 다목적 최적화 (Quantum Approximate Multi-Objective Optimization, Nature Computational Science, 2025)’는 본 튜토리얼에서 포트폴리오에 적용한 맥스컷(max-cut) 연구이다.
참조
- Kotil 외, "양자 근사 다목적 최적화," 《 네이처 컴퓨테이셔널 사이언스 》(2025). arXiv:2503.22797
- S. H. Sack과 M. Serbyn, “양자 근사 최적화 알고리즘의 양자 어닐링 초기화,” Quantum 5, 491 (2021). arXiv:2101.05742