量化交易中文教材

第 10 章 机器学习与神经网络在量化中的应用

机器学习在量化中的难点不在模型,而在数据:信噪比极低,样本之间高度相关,规律会随时间变化。本章用一个带非线性信号和"规律反转"的合成股票面板,把一条完整的机器学习流程走一遍,结论有五条。第一,可预测的部分非常小:即使知道真实的预期收益,5 日 RankIC 也只有 0.04 左右,任何声称远高于此的回测都应先怀疑泄露。第二,验证方案比模型选择重要:在完全没有信号的数据上,随机打乱的 K 折交叉验证给出 70% 的准确率,剔除加禁区的 K 折和前推验证则正确地给出 50%。第三,在低信噪比下,正则化线性模型往往难以被战胜:本例中岭回归的样本外 IC 和扣费收益都高于梯度提升树和神经网络。第四,非平稳性要靠训练窗口和监控来应对,弱信号的衰减往往要几百天才能在统计上确认;秩标准化后的特征分布不会漂移,所以分布漂移指标(PSI)发现不了"关系变了"。第五,可解释性工具要在时间结构下使用:横截面置换重要性和偏依赖能揭示模型学到的规律反转,但相关特征会瓜分重要性,重要性也不等于因果。

学习目标

  • 构造只用当时可见信息的特征,做横截面秩标准化,选择合适的预测目标。
  • 实现固定期限、波动率标准化和三重障碍三种标签,计算标签并发数与平均唯一性。
  • 实现剔除(purging)加禁区(embargo)的 K 折交叉验证和前推验证,用时间顺序的验证集做提前停止。
  • 在同一个前推框架下比较岭回归、梯度提升树和神经网络,用 RankIC 和扣费多空收益评价。
  • 比较扩展窗口与滚动窗口,设计特征级的 IC 监控,理解 PSI 的局限。
  • 计算横截面置换重要性和偏依赖,识别相关特征和代理变量带来的误读。

读前导读

这一章在解决什么问题。 你在 CFA 二级的数量方法里见过机器学习:监督学习、过拟合、交叉验证、LASSO、决策树、集成和神经网络。那里讲的是"这些工具是什么";本章讲的是"把它们用到股票收益预测上时,哪里会出错"。对应到工作场景,这一章相当于一家资管公司的量化研究组把一个 ML 选股模型从研发做到上线监控的全过程,同时也是模型风险管理部门(银行里做模型验证、对标 SR 11-7 的那个团队)会审查的全部要点:数据有没有未来信息、验证方法是否独立、模型上线后谁来发现它失效。

全章围绕一个事实展开:收益里可预测的部分极小。CFA 的"主动管理基本定律" \(IR\approx IC\times\sqrt{BR}\) 你一定记得,本章的评价指标正是 IC(信息系数)。这里 IC 只有 0.01–0.04,意味着模型每一步都在噪声边缘,任何微小的泄露(训练时偷看了测试期的信息)都会让回测好看得不真实。所以本章的重心是流程而不是模型:特征怎么做才时点正确(类似审计里的"截止测试",确认每笔收入记在正确期间),标签怎么定义,验证怎么做才不泄露,上线后怎么监控。

代码在做什么、为什么这样设计:本章用合成数据,因为只有合成数据才知道"真实答案",可以检验每种方法是不是把真相估对了。这和审计中先用已知错误的样本测试审计程序能否发现错误是同一个思路。

需要先想起来的数学。

  • 秩与 Spearman 相关(RankIC)。 把一组数换成它们的名次,再算普通相关系数,就是 Spearman 相关。例:预测值 (0.3, −0.1, 0.5) 的秩是 (2,1,3),实际收益 (2%, −1%, 1%) 的秩是 (3,1,2),两组秩的相关就是 RankIC = 0.5。它只看"排序对不对",不受极端值影响。速成见第 00 册第 07 章 概率中的分析工具。
  • 范数与带惩罚的最小化。 \(\|v\|^2\) 是向量各分量的平方和(\(\|v\|\) 读作"范数",即向量长度)。岭回归 \(\min_\beta\|y-X\beta\|^2+\alpha\|\beta\|^2\) 就是"残差平方和 + 系数平方和的罚款",罚款让系数向 0 收缩。求最小值靠对 \(\beta\) 求梯度并令其为零,见第 00 册第 05 章 多元微积分与优化;其中矩阵求逆见第 00 册第 06 章 线性代数速成。
  • 波动率按 \(\sqrt H\) 放大。 日收益独立、日波动 \(\sigma\) 时,\(H\) 日累计收益的波动是 \(\sigma\sqrt H\),因为方差可加:\(\text{Var}=H\sigma^2\)。例:日波动 2%,20 日波动约 \(2\%\times4.47\approx8.9\%\)。这和 CFA 里 VaR 的"平方根时间法则"是同一件事。
  • 条件概率 \(P(y\mid X)\)。 读作"已知特征 \(X\) 时标签 \(y\) 的分布"。\(P(X)\) 变了是"特征的样子变了",\(P(y\mid X)\) 变了是"特征和收益的关系变了",10.6 节的关键区分就在这里。见第 00 册第 07 章。
  • 对数与 \(\tanh\)。 对数收益可以直接相加(多日对数收益 = 各日之和),代码里用累加和 cumsum 求区间收益正是利用这一点。\(\tanh(x)\) 是一条 S 形曲线,\(x\) 小时近似 \(x\),\(x\) 大时趋于 \(\pm1\),用来模拟"动量越强、效应越饱和"。见第 00 册第 04 章 级数与收敛。

怎么读这一章。 核心必读是 10.3.2(重叠标签与有效样本数)、10.4(验证方案)和 10.5 的结论段落,这三处决定了你以后看任何 ML 回测时能不能识别出虚高。10.2 的代码第一次可以只看注释和输出,知道数据里埋了"价值、反转×波动、动量(第 900 天反号)"三个信号即可。10.6 的监控部分和风控工作最接近,建议细读输出解读。10.7 可解释性可以和 CFA 的业绩归因对照着读:归因告诉你收益来自哪里,置换重要性告诉你预测来自哪个特征,两者都会被相关变量和代理变量误导。建议顺序:10.1 → 10.3 → 10.4 → 10.5 → 10.6 → 10.7,最后回头补 10.2 的代码细节。

10.1 问题与动机

前面各册已经讲过机器学习的统计基础和神经网络的原理:分类器与 Bayes 规则见第 03 册第 22a 章,交叉验证与 VC 界见第 03 册第 22b 章,岭回归、Lasso 与主成分回归见第 05 册第 14 章,模型选择见第 03 册第 13b 章;多层网络、反向传播及其变形、泛化与提前停止见第 10 册第 11、12、13a、13b 章,实际训练问题见第 10 册第 22 章,性能优化见第 10 册第 09 章。这些方法在图像、语音等领域效果很好,搬到收益预测上却经常失败。原因可以归结为四点:

  1. 信噪比极低。 日度个股收益的波动在 2% 左右,可预测部分每天只有几个基点。一个在训练集上解释了 5% 方差的模型,几乎一定是过拟合。
  2. 样本不独立。 特征(估值、动量、波动)高度持续,标签(未来 \(H\) 天收益)相互重叠,横截面上的股票又受同一组因子驱动。名义上有几十万个样本,有效的独立样本可能只有几百个。
  3. 规律会变。 市场参与者会学习和套利,因子会拥挤,宏观环境会切换。训练集里的规律在测试期可能减弱甚至反向。
  4. 评价标准不同。 关心的不是预测误差,而是排序能力(IC)和扣除交易成本后的组合收益。一个 MSE 稍低但换手高得多的模型可能更差。

因此,本章的重点是数据和流程:特征怎么构造才不偷看未来,标签怎么定义,验证怎么做才不泄露,模型上线后怎么发现它失效了。模型本身用 scikit-learn 中的标准实现。

10.2 特征工程与合成面板

10.2.1 构造原则

  • 时点正确(point-in-time)。 第 \(t\) 日收盘时能算出的特征,只能用 \(t\) 日及以前的数据;财务数据要按公告日而不是报告期对齐(本册第 02 章)。标签从 \(t+1\) 日开始计算。
  • 横截面标准化。 对每个交易日,把特征转成横截面的秩或 z 分数。这样做去掉了特征的时间趋势和市场整体水平(例如牛市中所有股票的动量都高),模型只学"相对谁更好",也让不同时期的样本可比。秩变换还天然抗极端值(本册第 02 章的去极值)。
  • 目标的选择。 预测原始收益会让模型花大量精力解释市场和行业的共同波动;常用的做法是预测横截面去均值后的收益、行业中性收益或收益的横截面秩。本章用未来 5 日收益的横截面秩,与特征一样归一化到 \([-0.5,0.5]\)。
  • 特征类型。 价格类(动量、反转、波动)、基本面类(估值、质量)、规模、行业,以及它们的交互。树模型和神经网络能自动发现交互,但在低信噪比下,人工构造有经济含义的交互往往更稳健。

