Sari la conținutul principal

Optimizare cuantică aproximativă multi-obiectiv

Estimare de utilizare: 10 minute pe un procesor Heron r2 (NOTĂ: Aceasta este doar o estimare. Timpul tău de execuție poate varia.)

Rezultatele învățării​

Acest tutorial rezolvă o problemă de optimizare de portofoliu cu cardinalitate constrânsă: având constrângerea de a deține exact KK active, echilibrează obiectivele de risc, randament și diversificare pentru a găsi setul de portofolii optime.

După parcurgerea acestui tutorial, te poți aștepta să înțelegi:

  • Cum se exprimă o problemă de selecție de portofoliu cu trei obiective concurente — risc scăzut, randament ridicat și diversificare bună — ca problemă de optimizare cuantică.

  • Cum un singur circuit QAOA, parcurs peste un set de ponderi ale obiectivelor, trasează un front Pareto de portofolii optime de compromis.

  • Cum un mixer XY păstrează căutarea în interiorul subspațiului „alege exact K active", astfel încât nu este necesar niciun termen de penalizare, iar fezabilitatea este asigurată prin postselecția șirurilor de biți măsurate.

  • Cum se antrenează unghiurile circuitului cu un simulator de tip matrix-product-state la o scară prea mare pentru a fi optimizată exact, urmând Kotil et al. (arXiv:2503.22797).

Cerințe preliminare​

Este recomandat să fii familiarizat cu:

  • Fluxul de lucru Qiskit patterns (mapare, optimizare, execuție, postprocesare).

  • Noțiunile de bază despre QAOA.

Context​

Un manager de portofoliu optimizează rareori un singur număr. Vrea ca randamentele să fie ridicate, riscul (varianța acelor randamente) să fie scăzut, iar deținerile să fie răspândite pe sectoare, astfel încât portofoliul să nu fie supraexpus într-o singură parte a pieței. Aceste obiective se contrazic: activele cu cel mai mare randament sunt adesea cele mai volatile, iar concentrarea într-un singur sector aflat la modă dăunează diversificării.

Nu există un singur portofoliu „cel mai bun". În schimb există un front Pareto: setul de portofolii pentru care nu poți îmbunătăți un obiectiv fără a renunța la altul. Scopul nostru este să trasăm acest front, astfel încât cel care decide să poată alege compromisul preferat.

Formulăm problema ca alegerea a exact KK active din NN (fiecare activ este fie inclus, fie exclus — un qubit pe activ). Trei Hamiltonieni codifică cele trei obiective. Îi combinăm cu ponderi cc care se află pe un simplex (suma lor este unu), iar un QAOA sampler returnează portofolii bune pentru fiecare alegere a ponderilor. Parcurgerea ponderilor parcurge importanța relativă a riscului față de randament față de diversificare, iar reuniunea tuturor portofoliilor eșantionate trasează frontul Pareto.

Parcurgem mai întâi întregul flux de lucru pe un exemplu mic de opt active, pe care îl putem verifica prin forță brută, apoi rulăm aceeași metodă pe o instanță de 40 de active, dimensionată pentru hardware cuantic.

Acest tutorial predă fluxul de lucru (mapare, antrenarea unghiurilor, eșantionare constrânsă și postprocesare Pareto), nu demonstrează avantaj cuantic. La scara de 40 de active folosită aici, eșantionarea aleatoare uniformă se comportă aproximativ la fel de bine ca QAOA sampler, iar această comparație o arătăm explicit.

Cerințe​

Înainte de a începe acest tutorial, asigură-te că ai instalate următoarele:

  • Qiskit SDK v2.0 sau mai nou, cu suport pentru vizualizare

  • Qiskit Runtime v0.22 sau mai nou (pip install qiskit-ibm-runtime)

  • Qiskit Aer (pip install qiskit-aer)

  • Addon-ul Qiskit Optimization mapper (pip install qiskit-addon-opt-mapper) și QAOA training pipeline, fixat la tag-ul v0.1.0: pip install "git+https://github.com/qiskit-community/qaoa_training_pipeline.git@v0.1.0"

  • moocore pentru calculele frontului Pareto și ale hipervolumului (pip install moocore)

Configurare​

Importă bibliotecile folosite pe parcursul tutorialului și fixează un seed aleator pentru reproductibilitate.

# Added by doQumentation — installs the packages this notebook needs if they are missing
import importlib.util

_needed = {"matplotlib": "matplotlib", "moocore": "moocore", "numpy": "numpy", "qaoa_training_pipeline": "qaoa-training-pipeline", "qiskit": "qiskit", "qiskit_addon_opt_mapper": "qiskit-addon-opt-mapper", "qiskit_aer": "qiskit-aer", "qiskit_ibm_runtime": "qiskit-ibm-runtime", "scipy": "scipy"}
_missing = [pip for module, pip in _needed.items()
if importlib.util.find_spec(module) is None]
# One at a time, so a package that fails to install does not block the others
for _pip in _missing:
%pip install -q {_pip}
if not _missing:
print("\u2713 All required packages are installed")
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.")
Setup complete.

