ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

使用 xquad 的区间整数域构建投资组合再平衡(Portfolio Rebalance)模型

使用 xquad 的区间整数域构建投资组合再平衡(Portfolio Rebalance)模型

【免费下载链接】xquad

A rust implementation of the Quip Network's quantum virtual machine.

项目地址:https://gitcode.com/gh_mirrors/xq/xquad
点击查看免费下载

本文以 xquad 仓库中的 Portfolio Rebalance 示例 为主线,讲解如何用 XQCP 约束编程 DSL 为一类**允许空头(负权重)**的投资组合再平衡问题建立二次无约束二元优化(QUBO)模型:为每只资产选择一个[-5, 5]区间内的整数权重,在权重之和等于预算的约束下,用预期收益与风险矩阵权衡取舍。读完本文,你将掌握Domain.INTEGER+lo=/hi=区间整数域的建模方法、手写预算平方约束的展开技巧、三程序流水线(encoder / verifier / decoder)的调用方式,以及该示例当前能证明什么、不能证明什么。

问题定义:为什么必须用带符号的整数权重

投资组合再平衡的目标是:给每只资产分配一个带符号的整数权重,在预期收益与风险之间取得平衡,同时要求所有权重之和恰好等于一个预算值。关键点在于负权重代表空头(short position)——正是这一点决定了该问题不能退化成"选或不选"的二元模型:

  • 二元模型只能表达x_i ∈ {0, 1},即"持不持有";
  • 而再平衡需要x_i ∈ [-5, 5]这样的区间整数域,才能表达做多、空仓、做空三种状态。

因此该示例成为 xquad 中首个、也是核心的一个非二元示例:它完整展示了在Domain.INTEGER上建模、编译、验证与解码的完整链路。

QUBO 形式化:输入、模型、目标与约束

根据 示例 README 与 runner.py 的实现,问题的形式化如下:

要素定义
输入资产数量N、每只资产的预期收益r_i、按行优先展平的对称N × N风险矩阵C
模型N个整数变量x_i ∈ [-5, 5],用lo=-5, hi=5声明;模型内部存储y_i = x_i - lo ∈ {0, ..., 10},系数写在x上,sample.value()在输出端把y平移回x
目标-sum(r_i * x_i) + sum_{i <= j} C_ij * x_i * x_j(最小化)
约束sum(x_i) = B,以惩罚项P * (sum_i x_i - B)^2表达
能量被平移了两次,因此数值上与其他任何示例不可比(详见下文)

在 runner.py 中,这些参数是编译期常量:

_LO = -5 _HI = 5 _K = _HI - _LO + 1 # 域宽 = 11,模型实际持有 y ∈ {0, ..., 10} _BUDGET = 1 # 权重之和必须等于的预算 _PENALTY = 50 # 预算约束的惩罚强度

能量的两次平移:argmin 精确,数值不可比

README 明确指出,该示例的能量被平移了两次,因此最终数字不是目标函数的真实值,也不与其他示例可比:

  1. 区间域平移:代入x = y + lo后,二次项展开会产生一个常数w*lo^2;因为 XQMX 模型没有偏移(offset)字段,这个常数被丢弃。参见 problem.py 中 define_model 的文档:"The uniform energy shift the ranged form drops is unrepresentable, since XQMX carries no offset term; argmin is unaffected."
  2. 预算平方平移:P * (sum_i x_i - B)^2展开后包含常数P*B^2,与EQUALITY展开丢弃自己的P*b²常数项一致(见 spec/xqvm/HLF.md 中 EQUALITY 展开的说明)。

两次平移对所有可行赋值都施加相同的偏移,所以虽然返回的能量数字不是目标函数的真实值,argmin 却是精确的——排序结果不受影响。

编码策略:手写预算平方,而非 apply_equality

这是本示例最有技术含量的一节:预算约束sum(x_i) = B是手写展开的,而不是交给apply_equality()。

为什么不能交给 EQUALITY

在 xqcp/compiler.py 中,所有高层约束(onehot_row、onehot_col、exclude、implies、equality、atleast、atleastw、reduce等)都被标记为_BINARY_ONLY_KINDS。原因在 spec/xqvm/HLF.md 中写得很清楚:

所有高层函数展开都建立在x² = x之上,而该等式只对二元变量成立。

ONEHOTR、EQUALITY、ATLEAST等展开在推导时都利用了二元变量的幂等性,一旦应用到整数域模型上,生成的惩罚项就不再表达用户写下的约束。因此 xqcp 会拒绝在非二元模型上使用任何高层约束——这也是 spec/xqcp/SPEC.md 中记录的开放问题("per-domain expansions"):每个域是否都存在正确的展开,尚无定论。

手写展开的系数