10.2.2 代码:合成面板

面板为 1500 个交易日、200 只股票。收益由三部分组成:GARCH 驱动的市场因子(个股 β 不同)、10 个行业因子、t4 分布的特质噪声,小盘股的特质波动更大。预期收益由三个特征决定:价值(线性)、短期反转与波动率的交互(波动越大反转越强)、动量(非线性,tanh 形状)。第 900 天之后动量的作用反号,模拟一次"动量崩溃"式的规律变化。另有规模和 4 个持续性很强的纯噪声特征。

# 代码 10.1  合成股票面板、特征与横截面秩标准化(10.5–10.7 节的代码接在本段之后运行)
import numpy as np
import pandas as pd
from scipy.stats import kurtosis, spearmanr

def make_panel(N=200, T=1500, flip_day=900, seed=0):
    """合成日度股票面板:市场因子(GARCH) + 10 个行业因子 + t4 特质噪声;
    预期收益由 价值(线性) + 反转×波动(交互) + 动量(非线性) 决定,动量在 flip_day 后反号。"""
    rng = np.random.default_rng(seed)
    ind = rng.integers(0, 10, N)
    size = rng.normal(0, 1, N)                                   # 规模(固定),小盘波动大
    beta = np.clip(rng.normal(1, 0.3, N), 0.3, 1.8)
    idio_vol = 0.018 * np.exp(-0.25 * size)
    val = rng.normal(0, 1, N); noise_ch = rng.normal(0, 1, (4, N))
    R = np.zeros((T, N)); F = {k: np.zeros((T, N)) for k in
         ["mom", "rev", "vol", "val", "size", "n1", "n2", "n3", "n4"]}
    mu_true = np.zeros((T, N)); h = 1e-4
    z = lambda x: (x - x.mean()) / (x.std() + 1e-12)
    for t in range(T):
        # ---- 当日收盘可见的特征(只用 t-1 及以前的收益)----
        val = 0.995 * val + np.sqrt(1 - 0.995**2) * rng.normal(0, 1, N)
        noise_ch = 0.99 * noise_ch + np.sqrt(1 - 0.99**2) * rng.normal(0, 1, (4, N))
        past = R[max(0, t-60):t]
        mom = past[:-5].sum(0) if len(past) > 5 else np.zeros(N)
        rev = past[-5:].sum(0) if len(past) else np.zeros(N)
        vol = past[-20:].std(0) if len(past) > 2 else idio_vol
        for k, v in zip(F, [mom, rev, vol, val + 0.5 * rng.normal(0, 1, N), size, *noise_ch]):
            F[k][t] = v
        # ---- 真实预期收益(下一日起生效)----
        regime = 1.0 if t < flip_day else -1.0
        g = 0.5 * z(val) - 0.4 * z(rev) * (1 + np.tanh(z(vol))) + 0.5 * regime * np.tanh(1.5 * z(mom))
        mu_true[t] = 0.0005 * z(g)
        # ---- 收益:t+1 日实现 ----
        if t + 1 < T:
            e = R[t].mean()                                      # 用等权市场收益近似因子新息
            h = 2e-6 + 0.10 * e**2 + 0.88 * h
            f_mkt = np.sqrt(h) * rng.standard_t(5) / np.sqrt(5 / 3)
            f_ind = 0.007 * rng.standard_normal(10)
            eps = idio_vol * rng.standard_t(4, N) / np.sqrt(2)
            R[t+1] = mu_true[t] + beta * f_mkt + f_ind[ind] + eps
    return R, F, mu_true, ind

R, F, mu_true, ind = make_panel()
T, N = R.shape
H = 5                                                            # 标签:t+1..t+5 的累计收益
fwd = np.full((T, N), np.nan)
cs = np.cumsum(np.vstack([np.zeros(N), R]), axis=0)
fwd[:T-H] = cs[1+H:T+1] - cs[1:T-H+1]

# ---------- 横截面秩标准化:每天把特征变成 [-0.5, 0.5] 的均匀分数 ----------
def cs_rank(X):
    return (pd.DataFrame(X).rank(axis=1, pct=True).values - 0.5)
feat_names = list(F)
X = np.stack([cs_rank(F[k]) for k in feat_names], axis=-1)      # (T, N, K)
y = cs_rank(fwd)                                                 # 目标:未来收益的横截面秩

# ---------- 合成数据的"真实感"检查 ----------
mkt = R.mean(1)
ac_abs = np.mean([pd.Series(np.abs(mkt)).autocorr(l) for l in range(1, 21)])
print(f"面板 {T} 天 × {N} 只;个股日收益超额峰度中位数 {np.median(kurtosis(R[1:], axis=0)):.1f},"
      f"市场绝对收益 1–20 阶自相关均值 {ac_abs:.2f},个股日波动中位数 {100*np.median(R.std(0)):.2f}%")
ic_true = [spearmanr(mu_true[t], fwd[t])[0] for t in range(60, T-H)]
print(f"用真实预期收益做预测的 5 日 RankIC 均值 {np.mean(ic_true):.3f}(理论上限,现实中不可得)")
for k in ["mom", "rev", "val", "n1"]:
    j = feat_names.index(k)
    ic = np.array([spearmanr(X[t, :, j], fwd[t])[0] for t in range(60, T-H)])
    print(f"单因子 {k:4s}: 前 900 天 RankIC {ic[:840].mean():+.3f},之后 {ic[840:].mean():+.3f}")

输出:

面板 1500 天 × 200 只;个股日收益超额峰度中位数 3.1,市场绝对收益 1–20 阶自相关均值 0.16,个股日波动中位数 2.44%
用真实预期收益做预测的 5 日 RankIC 均值 0.040(理论上限,现实中不可得)
单因子 mom : 前 900 天 RankIC +0.018,之后 -0.021
单因子 rev : 前 900 天 RankIC -0.011,之后 -0.019
单因子 val : 前 900 天 RankIC +0.027,之后 +0.027
单因子 n1  : 前 900 天 RankIC -0.001,之后 +0.004

几项检查说明合成数据具备了真实市场的主要特征:个股日收益超额峰度约 3,市场绝对收益在 20 阶以内都有明显的正自相关(波动聚集),个股日波动约 2.4%。用"真实预期收益"去预测,5 日 RankIC 的均值也只有 0.040,这就是本例的理论上限。单因子的 IC 在 0.01 到 0.03 之间,动量在第 900 天后由正转负,噪声特征的 IC 接近零。

白话解释:代码 10.1 有三处设计值得看懂。 第一,循环里先算特征、再算真实预期收益 mu_true[t]、最后生成 R[t+1]。这个顺序就是"时点正确"的硬编码:\(t\) 日的特征只用 R[:t](到 \(t-1\) 日为止的收益),而它预测的收益在 \(t+1\) 日才发生,不可能偷看。 第二,标签 fwd 用累加和一次算出:设 \(C_t=\sum_{s\le t}R_s\),则 \(t+1\) 到 \(t+H\) 日的累计收益就是 \(C_{t+H}-C_t\)。这和用期末累计净值减期初累计净值求区间收益是一个道理,比逐日求和快得多。 第三,cs_rank 每天单独把 200 只股票排名,变成 \([-0.5,0.5]\) 上的分数。这相当于每天给全市场"打百分位",牛市熊市的绝对水平被抹掉,模型只学相对强弱。代价在 10.6 节会看到:排名后的分布每天都一样,分布监控因此失灵。 真实预期收益的式子 \(g=0.5z(\text{val})-0.4z(\text{rev})(1+\tanh z(\text{vol}))+0.5\cdot\text{regime}\cdot\tanh(1.5z(\text{mom}))\) 里,\(z(\cdot)\) 是横截面 z 分数;\(1+\tanh(\cdot)\) 在 0 到 2 之间,波动越大反转系数越接近 \(-0.8\),波动越小越接近 0,这就是"反转×波动"的交互。最后乘 0.0005 把它缩到每天约 5 个基点的量级,和 10.1 节说的"可预测部分只有几个基点"一致。

与真实数据的差别:真实数据的信号更弱、更不稳定,而且没有人知道"理论上限";真实面板有停牌、上市退市、涨跌停和缺失值;规律的变化通常是渐进的,不会在某一天突然反号。

10.3 标签

