Yingjie

Machine Learning Meets Markowitz:E2E模型解析与实现代码

薛英杰 / 2026-11-13


阅读与运行方式

依据 Wang、Gao、Harvey、Liu、Tao 的 Machine Learning Meets Markowitz,NBER Working Paper 34861,2026年2月用户提供版本。页码均指论文印刷页码。代码是独立教学实现,不是作者源代码;不声称复现论文收益。

本文件将所有核心 Python 定义嵌入 R Markdown,各代码块按顺序共享同一 Python 会话,无须另行寻找 models.py。每节先列公式,再说明对应的代码对象和变量。Python 保留 PyTorch、CVXPYLayers 求解栈;R Markdown 通过 reticulate 执行 Python,并不是将数值实现重写为 R。

默认 run_python: false,可直接生成包含全部公式与代码的阅读版。执行方法:

install.packages(c("rmarkdown", "reticulate"))
# 在全新的 R 会话中,指定装好依赖的 Python;必须在初始化 Python 前设置。
Sys.setenv(RETICULATE_PYTHON = "/absolute/path/to/venv/bin/python")
rmarkdown::render("E2E_Markowitz_formula_code.Rmd",
                  params = list(run_python = TRUE))

Python 依赖:numpy scipy torch cvxpy cvxpylayers clarabel diffcp。默认关闭执行使没有 Python 依赖的 R 环境也可阅读。实际执行还需要 R、rmarkdown、Pandoc 和 reticulate。本次已核查 Python 代码与数值测试,但当前环境没有 Rscript,未实际 Knit HTML。

统一符号与计算对象

数学符号 代码变量 维度 决策时可见性
\(X_t\) X[t] \(N\times P\) 已知特征
\(\mu_t=g_\theta(X_t)\) model(X[t]) \(N\) 预测下一期收益
\(y_t=r_{t+1}\) Y[t] \(N\) 仅训练损失或事后评价可用
\(w_t\) w \(N\) 本期求解的目标持仓
\(p_t=w_{t-1}\) prev \(N\) 本期交易前持仓,简化忽略价格漂移
\(R_t^\top R_t=\widehat\Sigma_t\) R[t] \(N\times N\) 历史收益估计,不能使用未来数据
\(v_t\) market_cap \(N\) 决策时市值,严格正数
\(c,\eta,\gamma,\zeta\) cfg.cap/eta/gamma/zeta 标量 预先设定或仅在训练/验证期选择

所有收益和费率使用十进制单位。共同可行域为

\[ \mathcal C(c)=\{w\in\mathbb R^N: \mathbf1^\top w=1,\ 0\le w_i\le c\},\qquad Nc\ge1.\tag{D1} \]

模型 原文 下层目标(省略约束) 代码参数
均值—方差 §3.2,p.20 \(-\mu^\top w+\eta w^\top\Sigma w\) kind="mv", zeta=0, cap=1
等权硬选股 式(14) \(-\mu^\top w\) kind="ew", zeta=0, cap=10/N
原文平滑选股 式(15) \(-\mu^\top w+\zeta\|w\|_2\) kind="ew", regularizer="l2"
平方正则替代 本报告另列 \(-\mu^\top w+\zeta\|w\|_2^2\) regularizer="squared"
市值加权 式(16)–(17) 先选股,再按市值归一化 kind="vw"
交易成本 式(20) \(-\mu^\top w+\frac\gamma2\|w-p\|_1\) kind="tc", zeta=0
均值—标准差 式(21) \(-\mu^\top w+\frac\gamma2\|w-p\|_1+\eta\|Rw\|_2\) kind="sd", zeta=0, cap=.1

重要:式(15)的正则是未平方的 L2 范数;式(21)是标准差而非方差。 开启 zeta>0 用于 MV、TC、SD 时属于本报告的数值扩展,不是这些原式本身。

一般 E2E 与反向传播

双层目标

\[ \min_\theta L(\theta)=\frac1T\sum_{t=1}^T\ell_t(w_t^*,y_t),\qquad w_t^*=\arg\min_{w\in\mathcal C}f_l(w,g_\theta(X_t)).\tag{D2} \]

前向中先预测收益,再求最优持仓,最后用真实收益评价。反向中求解器需要返回持仓相对于预测收益的敏感性:

\[ \nabla_\theta L=\frac1T\sum_t \left(\frac{\partial\mu_t}{\partial\theta}\right)^\top \left(\frac{\partial w_t^*}{\partial\mu_t}\right)^\top \nabla_{w_t}\ell_t.\tag{D3} \]

式(D3)仅在各期下层独立时直接适用;含历史持仓时采用后文时序递推。下层凸不意味着对网络参数的上层目标凸,因此整体训练不保证全局最优。

KKT 隐式求导

下层为 \(\min_w f(w,\mu)\),满足 \(Aw=b\)、\(h(w,\mu)\le0\)。令

\[ \mathscr L=f+\lambda^\top h+\nu^\top(Aw-b). \]

KKT 条件包括驻点、原始可行性、对偶可行性及互补松弛。取 \(z=(w,\lambda,\nu)\),统一残差顺序:

\[ G(z,\mu)= \begin{bmatrix}\nabla_w\mathscr L\\\lambda\odot h\\Aw-b\end{bmatrix}=0. \]

\[ K=G_z= \begin{bmatrix} H & J_h^\top & A^\top\\ \operatorname{diag}(\lambda)J_h & \operatorname{diag}(h)&0\\ A&0&0 \end{bmatrix},\quad H=\nabla^2_{ww}\mathscr L,\quad \frac{\partial z}{\partial\mu}=-K^{-1}G_\mu.\tag{D4} \]

实现不显式存储大雅可比。求解伴随系统

\[ K^\top a=(\nabla_w\ell,0,0)^\top,\qquad \nabla_\mu\ell=-G_\mu^\top a.\tag{D5} \]

KKT 的必要性要求适当约束资格条件;解映射的经典隐式导数还需要局部非退化。L1、锥边界或活跃集切换处不保证处处可微。CVXPYLayers 将问题规范化为锥规划后求导,不是对 abs 机械使用光滑 Hessian。

代码对应: CvxpyLayer(...) 建立式(D4)–(D5)的可微求解路径;loss.backward() 负责式(D3)的完整链式传播。

预测层:Linear 与 NN1–NN5

\[ \mu_{i,t}=g_\theta(x_{i,t}),\qquad h^{(j)}=\operatorname{Dropout}_{.05}\!\left[ \operatorname{BN}(\operatorname{ReLU}(W_jh^{(j-1)}+b_j))\right].\tag{D6} \]

输入首先使用 LayerNorm;隐藏层宽度依次为32、16、8、4、2。depth=0使用线性模型,depth=1..5分别为NN1–NN5。这是同一优化框架的不同预测架构。

下面是后续各代码块共享的导入和参数定义。

from dataclasses import dataclass
import copy
import numpy as np
import cvxpy as cp
import torch
from torch import nn
from cvxpylayers.torch import CvxpyLayer
torch.set_default_dtype(torch.float64)

@dataclass
class Config:
    kind: str = 'sd'  # mv, ew, vw, tc, sd
    eta: float = 1.0
    gamma: float = 0.002
    zeta: float = 1e-4  # lower-level smoothing strength
    regularizer: str = "l2"  # paper Eq15: norm, NOT squared norm; alternative: squared
    cap: float = 0.1
class Predictor(nn.Module):
    def __init__(self, features, depth=3):
        super().__init__()
        if depth not in range(6):
            raise ValueError('depth must be 0 (linear) or 1..5')
        if depth == 0:
            self.net = nn.Linear(features, 1)
        else:
            layers = [nn.LayerNorm(features)]
            last = features
            for width in [32,16,8,4,2][:depth]:
                layers += [nn.Linear(last,width), nn.ReLU(), nn.BatchNorm1d(width), nn.Dropout(.05)]
                last = width
            self.net = nn.Sequential(*layers,nn.Linear(last,1))
    def forward(self,x):
        return self.net(x).squeeze(-1)

均值—方差模型:解析解到 QP

等式约束下的解析解

先取 \(\zeta=0\),\(H=2\eta\Sigma\);平方正则扩展时 \(H=2\eta\Sigma+2\zeta I\)。则

\[ \min_w\frac12w^\top Hw-\mu^\top w,\quad\mathbf1^\top w=1. \]