P * (sum_i x_i - B)^2展开后为:

  • 每个对角项:P
  • 每个非对角对:2P
  • 每个线性系数:-2PB

这与EQUALITY展开(linear[idx_k] += P*a_k*(a_k - 2b)、quad[idx_k, idx_m] += 2P*a_k*a_m)在a_k = 1、b = B时是同一套数学。README 说得很直白:"Writing that square is the same work the HLF would have done"——手写这份展开,正是 HLF 本来要做的工作。

在 runner.py 中,这份展开与目标项一起写入:

# 收益目标 + 预算平方的线性项:-r_i * x_i - 2*P*B * x_i problem.model.linear[i].add(-ri - 2 * _PENALTY * _BUDGET) # 风险目标(上三角)+ 预算平方的对角项:C_ii + P problem.model.quadratic[i, i].add(cov_in.get(xq_grid(i, i, num_assets)) + _PENALTY) # 非对角项:C_ij + 2P problem.model.quadratic[i, j].add(cij + 2 * _PENALTY)

注意xq_grid(i, j, n)把二维风险矩阵按行优先映射成一维协方差输入向量——这正是 README 中"风险矩阵按行优先展平"的实际落地。

区间域在底层的二次重写

区间域在 DSL 之下还有一层自动重写。看 xqcp/symbols.py 中 CoefficientRef.add 的实现:当模型带lo_expr时,每次二次项写入除了记录w本身,还会把w*lo追加到两个命名索引的线性系数上,因为代入x = y + lo后:

w * x_i * x_j = w*y_i*y_j + w*lo*y_i + w*lo*y_j + w*lo²
  • 非对角写入:两条修正分别落在i和j的线性系数上;
  • 对角写入(i == j):两条修正落在同一个索引上,正好给出平方展开所要求的2*w*lo。

这些重写对模型构建代码完全不可见——你写的依然是model.quadratic[i, j].add(...),平移由记录层自动完成。这种设计带来的一个直接限制是:区间整数模型上不支持linear[i] = w和quadratic[i, j] = w的赋值式写法(setitem),因为"设置"会替换掉系数,而lo修正只能累加,两者会互相矛盾;LinearProxy.__setitem__和QuadraticProxy.__setitem__会直接抛出ValueError,提示改用.add(w)。

lo 和 hi 必须是字面量

README 特别强调:lo与hi是字面量。运行时的lo会占用 decoder 唯一的 calldata 标量——而那个标量已经被输出循环的上界(N)用掉了。从源码看,problem.py 的 define_model 中,字面量lo不需要寄存器,会被内联替换到所有需要平移的位置;而运行时lo则必须分配寄存器。xqcp 的策略是:宁可直接报错,也不在两个需求中二选一。

DSL 方法速查:区间整数域用到的三个 API

本示例只使用了非二元模型支持的全部操作:

# 1. 声明区间整数模型:size=N 个变量,域 [-5, 5] problem.define_model(size=num_assets, domain=Domain.INTEGER, lo=_LO, hi=_HI) # 2. 写入系数(非二元模型唯一支持的两种操作) problem.model.linear[i].add(w) problem.model.quadratic[i, j].add(w) # 3. 读取样本值:返回声明域内的权重 x,而不是存储值 y problem.sample.value(i)

背后机制:

  • Domain.INTEGER在 xqcp/problem.py 中定义,映射到 VM 层的XQMXDomain.INTEGER;define_model会做参数合法性检查(lo=/hi=必须成对、hi >= lo + 1、不允许k=与lo=/hi=同时出现,见 problem.py 的 _check_domain_args)。
  • sample.value(i)在 xqcp/symbols.py 中实现:getline(index) + lo_expr——只有当define_model给了lo=/hi=时才做平移,否则与getline完全一致。
  • 整数模型的k由hi - lo + 1计算得出(域宽),在编译期嵌入 XQMX 分配(allocator 弹出k后再弹size,宽度最后入栈,见 compiler.py 的 _emit_model_allocation)。

六步流水线:CP → 汇编 → 编码 → 采样(跳过)→ 验证 → 解码

与 xquad 其他示例一致,本示例遵循三程序架构(encoder / verifier / decoder,参见 docs/book/src/concepts/three-programs.md):

步骤动作说明
1. CPxqcp生成收益与对称风险矩阵,声明N个区间整数权重,写入目标与预算平方系数
2. Assemblexquad.asm把.xqasm文本汇编成字节码
3. Encode运行 encoder在所选 XQVM 后端上产出 XQMX 模型
4. Sample跳过没有求解器能采样整数模型,runner 直接提供手工挑选的赋值
5. Verify运行 verifier依据define_model重放的k,检查每个权重落在{0, ..., 10},再计算能量
6. Decode运行 decoder读取每个权重并加上lo,还原带符号的权重