10.3.1 三种标签

  • 固定期限:\(y_t=\text{sign}(\ln P_{t+H}-\ln P_t)\),或直接用收益本身。简单,但忽略了持有期间的路径:一笔交易可能先跌 15% 触发止损,到期又涨回来,标签却是"涨"。
  • 波动率标准化:把收益除以事前波动率 \(\hat\sigma_t\sqrt H\),或者把分类阈值设为 \(\pm k\hat\sigma_t\sqrt H\)。这样不同波动时期的标签可比,避免模型把"高波动期"当成主要的学习对象。
  • 三重障碍(López de Prado 2018 第 3 章):在事件时点 \(t\) 设上障碍 \(+k_u\hat\sigma_t\sqrt H\)、下障碍 \(-k_d\hat\sigma_t\sqrt H\) 和时间障碍 \(t+H\),标签由先触及哪个障碍决定。它把止盈、止损和最长持有期写进了标签,与实际交易规则一致。在此基础上还可以做"元标签"(meta-labeling):主模型决定方向,第二个模型只判断"这一次该不该做",用于决定仓位大小。

10.3.2 标签重叠与样本权重

若每天都打一个 \(H\) 天的标签,第 \(t\) 天和第 \(t+1\) 天的标签共享 \(H-1\) 天的收益。定义 \(t\) 日的并发数 \(c_t\) 为覆盖该日收益的标签个数,标签 \(i\) 的平均唯一性为

\[\bar u_i=\frac{1}{|T_i|}\sum_{t\in T_i}\frac{1}{c_t},\]

其中 \(T_i\) 是标签 \(i\) 覆盖的日期。\(\sum_i\bar u_i\) 近似等于有效独立样本数。实务中常用 \(\bar u_i\) 作为样本权重,或在随机森林中按唯一性做自助抽样(López de Prado 2018 第 4 章)。

推导拆解:用一个很小的例子算一遍。设 \(H=2\),每天打一个标签:标签 A 覆盖第 1、2 日,标签 B 覆盖第 2、3 日,标签 C 覆盖第 3、4 日。 第一步,数每一天被几个标签覆盖:\(c_1=1\)(只有 A),\(c_2=2\)(A、B),\(c_3=2\)(B、C),\(c_4=1\)(只有 C)。 第二步,每个标签在它覆盖的每一天"分到"\(1/c_t\) 的份额,再取平均:\(\bar u_A=\tfrac12(1+\tfrac12)=0.75\),\(\bar u_B=\tfrac12(\tfrac12+\tfrac12)=0.5\),\(\bar u_C=0.75\)。 第三步,加总:\(\sum\bar u_i=2\)。4 天的收益恰好够组成 2 个互不重叠的 2 日标签,所以"有效独立样本数约为 2",而不是名义上的 3 个。 为什么叫"近似":这里每一天的收益在所有标签之间被平均分摊,总份额正好等于被覆盖的天数除以 \(H\);标签长度不一(如三重障碍)时只是近似。符号 \(|T_i|\) 表示集合 \(T_i\) 里的元素个数,即标签 \(i\) 覆盖的天数。

金融直觉:这和用月度数据算滚动 12 个月收益再做回归是同一个问题。相邻两个 12 个月收益共享 11 个月,样本量看着有 120 个,独立信息只有约 10 个。你在 CFA 里学过序列相关会让 OLS 的标准误偏小、t 值虚高;重叠标签对 ML 模型的影响也一样,只是表现为验证分数虚高。

10.3.3 代码

# 代码 10.2  三种标签与平均唯一性
import numpy as np
import pandas as pd

# ---------- 一只股票的日价格:GARCH(1,1) + t5 ----------
rng = np.random.default_rng(3)
n = 2500
h = np.empty(n); r = np.empty(n); h[0] = 2e-4
for t in range(n):
    if t: h[t] = 4e-6 + 0.08 * r[t-1]**2 + 0.90 * h[t-1]
    r[t] = 0.0002 + np.sqrt(h[t]) * rng.standard_t(5) / np.sqrt(5 / 3)
logp = np.cumsum(r)
sigma = pd.Series(r).ewm(span=50).std().shift(1).bfill().values   # 事前波动率(只用过去)

def triple_barrier(logp, sigma, events, pt=2.0, sl=2.0, max_h=20):
    """返回每个事件的 (标签, 结束时点 t1, 触发类型)。
    上下障碍 = ±k·σ_t·sqrt(max_h);先触上障碍 +1,先触下障碍 -1,到期则看符号。"""
    out = []
    for t in events:
        width = sigma[t] * np.sqrt(max_h)
        path = logp[t+1:t+1+max_h] - logp[t]
        up = np.where(path >= pt * width)[0]; dn = np.where(path <= -sl * width)[0]
        first_up = up[0] if len(up) else np.inf; first_dn = dn[0] if len(dn) else np.inf
        if first_up < first_dn:   out.append((1, t + 1 + int(first_up), "上障碍"))
        elif first_dn < first_up: out.append((-1, t + 1 + int(first_dn), "下障碍"))
        else:                     out.append((int(np.sign(path[-1])), t + max_h, "到期"))
    return pd.DataFrame(out, index=events, columns=["label", "t1", "hit"])

events = np.arange(60, n - 21)                     # 每天一个事件(最密集的情形)
tb = triple_barrier(logp, sigma, events, pt=1.0, sl=1.0, max_h=20)
fixed = np.sign(logp[events + 20] - logp[events])  # 固定期限 20 天标签
print("三重障碍:触发方式占比", tb.hit.value_counts(normalize=True).round(3).to_dict())
print(f"三重障碍平均持有期 {np.mean(tb.t1 - tb.index):.1f} 天;与固定期限标签一致的比例 {np.mean(tb.label.values == fixed):.3f}")

# 波动率标准化后的标签更"平稳":比较高、低波动时期正标签比例与标签对应收益的离散度
ret20 = logp[events + 20] - logp[events]
hi = sigma[events] > np.median(sigma[events])
print(f"固定期限标签对应收益的标准差:高波动期 {ret20[hi].std():.3f},低波动期 {ret20[~hi].std():.3f}")
z = ret20 / (sigma[events] * np.sqrt(20))
print(f"除以事前波动后:高波动期 {z[hi].std():.2f},低波动期 {z[~hi].std():.2f}")

# ---------- 标签重叠:并发数与平均唯一性(López de Prado 2018 第 4 章)----------
def avg_uniqueness(t0, t1, n):
    conc = np.zeros(n)
    for a, b in zip(t0, t1): conc[a+1:b+1] += 1      # 标签覆盖 (t0, t1] 这些收益日
    return np.array([np.mean(1 / conc[a+1:b+1]) for a, b in zip(t0, t1)]), conc
u_tb, conc = avg_uniqueness(tb.index.values, tb.t1.values, n)
u_fx, _ = avg_uniqueness(events, events + 20, n)
print(f"平均唯一性:固定 20 天标签 {u_fx.mean():.3f}(≈1/20),三重障碍 {u_tb.mean():.3f};"
      f"有效独立样本约 {u_fx.sum():.0f} 与 {u_tb.sum():.0f},名义样本 {len(events)}")

# 只在"事件"发生时才打标签:例如过去 5 日涨跌超过 2σ(CUSUM 类过滤器的简化版)
move5 = (logp[events] - logp[events - 5]) / (sigma[events] * np.sqrt(5))
ev2 = events[np.abs(move5) > 1.5]
tb2 = triple_barrier(logp, sigma, ev2, pt=1.0, sl=1.0, max_h=20)
u2, _ = avg_uniqueness(tb2.index.values, tb2.t1.values, n)
print(f"事件过滤后样本 {len(ev2)} 个,平均唯一性 {u2.mean():.3f},有效样本约 {u2.sum():.0f}")

输出:

三重障碍:触发方式占比 {'到期': 0.488, '下障碍': 0.284, '上障碍': 0.228}
三重障碍平均持有期 15.5 天;与固定期限标签一致的比例 0.969
固定期限标签对应收益的标准差:高波动期 0.104,低波动期 0.058
除以事前波动后:高波动期 1.03,低波动期 1.26
平均唯一性:固定 20 天标签 0.050(≈1/20),三重障碍 0.069;有效独立样本约 122 与 166,名义样本 2419
事件过滤后样本 319 个,平均唯一性 0.365,有效样本约 116

在障碍宽度为 \(\pm1\cdot\hat\sigma_t\sqrt{20}\) 的设置下,约一半的标签由时间障碍决定,平均持有期 15.5 天,与 20 天固定期限标签的方向一致率为 97%。障碍越窄,两者差别越大,练习 2 会让你观察这一点。

波动率标准化的效果见输出的第三、四行:固定期限收益的标准差在高波动期(0.104)几乎是低波动期(0.058)的两倍;除以事前波动后,两者变为 1.03 和 1.26,量级可比了。低波动期的标准化值反而偏大,是因为事前波动率估计跟不上波动的突然上升,这本身就是一种需要注意的偏差。