当 \(H\succ0\),驻点条件给出

\[ w=H^{-1}(\mu-\nu\mathbf1),\qquad \nu=\frac{\mathbf1^\top H^{-1}\mu-1}{\mathbf1^\top H^{-1}\mathbf1}.\tag{D7} \]

\[ J=\frac{\partial w}{\partial\mu}= H^{-1}-\frac{H^{-1}\mathbf1\mathbf1^\top H^{-1}}{\mathbf1^\top H^{-1}\mathbf1}.\tag{D8} \]

下面使用线性方程求解,而不是显式求逆。仅适用于预算等式约束;存在非负或持仓上限时使用后文通用优化层。

def mv_equality_solution(mu, Sigma, eta, zeta=0.0):
    n = len(mu)
    H = 2 * eta * Sigma + 2 * zeta * torch.eye(n, dtype=mu.dtype)
    e = torch.ones_like(mu)
    a = torch.linalg.solve(H, mu)
    b = torch.linalg.solve(H, e)
    nu = (e @ a - 1) / (e @ b)
    return a - nu * b  # 式(D7)

非负与上限约束

固定自由资产集合 \(F=\{i:0<w_i<c\}\),其余资产在局部固定为0或 \(c\)。导数满足

\[ \begin{bmatrix}H_{FF}&\mathbf1_F\\\mathbf1_F^\top&0\end{bmatrix} \begin{bmatrix}dw_F\\d\nu\end{bmatrix} =\begin{bmatrix}d\mu_F\\0\end{bmatrix}.\tag{D9} \]

上层实现损失及其梯度:

\[ \ell_{MV}=-y^\top w+\eta w^\top\Sigma w, \qquad\nabla_w\ell_{MV}=-y+2\eta\Sigma w.\tag{D10} \]

代码对应: cp.sum_squares(R @ w)为下层方差;(R @ w).square().sum()为上层方差。数值稳定正则只进入下层,避免将其混入投资者效用。

等权模型:LP、原文范数与平方范数

硬排序 LP

\[ w^*=\arg\min_{w\in\mathcal C(10/N)}-\mu^\top w.\tag{D11} \]

最优解按预测收益降序填满持仓上限。只有 \(N/10\) 为整数且排名无并列时,才严格等于前10%等权;否则最后一只股票获得残余权重。排名不变时权重局部不变,导数为0;排名切换时可能不连续或多值。

原文式(15):未平方 L2

\[ w^*=\arg\min_{w\in\mathcal C(c)}-\mu^\top w+\zeta\|w\|_2.\tag{D12} \]

由于预算约束,\(r=\|w\|_2>0\)。正则梯度和 Hessian 为

\[ \nabla_w\zeta r=\zeta\frac wr,\qquad \nabla^2_w\zeta r=\zeta\left(\frac Ir-\frac{ww^\top}{r^3}\right).\tag{D13} \]

将此 Hessian 放入式(D9)得到自由集合内的导数。也可引入 \(s\ge\|w\|_2\),使目标成为 \(-\mu^\top w+\zeta s\),得到 SOCP。

两资产、\(c=1\)、\(d=\mu_1-\mu_2\)时:

\[ w_1^*=\begin{cases} 0,&d\le-\zeta,\\ \frac12+\frac{d}{2\sqrt{2\zeta^2-d^2}},&|d|<\zeta,\\ 1,&d\ge\zeta. \end{cases}\tag{D14} \]

代码对应: regularizer="l2"选择 cp.norm(w, 2);不是 sum_squares。范数在预算超平面上的可行线段严格凸,因而解唯一,但活跃集切换处仍不保证经典导数存在。

平方 L2:明确区分的替代方案

\[ w^*=\arg\min_{w\in\mathcal C(c)}-\mu^\top w+\zeta\|w\|_2^2 =\Pi_{\mathcal C(c)}\left(\frac\mu{2\zeta}\right).\tag{D15} \]

\[ w_i^*=\operatorname{clip}\left(\frac{\mu_i-\nu}{2\zeta},0,c\right), \qquad\sum_iw_i^*=1.\tag{D16} \]

固定自由集合时