在 runner.py 的 run() 中,这段流水线有清晰的实现:

vm = VM(backend=backend) vm.set_calldata([n, returns, cov]) vm.set_output_slots(1) vm.run(programs.encoder) model = vm.outputs()[0] sample = XQMX.integer_sample(n, _K) for index, weight in enumerate(weights): sample.linear[index] = weight - _LO # 手工构造 y = x - lo vm = VM(backend=backend) vm.set_calldata([n, returns, cov, model, sample]) vm.set_output_slots(2) vm.run(programs.verifier) energy, valid = vm.outputs()[0], vm.outputs()[1] vm = VM(backend=backend) vm.set_calldata([sample, n]) # sample + 唯一的标量 n vm.run(programs.decoder)

三个细节值得注意:

  1. 手工采样:XQMX.integer_sample(n, _K)创建{0, ..., k-1}域的样本(见 xqvm_py/xqmx.py),随后把手工挑选的权重减去lo存入sample.linear,即y = x - lo。
  2. 手工挑选的赋值:pick_assignment(runner.py)对n >= 2返回weights[0] = BUDGET + 1 = 2、weights[1] = -1——故意携带一个空头,让解码输出覆盖整个域(而不只是非负半边),并且总和恰好等于预算。
  3. decoder 的 calldata 布局:[sample, n],其中n是唯一的标量,被输出循环上界占用——这正是前文"运行时lo没有标量可用"的原因。decoder 的 calldata 契约见 compiler.py 的 compile_decoder。

运行方式与命令行参数

从仓库根目录运行(两种典型用法):

uv run python examples/portfolio_rebalance/runner.py --seed 42 uv run python examples/portfolio_rebalance/runner.py --n 6 --interpreter rust
参数默认值说明
--n5资产数量
--solverdwave-cpu接受但被忽略(详见下文)
--interpreterpythonXQVM 后端:python(纯 Python 参考 VM)或rust(经xqffi调用 Rust 解释器)
--seed42随机种子(决定收益与协方差矩阵的生成)
-ostdout把 JSON 结果写入文件

--interpreter选择的是执行编译后程序的 VM,而不是求解模型的后端——正确模型在两个解释器上总是产生相同的valid与energy。输出的 JSON 字段包括_seed、n、lo、hi、budget、returns、weights、sums_to_budget、energy、valid(见 runner.py 的 main())。

本示例证明什么、不证明什么

证明:valid == 1说明域检查(domain check)、记录层平移(record-layer shift)与解码(decode)三者能够组合在一条手工挑选的赋值上正确工作——一个包含空头、落在域内且总和等于预算的赋值通过了验证器,并解码回带符号的权重。

不证明:它不证明任何优化能力。README 明确写道:

It proves nothing about optimisation: no solver samples an integer model yet, so--solveris accepted, ignored, and the pipeline runs against a hand-picked weight vector that sits inside the domain and sums to the budget. Integer lowering is XQSA v0.5.0 work.

即:目前没有任何求解器能采样整数模型(整数域在 spec/xqsa/DOMAINS.md 中的求解器支持状态为 "Reserved",_validate_model()会对 INTEGER 域抛出ValueError),所以--solver仅为接口一致性而接受并忽略。runner 启动时会向 stderr 打印提示(runner.py)。整数下界(integer lowering)是 XQSA v0.5.0 的计划工作。

规范输出与 CI 守护

make example-smoke(对应 scripts/example-smoke.py)验证两个解释器在--seed 42 --solver dwave-cpu下都产生valid == 1。该冒烟测试是基于不变式的:只检查合法性(valid == 1),不比较精确输出——因为能量数值经过两次平移不可比,且手工赋值的意义在于链路正确性而非最优性。

延伸阅读

  • 示例总览与通用运行方式:每个示例目录的公共结构、--interpreter语义与改编方法
  • 三程序架构:encoder / verifier / decoder 的职责与调用链
  • XQCP 建模文档:本 runner 所用的 DSL 完整说明
  • 整数域与样本编码:{0, ..., k-1}域、integer_sample与域校验规则
  • HLF 展开规范:EQUALITY等展开为何只对二元域成立
  • XQCP 规格:约束仅限二元模型的开放问题记录

【免费下载链接】xquad

A rust implementation of the Quip Network's quantum virtual machine.

项目地址:https://gitcode.com/gh_mirrors/xq/xquad
点击查看免费下载

相关推荐

上一篇:Windows 11 PL2303驱动问题终极解决方案:告别黄色感叹号的完整指南
下一篇:Windows系统驱动兼容性重构:高效解决PL-2303停产芯片识别问题

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进