最重要的是最后两行。每天打一个 20 天标签,2419 个名义样本的平均唯一性只有 0.05,有效独立样本约 120 个;三重障碍因为平均持有期短一些,约 170 个。只在价格大幅波动后才打标签(事件过滤),样本降到 319 个,但平均唯一性升到 0.37,有效样本数和全量打标签差不多。也就是说,大量重叠样本并不提供多少额外信息,却会让模型和验证程序误以为数据很多。

白话解释:triple_barrier 函数对每个事件日 \(t\) 取出之后 max_h 天的累计对数收益路径 path,分别找出第一次 \(\ge\) 上障碍和第一次 \(\le\) 下障碍的位置;没触到就记为无穷大(np.inf),这样"谁先发生"只需比较两个数的大小,两者都是无穷大就落到"到期"分支。返回的 t1 是标签真正结束的日子,它决定了标签覆盖哪些收益日,也是后面算唯一性和做剔除(purge)时要用的关键信息。 障碍宽度用的 sigma 带了 .shift(1),即只用前一天及以前的数据估计波动,这又是一处时点正确的细节:如果用当天的波动,障碍宽度就混入了当天的收益。 把这套规则对应到交易台:上障碍是止盈单,下障碍是止损单,时间障碍是"最长持有 20 天"的纪律。标签因此回答的是"按这套交易规则做,这笔交易是赚是亏",而不是"20 天后价格是涨是跌"。

10.4 样本外验证

10.4.1 泄露从哪里来

第 03 册第 22b.7 节已经指出,随机 K 折在时间序列上会严重高估预测能力,原因是特征的持续性和标签的重叠:测试样本的"邻居"在训练集里,而邻居的特征和标签几乎与它相同。本册第 06 章 6.5 节已经用规则策略比较了几种验证方法;这里换成灵活的机器学习模型,并加入"真实样本外准确率"作对照,看各方案的估计偏乐观还是偏保守。比较的四种方案是:

  • 随机打乱 K 折:错误示范。
  • 连续 K 折(不剔除):测试集是连续的时间块,但测试块前后紧邻的训练样本,其标签区间与测试期重叠。
  • 剔除加禁区 K 折:从训练集中删除标签区间 \([t_0,t_1]\) 与测试期有交集的样本(purge),并删除测试期结束后一段时间内开始的样本(embargo),防止测试期的信息通过序列相关渗入随后的训练样本。
  • 前推验证:只用测试期开始之前、且标签已经结束的样本训练。它最接近实盘,但每折的训练集大小不同,最早的几折训练数据少。

超参数选择要嵌套在验证框架内部:外层用前推或剔除 K 折评估,内层在训练集内部再按时间顺序切出验证集。一个常被忽视的细节是提前停止:scikit-learn 的 HistGradientBoostingRegressor 和 MLPRegressor 的内置提前停止用随机抽取的验证集,在重叠标签下同样会泄露。10.5 节的代码改为手工按时间切分。

10.4.2 代码:四种验证方案

一个资产 3000 天,6 个持续性很强的特征(AR(1) 系数 0.99),标签是未来 20 日收益的方向。"无信号"情形下特征与标签无关;"弱信号"情形下第一个特征对标签有真实的预测力。最后用全部数据训练、在另外 20 段独立生成的新数据上测试,作为"真实样本外准确率"。

# 代码 10.3  四种验证方案:随机 K 折、连续 K 折、剔除加禁区 K 折、前推验证
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import KFold

def purged_kfold(t0, t1, n_splits=5, embargo=0):
    """t0/t1:每个样本标签覆盖区间的起止(样本按 t0 排序)。
    测试集为连续块;从训练集中删去标签区间与测试块重叠的样本(purge),
    并删去测试块之后 embargo 天内开始的样本(embargo)。"""
    idx = np.arange(len(t0))
    for test in np.array_split(idx, n_splits):
        a, b = t0[test[0]], t1[test].max()
        overlap = (t1 >= a) & (t0 <= b)                      # 与测试期 [a,b] 有交集
        after = (t0 > b) & (t0 <= b + embargo)
        train = idx[~overlap & ~after]
        yield train, test

def walk_forward(t0, t1, n_splits=5, min_train=0.4):
    idx = np.arange(len(t0)); start = int(min_train * len(idx))
    for test in np.array_split(idx[start:], n_splits):
        train = idx[t1 < t0[test[0]]]                         # 只用标签在测试期开始前已结束的样本
        yield train, test

def make_data(n, H, signal, seed):
    rng = np.random.default_rng(seed)
    r = 0.01 * rng.standard_t(4, n + H) / np.sqrt(2)
    feats = np.zeros((n, 6)); x = np.zeros(6)
    for t in range(n):                                        # 高度持续的特征,只有第 1 个可能有用
        x = 0.99 * x + np.sqrt(1 - 0.99**2) * rng.standard_normal(6); feats[t] = x
    fwd = np.array([r[t+1:t+1+H].sum() for t in range(n)]) + signal * 0.02 * feats[:, 0]
    return feats, (fwd > 0).astype(int)

n, H = 3000, 20
t0 = np.arange(n); t1 = t0 + H

def cv_score(y, splitter):
    """在给定划分方案下的平均测试准确率"""
    acc = []
    for tr, te in splitter:
        m = RandomForestClassifier(n_estimators=200, min_samples_leaf=50, n_jobs=-1, random_state=0)
        m.fit(feats[tr], y[tr]); acc.append(np.mean(m.predict(feats[te]) == y[te]))
    return np.mean(acc)

for signal, label in ((0.0, "无信号"), (1.0, "弱信号")):
    feats, y = make_data(n, H, signal, seed=1)
    res = {
        "随机打乱 K 折": cv_score(y, KFold(5, shuffle=True, random_state=0).split(feats)),
        "连续 K 折(不剔除)": cv_score(y, KFold(5, shuffle=False).split(feats)),
        "剔除+禁区 K 折": cv_score(y, purged_kfold(t0, t1, 5, embargo=H)),
        "前推验证": cv_score(y, walk_forward(t0, t1, 5)),
    }
    # "真相":用全部 3000 天训练,在另外生成的 20 段 3000 天新数据上测试
    m = RandomForestClassifier(n_estimators=200, min_samples_leaf=50, n_jobs=-1, random_state=0).fit(feats, y)
    res["真实样本外"] = np.mean([np.mean(m.predict(f2) == y2) for f2, y2 in
                                (make_data(n, H, signal, seed=100 + s) for s in range(20))])
    print(label + ":" + ";".join(f"{k} {v:.3f}" for k, v in res.items()))

输出:

无信号:随机打乱 K 折 0.698;连续 K 折(不剔除) 0.494;剔除+禁区 K 折 0.502;前推验证 0.482;真实样本外 0.514
弱信号:随机打乱 K 折 0.708;连续 K 折(不剔除) 0.555;剔除+禁区 K 折 0.562;前推验证 0.556;真实样本外 0.635

无信号时,随机打乱的 K 折给出 69.8% 的准确率,而真实样本外只有 51.4%(与 50% 的差异在噪声范围内)。随机森林只是记住了时间上相邻的样本。连续 K 折、剔除加禁区 K 折和前推验证都给出 48%–50%,正确地说明了"没有信号"。

有弱信号时,随机打乱 K 折给出 70.8%,几乎和无信号时一样,说明它根本分辨不出信号的有无。三种正确的方案给出 55%–56%,低于真实样本外的 63.5%。它们偏保守,原因是每折训练数据更少,特征又高度持续,只有 3000 天的样本里,特征只经历了有限的几轮波动。保守的估计在实务中是可以接受的,乐观的估计会直接导致亏损。

本例中连续 K 折和剔除 K 折的差别不大,因为 20 天的重叠只影响每个测试块两端的少量样本。当标签期限更长、折数更多时,剔除的作用会更明显。

推导拆解:purged_kfold 里判断"重叠"的一行是 (t1 >= a) & (t0 <= b)。为什么两个区间 \([t_0,t_1]\) 与 \([a,b]\) 有交集等价于 \(t_1\ge a\) 且 \(t_0\le b\)?反过来想:两个区间不相交只有两种情况,要么样本区间整个在测试期之前(\(t_1<a\)),要么整个在之后(\(t_0>b\))。对"\(t_1<a\) 或 \(t_0>b\)"取否定,就得到"\(t_1\ge a\) 且 \(t_0\le b\)"(逻辑上的德摩根律,见第 00 册第 08 章)。 禁区 after 处理的是测试期之后的样本:它们的标签不和测试期重叠,但特征的 AR(1) 系数是 0.99,测试期刚结束时的特征和测试期末几乎一样,模型仍能"认出"测试样本。禁区把这一段也挖掉。前推验证 walk_forward 则更简单:只要求训练样本的标签结束日 \(t_1\) 早于测试期起点,天然不存在"之后"的问题。