Exemplu la scară mică pe simulator​

Începem cu opt active din șase sectoare și selectăm exact K=4K=4 dintre ele. Cu doar opt active există doar (84)=70\binom{8}{4}=70 portofolii valide, așa că putem verifica ulterior rezultatul cuantic printr-o căutare exhaustivă.

Pasul 1: Mapează intrările clasice într-o problemă cuantică​

Fiecare activ este un qubit; un șir de biți precum 10110010 este un portofoliu (cifrele 1 sunt activele pe care le deținem). Avem nevoie de trei ingrediente: datele de piață, cei trei Hamiltonieni ai obiectivelor și un circuit care propune întotdeauna doar portofolii cu exact KK active.

# --- 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%}")
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")
H_risk : 36 Pauli terms
H_return : 8 Pauli terms
H_diversity : 34 Pauli terms

Impunerea „exact K active" fără penalizare. Un truc obișnuit este adăugarea unui termen de penalizare care pedepsește portofoliile de dimensiune greșită, dar acesta cuplează fiecare qubit cu fiecare alt qubit, ceea ce duce la circuite mult mai adânci după transpilare. În schimb folosim un mixer XY, care mută starea QAOA doar între șiruri de biți cu aceeași pondere Hamming. Dacă pornim dintr-o stare care are deja KK active selectate, fiecare portofoliu explorat de circuit are de asemenea exact KK active. Constrângerea este încorporată în structura circuitului, în loc să fie impusă printr-un termen de penalizare.

Pregătim starea inițială ieftin: rotim fiecare qubit astfel încât să fie „pornit" cu probabilitatea K/NK/N. Restricționată la rezultatele cu KK active, aceasta reproduce starea ideală cu ponderi egale (Dicke), așa că păstrăm pur și simplu șirurile de biți măsurate care au exact KK de 1 — un pas numit postselecție.

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"
)
Qubits: 8 | QAOA layers: 1
Tunable angles: 1 beta + 1 gamma, plus 3 objective weights

Pasul 2: Optimizează problema pentru execuția pe hardware cuantic​

Înainte de rulare, circuitul abstract este transpilat în porți native hardware. La această scară mică inspectăm doar costul: cât de adânc este circuitul și câte porți cu doi qubiți folosește? (Porțile cu doi qubiți sunt principala sursă de zgomot pe dispozitivele reale.)

# 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()}")
Circuit depth : 25
Two-qubit gate depth : 23
Two-qubit gate count : 42

Pasul 3: Execută folosind primitivele Qiskit​

Două etape. Mai întâi antrenăm o dată unghiurile QAOA, folosind ponderi egale ale obiectivelor, cu un simulator exact de statevector, pentru a găsi valori bune pentru β,γ\beta,\gamma. Apoi parcurgem mulți vectori de ponderi pe simplex și eșantionăm circuitul pentru fiecare, colectând portofolii candidate. Deoarece între parcurgeri se schimbă doar ponderile obiectivelor (nu și unghiurile antrenate), toți vectorii de ponderi sunt trimiși într-un singur job în batch.

# 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}")
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)}")
Sampling 200 weight vectors x 500 shots...
Distinct portfolios sampled: 256

Pasul 4: Postprocesează și returnează rezultatul în formatul clasic dorit​

Păstrăm doar portofoliile fezabile (exact KK active din pasul de postselecție), evaluăm fiecare portofoliu pe toate cele trei obiective și extragem frontul Pareto: portofoliile care nu sunt învinse simultan pe toate obiectivele. Hipervolumul este un singur număr care rezumă cât din spațiul obiectivelor domină frontul, iar mai mare este mai bine.

Cu 100.000 de shoturi pe doar 256 de șiruri de biți, această rulare vede toate cele 70 de portofolii valide, deci la această dimensiune este efectiv o verificare prin forță brută că pipeline-ul este conectat corect, nu o dovadă că QAOA a găsit frontul.

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}")
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 of the previous code cell

Exemplu la scară mare pe hardware​

