将夏普比率写进可微层:端到端稀疏切点组合优化实战
做了几年多因子选股我一直被一个矛盾卡着模型在预测收益上做得不错可换成组合权重之后夏普常常不尽如人意。后来我试着把夏普比率直接写进可微层用稀疏切点组合优化把整条链路改写成端到端训练这才算把这个矛盾摆在桌面上解决。先说结论这套做法最大的价值不是省掉中间步骤而是让组合优化器本身开始根据夏普反馈调整自己的输入。如果你也在做量化组合、资产配置或者多因子策略并且受够了预测模型和组合优化器各管各的这篇文章应该能帮你少走不少弯路。下面我会从原理、代码、训练细节到踩坑记录完整拆一遍。1. 从两阶段到端到端我为什么要折腾可微层1.1 两阶段流程的三宗罪大多数人做组合优化用的是教科书里经典的两阶段流程先用机器学习模型预测下一期收益和协方差再把预测结果丢进均值-方差优化器或切点组合求解器得到最终权重。这个流程本身没毛病但实际跑久了会明显感觉到三个问题。第一目标不一致。预测模型训练时用的是 MSE 或分类准确率而组合优化真正关心的是夏普比率、最大回撤、换手率。预测误差在非线性优化过程中会被放大收益预测的小偏差可能导致权重剧烈变化。一个在验证集上 RMSE 很漂亮的收益预测模型换成组合权重之后表现平庸甚至不如简单的等权组合这种场景我见过太多次。第二误差无法回传。组合优化器是一个独立的求解步骤它内部没有梯度通路。哪怕你发现组合夏普很差也没办法计算“优化器对预测模型参数应该做什么修改”。于是所有调参都只能靠人工在外部试比如改收益预测的窗口、调整风险厌恶系数、加各种正则项。整个过程又慢又靠手感。第三正则化参数难调。为了不让优化器产生极端权重大家通常会加很多约束比如单只股票权重上限、行业中性化、换手惩罚等。约束一多系统复杂度就上去了每个参数都得手动搜索组合表现对某个约束特别敏感时整个策略会变得极其脆弱。对比之下端到端训练是直接以组合层面的目标来驱动整个模型。组合优化器不再是一个不可干预的黑盒而是一个可以被梯度穿过的可微层。这两者的差别相当于“先把零件各自打磨好再组装”和“整机一起联调”的差别。自动驾驶里的端到端方案也是同一个思路与其分模块堆感知、预测、规划不如让最终行驶表现直接影响中间模块参数。1.2 优化器变成可微层意味着什么所谓可微层本质上就是神经网络里一个特殊的算子前向传播时它接收输入参数求解一个优化问题输出解作为结果反向传播时它根据优化问题的最优性条件把损失对解的梯度传回给输入参数。放到组合优化场景里输入参数就是预测出来的收益期望 μ 和协方差 Σ输出就是组合权重 w。如果这个优化问题是凸的那么当它的解对参数连续变化时梯度可以通过隐函数定理或者 KKT 条件解析地算出来。这个技术叫可微凸优化层cvxpylayers 这类库就是专门干这个的。把优化器变成一层之后整个训练流程就变成了一个大的计算图特征 → 编码网络 → μ、Σ → 可微优化层 → 权重 → 组合收益序列 → 夏普损失 → 反向传播。每一环都参与训练损失函数可以精确地告诉特征编码器“你输出的 μ 让权重偏向低夏普方向需要调整。”我第一次跑通这个图的时候很直观的感受是之前的模型是“模型自己在猜正确答案”现在变成了“模型在帮着组合一起找正确答案”两者的搜索空间完全不同。1.3 谁适合用这套方案这套方案适合三种人。第一种是做多因子选股的研究员手里已经有因子和收益预测模型但苦于优化环节拖后腿。第二种是做资产配置的需要周期性输出一篮子资产权重并且在意换手和持仓数量。第三种是刚接触深度学习加组合优化方向的量化工程师想在自己的系统里引入端到端框架但不知道怎么把优化器塞进网络。当然基础门槛也有。你得熟悉 PyTorch 的基本用法知道凸优化是什么对夏普比率、协方差这些概念不能太陌生。如果你只会调参不会写模型那建议先拿小型组合做个验证再往生产环境迁移。2. 稀疏切点组合优化到底是什么2.1 夏普比率、切点组合和有效前沿夏普比率是组合超额收益除以组合波动率衡量每承担一单位风险能换来多少超额回报。把夏普比率最大化得到的组合在有效前沿上有一个专门的名字叫切点组合。它和无风险收益率组合之后可以构成资本配置线上的最优组合。如果预期收益向量是 μ协方差矩阵是 Σ无风险利率是 r_f那么无约束情况下切点组合的权重可以写成w_tangency Σ^{-1}(μ - r_f 1) / (1^T Σ^{-1}(μ - r_f 1))这个公式很多教科书都写过但实际应用有个问题它默认可以做空而且权重可能非常极端某个资产权重是正 300%另一个是负 250%这种结果在实盘里根本没法用。于是我们会加约束权重和为 1权重大于等于 0甚至给单资产设上限。加了约束之后切点组合不再有一个简单的解析式而是一个需要数值求解的优化问题。而一旦我们想把稀疏性加进去这个问题会变得更复杂。这也是为什么我建议把优化器做成可微层来求解而不是人肉推公式。2.2 为什么要加稀疏约束金融世界里有一个很实际的诉求组合里不要装太多资产。持仓数量越多交易成本越高冲击成本越难控制投后管理也越麻烦。比如你选出 500 只股票的收益预测如果优化器全部拿去配权重哪怕单只权重很小买卖一遍的费用也会吃掉不少超额收益。与其这样不如只选其中最有把握的 20 到 30 只。稀疏切点组合优化就是要在保留切点组合风险收益性质的同时让权重尽量稀疏。这里“稀疏”有两个方向可以理解一个是硬约束比如要求非零权重数量不超过 K另一个是软约束比如在目标函数里加 L1 正则项让权重朝 0 收缩。L0 范数直接约束持仓数量但不可导不适合放进优化器求梯度。L1 范数是 L0 的凸松弛既能让权重变稀疏又能保持整个优化问题是凸的放在可微层里很合适。实际落地时我会用“L1 惩罚 权重非负 权重和等于 1”的组合方式这样既能控制持仓数量又不会出现空头头寸。2.3 理论层好写落地层要另想办法理论上的稀疏切点组合问题写成数学形式很简单max w^T μ - λ ||w||_1 s.t. w^T Σ w ≤ σ^2, 1^T w 1, w ≥ 0或者直接写成均值-方差加 L1 的形式max w^T μ - λ ||w||_1 - (γ/2) w^T Σ w s.t. 1^T w 1, w ≥ 0问题是怎么把它放进深度学习框架。普通求解器只负责给解不负责给梯度。你不能指望模型训练时求解器自动告诉你“μ 该往哪个方向调”。这就是可微层存在的意义它把“求解”和“求梯度”一起解决了。但要注意并不是随便什么优化问题都能做可微层前向问题必须是凸的并且最好能被标准锥规划求解器识别。所以我们的目标函数设计要优先考虑凸性而不是纯粹地追求最大化夏普这个非凸目标。3. 把夏普比率写进可微层的设计思路3.1 为什么不能把最大化夏普直接扔进凸优化层看到标题你可能会想既然目标是最大化夏普那为什么不直接在可微层里求解一个最大化夏普的问题比如max (μ - r_f 1)^T w / sqrt(w^T Σ w)这个想法很自然但它不是凸优化。因为目标函数是一个分式分子是线性的分母是二次根号整个比例函数在组合空间里既非凹也非凸。cvxpylayers 和大多数可微凸优化库都要求问题满足 DCP 规则这种形式大概率过不了 DCP 校验。有人会通过 Charnes-Cooper 变换把最大化夏普转换成 SOCP 问题这在一定条件下是可行的。但一旦再加上 L1 稀疏惩罚变换会变得非常绕求解稳定性和梯度质量都很难保证。我在实验早期试过这条路调试成本很高最后放弃了。更务实的做法是在优化层内部我们不直接最大化夏普而是用一个“代理目标”外层再用真实夏普作为损失函数。代理目标就是前面提到的均值-方差加 L1 的凸优化问题。它虽然不是夏普本身但在风险厌恶系数 γ 的调节下它的无约束解方向与切点组合一致。加上 L1 后输出就成了稀疏切点组合的数值近似。3.2 外层夏普损失内层凸代理端到端的真正写法我最后采用的端到端架构长这样特征向量 x → 编码网络 → 输出 μ 和 Σ → 进入凸优化可微层 → 得到稀疏权重 w → 用 w 构造组合收益序列 → 计算组合夏普 → 作为损失函数 → 反向传播。可微层内部实际求解的问题是max w^T μ - λ ||w||_1 - (γ/2) w^T Σ w s.t. 1^T w 1, w ≥ 0其中 μ 和 Σ 是网络输出λ 和 γ 可以是超参数也可以是可学习参数。外层损失函数就是负的样本内/样本外夏普也就是把夏普比率真正“写进”了可微优化层的训练信号里。这个架构的好处是梯度可以从夏普损失一路传回编码网络。因为可微层根据 KKT 条件提供了优化层输出的梯度所以编码网络能学习到“什么样的 μ 和 Σ 会让组合更接近最优夏普”。这比两阶段方法里“预测得准”要直接得多。3.3 梯度是怎么传回来的关于梯度回传很多人会担心不可导问题。实际上对于一个参数化的凸优化问题最优解 w* 是参数 θ这里是 μ、Σ、λ、γ的隐函数。我们写出 KKT 条件再对参数求导可以得到一个线性系统解出 dw*/dθ。这个矩阵的规模取决于变量和约束的数量通常可以用伴随法求解计算量不大。cvxpylayers 把这套求导封装好了。你只要定义好 cvxpy 的优化问题注明哪些是参数哪些是变量它就会在反向传播时自动计算梯度。它不是用数值差分而是基于锥规划求解器返回的雅可比信息所以梯度精度很高。不过有个坑L1 项在 w_i 0 处不可导。实际处理中次梯度或近似梯度可以工作但如果很多权重被稀疏到 0梯度中关于 λ 的路径会变得不稳定。这也是我建议 λ 在初期不要作为可学习参数的原因先固定它等模型结构稳定了再考虑放开。4. 手写一个稀疏切点可微层代码实战4.1 环境依赖与问题定义先说明一下我用到的环境Python 3.10PyTorch 2.1cvxpy 1.4cvxpylayers 0.1.5。安装直接用 pip 就能搞定。pip install cvxpy cvxpylayerscvxpylayers 会依赖 cvxpy 和若干求解器默认建议把 SCS 和 CLARABEL 装好因为它内部求导依赖这些锥规划求解器。我们的资产数量先设置成 20 只。需要定义四个参数预测收益 μ、协方差 Σ、稀疏系数 λ、风险厌恶系数 γ。还需要定义一个变量 w也就是最终组合权重。4.2 用cvxpylayers实现可微层下面这个代码片段就是核心可微层的定义import cvxpy as cp import torch from cvxpylayers.torch import CvxpyLayer n 20 mu cp.Parameter(n) Sigma cp.Parameter((n, n), PSDTrue) lam cp.Parameter(nonnegTrue) gamma cp.Parameter(nonnegTrue) w cp.Variable(n) objective cp.Maximize( mu w - lam * cp.norm(w, 1) - gamma / 2 * cp.quad_form(w, Sigma) ) constraints [cp.sum(w) 1, w 0] problem cp.Problem(objective, constraints) assert problem.is_dcp() layer CvxpyLayer( problem, parameters[mu, Sigma, lam, gamma], variables[w] )这里有三个细节值得注意。第一个Sigma必须声明为PSDTrue否则求解器不会把它当半正定矩阵cp.quad_form(w, Sigma)的凸性校验会出问题。第二个lam和gamma都声明为nonnegTrue这样能保证目标函数是凹函数且优化问题符合 DCP。第三个CvxpyLayer的参数顺序必须和parameters列表顺序一致后面调用时也要保持同样的顺序。定义好之后前向和反向都非常直接mu_t torch.randn(n, requires_gradTrue, dtypetorch.double) A torch.randn(n, n, dtypetorch.double) Sigma_t A A.T torch.eye(n) * 1e-4 lam_t torch.tensor(0.1, requires_gradTrue, dtypetorch.double) gamma_t torch.tensor(5.0, requires_gradTrue, dtypetorch.double) w_t, layer(mu_t, Sigma_t, lam_t, gamma_t) portfolio_return (mu_t * w_t).sum() portfolio_vol torch.sqrt(w_t Sigma_t w_t) sharpe portfolio_return / portfolio_vol loss -sharpe loss.backward()我一般会强制使用torch.double类型因为 float32 在求解高维协方差问题时精度不够容易导致梯度偏差。如果模型本身是 float32可以把可微层内部单独转成 double算完再转回去。4.3 把可微层接进PyTorch网络可微层本身不是一个torch.nn.Module所以我会包一层方便放进网络里使用class SparseTangencyLayer(torch.nn.Module): def __init__(self, n_assets): super().__init__() self.n_assets n_assets mu cp.Parameter(n_assets) Sigma cp.Parameter((n_assets, n_assets), PSDTrue) lam cp.Parameter(nonnegTrue) gamma cp.Parameter(nonnegTrue) w cp.Variable(n_assets) objective cp.Maximize( mu w - lam * cp.norm(w, 1) - gamma * cp.quad_form(w, Sigma) / 2 ) constraints [cp.sum(w) 1, w 0] self.layer CvxpyLayer( cp.Problem(objective, constraints), parameters[mu, Sigma, lam, gamma], variables[w] ) def forward(self, mu, sigma, lam, gamma): return self.layer(mu, sigma, lam, gamma)[0]然后前面的编码网络可以直接输出 mu 和一个低秩因子用来合成协方差矩阵class Encoder(torch.nn.Module): def __init__(self, input_dim, n_assets, hidden_dim64): super().__init__() self.n_assets n_assets self.fc1 torch.nn.Linear(input_dim, hidden_dim) self.fc_mu torch.nn.Linear(hidden_dim, n_assets) self.fc_A torch.nn.Linear(hidden_dim, n_assets * n_assets) def forward(self, x): h torch.relu(self.fc1(x)) mu self.fc_mu(h) A self.fc_A(h).view(-1, self.n_assets, self.n_assets) sigma A A.transpose(1, 2) torch.eye(self.n_assets) * 1e-4 return mu, sigma这里我用了低秩分解来保证协方差矩阵一定是对称半正定这是这个框架里最容易踩坑的地方。如果直接让网络输出一个 n×n 矩阵大概率不是半正定求解器会直接报错或者给出无效解。多一步A A.T εI问题就没了。5. 训练端到端模型的关键节点5.1 数据准备与特征构造训练这种端到端模型数据准备与普通 ML 有很大区别。不能简单地把所有样本混在一起随机打乱因为金融数据有时间序列属性否则会引入前视偏差。我用的数据是截面日频数据主要包括动量、估值、质量、波动率等标准化因子。每一时刻 t一组 N 只股票的因子矩阵是 X_t下一期收益向量是 r_{t1}。模型输入 X_t输出下一期的组合权重 w_t然后用 r_{t1} 计算这期的组合收益。滚动窗口训练训练集、验证集、测试集严格按时间顺序切分。还要注意特征标准化。因子横截面的 Z-Score 很常用但 Z-Score 的均值和标准差只能用当时已经发生的数据计算不能使用未来数据。我一般会先用滚动窗口计算均值和标准差再对当期特征做标准化。协方差矩阵的估计也很关键。网络输出的协方差不是用历史数据直接算而是要经过低秩分解保证正定。不过在实际实验中我建议用两种协方差做对比一种是网络输出的动态协方差一种是历史样本协方差看看可微层到底靠收益 μ 的改进多还是靠协方差 Σ 的改进多。很多时候你会发现收益预测端的改进更显著。5.2 模型结构和超参数设置模型结构不需要太复杂我用的是一个两层的编码网络隐藏单元 64 或者 128。激活函数用 GELU因为它在训练早期比 ReLU 更平滑能减少优化层的梯度冲击。损失函数是负夏普loss - (mean(R_p) / std(R_p) eps) * sqrt(252)其中 R_p 是组合在一个训练批次里的日收益序列。加 eps 是为了防止波动率接近 0 时除零。计算夏普时用哪个频率的平方根取决于收益序列的频率日频就乘 sqrt(252)。超参数方面我常用的初始值是Adam 优化器学习率 1e-3λ 初始值 0.1γ 初始值 5.0梯度裁剪 max grad norm 1.0dropout 0.1λ 和 γ 前 50 轮固定等编码网络基本稳定之后再考虑把 γ 作为可学习参数。为什么因为如果训练一开始就让 γ 参与学习优化层的输出权重会很不稳定梯度信号容易在早期被拉飞。固定一段再放开模型能更快收敛。5.3 回测验证别让夏普损失变成自欺欺人端到端模型很容易陷入一种情况训练集上的夏普做得很好验证集上一塌糊涂。原因就是模型学会了过度拟合历史收益序列优化层又把这种过拟合放大到权重上。我通常会在验证集上做滚动回测每 20 个交易日更新一次组合权重维护一个组合净值曲线。不只关注夏普还关注最大回撤、卡玛比率、换手率以及持仓数量中位数。持仓数量是稀疏性的直接指标。如果 λ0.1 时组合里平均持仓数量是 30说明稀疏性达到预期如果变成 5说明 λ 过大模型基本是在赌少数几只股票。这个指标比夏普更容易暴露问题。换手率也要盯。端到端模型的权重可能会频繁变化因为梯度信号鼓励模型追逐短期收益。我会在损失函数里加一个换手惩罚项比如loss -sharpe eta * mean(|w_t - w_{t-1}|)eta 不需要太大0.01 到 0.05 就足够抑制过度交易。6. 踩坑实录常见问题与排查技巧6.1 协方差矩阵不满足半正定怎么办这个是最常见的问题。如果你直接让神经网络输出一个 n×n 协方差矩阵训练几次之后大概率会遇到 cvxpy 报错信息类似于“You are solving a parameterized problem that is not DCP”或者“PSD constraint not satisfied”。解决办法我在前面已经提过用Sigma A A.T epsilon * I来参数化。这里 A 的形状可以设成 (n, k)k 取小于 n 的数比如 n20 时 k10这样既保证半正定又降低了网络输出维度还能起到正则化作用。有个细节epsilon 别设太大否则协方差矩阵会被对角项主导失去资产间的相关性信息。我常用 1e-4 到 1e-3在数值稳定和信息保留之间取平衡。6.2 稀疏参数lambda到底怎么调λ 调不好会出现两种极端。λ 太小权重完全不稀疏每只股票都有小仓位的权重看上去像被稀释了一样λ 太大权重集中到一两只股票组合风险急剧上升。我一开始把 λ 设成可学习参数用梯度直接更新。结果发现 λ 经常跑到一个极端值然后很难拉回来。原因很简单λ 的梯度在权重稀疏到 0 的区域连续性很差更新不稳定。后来我改用固定 λ 加手动微调的策略训练一小段查看当前非零权重数量如果数量太多就把 λ 乘以 1.5重新训练如果太少就除以 1.5。这样迭代几轮很容易找到符合预期的稀疏度。如果你一定想让它可学习可以加一个直通估计器但收益有限成本不小我不太推荐。还有个办法是按比例缩放 μ。因为 L1 惩罚项和收益项之间存在尺度关系如果 μ 的均值为 0.001λ0.1 可能显得非常大权重会被极度稀疏化。所以在输入可微层之前我会对 μ 做标准化处理让它处在一个类似 Z-Score 的尺度上这样 λ 的取值就有了稳定的语义。6.3 训练慢、梯度不稳、过拟合怎么办可微凸优化层最让人头疼的就是慢。cvxpylayers 的底层要调锥规划求解器前向求解一个 20 资产的组合优化问题单次只要几毫秒但如果 batch 大、资产数量多比如 100 只股票每个 batch 32 个样本一次前向就要解 32 个凸优化问题速度会让人崩溃。我的应对思路有三个。第一batch size 设小一点比如 16反正金融数据样本量不像 CV 那么大batch 太大也没必要。第二资产维度太高时先用 PCA 或者聚类降到 20 维以内把降维后的资产当作组合标的可微层只处理降维后的组合网络主体部分仍然处理原始特征。第三如果必须用大 batch可以探索用 GPU 版的求解器但目前生态还不成熟我一般还是用 CPU 跑把 batch 控制在合理范围。梯度不稳通常表现为训练早期的 loss 剧烈震荡。我会做两件事一是给编码网络最后一层的输出做 LayerNorm把 μ 的输出限制在一个比较稳定的区间二是梯度裁剪把梯度的 L2 范数裁剪到 1.0。这两步加起来能解决大部分震荡问题。过拟合是端到端模型的长期对手。除了常规的 dropout 和 weight decay我还会刻意在验证集上做“冻结参数回测”也就是训练完模型后把参数完全冻结然后在历史数据上滚动回测看看它的真实稳定性。如果训练集和验证集差距过大我宁可减少模型复杂度比如把隐藏层从 64 降到 32只保留最核心的特征。6.4 常见问题速查表我把实操中遇到比较多的问题整理成了一张表方便你有问题的时候快速对照。问题现象可能原因解决办法cvxpy 报 DCP 校验失败目标函数非凹或协方差矩阵未声明 PSD改用代理目标给 Sigma 参数加 PSDTrue求解器输出权重全是 NaN协方差矩阵不正定或 dtype 精度低用 AA.T epsilon*I 参数化协方差改用 double 类型权重不稀疏λ 太小或 μ 尺度太大增大 λ或对 μ 做 Z-Score 标准化权重集中到一只股票λ 太大或 γ 太小调小 λ调大 γ训练时 loss 剧烈震荡梯度范数过大λ 或 γ 更新过快梯度裁剪固定 λ 和 γ 若干轮验证集夏普远低于训练集过拟合未来信息泄漏严格时间切分降低模型容量增加换手惩罚训练速度非常慢batch 太大资产维度太高减小 batch先降维到 20 维以下7. 实战中最值得留意的几个细节最后分享几个我在实际跑这套方案时踩过坑之后总结出的细节希望对你有用。第一个细节可微层一定要单独测试。别急着把它接进大网络。我习惯先用随机生成的正定矩阵和收益向量把可微层的输出与闭式解对比一下。如果是无约束情况直接对比Σ^{-1}(μ - r_f 1)的方向如果有非负约束就对比 cvxpy 直接求解的结果。这一关过了再接网络会省很多事。第二个细节训练早期把 γ 固定得大一点。γ 越大优化层越重视风险输出的权重会更平滑梯度信号也更稳定。等模型开始收敛再逐步调小 γ让组合有更多收益进攻性。这个顺序基本适用于大多数资产池。第三个细节不要贪心别一上来就端到端。你可以先让编码网络单独预测收益把预测结果丢进一个非可微的稀疏切点优化器得到一组基线权重和基线夏普。然后把优化器换成可微层用端到端损失微调整个网络。这样能直观看到端到端训练带来的增益也方便排查是网络的问题还是优化层的问题。第四个细节记录每次前向之后非零权重的数量。我在训练循环里会打印当前 batch 平均持仓数、平均换手率、组合样本内夏普。这三个指标比单独看 loss 更能说明模型是否健康。如果持仓数一直走低说明 λ 的影响正在变大如果换手率飙高说明模型开始追逐噪声了。这套端到端方案并不是万能药它会增加系统复杂度也要处理更多训练细节。但当你把夏普比率真正写进可微层那一刻组合优化就从“预测好了再去优化”变成了“为了组合最优而学习预测”这个思路上的转变值回票价。