\[ \frac{\partial w_i^*}{\partial\mu_j} =\frac1{2\zeta}\left(\delta_{ij}-\frac1{|F|}\right),\quad i,j\in F,\tag{D17} \]

其余坐标局部导数为0。两资产解为 \(w_1=\operatorname{clip}(1/2+d/(4\zeta),0,1)\),与式(D14)不同。

代码对应: regularizer="squared"选择 cp.sum_squares(w)。这不是原文式(15)。平滑权重通常不再严格等权,甚至在某些顶点区域仍可能梯度为0;不能宣称所有梯度都得到恢复。

市值加权模型:两阶段导数

第一阶段得到选股权重 \(a\),第二阶段用市值 \(v\) 转换:

\[ s=v^\top a>0,\qquad q_i=\frac{v_i a_i}{s}.\tag{D18} \]

\[ \frac{\partial q}{\partial a}=\frac{\operatorname{diag}(v)}s -\frac{(v\odot a)v^\top}{s^2},\qquad \frac{\partial(-y^\top q)}{\partial a_j} =-\frac{v_j}{s}(y_j-q^\top y).\tag{D19} \]

代码对应: w = w * market_cap; w = w / w.sum()。PyTorch 自动将式(D19)与优化层导数相乘。式(16)字面使用硬LP,因此仅增加归一化不能恢复其梯度;训练时需显式启用选股平滑。

最终 \(q\) 可能超过第一阶段的上限 \(c\),不能把 \(a_i\le c\) 解释为 \(q_i\le c\)。若约束最终权重,需重新设计可行域。

交易成本模型:LP 与时序梯度

令 \(\kappa=\gamma/2\)。论文式(20)的下层及上层分别为

\[ w_t^*=\arg\min_{w\in\mathcal C(c)} -\mu_t^\top w+\kappa\|w-p_t\|_1,\quad p_t=w_{t-1}^*,\tag{D20} \]

\[ L_{TC}=\frac1T\sum_t\left[-y_t^\top w_t+\kappa\|w_t-p_t\|_1\right].\tag{D21} \]

引入 \(u\ge0\),成本项可写为 \(\kappa\mathbf1^\top u\),约束 \(w-p\le u\)、\(p-w\le u\)。原问题为LP;加平方正则得到QP;加未平方L2正则得到SOCP。这些正则是式(20)的可选扩展。

代码对应: 下层 cfg.gamma/2 * cp.norm1(w-prev);上层 cfg.gamma/2 * (w-prev).abs().sum()。费率口径遵循论文的 \(\gamma/2\),实际单边收费定义若不同,需要先统一。

令 \(w_t=h_t(\mu_t,w_{t-1})\),\(A_t=\partial h_t/\partial\mu_t\),\(B_t=\partial h_t/\partial w_{t-1}\),则

\[ D_t=\frac{\partial w_t}{\partial\theta} =A_t\frac{\partial\mu_t}{\partial\theta}+B_tD_{t-1}.\tag{D22} \]

对单期损失 \(\ell_t(w_t,w_{t-1})\),总反向伴随满足

\[ a_t=\partial_1\ell_t+\partial_2\ell_{t+1}+B_{t+1}^\top a_{t+1},\quad \nabla_\theta L=\frac1T\sum_t \left(\frac{\partial\mu_t}{\partial\theta}\right)^\top A_t^\top a_t.\tag{D23} \]

最后一期没有未来项。式(D23)对未平均的损失和定义伴随,最终统一除以 \(T\)。代码用 torch.stack(losses).mean() 自动完成尺度处理。

代码对应: rollout内部每期 prev = w,不调用 detach(),以保留式(D22)的跨期梯度。按日打乱或逐日detach会破坏这条链。连续8期分块并detach是截断反传近似,不是完整梯度。

若考虑价格漂移,可改为

\[ p_t=\frac{w_{t-1}\odot(1+r_t)}{1+w_{t-1}^\top r_t}, \]

同时保留这个变换的梯度;\(r_t\)必须在决策时已经实现。当前代码遵循论文简化,无资产池变化与价格漂移处理。

风险偏好模型:Mean–SD 与 SOCP

论文式(21)为

\[ w_t^*=\arg\min_{w\in\mathcal C(.1)} -\mu_t^\top w+\kappa\|w-p_t\|_1+\eta\sqrt{w^\top\Sigma_tw}.\tag{D24} \]