Acum același flux de lucru pe 40 de active (8 sectoare × 5), alegând K=6K=6. Patruzeci de qubiți sunt prea mulți pentru a fi simulați exact (un statevector cu 2402^{40} amplitudini), așa că unghiurile nu pot fi optimizate ca în cazul celor opt active, iar un circuit dens ar fi prea adânc pentru hardware-ul actual. Cele (406)≈3.8\binom{40}{6} \approx 3.8M portofolii valide sunt totuși suficient de puține pentru a fi enumerate clasic, ceea ce folosim la final ca benchmark exact. Mai multe lucruri se schimbă, iar nimic altceva din metodă nu se schimbă:

  1. Antrenează unghiurile cu un simulator matrix-product-state (MPS), nu cu statevector exact. Urmând referința (Kotil et al.), fixăm ponderile obiectivelor la valori egale, optimizăm un singur β, γ pe simulatorul MPS și le reutilizăm pentru fiecare vector de ponderi din parcurgere. (Antrenăm la dimensiunea la care rulăm — fără transfer de unghiuri de la mic la mare.)

  2. Rărește modelul de risc pentru a se potrivi hardware-ului. O covarianță completă cuplează toate cele 780 de perechi de active. Păstrăm doar cuplajele cele mai puternice și cele mai ieftin de rutat, folosind trunchierea QAP conștientă de importanță, și cuplăm fiecare sector într-un inel ușor pentru termenul de diversitate. Astfel obiectivele rămân semnificative, iar circuitul se menține la o dimensiune prietenoasă cu hardware-ul.

  3. Păstrează circuitul puțin adânc și evaluează onest. Rutarea este stocastică, așa că transpilăm cu mai multe seed-uri și păstrăm cel mai puțin adânc circuit (și nu se consumă timp cuantic). Portofoliile sunt întotdeauna evaluate în raport cu obiectivele complete, adevărate. Rărirea modelează doar circuitul, nu felul în care sunt judecate portofoliile.

De ce trunchiere QAP?

Păstrarea celor mai mari cuplaje ale fiecărui activ doar după mărime poate duce la un circuit rar, dar totuși incomod de rutat. Trunchierea QAP păstrează în schimb cuplajele care sunt și mari, și apropiate fizic pe cip, astfel încât același buget de porți cumpără un circuit mai puțin adânc și mai prietenos cu hardware-ul.

Pasul 1: Mapează intrările (rărite pentru hardware)​

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}"
)
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})"
)
QAP truncation (k=2): risk edges kept = 78
Cost-layer interactions: 102 (dense would be 780)

Pașii 2-3: antrenează unghiurile, apoi construiește și trimite jobul pe hardware​

Patruzeci de qubiți sunt prea mulți pentru a optimiza unghiurile exact, așa că antrenăm un singur β,γ\beta, \gamma pe un simulator matrix-product-state cu ponderi egale ale obiectivelor și le reutilizăm în toată parcurgerea. Celula de mai jos încarcă valorile pre-antrenate dintr-un fișier. Unghiul antrenat al stratului de cost este mic (γ≈0.32\gamma \approx 0.32), astfel încât circuitul aplică o înclinare ușoară, nu o proiecție abruptă.

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)}"
)
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)"
)
Submitted to ibm_kingston: job id darcaalvr3kc73einokg (24 circuits)

Pasul 4: Postprocesează în frontul Pareto și citește portofoliile optime​

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)}")
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"
)
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

La această dimensiune a problemei, QAOA se comportă aproximativ la fel de bine ca eșantionarea aleatoare uniformă. Ambele recuperează o fracție mare din hipervolumul optimului exact, iar niciuna nu este clar înainte. Acest rezultat este de așteptat pentru un singur strat QAOA puțin adânc, cu un operator de cost puternic trunchiat, pe hardware cu zgomot; valoarea acestui exemplu este fluxul de lucru multi-obiectiv de la cap la coadă (mapare, antrenarea unghiurilor, eșantionare constrânsă și postprocesare Pareto), nu o accelerare cuantică. Reducerea diferenței față de optim ar necesita circuite mai adânci (mai multe straturi QAOA), o trunchiere mai blândă sau hardware cu zgomot mai mic.

# 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()
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

Output of the previous code cell

Pașii următori​

Recomandări

Dacă ți s-a părut interesant acest tutorial, ia în considerare următoarele:

  • Înlocuiește datele de piață descărcate (market_data.csv) cu propriile tale estimări de randamente și covarianță, obținute din istoricul real al prețurilor.

  • Mărește numărul de straturi QAOA sau antrenează la 12–16 active și transferă acele unghiuri, pentru a apropia frontul de pe hardware de cel optim.

  • Citește Kotil et al., Quantum Approximate Multi-Objective Optimization (Nature Computational Science, 2025), studiul max-cut pe care acest tutorial îl adaptează la portofolii.

Referințe​

  1. Kotil et al., „Quantum Approximate Multi-Objective Optimization," Nature Computational Science (2025). arXiv:2503.22797

  2. S. H. Sack and M. Serbyn, „Quantum annealing initialization of the quantum approximate optimization algorithm," Quantum 5, 491 (2021). arXiv:2101.05742