【免费下载链接】xquad
A rust implementation of the Quip Network's quantum virtual machine.
本文以 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 明确指出,该示例的能量被平移了两次,因此最终数字不是目标函数的真实值,也不与其他示例可比:
- 区间域平移:代入
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." - 预算平方平移:
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. CP | xqcp | 生成收益与对称风险矩阵,声明N个区间整数权重,写入目标与预算平方系数 |
| 2. Assemble | xquad.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)三个细节值得注意:
- 手工采样:
XQMX.integer_sample(n, _K)创建{0, ..., k-1}域的样本(见 xqvm_py/xqmx.py),随后把手工挑选的权重减去lo存入sample.linear,即y = x - lo。 - 手工挑选的赋值:
pick_assignment(runner.py)对n >= 2返回weights[0] = BUDGET + 1 = 2、weights[1] = -1——故意携带一个空头,让解码输出覆盖整个域(而不只是非负半边),并且总和恰好等于预算。 - 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| 参数 | 默认值 | 说明 |
|---|---|---|
--n | 5 | 资产数量 |
--solver | dwave-cpu | 接受但被忽略(详见下文) |
--interpreter | python | XQVM 后端:python(纯 Python 参考 VM)或rust(经xqffi调用 Rust 解释器) |
--seed | 42 | 随机种子(决定收益与协方差矩阵的生成) |
-o | stdout | 把 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.
相关推荐
Claude for Financial Services 财富管理插件实战:用 /rebalance 命令与 portfolio-rebalance 技能完成税务感知的组合再平衡
Claude for Financial Services 财富管理插件实战:用 /rebalance 命令与 portfolio rebalance 技能完成
人工智能AI 应用AI 技能/插件AI Agent金融科技XQuad Portfolio Rebalance 示例深度解析:XQVM 整数域建模与带符号权重的端到端流水线
XQuad Portfolio Rebalance 示例深度解析:XQVM 整数域建模与带符号权重的端到端流水线 本篇技术指南围绕 xquad 仓库中的 Por
FinRobot 财富管理技能详解:用 Portfolio Rebalance 技能实现组合漂移分析与再平衡交易建议
FinRobot 财富管理技能详解:用 Portfolio Rebalance 技能实现组合漂移分析与再平衡交易建议 导读 本文以 FinRobot 桌面端(
人工智能AI Agent金融科技AI 应用大模型RAG
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考