金融直觉:随机打乱的 K 折相当于审计抽样时,把同一张发票的复印件既放进"已核对"组又放进"待测试"组,测试组的通过率当然很高。69.8% 和 70.8% 几乎一样,说明这个分数衡量的是"记住邻居"的能力,与真实信号无关。模型验证报告里如果只写了"5 折交叉验证"而没说怎么切分,应当追问。

10.5 模型:正则化线性、树模型与神经网络

10.5.1 三类模型

  • 岭回归(第 05 册第 14.3 节):\(\min_\beta\|y-X\beta\|^2+\alpha\|\beta\|^2\)。只能表达线性关系,但方差小,在低信噪比下常常是最难战胜的基准。
  • 梯度提升树:逐棵树拟合残差,自动捕捉非线性和交互。关键的正则化参数是学习率、树的叶子数、叶子最小样本数和树的棵数;棵数用时间顺序验证集上的 IC 做提前停止选取。
  • 多层感知机:第 10 册第 11 章的多层网络,用 Adam 优化(第 10 册第 12 章介绍了反向传播的各种变形),用 L2 权重衰减和提前停止控制泛化(第 10 册第 13a 章)。第 10 册第 22 章建议"先用比需要更多的神经元,再用提前停止或正则化防止过拟合",并用多次随机初值组成委员会。在低信噪比下,网络的初值敏感性和训练不稳定性尤其突出。

三者的预测按横截面秩平均,组成一个简单的集成。评价指标是测试期逐日的 RankIC 均值及其 t 值(按 \(H\) 天重叠做了粗略调整),以及每 5 天调仓、做多预测最高 20%、做空最低 20% 的组合扣除单边 10bp 成本后的年化收益。

推导拆解:岭回归为什么"方差小"。对目标函数 \(L(\beta)=\|y-X\beta\|^2+\alpha\|\beta\|^2\) 关于 \(\beta\) 求梯度(各分量的偏导数排成的向量,见第 00 册第 05 章):\(\nabla L=-2X^\top(y-X\beta)+2\alpha\beta\)。令其为零,整理得 \((X^\top X+\alpha I)\beta=X^\top y\),所以

\[\hat\beta_{\text{ridge}}=(X^\top X+\alpha I)^{-1}X^\top y.\]
和普通 OLS 的 \((X^\top X)^{-1}X^\top y\) 相比,只是对角线上加了 \(\alpha\)。单个特征、标准化后的情形最直观:\(\hat\beta_{\text{ridge}}=\hat\beta_{\text{OLS}}\cdot\frac{n}{n+\alpha}\),系数被统一打了个折。打折带来偏差,但估计值随样本波动的幅度也同比缩小。信号弱、噪声大时,减少的方差远大于增加的偏差。 评价代码中 t 值写成 \(\bar{IC}/s_{IC}\cdot\sqrt{n/H}\):逐日 IC 共 \(n\) 个,但相邻 5 天的标签重叠,独立的大约只有 \(n/H\) 个,所以用 \(\sqrt{n/H}\) 而不是 \(\sqrt n\)。这正是上一节"有效样本数"的直接应用。

10.5.2 代码:前推比较

本节代码接代码 10.1,在同一 Python 会话中运行(Jupyter 中依次执行各单元即可)。从第 500 天开始,每 250 天重新训练一次,训练集为当时之前的全部数据(扩展窗口),并剔除标签尚未结束的最后 \(H\) 天。

# 代码 10.4  前推比较:岭回归、梯度提升树、神经网络与集成
# —— 接代码 10.1(同一会话):使用 R, F, X, y, fwd, mu_true, feat_names, T, N, H ——
import copy, warnings
from sklearn.linear_model import Ridge
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.neural_network import MLPRegressor
warnings.filterwarnings("ignore")

def rank_ic(pred, t):
    ok = ~np.isnan(fwd[t]); return spearmanr(pred[ok], fwd[t][ok])[0]

def rows(days):
    """把若干天的 (N, K) 特征摊平成训练矩阵,同时返回每行所属的日期"""
    d = np.repeat(days, N)
    return X[days].reshape(-1, X.shape[2]), y[days].reshape(-1), d

def fit_model(kind, tr_days, seed=0):
    """时间顺序切出最后 20% 训练日做验证集(在其前面再空出 H 天),用于提前停止"""
    cut = int(len(tr_days) * 0.8)
    fit_d, val_d = tr_days[:cut - H], tr_days[cut:]
    Xf, yf, _ = rows(fit_d); Xv, yv, dv = rows(val_d)
    def val_ic(pred):                                      # 验证集上逐日 RankIC 的均值
        P = pred.reshape(len(val_d), N)
        return np.mean([spearmanr(P[i], fwd[t])[0] for i, t in enumerate(val_d)])
    if kind == "ridge":
        return Ridge(alpha=10.0).fit(*rows(tr_days)[:2])
    if kind == "gbm":
        m = HistGradientBoostingRegressor(max_iter=400, learning_rate=0.03, max_leaf_nodes=15,
                                          min_samples_leaf=500, l2_regularization=1.0,
                                          early_stopping=False, random_state=seed).fit(Xf, yf)
        scores = [val_ic(p) for p in m.staged_predict(Xv)]
        best = int(np.argmax(scores)) + 1
        return HistGradientBoostingRegressor(max_iter=best, learning_rate=0.03, max_leaf_nodes=15,
                                             min_samples_leaf=500, l2_regularization=1.0,
                                             early_stopping=False, random_state=seed).fit(*rows(tr_days)[:2])
    if kind == "mlp":
        rng = np.random.default_rng(seed)
        sub = rng.choice(len(yf), size=min(80_000, len(yf)), replace=False)
        m = MLPRegressor(hidden_layer_sizes=(32, 16), alpha=1e-3, learning_rate_init=1e-3,
                         batch_size=512, random_state=seed)
        best, best_m, bad = -np.inf, None, 0
        for epoch in range(40):
            m.partial_fit(Xf[sub], yf[sub])
            s = val_ic(m.predict(Xv))
            if s > best: best, best_m, bad = s, copy.deepcopy(m), 0
            else:
                bad += 1
                if bad >= 5: break                          # 连续 5 轮验证 IC 不升就停
        return best_m

def walk_forward(kind, window=None, start=500, step=250):
    pred = np.full((T, N), np.nan)
    for s in range(start, T - H, step):
        lo = 60 if window is None else max(60, s - window)
        tr_days = np.arange(lo, s - H)                      # 剔除:训练标签必须在 s 之前结束
        m = fit_model(kind, tr_days)
        te = np.arange(s, min(s + step, T - H))
        pred[te] = m.predict(X[te].reshape(-1, X.shape[2])).reshape(len(te), N)
    return pred

def evaluate(pred, name, cost=0.001):
    days = np.where(~np.isnan(pred[:, 0]))[0]
    ic = np.array([rank_ic(pred[t], t) for t in days])
    # 每 5 天调仓的多空组合:做多预测最高 20%、做空最低 20%,等权;单边成本 10bp
    pnl, to, w_old = [], [], np.zeros(N)
    for t in days[::H]:
        q = pd.Series(pred[t]).rank(pct=True).values
        w = (q > 0.8) / (q > 0.8).sum() - (q <= 0.2) / (q <= 0.2).sum()
        to.append(np.abs(w - w_old).sum() / 2)
        pnl.append(w @ fwd[t] - cost * np.abs(w - w_old).sum()); w_old = w
    pnl = np.array(pnl); pre = days < 900
    print(f"{name:14s} IC {ic.mean():+.4f} (t={ic.mean()/ic.std()*np.sqrt(len(ic)/H):+.1f})  "
          f"翻转前 {ic[pre].mean():+.4f}  翻转后 {ic[~pre].mean():+.4f}  "
          f"换手 {np.mean(to[1:]):.2f}  多空年化(扣费) {100*pnl.mean()*252/H:+5.1f}%  Sharpe {pnl.mean()/pnl.std()*np.sqrt(252/H):+.2f}")
    return ic

preds = {k: walk_forward(k) for k in ("ridge", "gbm", "mlp")}
rank_avg = lambda P: pd.DataFrame(P).rank(axis=1, pct=True).values
preds["ensemble"] = np.where(np.isnan(preds["ridge"]), np.nan,
                             (rank_avg(preds["ridge"]) + rank_avg(preds["gbm"]) + rank_avg(preds["mlp"])) / 3)
ics = {k: evaluate(v, k) for k, v in preds.items()}
evaluate(np.where(np.isnan(preds["ridge"]), np.nan, mu_true), "真实预期收益")

输出:

ridge          IC +0.0300 (t=+5.4)  翻转前 +0.0421  翻转后 +0.0218  换手 0.87  多空年化(扣费)  +8.7%  Sharpe +0.97
gbm            IC +0.0177 (t=+3.3)  翻转前 +0.0242  翻转后 +0.0134  换手 0.87  多空年化(扣费)  +6.6%  Sharpe +0.74
mlp            IC +0.0116 (t=+2.3)  翻转前 +0.0157  翻转后 +0.0088  换手 1.03  多空年化(扣费)  -4.9%  Sharpe -0.66
ensemble       IC +0.0260 (t=+5.0)  翻转前 +0.0383  翻转后 +0.0177  换手 0.96  多空年化(扣费)  +5.8%  Sharpe +0.69
真实预期收益         IC +0.0479 (t=+8.2)  翻转前 +0.0558  翻转后 +0.0426  换手 0.91  多空年化(扣费) +21.2%  Sharpe +2.20

结果和许多实证研究的经验一致:在这个低信噪比的面板上,岭回归的样本外 IC 最高(0.030,t=5.4),扣费后多空年化 8.7%、Sharpe 0.97;梯度提升树的 IC 只有 0.018;神经网络的 IC 为 0.012,换手最高,扣费后亏损。集成的 IC(0.026)介于中间,低于最好的单个模型,因为两个较弱成员的噪声稀释了岭回归的信号。用真实预期收益构建组合,IC 为 0.048,扣费后 Sharpe 2.2,这是一个上限,与最好的模型之间仍有很大距离。

为什么非线性模型反而更差?真实信号确实是非线性的(tanh 形状的动量、反转与波动的交互),但在每天 200 只股票、几百天训练数据、IC 只有几个百分点的情况下,树和网络从噪声中学到的"非线性"多于从信号中学到的。岭回归虽然只能用线性近似,但近似误差远小于估计误差。这是偏差–方差权衡(第 03 册第 22b.5 节、第 10 册第 13a 章)在金融数据上的典型表现。Gu、Kelly 与 Xiu(2020)在美国股票上的研究发现非线性模型能带来提升,但那是在几万只股票、几十年数据和上百个特征的条件下;数据量不够时,复杂模型的优势很难兑现。

还要注意 IC 和组合收益的关系:神经网络的 IC 为正(0.012,t=2.3),扣费后却亏损,原因是它的预测在相邻两天之间不稳定,换手高于其他模型,成本吃掉了微弱的信号。实务中常对预测做时间平滑或在组合优化中加入换手惩罚(本册第 05、07 章)。

白话解释:代码 10.4 的几个设计选择都是为了堵住泄露。 fit_model 先把训练日按时间切成前 80% 和后 20%,并在中间空出 \(H\) 天,后 20% 当验证集。这就是 10.4.1 节说的"不用库函数内置的随机验证集":两段之间留空,是为了让前段最后一个标签结束后,验证段才开始。 梯度提升树先训练 400 棵,用 staged_predict 逐棵查看验证集 IC,挑出最好的棵数 best,再用全部训练日重新训练这么多棵。神经网络用 partial_fit 一轮一轮地训练,每轮都看验证 IC,连续 5 轮没有改进就停,并保留历史最好的那一版(deepcopy)。两者都是"按时间顺序的提前停止"。 walk_forward 每 250 天重训一次,训练集截到 \(s-H\),保证训练标签在测试期开始前都已实现。evaluate 每隔 \(H\) 天取一次信号调仓,用 \(|w_{\text{新}}-w_{\text{旧}}|\) 之和乘 10bp 扣成本,所以换手越高扣得越多,这就是神经网络 IC 为正却亏钱的直接原因。

白话解释:一个细节。本例每次训练有几十万行(200 只股票 × 数百天),秩特征的方差约 \(1/12\),\(X^\top X\) 的对角线在几万的量级,相比之下 \(\alpha=10\) 的惩罚几乎不起作用,岭回归在这里实际上接近普通 OLS。所以它胜出的主要原因是"线性模型只有 9 个参数,本身方差就小",而不是惩罚项。样本更少或特征更多时,\(\alpha\) 的作用才会显现。

10.6 非平稳性与概念漂移

10.6.1 训练窗口

规律变化时,扩展窗口会让旧规律长期占据训练集,滚动窗口则能更快地遗忘,但样本更少、方差更大。这是另一个偏差–方差权衡。常见的折中有:滚动窗口;扩展窗口加样本时间衰减权重;以及在扩展窗口上训练、但对近期数据单独校准。

10.6.2 监控

上线之后要回答"模型是否还在工作"。可以监控的对象有三层:

  • 输入:特征的分布是否变化,常用群体稳定性指数 \(\text{PSI}=\sum_b(p_b-q_b)\ln(p_b/q_b)\),\(p_b,q_b\) 为训练期和上线期落在第 \(b\) 个分箱的比例。
  • 输出:预测值的分布、横截面离散度和换手。
  • 表现:模型和各个特征的滚动 IC、组合收益。

表现层的监控最直接,但也最慢,因为 IC 的噪声很大。两种常用的检测方法是:滚动窗口均值偏离基准若干个标准误;以及 CUSUM(Page 1954)类的累积和检验,它对持续的小幅下降更敏感,但更容易误报。

白话解释:PSI 的每一项 \((p_b-q_b)\ln(p_b/q_b)\) 都不小于 0:\(p_b>q_b\) 时两个因子都为正,\(p_b<q_b\) 时都为负,乘积总是非负,两个分布完全一样时为 0。例:某分箱训练期占 10%、上线期占 20%,贡献 \((0.2-0.1)\ln 2\approx0.069\)。PSI 在信用评分卡的模型监控中很常用,你在银行风控或评级模型验证报告里可能见过。 CUSUM 的递推是 \(S_k=\max(0,\,S_{k-1}+z_k-k_0)\)(代码中 \(k_0=0.25\),报警阈值 5):\(z_k\) 是第 \(k\) 个 5 日块"IC 低于基准"的标准化程度,每期先减去一个容忍量 \(k_0\),偶尔的坏消息会被 \(\max(0,\cdot)\) 清零,只有持续的坏消息才会一路累积到阈值。这和风控里"连续多日超限才升级处理"的思路一致:\(k_0\) 和阈值越小,发现越快,但误报越多。

10.6.3 代码

本节代码接上节,在同一会话中运行。

# 代码 10.5  训练窗口、IC 监控与 PSI
# —— 接上段(同一会话)——
# ---------- 1) 训练窗口:扩展窗口 vs 滚动 500 天 vs 滚动 250 天 ----------
for kind in ("ridge", "gbm"):
    for win in (None, 500, 250):
        p = preds[kind] if win is None else walk_forward(kind, window=win, step=125)
        evaluate(p, f"{kind}-{'扩展' if win is None else f'滚动{win}'}")

# ---------- 2) 监控:逐个特征的 IC,模型在第 800 天上线,此前的 IC 作为基准 ----------
days_all = np.arange(60, T - H)
fic = pd.DataFrame({k: [spearmanr(X[t, :, j], fwd[t])[0] for t in days_all]
                    for j, k in enumerate(feat_names)}, index=days_all)