使用 \(R_t^\top R_t=\Sigma_t\),风险项为 \(\eta\|R_tw\|_2\),引入辅助变量 \(s\ge\|R_tw\|_2\) 即可表示为SOCP。上层用真实收益替换预测收益,其余经济惩罚保持相同。

对 \(\sigma=\sqrt{w^\top\Sigma w}>0\),

\[ \nabla_w\sigma=\frac{\Sigma w}{\sigma},\qquad \nabla_w^2\sigma=\frac\Sigma\sigma- \frac{(\Sigma w)(\Sigma w)^\top}{\sigma^3}.\tag{D25} \]

因而 \(\nabla_w\ell=-y+\kappa\operatorname{sign}(w-p)+\eta\Sigma w/\sigma\),在L1折点使用次梯度或锥求导。

代码对应: 下层 cp.norm(R @ w, 2),上层 torch.linalg.vector_norm(R @ w)。不能写成方差。Mean–SD的Hessian在径向方向具有零曲率,不能据此承诺强凸或普遍更稳定。相同 \(\eta\) 也不意味着Mean–SD与Mean–Var持仓相同;在同一候选最优点匹配一阶条件时有 \(\eta_{SD}=2\eta_{MV}\sigma(w)\)。

通用优化层:把上述公式集中为可执行代码

以下完整定义覆盖式(D10)–(D24)的下层问题:

代码位置 对应公式 含义
obj = -mu @ w + ... D12、D15 预测收益与可选正则
cp.norm1(w-prev) D20 交易成本
cp.sum_squares(R@w) D10 方差
cp.norm(R@w,2) D24 标准差
[w>=0,w<=cap,cp.sum(w)==1] D1 共同可行域
CvxpyLayer D4–D5 可微锥求解与伴随梯度
市值归一化两行 D18–D19 市值权重及链式传播

用 \(R\) 而非直接把可变协方差放入 quad_form,使参数化问题满足DPP要求。求解器使用Clarabel。

class Portfolio(nn.Module):
    def __init__(self,n,cfg):
        super().__init__()
        if cfg.kind not in {'mv','ew','vw','tc','sd'}:
            raise ValueError('unknown model')
        if cfg.cap*n < 1-1e-12 or not 0 < cfg.cap <= 1:
            raise ValueError('infeasible concentration cap')
        if min(cfg.eta,cfg.gamma,cfg.zeta)<0:
            raise ValueError('penalties must be nonnegative')
        self.cfg = cfg
        w=cp.Variable(n)
        mu=cp.Parameter(n)
        prev=cp.Parameter(n)
        # R.T @ R = Sigma. Passing the factor makes the problem DPP.
        R=cp.Parameter((n,n))
        if cfg.regularizer not in {"l2", "squared"}:
            raise ValueError("regularizer must be l2 or squared")
        penalty = cp.norm(w,2) if cfg.regularizer=="l2" else cp.sum_squares(w)
        obj=-mu@w+cfg.zeta*penalty
        params=[mu]
        if cfg.kind in {'tc','sd'}:
            obj += cfg.gamma/2*cp.norm1(w-prev)
            params.append(prev)
        if cfg.kind in {'mv','sd'}:
            obj += cfg.eta*(cp.sum_squares(R@w) if cfg.kind=='mv' else cp.norm(R@w,2))
            params.append(R)
        problem=cp.Problem(cp.Minimize(obj),[w>=0,w<=cfg.cap,cp.sum(w)==1])
        assert problem.is_dpp()
        self.layer=CvxpyLayer(problem,parameters=params,variables=[w])
    def forward(self,mu,prev,R,market_cap=None):
        args=[mu]
        if self.cfg.kind in {'tc','sd'}: args.append(prev)
        if self.cfg.kind in {'mv','sd'}: args.append(R)
        (w,)=self.layer(*args,solver_args={'solve_method':'Clarabel', 'tol_gap_abs':1e-10, 'tol_gap_rel':1e-10, 'tol_feas':1e-10})
        if self.cfg.kind=='vw':
            if market_cap is None or bool((market_cap<=0).any()):
                raise ValueError('strictly positive market caps required')
            w=w*market_cap
            w=w/w.sum()
        return w

