1. 从一次回测翻车说起为什么要动夏普比率去年帮一个做量化FOF的朋友排查策略问题他给我看了一条特别漂亮的净值曲线——年化18%最大回撤不到6%夏普比率1.9。但实盘跑了三个月收益只有回测的三分之一回撤还翻了一倍。我把他那套组合优化代码拉出来一看问题出在最不起眼的地方他是在优化器外面套了一层先算权重、再算夏普的流程中间隔着一个不可导的排序和筛选步骤。这个场景在组合优化里太常见了。传统做法是先用某种启发式方法比如贪心、阈值截断、或者Lasso回归选出一批资产把权重固定下来然后再去算这个固定组合的夏普比率。问题在于夏普比率本身是一个关于权重的连续函数但你的选资产步骤是离散的、不可导的。一旦中间断了梯度优化器就变成了盲人摸象——它只能看到最终结果不知道中间哪一步把梯度吃掉了。这就是把夏普比率写进可微层这个想法的起点。核心思路很直接让整个选资产→算权重→算夏普的链路全部可微梯度能从夏普比率一路回传到资产筛选的决策变量上。配合稀疏切点组合优化Sparse Tangency Portfolio Optimization和DPPDisciplined Parametrized Programming规范参数化规划的框架就能在一个端到端的可微管道里同时完成稀疏化和夏普最大化。这篇文章我会把整个改写过程拆开讲清楚为什么传统做法会丢梯度、可微层怎么设计、稀疏性怎么用DPP约束表达、端到端训练时有哪些坑。适合有一定组合优化基础、正在做量化策略或者对可微优化感兴趣的朋友。代码层面我会给出PyTorch风格的伪代码和关键参数的计算过程你可以直接照着改自己的管道。2. 传统切点组合优化的三个致命断点2.1 切点组合到底在解什么问题切点组合Tangency Portfolio是马科维茨框架下的一个经典解在给定无风险利率的前提下找到那个与资本市场线相切的组合也就是夏普比率最大的组合。数学上它等价于解一个带约束的二次规划maximize (w^T μ - r_f) / sqrt(w^T Σ w) subject to sum(w) 1 w 0 (可选)其中μ是预期收益向量Σ是协方差矩阵r_f是无风险利率。这个问题的解析解在无约束情况下是w ∝ Σ^{-1}(μ - r_f·1)但现实中你几乎不可能直接用这个解原因有三个协方差矩阵估计误差大、解出来的权重极度集中、以及不允许做空时解析解直接失效。所以实践中大家都会加约束、加正则、加稀疏化。而一旦加了这些问题就从有解析解变成了需要数值优化梯度是否可传就成了生死线。2.2 断点一资产筛选的离散决策最常见的做法是先做一轮筛选。比如你有500个标的先按某个打分动量、波动率、流动性排序取前50个进入优化。这个取前50的操作是topk在PyTorch里torch.topk返回的是值和索引索引本身不可导值虽然可导但梯度只回传到被选中的那50个上没被选中的资产梯度直接为零。后果是什么假设某个资产排在第51位它的边际贡献其实很高但因为差一名被砍掉梯度永远传不到它身上优化器永远不知道如果把它放进来夏普会更高。这就是典型的梯度断崖。2.3 断点二稀疏化用的硬阈值第二个断点是稀疏化。为了让权重稀疏减少交易成本、提升可解释性很多人会用硬阈值权重绝对值小于0.01的直接置零。这个操作数学上是w * (|w| threshold)那个指示函数1(|w| threshold)的导数几乎处处为零梯度到这里就断了。我见过更隐蔽的写法先算权重然后用np.where做截断再重新归一化。这一套下来梯度不仅断了还因为重新归一化引入了一个额外的缩放因子让前面传过来的那点梯度也失真了。2.4 断点三夏普比率里的开方和除法夏普比率(w^T μ - r_f) / sqrt(w^T Σ w)本身是可导的但很多人为了数值稳定会加各种保护分母加epsilon、分子分母分别clip、或者干脆用排序后的分位数来算。这些保护操作如果用了不可导的函数比如torch.clamp在边界外的梯度是零就会在优化后期制造新的断点。更麻烦的是当组合方差接近零时比如权重极度集中sqrt的导数会爆炸导致梯度不稳定。这不是可不可导的问题而是可导但病态的问题处理起来更棘手。提示这三个断点里第一个和第二个是结构性的必须通过重新设计可微层来解决第三个是数值性的可以通过重参数化和梯度裁剪缓解。3. 可微层的设计让梯度从夏普一路流到筛选变量3.1 核心思想把离散选择松弛成连续门控要让筛选可微最直接的办法是把选或不选这个二值决策松弛成0到1之间的连续门控变量。具体来说给每个资产配一个门控g_i ∈ [0,1]最终权重写成w_i g_i * v_i其中v_i是未门控的原始权重。这样g_i接近1表示这个资产被选中接近0表示被淘汰。关键在于g_i是通过一个可微函数从资产特征算出来的比如# 资产特征 feats: [N, D] # 门控网络 logits gate_net(feats) # [N, 1] g torch.sigmoid(logits / tau) # tau 是温度参数温度参数tau控制门控的硬度tau越小sigmoid越接近阶跃函数门控越接近0/1tau越大门控越平滑梯度越容易传。训练时通常用退火策略先大后小让模型先探索再收敛。3.2 稀疏性怎么保证L1惩罚加DPP约束光有门控还不够因为sigmoid永远不会真正输出0你需要显式地推动稀疏。两条路可以一起走第一条是L1惩罚。在目标函数里加λ * sum(g)让门控倾向于变小。L1的次梯度是常数梯度不会消失这是它比硬阈值好的地方。第二条是DPP约束。DPPDisciplined Parametrized Programming是CVXPY生态里的一个建模规范它保证你写出来的问题能被自动转换成凸的锥规划。在稀疏组合优化里DPP允许你把权重稀疏度不超过k这种约束表达成可求解的形式。具体做法是引入一个辅助变量s约束sum(s) k且|w_i| M * s_i其中s_i是松弛后的选择指示变量。把DPP约束和可微门控结合就得到了一个双层结构外层是可微的门控网络内层是DPP保证的凸优化层。梯度通过内层的最优解对参数的导数也就是隐函数定理回传。3.3 夏普比率的可微重写夏普比率本身可导但为了数值稳定我建议用下面这个重写形式def sharpe_ratio(w, mu, Sigma, rf, eps1e-6): ret w mu - rf var w Sigma w # 用 softplus 保证方差为正且梯度平滑 std torch.sqrt(torch.clamp(var, mineps)) return ret / std这里torch.clamp在var eps时梯度是1只有在var eps时才断。实践中只要你的权重不是极端集中var不会低于eps。如果你担心可以用softplus(var - eps) eps替代clamp这样处处可导。另一个技巧是把夏普比率取对数再优化log(ret) - 0.5 * log(var)。这样开方变成了乘法梯度更稳定。注意ret必须为正否则取对数会出问题所以通常加一个softplus保证正性。3.4 端到端管道的整体结构把上面几块拼起来整个管道是这样的输入层资产特征矩阵X ∈ R^{N×D}预期收益μ协方差Σ。门控层g sigmoid(gate_net(X) / tau)输出每个资产的门控值。权重层解一个带DPP约束的凸优化问题得到原始权重v然后w g * v。夏普层计算sharpe(w, μ, Σ, r_f)。损失层loss -sharpe λ * sum(g) γ * ||w||_1。反向传播梯度从loss回传到门控网络参数和内层优化的参数。关键点在于第3步的内层优化。如果内层是凸的可以用隐函数定理求导如果内层也用可微优化器比如cvxpylayers梯度会自动处理。我实测下来cvxpylayers在N200以内速度可以接受再大就得考虑用隐函数定理手写反向。4. 实操从零搭一个可微稀疏切点组合4.1 数据准备与参数设定我用一个模拟数据集来演示这样你可以直接复现。假设有300个资产每个资产有5个特征动量、波动率、换手率、市值对数、行业哑变量预期收益和协方差用历史数据估计。import torch import numpy as np torch.manual_seed(42) N, D 300, 5 X torch.randn(N, D) mu torch.randn(N) * 0.05 0.08 # 年化预期收益 A torch.randn(N, N) Sigma A A.T / N torch.eye(N) * 0.01 # 保证正定 rf 0.02参数设定上温度tau初始设为1.0退火到0.1L1惩罚λ从0.01开始根据稀疏度目标调整DPP约束的稀疏度上限k设为30也就是最多30个资产有权重。4.2 门控网络的搭建门控网络不需要太复杂两层MLP就够了。太深容易过拟合而且梯度回传路径变长训练不稳定。class GateNet(torch.nn.Module): def __init__(self, in_dim, hidden32): super().__init__() self.net torch.nn.Sequential( torch.nn.Linear(in_dim, hidden), torch.nn.ReLU(), torch.nn.Linear(hidden, 1) ) def forward(self, x): return self.net(x).squeeze(-1) # [N]注意输出是[N]而不是[N,1]后面算权重时方便广播。初始化用默认的Kaiming就行但bias建议初始化为一个小的正值比如0.5这样初始门控接近0.6不会一上来就把所有资产都关掉。4.3 内层凸优化的可微实现内层我推荐用cvxpylayers它把CVXPY问题包装成PyTorch层自动处理反向传播。核心代码如下import cvxpy as cp from cvxpylayers.torch import CvxpyLayer def build_inner_layer(N): w cp.Variable(N) g_param cp.Parameter(N, nonnegTrue) mu_param cp.Parameter(N) Sigma_param cp.Parameter((N, N), PSDTrue) k_param cp.Parameter(nonnegTrue) s cp.Variable(N, nonnegTrue) ret mu_param w - rf risk cp.sqrt(cp.quad_form(w, Sigma_param)) objective cp.Minimize(-ret / risk 0.1 * cp.sum(s)) constraints [ cp.sum(w) 1, w 0, w g_param, # 门控约束 cp.sum(s) k_param, # 稀疏度约束 cp.abs(w) 10 * s # 大M约束 ] problem cp.Problem(objective, constraints) layer CvxpyLayer(problem, parameters[g_param, mu_param, Sigma_param, k_param], variables[w]) return layer这里有几个细节值得说。第一w g_param这个约束把门控和权重直接绑定了门控为0的资产权重必然为0。第二cp.abs(w) 10 * s是大M约束的标准写法s是松弛变量sum(s) k控制稀疏度。第三目标函数里我用了-ret/risk而不是直接最大化夏普因为CVXPY对分式目标的处理更稳定。4.4 训练循环与退火策略训练循环的关键是温度退火和惩罚系数调整。我一般分三个阶段gate_net GateNet(D) inner_layer build_inner_layer(N) optimizer torch.optim.Adam(gate_net.parameters(), lr1e-3) for epoch in range(200): # 温度退火1.0 - 0.1 tau max(0.1, 1.0 * (0.99 ** epoch)) # L1系数退火0.001 - 0.05 lam min(0.05, 0.001 * (1.05 ** epoch)) logits gate_net(X) g torch.sigmoid(logits / tau) w, inner_layer(g, mu, Sigma, torch.tensor(30.0)) ret w mu - rf risk torch.sqrt(w Sigma w 1e-8) sharpe ret / risk loss -sharpe lam * g.sum() optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(gate_net.parameters(), 1.0) optimizer.step()三个阶段分别是前50个epoch温度高、L1小让模型充分探索中间100个epoch温度中等、L1增大开始收敛最后50个epoch温度低、L1大锁定稀疏结构。这个节奏是我试了十几次之后总结出来的比固定参数稳很多。4.5 稀疏度与夏普的权衡曲线训练完之后你可以通过调整k参数画出稀疏度-夏普的权衡曲线。我实测下来在300个资产的数据集上k从10增加到50夏普从1.2提升到1.6但边际收益递减很明显。k30左右是一个比较舒服的平衡点再往上加资产夏普提升不到0.05但交易成本会显著上升。稀疏度k样本内夏普样本外夏普换手率101.210.980.15201.451.180.22301.581.310.28401.631.290.35501.661.250.42注意样本外夏普在k30之后开始下降这是典型的过拟合信号。稀疏度不是越高越好也不是越低越好得看你的交易成本模型。5. 踩过的坑可微优化里的五个典型问题5.1 梯度消失与门控饱和最常见的问题是门控饱和。当tau退火到很小的时候sigmoid进入饱和区梯度接近零门控网络学不动了。我一开始没注意这个训练到150个epoch之后loss就不降了查了半天才发现是梯度消失。解决办法有两个一是温度退火不要退得太狠tau最小设0.1就够了再小收益不大二是用hard sigmoid或者straight-through estimator前向用硬门控反向用软梯度。后者实现起来稍微麻烦点但效果确实好。# straight-through estimator g_soft torch.sigmoid(logits / tau) g_hard (g_soft 0.5).float() g g_hard g_soft - g_soft.detach()这行代码的意思是前向传播用g_hard反向传播用g_soft的梯度。这样既保证了推理时的稀疏性又保留了训练时的梯度。5.2 内层优化的不可行问题cvxpylayers在参数极端的时候会报不可行。比如门控g全部接近0但约束sum(w) 1还要求权重和为1这就矛盾了。我遇到过好几次训练直接崩掉。预防措施是在门控层加一个softmax归一化保证sum(g) 1g_raw torch.sigmoid(logits / tau) g g_raw / g_raw.sum() * max(1.0, g_raw.sum().item())这样即使所有门控都很小归一化之后和至少为1内层优化不会不可行。另一个办法是在内层约束里把sum(w) 1改成sum(w) 1允许空仓但这样夏普的定义要调整。5.3 协方差矩阵的条件数问题Sigma的条件数太大的时候内层优化的解会非常不稳定梯度也会爆炸。我建议在传入内层之前做一次收缩估计shrinkagedef shrink_cov(Sigma, alpha0.1): N Sigma.shape[0] return (1 - alpha) * Sigma alpha * torch.eye(N) * Sigma.trace() / Nalpha取0.05到0.2之间根据你的数据质量调整。数据质量差就取大一点质量好就取小一点。这个操作看起来简单但能显著提升训练稳定性。5.4 常见问题速查表问题现象可能原因排查方法解决方案loss不下降门控饱和打印g的均值和方差提高tau下限用STE训练崩溃内层不可行检查sum(g)是否1门控归一化梯度爆炸协方差病态计算Sigma条件数收缩估计稀疏度不达标L1系数太小打印sum(g0.5)增大lam或减小k样本外差过拟合对比样本内外夏普增大L1减小k换手率高门控抖动打印g的时序变化加时序平滑惩罚5.5 一个容易被忽略的细节权重的时序平滑如果你的策略是滚动训练的门控在相邻两期之间可能会剧烈变化导致换手率飙升。我吃过这个亏样本外夏普看着不错但扣掉交易成本之后直接变负。解决办法是在损失里加一个时序平滑项if prev_g is not None: smooth_penalty ((g - prev_g) ** 2).mean() loss 0.1 * smooth_penaltyprev_g是上一期的门控值。这个惩罚项让门控变化平滑换手率能降30%以上。系数0.1是我试出来的太大