live = 800
base = fic.loc[:live - H]
# (a) 滚动 250 天均值偏离基准超过 3 个标准误(重叠标签:有效样本约 250/H)
roll = fic.rolling(250).mean(); se = base.std() / np.sqrt(250 / H)
alarm = (roll - base.mean()).abs() > 3 * se
# (b) 单边 CUSUM:用不重叠的 5 日块,累计"低于基准"的标准化偏差,超过 5 报警
blk = fic.loc[live:].groupby((fic.loc[live:].index - live) // H).mean()
for k in feat_names:
    hits = alarm.index[alarm[k] & (alarm.index > live)]
    z = (base[k].mean() - blk[k]) / base[k].iloc[::H].std()
    S, first = 0.0, None
    for i, v in enumerate(z):
        S = max(0.0, S + v - 0.25)
        if S > 5 and first is None: first = live + i * H
    print(f"  {k:4s} 基准 IC {base[k].mean():+.4f}  滚动窗口报警 {hits[0] if len(hits) else '无':>4}  CUSUM 报警 {first if first else '无'}")
# ---------- 3) 分布漂移:PSI 只能发现"特征分布变了",发现不了"关系变了" ----------
def psi(a, b, bins=10):
    q = np.quantile(a, np.linspace(0, 1, bins + 1)); q[0], q[-1] = -np.inf, np.inf
    pa = np.histogram(a, q)[0] / len(a) + 1e-6; pb = np.histogram(b, q)[0] / len(b) + 1e-6
    return np.sum((pb - pa) * np.log(pb / pa))
jv, jm = feat_names.index("vol"), feat_names.index("mom")
tr, live = slice(300, 800), slice(1000, 1250)
print(f"PSI 原始波动率特征 {psi(F['vol'][tr].ravel(), F['vol'][live].ravel()):.3f};"
      f"秩标准化后 {psi(X[tr, :, jv].ravel(), X[live, :, jv].ravel()):.3f};"
      f"秩标准化动量 {psi(X[tr, :, jm].ravel(), X[live, :, jm].ravel()):.3f}(但动量与收益的关系已经反号)")

输出:

ridge-扩展       IC +0.0300 (t=+5.4)  翻转前 +0.0421  翻转后 +0.0218  换手 0.87  多空年化(扣费)  +8.7%  Sharpe +0.97
ridge-滚动500    IC +0.0334 (t=+5.8)  翻转前 +0.0426  翻转后 +0.0273  换手 0.92  多空年化(扣费) +12.5%  Sharpe +1.35
ridge-滚动250    IC +0.0353 (t=+5.9)  翻转前 +0.0392  翻转后 +0.0327  换手 0.91  多空年化(扣费) +11.8%  Sharpe +1.29
gbm-扩展         IC +0.0177 (t=+3.3)  翻转前 +0.0242  翻转后 +0.0134  换手 0.87  多空年化(扣费)  +6.6%  Sharpe +0.74
gbm-滚动500      IC +0.0226 (t=+4.2)  翻转前 +0.0280  翻转后 +0.0189  换手 0.96  多空年化(扣费)  +5.4%  Sharpe +0.60
gbm-滚动250      IC +0.0162 (t=+3.2)  翻转前 +0.0173  翻转后 +0.0155  换手 0.99  多空年化(扣费)  -2.1%  Sharpe -0.23
  mom  基准 IC +0.0166  滚动窗口报警 1320  CUSUM 报警 1125
  rev  基准 IC -0.0087  滚动窗口报警    无  CUSUM 报警 无
  vol  基准 IC +0.0103  滚动窗口报警    无  CUSUM 报警 无
  val  基准 IC +0.0272  滚动窗口报警    无  CUSUM 报警 无
  size 基准 IC -0.0005  滚动窗口报警    无  CUSUM 报警 无
  n1   基准 IC -0.0034  滚动窗口报警    无  CUSUM 报警 990
  n2   基准 IC +0.0089  滚动窗口报警    无  CUSUM 报警 无
  n3   基准 IC -0.0004  滚动窗口报警    无  CUSUM 报警 无
  n4   基准 IC +0.0099  滚动窗口报警    无  CUSUM 报警 975
PSI 原始波动率特征 0.183;秩标准化后 0.000;秩标准化动量 0.000(但动量与收益的关系已经反号)

训练窗口:对岭回归,滚动 500 天和 250 天窗口把翻转后的 IC 从 0.022 提高到 0.027 和 0.033,扣费 Sharpe 从 0.97 提高到 1.3 左右;对梯度提升树,滚动 500 天略有改善,滚动 250 天则因为样本太少,翻转前的 IC 明显下降,扣费后亏损。复杂模型更需要数据,也就更难通过缩短窗口来适应变化。

监控:动量在第 900 天反号,滚动 250 天窗口直到约 400 天后(第 1320 天)才报警;CUSUM 更快(第 1125 天),但也在两个纯噪声特征 n1、n4 上误报:它们在基准期的 IC(−0.003、+0.010)本身就是噪声,之后的随机波动被当成了"下降"。这说明对于 IC 只有 0.02 的弱信号,统计上确认它失效需要一年以上的时间,在此期间模型一直在用错误的规律交易。这正是要用多个弱相关信号分散风险、并对单个信号的权重设上限的原因。实务中还会结合经济判断:如果某个因子的拥挤度指标(如估值价差、多空持仓)明显上升,可以在统计证据充分之前就降低它的权重。

PSI:原始的波动率特征在训练期和上线期之间 PSI 为 0.18,按常用的经验阈值(0.1 为轻微、0.25 为显著)已经有明显漂移,因为市场波动水平变了。但秩标准化之后,每天的横截面分布都是均匀分布,PSI 恒为 0。动量的预测关系已经反号,PSI 却完全看不出来。分布漂移和概念漂移是两回事:前者是 \(P(X)\) 变了,后者是 \(P(y\mid X)\) 变了,只有表现层的监控能发现后者。

10.7 可解释性

10.7.1 方法与注意事项

  • 置换重要性:打乱某个特征,看样本外指标下降多少。对面板数据,应在每个交易日内部打乱(横截面置换),保留时间结构和当天的市场状态;指标用 RankIC 而不是 MSE。López de Prado(2018 第 8 章)讨论了 MDI、MDA 等方法在金融数据上的问题。
  • 偏依赖:把某个特征固定在一系列取值上,对其余特征取平均,得到模型预测随该特征变化的曲线。它能显示模型学到的形状和方向。
  • SHAP(Lundberg 与 Lee 2017)把单个预测分解到各个特征上,适合解释具体的某一天、某一只股票。

使用时有三个常见陷阱。第一,相关特征会"瓜分"重要性:两个高度相关的特征,打乱其中一个时模型可以用另一个替代,两者的重要性都会被低估。第二,重要性不等于因果,一个没有直接作用的特征可能因为它是其他变量的代理而显得重要。第三,重要性本身有很大的估计误差,在 IC 只有 0.01–0.03 的情况下尤其如此,应当多次重复并给出区间。

10.7.2 代码

本节代码接上节,在同一会话中运行。分别用翻转前(第 60–640 天)和翻转后(第 900–1240 天)的数据训练两个梯度提升模型,在各自之后的时期测试。

# 代码 10.6  横截面置换重要性、偏依赖与相关特征
# —— 接上段(同一会话)——
def gb():
    return HistGradientBoostingRegressor(max_iter=150, learning_rate=0.03, max_leaf_nodes=15,
                                         min_samples_leaf=500, l2_regularization=1.0, random_state=0)

def perm_importance(model, Xa, days, n_rep=3, seed=0):
    """横截面置换重要性:在每个交易日内部打乱某一特征,测试期平均 RankIC 下降多少。
    只在同一天内打乱,保留了时间结构和当天的市场状态。"""
    rng = np.random.default_rng(seed); K = Xa.shape[2]
    def mean_ic(Xd):
        P = model.predict(Xd.reshape(-1, K)).reshape(len(days), N)
        return np.mean([rank_ic(P[i], t) for i, t in enumerate(days)])
    base = mean_ic(Xa[days]); out = {}
    for j in range(K):
        drops = []
        for _ in range(n_rep):
            Xp = Xa[days].copy()
            for i in range(len(days)): Xp[i, :, j] = rng.permutation(Xp[i, :, j])
            drops.append(base - mean_ic(Xp))
        out[j] = np.mean(drops)
    return base, out

def partial_dependence(model, Xa, days, j, grid=np.linspace(-0.45, 0.45, 7)):
    K = Xa.shape[2]; Xd = Xa[days].reshape(-1, K).copy(); res = []
    for v in grid:
        Xd[:, j] = v; res.append(model.predict(Xd).mean())
    return np.array(res) - np.mean(res)

early_tr, early_te = np.arange(60, 640), np.arange(650, 895)
late_tr, late_te = np.arange(900, 1240), np.arange(1250, T - H)
m_early = gb().fit(*rows(early_tr)[:2]); m_late = gb().fit(*rows(late_tr)[:2])
for name, m, te in (("早期模型", m_early, early_te), ("晚期模型", m_late, late_te)):
    base, imp = perm_importance(m, X, te)
    top = sorted(imp.items(), key=lambda kv: -kv[1])
    print(f"{name}: 测试 IC {base:+.4f};置换重要性 " + "  ".join(f"{feat_names[j]} {v*1e3:+.1f}" for j, v in top[:6]) + "(×1e-3)")
jm = feat_names.index("mom")
print("动量的偏依赖(早期模型):", np.round(partial_dependence(m_early, X, early_te, jm), 3))
print("动量的偏依赖(晚期模型):", np.round(partial_dependence(m_late, X, late_te, jm), 3))

# ---------- 相关特征会"瓜分"重要性:加入一个与 val 几乎相同的特征 ----------
dup = cs_rank(F["val"] + 0.1 * np.random.default_rng(5).standard_normal(F["val"].shape))
X2 = np.concatenate([X, dup[..., None]], axis=-1)
Xf2 = X2[early_tr].reshape(-1, X2.shape[2])
m_dup = gb().fit(Xf2, y[early_tr].reshape(-1))
_, imp1 = perm_importance(m_early, X, early_te)
_, imp2 = perm_importance(m_dup, X2, early_te)
jv = feat_names.index("val")
print(f"val 的重要性:单独 {imp1[jv]*1e3:+.1f};加入副本后 val {imp2[jv]*1e3:+.1f},副本 {imp2[len(feat_names)]*1e3:+.1f}(×1e-3)")

输出:

早期模型: 测试 IC +0.0318;置换重要性 val +13.6  size +7.9  rev +5.7  mom +3.2  n3 +0.6  vol +0.4(×1e-3)
晚期模型: 测试 IC +0.0148;置换重要性 val +3.4  rev +3.3  n2 +1.4  n3 -0.9  vol -1.4  mom -1.7(×1e-3)
动量的偏依赖(早期模型): [-0.001 -0.002  0.001  0.001  0.002  0.003 -0.005]
动量的偏依赖(晚期模型): [ 0.009  0.008  0.005  0.005 -0.005 -0.007 -0.016]
val 的重要性:单独 +13.6;加入副本后 val +8.2,副本 +3.4(×1e-3)

早期模型中,价值最重要,符合数据生成过程。规模排第二,但在生成过程中规模对预期收益没有直接作用:它决定特质波动,而波动决定反转效应的强弱。实现波动率特征 vol 是用 20 天数据估计的,噪声大,固定不变的规模反而是更干净的"波动代理"。这就是"重要性不等于因果"的一个具体例子。如果据此认为"小盘股有超额收益",就误读了模型。

金融直觉:置换重要性的逻辑和 CFA 业绩归因里"把某个决策换成基准,看超额收益少了多少"是同一个思路:在每个交易日内部把某个特征在 200 只股票之间随机重排,相当于让这个特征"失效",IC 下降得越多,模型越依赖它。之所以只在同一天内打乱,是因为跨日打乱会把牛市的特征值塞到熊市的样本里,测出来的是"破坏时间结构"的损失,而不是这个特征本身的贡献。 规模的例子则像归因中的风格漂移误读:一只基金的超额收益在归因里挂在"小盘"上,可能只是因为小盘股恰好承载了它真正押注的东西(这里是高波动带来的反转)。偏依赖的计算也很朴素:把所有样本的动量特征强行设成同一个值,看模型平均预测是多少,扫一遍取值就得到一条曲线,相当于对模型做"单因子敏感性分析"。

偏依赖揭示了规律的反转:早期模型中动量的偏依赖大体递增(两端样本少,有噪声),晚期模型中则从 +0.009 单调降到 −0.016,模型学到了反号的动量。晚期模型的测试 IC 只有 0.015,置换重要性的差异都在 \(10^{-3}\) 量级,基本在噪声范围内,说明短窗口训练的模型很难可靠地估计弱信号的重要性。

最后一行:加入一个与价值几乎相同的副本后,价值的重要性从 13.6 降到 8.2,副本得到 3.4,两者之和也低于原来的值。如果只看单个特征的重要性,会以为价值的作用下降了。处理方法是先对特征做聚类,按簇计算重要性,或者在建模前去掉冗余特征。

10.8 常见陷阱与检查清单

数据与特征

  • 每个特征都核对时点:\(t\) 日的特征只用 \(t\) 日及以前的数据;财务数据按公告日对齐。
  • 横截面标准化在每个交易日内部做,不要用全样本的均值和标准差。
  • 先估计信噪比的上限:用简单的线性模型或单因子 IC 作为基准,远高于基准的结果先查泄露。

标签与样本

  • 标签期限与交易持有期一致;考虑波动率标准化和三重障碍。
  • 计算平均唯一性,知道有效样本数;用唯一性作样本权重或做事件过滤。

验证

  • 不用随机 K 折;用剔除加禁区 K 折或前推验证。
  • 超参数选择、特征选择、提前停止都嵌套在训练集内部,并按时间顺序切分验证集;不用库函数内置的随机验证集。
  • 记录试过的所有配置,按多重检验调整(第 03 册第 10b 章,本册第 06 章的 Deflated Sharpe)。

模型

  • 以正则化线性模型为基准;非线性模型必须在样本外、扣费后战胜它。
  • 评价用 RankIC、换手和扣费组合收益,而不是 MSE。
  • 神经网络用多个随机初值的委员会,报告初值之间的差异。

上线与监控

  • 训练窗口长度和重训频率作为超参数在前推框架中选择。
  • 监控模型和各特征的滚动 IC;接受"弱信号的衰减需要很长时间才能确认"这一事实,用分散化控制风险。
  • PSI 只能发现分布漂移,发现不了关系变化。

解释

  • 置换重要性在横截面内部打乱,用样本外 IC 评价,多次重复。
  • 相关特征按簇计算重要性;把重要性和经济含义对照,警惕代理变量。

本章小结

机器学习在量化中的成败取决于数据和流程。特征要时点正确并做横截面标准化;标签要与交易规则一致,并认清重叠标签下有效样本数远小于名义样本数。验证必须尊重时间结构:随机 K 折会在纯噪声上给出 70% 的准确率,剔除加禁区的 K 折和前推验证则给出正确的结论。在低信噪比的面板上,正则化线性模型往往胜过梯度提升树和神经网络,复杂模型的优势需要大量数据才能兑现;评价要看扣费后的组合收益,而不只是 IC。规律会变,滚动窗口可以加快适应,但弱信号的失效需要很长时间才能被统计确认,PSI 之类的分布指标也发现不了关系的变化。可解释性工具能揭示模型学到的规律反转,但要警惕相关特征瓜分重要性和代理变量带来的误读。

练习

  1. 信号强度与模型选择。 在 10.2.2 的 make_panel 中把 mu_true 的系数从 0.0005 调到 0.001 和 0.002,重新运行 10.5.2 的比较。信号增强后,梯度提升树和神经网络能否超过岭回归?提示:画出"信号强度–各模型 IC"的曲线,找交叉点。

  2. 障碍宽度。 在 10.3.3 的代码中,把 pt=sl 从 1.0 改为 0.5 和 2.0,比较三种触发方式的比例、平均持有期、与固定期限标签的一致率和平均唯一性。提示:障碍越窄,标签越接近"短期方向"。

  3. 剔除的作用。 在 10.4.2 的代码中把 \(H\) 改为 60,折数改为 10,比较连续 K 折和剔除加禁区 K 折的差别。提示:测试块两端受重叠影响的样本比例约为 \(2H/(n/K)\)。

  4. 人工交互特征。 在 10.5.2 的岭回归中加入特征"反转 × 波动"的乘积,比较 IC 的变化。这对梯度提升树是否有帮助?提示:有经济含义的人工特征相当于把先验知识注入模型,在低信噪比下尤其有效。

  5. 神经网络委员会。 修改 fit_model 中的 mlp 分支,用 5 个随机种子训练 5 个网络并平均预测。比较 IC、换手和扣费收益的变化,并报告 5 个单独网络之间 IC 的离散程度。提示:参考第 10 册第 22 章 22.5.5 节。

  6. 时间衰减权重。 在扩展窗口训练时,给样本加上权重 \(w_t=0.5^{(s-t)/\tau}\)(\(s\) 为训练截止日,\(\tau\) 为半衰期),对 \(\tau=125,250,500\) 天比较岭回归的翻转后 IC。提示:Ridge.fit 支持 sample_weight 参数。

  7. 监控的误报率。 在没有规律变化的面板(把 flip_day 设为大于 T)上运行 10.6.3 的两种监控,统计 9 个特征中误报的个数;调整 CUSUM 的参数 \(k\) 和阈值 \(h\),画出"检测延迟–误报数"的权衡曲线。

延伸阅读

  • 第 03 册第 22a 章(分类器)、第 22b 章(交叉验证与时间序列中的交叉验证)、第 13b 章(模型选择)、第 10b 章(多重检验与策略过拟合)。
  • 第 05 册第 14 章(岭回归、Lasso 与主成分)、第 15b 章(结构突变)。
  • 第 10 册第 09 章(性能优化)、第 11、12 章(多层网络与反向传播、反向传播的变体)、第 13a、13b 章(泛化:提前停止、正则化与贝叶斯正则化)、第 14 章(动态网络)、第 22 章(实际训练问题)、第 23 章(函数逼近与概率估计案例)、第 27 章(预测与金融时间序列案例)。
  • 本册第 02 章(时点数据)、第 03 章(因子研究与 IC)、第 06 章(回测方法论与 Deflated Sharpe)。
  • López de Prado, M. (2018). Advances in Financial Machine Learning. Wiley.(第 3 章标签、第 4 章样本权重、第 7 章交叉验证、第 8 章特征重要性)
  • Gu, S., Kelly, B., & Xiu, D. (2020). Empirical asset pricing via machine learning. Review of Financial Studies, 33(5).
  • Hastie, T., Tibshirani, R., & Friedman, J. (2009). The Elements of Statistical Learning (2nd ed.). Springer.
  • Friedman, J. (2001). Greedy function approximation: a gradient boosting machine. Annals of Statistics, 29(5).
  • Breiman, L. (2001). Random forests. Machine Learning, 45(1).
  • Lundberg, S., & Lee, S.-I. (2017). A unified approach to interpreting model predictions. Advances in Neural Information Processing Systems 30.
  • Page, E. S. (1954). Continuous inspection schemes. Biometrika, 41(1/2).