协方差估计与矩阵方向

若 Cholesky 返回 \(\Sigma=LL^\top\),应传入 \(R=L^\top\),才能保证 \(\|Rw\|_2^2=w^\top\Sigma w\)。默认对角估计对应原文风险设定;额外ridge属于数值处理。估计窗口必须截止于决策时点。

def covariance_factor(history, ridge=1e-8, diagonal=True):
    """Use only returns observable at decision time. Output R.T R = Sigma."""
    cov=np.atleast_2d(np.cov(np.asarray(history),rowvar=False,ddof=1))
    if diagonal: cov=np.diag(np.diag(cov))
    cov=(cov+cov.T)/2+ridge*np.eye(cov.shape[0])
    return torch.tensor(np.linalg.cholesky(cov).T)

上层效用与时序展开

以下两段分别对应上层经济损失和式(D22)的顺序展开。realized_loss不包含下层平滑正则。VW传入的w已经是最终市值权重。

def realized_loss(w,y,prev,R,cfg):
    loss=-(w*y).sum()
    if cfg.kind in {'tc','sd'}: loss=loss+cfg.gamma/2*(w-prev).abs().sum()
    if cfg.kind=='mv': loss=loss+cfg.eta*(R@w).square().sum()
    if cfg.kind=='sd': loss=loss+cfg.eta*torch.linalg.vector_norm(R@w)
    return loss
def rollout(model,layer,X,Y,R,w0,market_caps=None):
    """No detach inside a sequence: full BPTT through previous holdings."""
    prev=w0
    losses=[]
    weights=[]
    for t in range(len(X)):
        w=layer(model(X[t]),prev,R[t],None if market_caps is None else market_caps[t])
        losses.append(realized_loss(w,Y[t],prev,R[t],layer.cfg))
        weights.append(w)
        prev=w
    return torch.stack(losses).mean(),torch.stack(weights)

预训练、Adam 与验证早停

预训练目标为

\[ L_{MSE}(\theta)=\frac1T\sum_t\frac1N\|g_\theta(X_t)-y_t\|_2^2.\tag{D26} \]

然后以 \(L_{E2E}\) 替换训练目标,保留网络参数作为warm start。MSE只用于初始化,不在本实现的E2E损失中额外混入。

fit每轮训练都从指定初始持仓重新推进;更新参数后,用当前参数重新计算训练段末持仓,再推进验证段。验证标签只用于损失,不进入下层决策。记录最佳验证参数并恢复。

本实现全序列反传,未逐字实现论文8个横截面的梯度累积安排;大规模应用需选择完整反传或明确标注的截断近似。学习率采用论文的一般设置,另加入梯度裁剪用于稳定数值。原文验证集为9个月且按季度滚动重训,本演示仅做合成时间切分。

def pretrain(model,X,Y,epochs=10,lr=1e-3):
    model.train()
    opt=torch.optim.Adam(model.parameters(),lr=lr)
    for _ in range(epochs):
        opt.zero_grad()
        loss=torch.stack([(model(x)-y).square().mean() for x,y in zip(X,Y)]).mean()
        loss.backward(); opt.step()
def fit(model,layer,train,valid,w0,epochs=50,patience=10):
    """train/valid=(X,Y,R,caps). Full-sequence reference training, not 8-day TBPTT.
    Validation holdings are recomputed from training under current fixed parameters.
    """
    import copy
    opt=torch.optim.Adam(model.parameters(),lr=1e-4 if layer.cfg.eta<1 else 1e-3)
    best=float('inf'); state=None; stale=0; log=[]
    for epoch in range(epochs):
        model.train(); opt.zero_grad()
        loss,_=rollout(model,layer,*train[:3],w0,train[3])
        loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(),10); opt.step()
        model.eval()
        with torch.no_grad():
            _,train_w=rollout(model,layer,*train[:3],w0,train[3])
            val,_=rollout(model,layer,*valid[:3],train_w[-1],valid[3])
        score=float(val); log.append([epoch,float(loss.detach()),score])
        if score<best:
            best=score; state=copy.deepcopy(model.state_dict()); stale=0
        else: stale+=1
        if stale>=patience: break
    model.load_state_dict(state)
    return log

可执行合成数据示例

下列示例按顺序运行:构造数据、预训练、E2E训练、验证。为了观察小型横截面的稳定自由集,示例使用较强的平方正则替代模型。它不是论文默认超参数。\(N=20,c=.1\)最多单只10%,并不表示前10%选股;若要前10%,应设 \(c=10/N=.5\)。

真实数据应提供 X:[T,N,P]、Y:[T,N]、R:[T,N,N]、market_caps:[T,N]和初始持仓w0:[N]。初始均匀持仓意味着未计入首次由现金建仓的费用。

torch.manual_seed(17)
T, N, P = 12, 20, 5
X = torch.randn(T, N, P)
Y = .004 * X[:, :, 0] + .01 * torch.randn(T, N)
R = torch.diag(torch.linspace(.015, .03, N)).repeat(T, 1, 1)
market_caps = torch.exp(torch.randn(T, N))
w0 = torch.ones(N) / N

training_summary = {}
for kind in ["mv", "ew", "vw", "tc", "sd"]:
    cfg = Config(kind=kind, eta=1, gamma=.002, zeta=.02,
                 regularizer="squared", cap=.1)
    model = Predictor(P, depth=0)
    layer = Portfolio(N, cfg)
    pretrain(model, X[:8], Y[:8], epochs=2)
    train = (X[:8], Y[:8], R[:8], market_caps[:8])
    valid = (X[8:], Y[8:], R[8:], market_caps[8:])
    log = fit(model, layer, train, valid, w0, epochs=3)
    model.eval()
    with torch.no_grad():
        loss, W = rollout(model, layer, X, Y, R, w0, market_caps)
    assert torch.isfinite(W).all()
    assert torch.max(abs(W.sum(1) - 1)) < 1e-5
    assert W.min() > -1e-5
    if kind != "vw":
        assert W.max() < cfg.cap + 1e-5
    training_summary[kind] = {"loss": float(loss), "epochs": len(log)}
print(training_summary)

解析公式与自动微分的数值核查

核查原文范数式(D14)

先检验两资产权重,再检验内点导数:

\[ \frac{\partial w_1}{\partial\mu_1}= \frac{\zeta^2}{(2\zeta^2-d^2)^{3/2}},\quad |d|<\zeta.\tag{D27} \]

norm_solution_errors = []
for d in [-.15, -.07, 0, .07, .15]:
    w_cp = cp.Variable(2)
    prob = cp.Problem(cp.Minimize(-np.array([d, 0]) @ w_cp + .1 * cp.norm(w_cp, 2)),
                      [w_cp >= 0, cp.sum(w_cp) == 1])
    prob.solve(solver="CLARABEL", tol_gap_abs=1e-10, tol_feas=1e-10)
    exact = (0 if d <= -.1 else 1 if d >= .1
             else .5 + d / (2 * np.sqrt(.02 - d*d)))
    norm_solution_errors.append(abs(float(w_cp.value[0]) - exact))

norm_layer = Portfolio(2, Config(kind="ew", cap=1, zeta=.1, regularizer="l2"))
mu = torch.tensor([.03, 0.], requires_grad=True)
w = norm_layer(mu, torch.ones(2)/2, torch.eye(2))
grad = torch.autograd.grad(w[0], mu)[0][0]
exact_grad = .01 / (.02 - .03**2)**1.5
assert max(norm_solution_errors) < 1e-4
assert abs(float(grad)-exact_grad) < 1e-3
print({"max_weight_error": max(norm_solution_errors),
       "derivative_error": abs(float(grad)-exact_grad)})

核查式(D3)中的优化层方向导数

对固定评价向量 \(b\) 和方向 \(d\),检查

\[ d^\top\nabla_\mu[b^\top w^*(\mu)] \approx\frac{b^\top w^*(\mu+hd)-b^\top w^*(\mu-hd)}{2h}.\tag{D28} \]

测试点应远离活跃集切换与L1折点。步长太小会放大求解器误差;这里使用 \(h=10^{-3}\)。成本模型的测试锚点使用零向量,仅为使L1符号在邻域中固定,不表示真实账户的预算持仓。

gradient_checks = {}
for kind in ["mv", "ew", "vw", "tc", "sd"]:
    layer_check = Portfolio(N, Config(kind=kind, eta=1, gamma=.002,
                            zeta=.02, cap=.1, regularizer="squared"))
    mu0 = torch.zeros(N, requires_grad=True)
    anchor = torch.zeros(N) if kind in {"tc", "sd"} else w0
    direction = torch.linspace(-1, 1, N)
    direction = direction / direction.norm()
    reward = torch.cos(torch.arange(N, dtype=torch.float64))
    def scalar(m):
        return (layer_check(m, anchor, R[0], market_caps[0]) * reward).sum()
    automatic = torch.autograd.grad(scalar(mu0), mu0)[0] @ direction
    h = 1e-3
    finite_diff = (scalar(mu0.detach()+h*direction)
                   - scalar(mu0.detach()-h*direction)) / (2*h)
    error = float(abs(automatic-finite_diff)/(1+abs(finite_diff)))
    assert error < 2e-3, (kind, error)
    gradient_checks[kind] = error
print(gradient_checks)

已有测试记录与范围

以下是上一版独立Python脚本实际执行得到的记录,不伪装成R Knit输出。当前文档的全部12个Python代码块也已按顺序提取并独立执行通过,结果一致。

模型 优化层方向导数误差 完成训练轮数
mv 0.0011764 3
ew 0.0000956 3
vw 0.0005764 3
tc 0.0000228 3
sd 0.0000920 3

误差定义为式(D28)两边差的绝对值除以 \(1+|\text{中心差分}|\)。原文未平方范数两资产权重误差约 \(2.68\times10^{-7}\),内点导数绝对误差约 \(5.16\times10^{-7}\)。

这些检查覆盖小规模可行性、训练接口和单期优化层梯度;不等于核查了所有活跃集边界、完整时序梯度、大规模股票池或全部NN深度。未取得真实研究样本,不能声称复现收益与显著性。

公式—代码定位总表

推导编号 概念 对应代码块 关键对象
D1 预算、非负、持仓上限 portfolio-optimization-layer cp.Problem约束
D2–D3 E2E损失与链式法则 temporal-rollout, e2e-fit mean, backward
D4–D5 隐式求导与伴随 portfolio-optimization-layer CvxpyLayer内部实现
D6 线性/神经网络预测 prediction-layer Predictor
D7–D8 等式约束MV解析解 mv-equality-solution torch.linalg.solve
D9 活跃集导数 portfolio-optimization-layer 可微锥求解器处理约束
D10 MV上下层效用 portfolio-optimization-layer, realized-objective 方差分支
D11–D14 LP与原文L2平滑 portfolio-optimization-layer, check-original-norm norm与解析核查
D15–D17 平方L2替代 portfolio-optimization-layer sum_squares
D18–D19 市值归一化与导数 portfolio-optimization-layer kind="vw"
D20–D21 成本上下层目标 优化层、realized-objective norm1, abs
D22–D23 跨期依赖与反传 temporal-rollout, e2e-fit prev=w, backward
D24–D25 Mean–SD及风险导数 优化层、realized-objective 二阶锥风险分支
D26 MSE预训练 mse-pretrain MSE均值
D27–D28 解析/数值梯度核查 最后两个检查块 autograd.grad

复现边界与扩展

  1. MSE-Opt:先独立训练MSE预测器,冻结参数,再调用同一个优化层;不要反向传播经济损失。MSE-Top:冻结后用硬top-k;硬排序仅供评价,不能当成可训练梯度层。PPP直接输出权重,和本文E2E并非同一结构。
  2. 原文EW/VW/TC上限为 \(10/N\),Mean–SD上限为 \(.1\),MV示例仅非负与预算。代码参数必须按所分析的模型选择。
  3. 平滑训练后若切换硬top-k评价,须额外报告这一切换的效果。VW归一化后不承诺保留初始上限。
  4. 真实研究需隔离训练、验证、测试,避免未来协方差与标签泄漏,并处理证券退出、停牌、可成交性、特征缺失和持有期重叠。这里固定资产池,不额外承诺可实盘成交。
  5. 后续若采用连续时间块截断反传,应明确标为近似,并验证截断长度的敏感性。完整时序计算图内不能随意detach。

参考定位