把机器学习塞进量化选股,并不是「特征拼一拼、丢给 LightGBM、看 AUC」这么简单。一个能在 Kaggle 上排进前列的工程师,在 A 股截面上跑出 IC 0.10、年化超额 30% 的回测时,往往会在上线后第一周看着模型快速衰减、风格暴露失控、回测里大放异彩的几个特征在实盘里完全不发力,怀疑自己是不是搞错了。问题极少出在算法,绝大多数出在三件事:标签构造的口径错了、训练协议把未来信息泄漏进了训练集、模型行为没有被解释清楚就直接接到了组合层。
本文把机器学习选股拆成五个互相独立、互相约束的工程问题:标签怎么构造、特征怎么处理、训练协议怎么写、模型怎么解释、上线后怎么监控。算法本身只占很小篇幅;真正决定生死的,是这些被论文和教程一笔带过的工程细节。文中的代码示例使用
Python 3.11,依赖
numpy、pandas、scikit-learn、lightgbm、shap、optuna
这些标准生态库;SVG 图位于本文同目录 images/
下。
风险提示:本文出现的所有标签、特征、模型超参、回测数字均用于说明工程方法,不构成任何投资建议。机器学习选股在不同市场、不同周期、不同样本上的表现差异极大;任何把本文示例直接搬到实盘的尝试都需要结合所在市场的交易规则、合规约束与自身风险承受能力做完整评估。文中提到的 IC、Sharpe、超额收益等指标在多重检验、过拟合与未来函数没有得到妥善处理时,都可能严重高估真实表现。
一、机器学习在量化中的位置
机器学习在量化交易系统里的位置,比绝大多数初学者想象的要克制。它不是替代整套策略,而是嵌入在某一个或某几个具体环节里,承担一类被传统线性模型做得不够好的任务。把这件事先理清楚,后面所有讨论才不会失焦。
一个完整量化系统的层级
一个机构级别的量化选股系统,从原始数据到执行落地,大致包含五层:
- 数据层:行情、财报、公告、舆情、宏观指标、另类数据等的接入、清洗、对齐、缺失值处理,最终落到一个面板(panel)数据集上。
- 因子层:把原始数据加工成有经济意义、有可解释性、有相对独立性的因子。例如动量、反转、波动、估值、盈利、成长、质量、流动性、北向、分析师、文本情感等几十到几百个因子。
- 信号层(合成层):把因子池压缩成一个或几个综合预测信号,输入到组合优化器。
- 组合层:在风险约束、行业约束、个股约束、换手约束下,把信号转成持仓权重。
- 执行层:把目标持仓拆解成订单流,控制冲击成本与滑点。
机器学习并不是只能在第三层出现。事实上每一层都可以用 ML:
- 数据层:异常点检测、停牌补值、新闻去重。
- 因子层:用神经网络从分钟线、Tick、订单簿里学习高频微结构因子;用 NLP 从公告里提取事件类因子。
- 信号层:把数百个因子合成为一个排序信号——本文的主战场。
- 组合层:用强化学习直接学习持仓决策(这是另一个领域,本文不展开)。
- 执行层:用 LSTM 或 Transformer 学习订单簿冲击模型,做最优执行。
本文聚焦的环节
本文专门讨论第三层——信号合成。这是一个非常具体的监督学习问题:
给定截面 \(t\) 上某只股票的因子向量 \(x_{i,t}\),预测它在持仓窗口 \([t, t+h]\) 内的排名收益 \(y_{i,t}\)。
把这个问题写出来后,机器学习的角色就清晰了:它不是在「发现新的 alpha」,而是在「把研究员已经发现的因子用一种比线性回归更聪明的方式合成起来」。它的对手不是基本面研究员,而是 Fama-MacBeth 截面回归 和 行业市值中性后的等权打分。
如果机器学习的边际收益没有显著高于这两个基线,那就要严肃怀疑 ML 用错了地方。一个常见的误区是把「因子之间的非线性交互」想得过于丰富——很多被发现的「非线性关系」其实只是噪声、风格暴露或样本选择偏差的伪装。
与传统线性模型的边界
我把传统线性模型与机器学习的边界画在三个判据上。
第一,因子之间是否存在强非线性交互。 如果交互结构稳定且经济上可解释(如低估值股票里只有低杠杆的才能获得估值修复),GBDT 类模型可以显著优于线性。如果交互只是回测拟合的产物,模型上线后会比线性更快衰减。
第二,样本量是否足够支撑模型复杂度。 A 股截面每天约 5000 只股票,10 年面板大约 1200 万样本。听起来很多,但有效独立样本远小于此:同一截面上股票之间的横向相关使得「等价样本」要除以一个明显大于 1 的因子,再叠加 5 至 20 日的标签窗口(同一只股票相邻样本的标签相关),等价独立样本可能只有原始样本的 1/20 到 1/50。GBDT 在 10 万到 100 万独立样本量级表现稳定,再低就要警惕过拟合。
第三,是否需要对模型行为做强解释。 监管、风控、研究员复盘都要求知道「这个仓位为什么被加进来」。线性模型天然可解释;GBDT 必须配合 SHAP 等工具;深度网络需要更复杂的归因。如果业务方对解释性要求很高而样本量又不大,线性或 Ridge 几乎一定是更好的选择。
把这三件事问完,再决定要不要上 ML。绝大多数情况下,正确答案是「线性 + 行业市值中性 + 鲁棒回归」就足够,ML 只是锦上添花。
二、标签构造
机器学习的第一性原理是「你想预测什么」。这件事在量化选股里远比想象中微妙。一个只会调
sklearn 的工程师会下意识地把「未来 5
日收益」当成标签,但这里至少藏着五个口径问题:用什么收益(对数还是简单)、是否做风险调整、用什么周期、是否做截面排名、如何处理同期事件。每一个都会显著改变模型学到的东西。
截面标签
截面标签指的是在固定截面 \(t\) 上,把全市场(或某个股票池)的标的按照某个目标变量进行排名或分位划分。最常用的三种:
连续收益标签直接使用未来 \(h\) 日的对数收益:
\[y_{i,t} = \log\left(\frac{p_{i,t+h}}{p_{i,t}}\right)\]
风险调整收益把波动率作为分母,使得不同标的的标签量级可比:
\[y_{i,t} = \frac{r_{i,t \to t+h}}{\sigma_{i,t}}\]
其中 \(\sigma_{i,t}\) 是 \(t\) 时刻基于过去 \(N\) 日收益估计的波动率。这个标签下的模型会更倾向于学习「夏普比」而非「绝对涨跌幅」,对低波动行业更友好。
分位标签(Quantile Label)把当期截面的连续收益切成 \(K\) 个桶,最高桶标 1,最低桶标 0(二分类)或者 \(0, 1, 2, \dots, K-1\)(多分类)。常见做法是把每个截面切成 5 或 10 分位,然后只取最高分位与最低分位做二分类,中间舍弃。这种做法的好处是把「绝对收益预测」变成「相对排名预测」,对异常截面(如熔断日、政策日)更鲁棒;坏处是丢弃了大部分样本,且模型输出没有直接对应到组合权重。
Rank 转换的连续标签是工业界更常用的折中:先在每个截面上做秩变换 \(r_{i,t} = \text{rank}(y_{i,t}) / N_t\),再做正态化(如 Inverse Normal Transform),得到 \(\tilde{y}_{i,t}\)。这样既保留全部样本,又把不同截面的尺度统一到 \(\mathcal{N}(0, 1)\),模型学到的是「相对位次」而非「绝对涨幅」。
时序标签
时序标签关注的是同一只标的随时间演化的标签序列。最常见的几种。
固定窗口收益就是简单地用 \(r_{i, t \to t+h}\),\(h\) 是预设的持仓周期。问题是:所有样本被强制持有 \(h\) 天,无论中途发生什么。一只股票第二天就涨停,标签依然是 \(h\) 日后的收益,把后面 \(h-1\) 天的随机噪声硬塞进了标签。
Triple-Barrier 标签是 Marcos López de Prado 在《Advances in Financial Machine Learning》中提出的一类标签。它从事件触发点 \(t_0\) 出发,定义三条「沿」:
- 上沿(profit-taking barrier):\(pt = \text{entry} \cdot (1 + k_{\text{up}} \cdot \sigma)\),先触碰则标签 \(y = +1\)(止盈);
- 下沿(stop-loss barrier):\(sl = \text{entry} \cdot (1 - k_{\text{dn}} \cdot \sigma)\),先触碰则 \(y = -1\)(止损);
- 时间沿(vertical barrier):\(t_1 = t_0 + H\),到期都没触碰则 \(y = \text{sign}(\text{ret}_{t_0 \to t_1})\) 或直接置 0。
\(\sigma\) 是基于近 \(N\) 日收益的指数加权波动率,\(k_{\text{up}}, k_{\text{dn}}\) 是对称或不对称的倍率系数(如 \(2.0\) 和 \(1.0\)),\(H\) 是持仓上限(如 10 个交易日)。
下面的图直观展示了三条沿与三类典型路径:
Triple-Barrier 比固定窗口标签好在三件事:第一,标签反映了「真正会被触发的退出条件」,更贴近真实交易;第二,波动率自适应,高波动股票的沿更宽,避免被噪声打穿;第三,时间沿强制了最长持仓,防止僵尸样本。
代价是实现复杂、并行化要小心,并且对 \(k_{\text{up}}, k_{\text{dn}}, H\) 三个超参敏感。
Meta-Labeling 是 de Prado 在 Triple-Barrier 之上的二次封装。思路是:先用一个一阶模型(甚至是一个简单的趋势/反转规则)产生买卖信号 \(\hat{y}_{i,t} \in \{-1, 0, +1\}\),然后用 Triple-Barrier 标记这些信号实际的盈亏方向 \(y^{\text{tb}}_{i,t}\),最后训练一个二阶模型预测「一阶信号是否值得跟」——也就是 \(\Pr(y^{\text{tb}}_{i,t} = \hat{y}_{i,t} \mid x_{i,t})\)。这个二阶概率被用作仓位的置信度(position sizing),而不是用来产生信号本身。
Meta-Labeling 的工程价值在于:它把「方向」与「置信度」解耦,方向由可解释的一阶规则给出,置信度由黑盒模型给出,模型即使不能稳定预测涨跌方向,也能稳定预测「现在是否适合下重仓」。
标签构造的几条工程纪律
无论选择哪种标签,都要服从以下几条纪律。
第一,标签时间不能与特征时间重叠。 如果特征里包含 \(t\) 时刻的收盘价、量、成交量,而标签是 \(t \to t+h\) 的收益,必须确认特征所用数据在 \(t\) 时刻交易结束前可获得。任何一根「夜盘后才公布的财报快讯」如果按交易日 \(t\) 入特征,但实际只能在 \(t+1\) 才能交易,就是 0.5 天的未来函数。
第二,停牌、退市、ST 必须显式处理。 一只票在持仓窗口内停牌,连续收益的算法是「停牌期间收益按 0 算」「按上一交易日收盘价不动」「按复牌后第一个交易日补齐」三种口径中的哪一种?回测引擎的口径必须与标签构造的口径一致。退市股的最终收益按 \(-100\%\) 还是按最后一个交易日收盘价封盘?这两个选择会让回测年化差出 1 至 3 个百分点。
第三,复权方式要前后一致。 训练用的是后复权价、回测用前复权价、实盘订阅的是不复权行情,是三件常见的事故。除权除息日的标签会突变。统一使用「后复权连续价」做收益计算,是行业里最不容易出错的一种选择。
第四,标签分布要看一眼。 截面标签如果未做 rank 变换,往往呈现长尾、左偏;时序标签如果用了 Triple-Barrier,三类的占比可能严重不均衡(如 \(+1: 25\%, -1: 25\%, 0: 50\%\))。这些是模型选择损失函数、是否做类别加权、是否设阈值的依据,写代码前必须先 plot 一遍。
三、特征工程
特征是机器学习选股里最容易掉以轻心的环节。绝大多数 Kaggle
风格的「特征工程技巧」(例如
target encoding、KFold mean encoding、leave-one-out encoding)在金融数据上要么直接造成未来函数,要么把噪声放大成假信号。这一节只讨论真正在工业界量化里被验证有效的几类操作。
因子标准化
原始因子的量级、分布、量纲千差万别:市盈率是个位数,市值是亿元级别,换手率是百分比,公告情感是 \([-1, 1]\) 区间。直接喂给 GBDT 不会出问题(GBDT 对单调变换不敏感),但喂给线性模型或神经网络会立刻引发数值问题。
工业界的标准三步:
去极值(Winsorize):在每个截面上,把每个因子的取值限制在 \([\mu - k\sigma, \mu + k\sigma]\) 区间内,\(k\) 通常取 3。或者用更鲁棒的 MAD(median absolute deviation)口径:\([\text{median} - k \cdot \text{MAD}, \text{median} + k \cdot \text{MAD}]\)。
标准化(Z-Score):\(\tilde{x}_{i,t} = (x_{i,t} - \mu_t) / \sigma_t\),其中 \(\mu_t, \sigma_t\) 是当期截面的均值和标准差。注意是截面均值/标准差,而不是滚动时间窗口均值/标准差——后者会引入跨样本污染。
秩变换 + 反正态变换:\(\tilde{x}_{i,t} = \Phi^{-1}((\text{rank}(x_{i,t}) - 0.5) / N_t)\)。这个变换把任何分布的因子映射成标准正态,对极端值天然鲁棒。线性模型几乎一定要这么做;GBDT 可做可不做,但做了之后特征贡献的可解释性会更好。
行业市值中性
「行业市值中性」是 A 股因子工程的基本动作,本质上是要剥离「行业暴露」和「市值暴露」对因子的污染。
具体做法:在每个截面上,对原始因子 \(x_{i,t}\) 做下面的截面回归:
\[x_{i,t} = \alpha_t + \sum_{k} \beta_{k,t} \cdot \mathbb{1}[\text{ind}(i) = k] + \gamma_t \cdot \log(\text{mcap}_{i,t}) + \epsilon_{i,t}\]
把残差 \(\hat{\epsilon}_{i,t}\) 作为中性化后的因子。这一步在每个截面上独立做,行业哑变量用一级行业(中信一级或申万一级),市值取对数后做 z-score。
为什么必须中性?两个原因:
第一,因子的有效性会被风格暴露伪装。某个看起来有效的「质量因子」可能本质上是「大盘股」,在 2017 年「上证 50 行情」里看起来神准,到了 2021 年「中小盘行情」就立刻反转。中性化把风格剥掉,只留下「同行业、同市值组里的相对优劣」。
第二,ML 模型对偏差敏感。GBDT 对单调变换不敏感,但对条件期望偏差敏感——如果某个行业的某个因子普遍偏高,模型会学到「这个行业 = 高分」,而不是真正的因子信号。中性化把这种偏差消掉。
注意:中性化后再做 z-score 或 rank 变换,否则尺度还是不齐。
滞后处理
任何「使用 \(t\) 时刻数据」的特征都要确认数据在 \(t\) 时刻交易结束前可得。常见陷阱:
- 财务因子:财报日 \(t_r\) 和实际公布日 \(t_p\) 不一定一致,季报披露窗口可能拉到 \(t_r + 30\) 至 \(90\) 天。要么用 \(\text{point-in-time}\) 数据(PIT),要么对每个财务因子做 \(\text{lag}_n\) 处理(\(n\) 取 3 至 6 个月)。
- 分析师预期:预测变更可能延迟数日才进入数据库。
- 北向持股:港交所每日 8 点左右公布昨日数据,T 日决策只能用 T-1 日的北向数据。
- 舆情情感:取数 API 可能本身有延迟,必须以「数据写入时间戳」而非「事件时间戳」为准。
工程上最稳的做法是给每个因子标注
available_at 时间戳,统一用「截至 \(t\) 时刻的市场收盘 5
分钟前」过滤。
多维交互与滚动统计
GBDT 自然能学到二阶交互,但显式构造的交互特征有时仍然有用,特别是对于线性模型作为基线时。常见操作:
- 乘积/比值:\(\text{value} \times \text{momentum}\) 把估值修复 + 动量启动两个条件交叉。
- 分组统计:对每个行业 / 市值分组,计算因子分位 \(\text{pct\_rank}_{\text{ind}}(x_{i,t})\)。
- 滚动统计:对时间序列因子计算 \(5/10/20\) 日均值、标准差、最大回撤、与自身均值的偏离度。
注意所有滚动窗口都要使用 \(\text{rolling}(\text{closed=‘left’})\) 或显式 \(\text{shift}(1)\),否则当期值会把未来信息带入。
四、模型选择
模型选择的第一原则是匹配样本量与模型复杂度。把这一原则展开成可操作的判断:
线性、Ridge、Lasso
线性模型几乎永远是基线。它的优势:
- 样本利用率高:参数量与因子数同阶,不需要很多样本就能稳定估计。
- 可解释:每个系数对应一个因子的边际贡献,可以直接做因子归因。
- 抗过拟合:天然地不能过拟合到非线性细节。
- 与组合优化兼容:线性预测 = 因子加权 IC,自然嵌入到 mean-variance 优化里。
Ridge 在因子之间高度相关时(A 股里非常常见,例如多种动量因子)能稳定系数估计。Lasso 在因子池非常大、希望做自动选股时有用,但要注意 Lasso 对相关因子会随机选择其中一个,结果不稳定。
经验:因子数 \(p < 50\),样本数 \(n > 10p\) 时,Ridge 通常足够。Lasso 在 \(p > 200\) 时才有用。
GBDT
梯度提升决策树(LightGBM、XGBoost、CatBoost)是过去 10 年量化选股的主力非线性模型。它的核心优势:
- 自然处理缺失值与混合类型变量;
- 对单调变换不敏感,特征工程门槛低;
- 在 \(n \in [10^5, 10^7]\)、\(p \in [50, 500]\) 的样本量下表现稳定;
- 训练速度快,CPU 上分钟级别可完成。
三家实现的工程差异:
- LightGBM:
leaf-wise生长策略,速度最快;对类别变量原生支持(categorical_feature);对极端不均衡和小叶子比较敏感。 - XGBoost:
level-wise生长策略,更稳但稍慢;正则化项(reg_alpha、reg_lambda)做得最完整;GPU 实现成熟。 - CatBoost:对类别特征做 ordered target encoding,理论上对类别多的数据集有优势;速度比 LightGBM 慢。
A 股选股的工业实践里,LightGBM 是默认选择。本文后面的代码以 LightGBM 为例。
神经网络
在选股的「截面 + 短中期」设定下,神经网络相对 GBDT 的优势并不明显,原因有三:
- 因子之间的交互结构稀疏,GBDT 的树结构刚好高效;
- 截面样本不是图像或文本,没有空间/序列结构可供 CNN/RNN 利用;
- 神经网络对训练协议和正则化的要求更高,工程成本远大于 GBDT。
神经网络在以下场景才显著优于 GBDT:
- 高频/微结构:分钟线、Tick、订单簿数据有时间序列结构,CNN/Transformer 显著优于 GBDT。
- 多模态:行情 + 文本 + 图像(卫星图、研报截图)需要联合编码。
- 大规模:当样本量进入 \(10^8\) 量级、特征上千时,神经网络的扩展性优势开始显现。
下一篇会专门讨论时序深度学习(LSTM、Transformer、PatchTST 等)在量化里的应用,本文不展开。
数据量与模型复杂度匹配
一个粗糙但实用的经验法则:
| 等价独立样本量 | 推荐模型 |
|---|---|
| \(< 10^4\) | 线性 / Ridge |
| \(10^4 \sim 10^5\) | Ridge / 浅 GBDT(叶子数 \(< 64\)) |
| \(10^5 \sim 10^6\) | LightGBM(叶子数 \(64 \sim 256\)) |
| \(10^6 \sim 10^7\) | LightGBM 深一些 / 浅 NN |
| \(> 10^7\) | 深度网络 |
注意:A 股 10 年面板的「等价独立样本」是 \(20 \sim 50\) 万,不是 \(1200\) 万。所以选股场景下 LightGBM 配中等深度(叶子数 64 到 128)几乎一定是最优工作点。
五、过拟合的根本原因
机器学习选股里 90% 的事故都来自过拟合。但过拟合并不是「模型在训练集上表现好、测试集差」这么简单。在金融数据上,过拟合有三个结构性原因,要单独拆开看。
数据量 vs 参数
最显然的过拟合:参数太多,样本太少。GBDT 在 \(1200\) 万行面板上跑出 \(10000\) 棵树、每棵 \(256\) 叶子,参数数量约等于 \(2.5 \times 10^6\),与等价独立样本数 \(5 \times 10^5\) 相比已经超配。
这个层面的过拟合相对好处理:限制叶子数(num_leaves)、最小叶子样本数(min_child_samples)、最大深度(max_depth),叠加
L1/L2 正则化。LightGBM 的关键超参:
params = {
"num_leaves": 63,
"min_child_samples": 200,
"max_depth": -1,
"learning_rate": 0.05,
"n_estimators": 2000,
"feature_fraction": 0.8,
"bagging_fraction": 0.8,
"bagging_freq": 5,
"reg_alpha": 0.1,
"reg_lambda": 0.1,
}但即使所有这些参数都调到最优,第二、第三类过拟合依然会让模型崩。
时序 IID 假设破坏
机器学习的所有理论保证(包括交叉验证的有效性)都建立在样本之间独立同分布(IID)这个假设上。金融数据违反 IID 的方式有两种:
横向相关:同一截面上不同股票的标签强相关。最典型的是「行业 beta」——同一行业的股票今天集体涨跌 3%,标签之间的截面相关接近 0.7。这意味着虽然有 5000 只股票 = 5000 个样本,但等价独立样本只有几十到几百。
纵向相关:同一只股票相邻时间点的标签相关。如果用 5 日未来收益作为标签,那么 \(t\) 与 \(t+1\) 两个样本的标签共享 4 天数据,相关系数极高。
这两种相关性会让朴素的 K-Fold 交叉验证完全失效:随机划分会让训练集和验证集共享时间窗口,验证集的「优秀表现」实际上来自标签泄漏。
多重检验
第三类过拟合最难发现:研究流程本身的过拟合。
一个量化研究员在 1 年内可能尝试:
- 10 种标签(不同 \(h\)、不同 Triple-Barrier 参数、不同风险调整方式);
- 50 种特征组合;
- 5 种模型(线性、Ridge、LightGBM、XGBoost、NN);
- 每种模型各 20 组超参;
总计 \(10 \times 50 \times 5 \times 20 = 50000\) 次实验。哪怕每次实验只贡献 0.001 的「真实 alpha」,多重检验下「最优组合」的样本外表现期望值也是显著为正的——但这是回测污染,不是真实 alpha。
de Prado 在《Backtest Overfitting》里给出了这一现象的概率上界:当尝试 \(N\) 个独立策略,挑出最优夏普 \(S^*\) 时,
\[\mathbb{E}[S^*] \approx \mu + \sigma \cdot \sqrt{2 \log N}\]
也就是说,尝试 1000 个策略,最好的策略期望夏普会比真实 \(\mu\) 高出约 \(3.7 \sigma\)。如果真实 \(\mu = 0\)、\(\sigma = 1\),最优策略「看起来夏普 3.7」完全是检验数量带来的虚假信号。
de Prado 七宗罪
de Prado 在《Advances in Financial Machine Learning》第 11 章把研究过拟合归纳为「七宗罪(Seven Sins)」:
- 生存者偏差(Survivorship Bias):用现在还活着的股票池做回测。
- 前视偏差(Look-Ahead Bias):未来数据进入训练或回测。
- 讲故事(Storytelling):先看到回测结果,再编经济解释。
- 数据挖掘(Data Mining):用同一份数据反复尝试,直到找到「显著」的策略。
- 交易成本(Transaction Costs):忽视滑点、冲击、佣金、印花税。
- 异常值(Outliers):少数极端事件主导了回测收益。
- 空头不可执行(Shorting):A 股做空有限制,回测里却假设了完美对冲。
这七条没一条是新观点,但每一条都能让一个看起来漂亮的回测从 Sharpe 3.0 掉到 0.3。机器学习放大了第 4 条——超参搜索 + 交叉验证让数据挖掘的成本接近于零。
六、严格的训练协议
针对上一节的三类过拟合,工业界形成了一套训练协议。这套协议是机器学习选股能否上线的分水岭。
嵌入式交叉验证
在金融数据上做交叉验证的第一原则:测试集必须来自训练集之后的时间。任何形式的「随机划分」都会破坏这一原则。
最简单的版本是 Walk-Forward Validation:
Train: [t_0, t_1) Test: [t_1, t_2)
Train: [t_0, t_2) Test: [t_2, t_3)
Train: [t_0, t_3) Test: [t_3, t_4)
...
每一折训练集只包含测试集之前的数据。这个版本很安全,但有两个缺点:早期折的训练样本太少;不同折的测试集长度不一致。
Purged K-Fold
de Prado 提出的 Purged K-Fold 解决了上面的问题,同时处理了标签时间重叠的污染。核心思想:
每一折训练集需要剔除(purge)所有标签时间区间 \([t_0^i, t_1^i]\) 与测试折时间区间 \([T_{lo}, T_{hi}]\) 相交的样本。
为什么?考虑一个样本 \(i\) 的特征时间 \(t_0^i = T_{lo} - 5\),标签窗口 \([T_{lo} - 5, T_{lo} + 5]\)。这个样本的标签包含了 \(T_{lo}\) 之后 5 天的市场行情——而这正是测试折要预测的内容。如果让模型在训练时看到这个样本,等价于把测试集的部分答案告诉模型。
Embargo 机制
仅仅 Purge 还不够。考虑测试折之后紧邻的训练样本——它们的特征里可能包含「反映了测试期市场冲击的滞后信息」(如 5 日动量)。即使标签时间不重叠,特征也已经被「污染」。
Embargo 是在测试折之后留一段隔离带,把这一段时间内的训练样本也剔除。Embargo 长度通常取最大持仓窗口 \(H\) 的 1 至 2 倍。
下面的图把 Purge 与 Embargo 在 5 折时间序列交叉验证里的位置画出来:
Combinatorial Purged CV
Purged K-Fold 还有一个隐忧:每一组超参只在 \(K\) 个测试折上做了评估,等价独立的「样本外评估」次数只有 \(K\)。当超参组合 \(> 100\) 时,多重检验问题再次浮现。
de Prado 提出的 Combinatorial Purged CV (CPCV) 把数据切成 \(N\) 段,每次随机选 \(k\) 段作为测试,其余作为训练(同样需要 Purge 与 Embargo),共 \(\binom{N}{k}\) 种组合。\(N=10, k=2\) 时有 45 种组合,远多于 5 折,能给出超参表现的更稳定分布。
CPCV 的代价是计算量大约是 \(K\)-Fold 的 \(\binom{N}{k}/K\) 倍。在 LightGBM 上跑得动,在深度网络上往往跑不动,需要权衡。
Python 实现:Purged K-Fold
下面给出 Purged K-Fold 的最小实现,符合 sklearn 的
BaseCrossValidator 接口:
from typing import Iterator, Tuple
import numpy as np
import pandas as pd
class PurgedKFold:
"""
Purged K-Fold for time-series labels with overlap.
Args:
n_splits: number of folds.
t1: pandas Series indexed by sample id (or row order),
value is the label end time t1_i for each sample.
embargo_pct: fraction of total bars used as embargo.
"""
def __init__(self, n_splits: int = 5, t1: pd.Series = None, embargo_pct: float = 0.01):
if t1 is None:
raise ValueError("t1 (label end times) required")
self.n_splits = n_splits
self.t1 = t1.sort_index()
self.embargo_pct = embargo_pct
def split(self, X: pd.DataFrame) -> Iterator[Tuple[np.ndarray, np.ndarray]]:
if (X.index != self.t1.index).any():
raise ValueError("X and t1 must share the same index")
indices = np.arange(X.shape[0])
embargo = int(X.shape[0] * self.embargo_pct)
test_ranges = np.array_split(indices, self.n_splits)
for test_idx in test_ranges:
test_lo = X.index[test_idx[0]]
test_hi = X.index[test_idx[-1]]
train_mask = np.ones(X.shape[0], dtype=bool)
train_mask[test_idx] = False
# Purge: drop training samples whose label horizon
# overlaps the test window
label_end = self.t1.loc[X.index]
label_start = X.index
overlap = (label_end >= test_lo) & (label_start <= test_hi)
train_mask &= ~overlap.values
# Embargo: drop training samples right after the test window
embargo_hi_pos = min(test_idx[-1] + embargo, X.shape[0] - 1)
train_mask[test_idx[-1] + 1: embargo_hi_pos + 1] = False
yield indices[train_mask], test_idxt1
是每个样本的标签结束时间。embargo_pct
表示用样本总数的多大比例作为隔离带。
七、模型解释
一个上线的机器学习模型,必须能回答三类问题:
- 这一期的预测值,主要由哪些因子贡献?
- 模型整体对哪些因子最敏感?这些因子的稳定性如何?
- 在最近一段时间,模型行为是否发生了漂移?
回答不了这三类问题,模型就不能上线——不是因为算法不行,而是因为运营层面无法在异常时介入。
SHAP
SHAP(SHapley Additive exPlanations) 把每一次预测分解为「基线值」加上每个特征的边际贡献:
\[\hat{y}_i = \phi_0 + \sum_{j=1}^{p} \phi_{i,j}\]
其中 \(\phi_0\) 是模型在训练集上的均值预测,\(\phi_{i,j}\) 是第 \(j\) 个特征对样本 \(i\) 的 Shapley 值。Shapley 值有可加性、对称性、效率性等公理保证,是博弈论中唯一满足这几条性质的归因方式。
对于 GBDT,SHAP 有专门的快速实现 TreeSHAP,复杂度 \(O(TLD^2)\),\(T\) 是树数,\(L\) 是叶子数,\(D\) 是深度。在 LightGBM 模型上,SHAP 计算大约比预测慢 5 至 20 倍,工程上完全可接受。
Permutation Importance
Permutation Importance(PI) 是一种模型无关的全局解释:把某个特征列随机打乱,重新计算模型在验证集上的损失,损失上升越多说明该特征越重要。
\[\text{PI}_j = \mathbb{E}[L(y, \hat{y}_{\pi_j})] - \mathbb{E}[L(y, \hat{y})]\]
PI 的优势是不依赖具体模型;劣势是对相关因子会低估重要性——两个高度相关的因子互为替代,打乱其中一个不会显著恶化预测。
LIME
LIME(Local Interpretable Model-agnostic Explanations) 在被解释样本的局部邻域内拟合一个简单线性模型,用线性系数解释黑盒预测。LIME 在表格数据上不如 SHAP 稳定,工业界量化里很少作为主力解释工具,更多用作 SHAP 的交叉验证。
因子归因
把 SHAP 应用到选股模型上,每一期预测都可以分解到因子层面。这给出了三个具体可用的工程产物:
- 持仓归因:今天加仓某只股票的原因,是因为它在「估值」上贡献了 +0.3、在「动量」上贡献了 +0.2、在「质量」上贡献了 +0.1,而不是某个不可解释的 GBDT 黑盒输出。
- 风格暴露监控:把每一期所有持仓的 SHAP 值按因子加权,得到组合在每个因子上的「事实暴露」,与组合优化器设定的「目标暴露」比较,差异过大时触发告警。
- 模型稳定性诊断:对比相邻几个月的 SHAP 分布,如果某个特征的边际贡献从 +0.2 变到 -0.1,说明模型在该特征上的行为反转,要立刻人工复核。
稳定性诊断
机器学习模型在金融数据上会衰减,这不是 bug 而是常态。衰减来自三个源头:
- 市场风格切换:训练样本里没有的极端行情(2018 年贸易战、2020 年熔断、2022 年俄乌)让模型学到的关系失效。
- 因子拥挤:同一个 alpha 被越来越多机构挖掘,超额收益逐渐被套利掉。
- 数据生成过程的真实变化:注册制、双创注册制、退市新规、做市制度引入,都会改变数据本身的统计性质。
工程上需要监控的三个量:
- 预测分布漂移:每天 / 每周对比当前预测分布与训练时验证集预测分布的 KS 统计量、均值、标准差。
- IC 移动平均:日 IC 的 20 日 / 60 日移动平均,跌破训练时的 1/2 触发告警。
- SHAP 值漂移:每个特征 SHAP 均值的滚动监控。
八、上线工程
模型训练好不等于能上线。上线工程关注的是「训练时的模型」与「实盘上跑的模型」的一致性,以及上线后的运营。
训练-推理一致
模型代码里有两类容易出问题的地方:
特征预处理一致:训练时用的 z-score
必须使用「截至训练截止日的截面统计量」,推理时用的 z-score
必须使用「截至推理日的截面统计量」。如果训练用的是历史所有截面的全局均值/方差,推理却用了截面均值/方差,就会出现训练-推理不一致。最稳的做法:把所有预处理逻辑封装成
sklearn 的 Pipeline 或自定义
Transformer,pickle 时连预处理一起序列化。
特征顺序一致:LightGBM
训练时记录了特征顺序,推理时如果传入的 DataFrame
列顺序变化,预测结果完全错乱。务必使用
model.feature_name()
获取训练时的顺序,推理前显式 reindex。
Python 版本一致:pickle 在不同 Python 版本之间不保证兼容。生产环境的 Python 版本必须与训练环境一致。
模型版本化
每一次模型重训练都要落盘以下内容:
- 模型文件(
model.txt或model.pkl); - 训练数据的元信息(起止日期、样本数、特征列表、特征 hash);
- 训练超参(json);
- 验证集指标(IC、Sharpe、AUC、MSE);
- 训练代码的 git commit hash;
- 训练环境(Python、LightGBM、pandas 版本)。
工业界常用 MLflow、Weights & Biases、自建 S3 + 元数据数据库等方案。底线是:任何一天的实盘预测都能精确复现到当时使用的模型与代码。
A/B 测试
新模型上线前要做 A/B 测试。一种常见做法是「纸面交易(paper trading)」:
- 旧模型继续在生产组合里运行;
- 新模型同步生成预测,但只记录信号、不下单;
- 跑 1 至 3 个月,对比两者在同一持仓约束下的虚拟净值。
如果新模型在 OOS 期表现稳定优于旧模型,再切流量。直接全量切换是事故源——新模型的真实样本外表现,永远比验证集差。
在线监控与衰减预警
最低限度的监控指标:
| 指标 | 频率 | 触发阈值 |
|---|---|---|
| 日 IC | 日 | 20 日均值跌破训练 IC 的 50% |
| 预测分布 KS | 日 | KS > 0.1 |
| 持仓换手 | 日 | 较 5 日均值 +50% |
| 行业暴露 | 日 | 任一行业偏离基准 > 5% |
| 单票权重 | 日 | 任一标的 > 设定上限 |
| SHAP 漂移 | 周 | 任一 top-10 特征 SHAP 均值变号 |
监控告警出去之后,要有明确的处置流程:人工复核 → 临时降仓 → 强制重训练 → 模型下线。这套流程比模型本身更重要。
九、完整 Python 示例
把前面的所有要素串起来,给出一个端到端的最小可运行示例。代码刻意简化了数据加载部分(用合成数据),重点演示工程结构。
Triple-Barrier 标签
import numpy as np
import pandas as pd
def get_daily_vol(close: pd.Series, span: int = 20) -> pd.Series:
"""Exponentially weighted daily volatility, indexed by date."""
ret = close.pct_change()
return ret.ewm(span=span).std()
def apply_triple_barrier(
close: pd.Series,
events: pd.DataFrame,
pt_sl: tuple = (2.0, 1.0),
) -> pd.DataFrame:
"""
For each event, find which barrier (upper / lower / vertical) is hit first.
Args:
close: pd.Series of close prices indexed by datetime.
events: pd.DataFrame indexed by event start time t0,
with columns:
t1 -- vertical barrier time
trgt -- volatility used to size barriers
side -- {-1, +1} desired position direction
pt_sl: (k_up, k_dn) multipliers for upper / lower barriers.
Returns:
DataFrame indexed by t0 with columns:
t1_hit -- time of first barrier hit
ret -- realized return at t1_hit
label -- {-1, 0, +1}
"""
out = events[["t1"]].copy()
out["t1_hit"] = pd.NaT
out["ret"] = np.nan
out["label"] = 0
for t0, row in events.iterrows():
t1 = row["t1"]
trgt = row["trgt"]
side = row["side"]
path = close.loc[t0:t1]
if path.empty:
continue
path_ret = (path / path.iloc[0] - 1.0) * side
pt = pt_sl[0] * trgt
sl = -pt_sl[1] * trgt
hit_pt = path_ret[path_ret >= pt].index.min()
hit_sl = path_ret[path_ret <= sl].index.min()
hit_t1 = t1
first = min([t for t in [hit_pt, hit_sl, hit_t1] if pd.notna(t)])
out.at[t0, "t1_hit"] = first
out.at[t0, "ret"] = path_ret.loc[first]
if first == hit_pt:
out.at[t0, "label"] = +1
elif first == hit_sl:
out.at[t0, "label"] = -1
else:
out.at[t0, "label"] = int(np.sign(path_ret.loc[first]))
return out事件 events 通常来自一阶过滤(如 CUSUM
filter、波动率突破、特定信号),side 在
Meta-Labeling 模式下来自一阶模型,原始 Triple-Barrier
模式下可设为 \(+1\)。
截面特征中性化
import statsmodels.api as sm
def neutralize_factor(
factor: pd.Series,
industry: pd.Series,
log_mcap: pd.Series,
) -> pd.Series:
"""
Cross-sectional neutralization on industry dummies and log market cap.
All inputs share the same index (per cross-section).
"""
df = pd.concat([factor, industry, log_mcap], axis=1).dropna()
df.columns = ["y", "ind", "lmc"]
dummies = pd.get_dummies(df["ind"], prefix="ind", drop_first=True).astype(float)
X = pd.concat([dummies, df[["lmc"]]], axis=1)
X = sm.add_constant(X)
model = sm.OLS(df["y"], X).fit()
resid = pd.Series(np.nan, index=factor.index)
resid.loc[df.index] = model.resid
return resid
def cross_sectional_zscore(s: pd.Series, k: float = 3.0) -> pd.Series:
s = s.copy()
mu, sd = s.mean(), s.std()
s = s.clip(lower=mu - k * sd, upper=mu + k * sd)
return (s - s.mean()) / s.std()实盘里这两个函数会在每个截面
groupby(date).apply(...) 一次。
LightGBM 训练 + Purged K-Fold
import lightgbm as lgb
from sklearn.metrics import roc_auc_score
def train_lgb_purged(
X: pd.DataFrame,
y: pd.Series,
t1: pd.Series,
params: dict,
n_splits: int = 5,
embargo_pct: float = 0.01,
) -> dict:
"""
Train LightGBM under Purged K-Fold and return CV diagnostics.
"""
cv = PurgedKFold(n_splits=n_splits, t1=t1, embargo_pct=embargo_pct)
fold_metrics = []
models = []
oof_pred = pd.Series(np.nan, index=X.index)
for fold_idx, (train_idx, test_idx) in enumerate(cv.split(X)):
X_tr, y_tr = X.iloc[train_idx], y.iloc[train_idx]
X_te, y_te = X.iloc[test_idx], y.iloc[test_idx]
train_set = lgb.Dataset(X_tr, label=y_tr)
valid_set = lgb.Dataset(X_te, label=y_te, reference=train_set)
model = lgb.train(
params,
train_set,
num_boost_round=params.get("n_estimators", 2000),
valid_sets=[valid_set],
callbacks=[
lgb.early_stopping(stopping_rounds=100),
lgb.log_evaluation(period=0),
],
)
pred = model.predict(X_te, num_iteration=model.best_iteration)
oof_pred.iloc[test_idx] = pred
if y.nunique() == 2:
score = roc_auc_score(y_te, pred)
metric_name = "auc"
else:
score = np.corrcoef(pred, y_te)[0, 1]
metric_name = "ic"
fold_metrics.append({"fold": fold_idx, metric_name: score})
models.append(model)
return {"models": models, "oof": oof_pred, "metrics": pd.DataFrame(fold_metrics)}Optuna 超参搜索骨架
import optuna
from optuna.samplers import TPESampler
def objective(trial: optuna.Trial, X, y, t1) -> float:
params = {
"objective": "binary",
"metric": "auc",
"verbosity": -1,
"boosting_type": "gbdt",
"num_leaves": trial.suggest_int("num_leaves", 16, 255),
"max_depth": trial.suggest_int("max_depth", -1, 12),
"learning_rate": trial.suggest_float("learning_rate", 0.01, 0.1, log=True),
"min_child_samples": trial.suggest_int("min_child_samples", 50, 500),
"feature_fraction": trial.suggest_float("feature_fraction", 0.5, 1.0),
"bagging_fraction": trial.suggest_float("bagging_fraction", 0.5, 1.0),
"bagging_freq": 5,
"reg_alpha": trial.suggest_float("reg_alpha", 1e-8, 10.0, log=True),
"reg_lambda": trial.suggest_float("reg_lambda", 1e-8, 10.0, log=True),
"n_estimators": 2000,
}
res = train_lgb_purged(X, y, t1, params, n_splits=5, embargo_pct=0.01)
return res["metrics"]["auc"].mean()
def run_search(X, y, t1, n_trials: int = 50) -> optuna.Study:
sampler = TPESampler(seed=42)
study = optuna.create_study(direction="maximize", sampler=sampler)
study.optimize(lambda t: objective(t, X, y, t1), n_trials=n_trials, show_progress_bar=False)
return studyn_trials 不要超过 100。每多一次 trial
都在加重多重检验,最优 AUC
越来越漂亮,但样本外越来越难复现。
SHAP 解释
import shap
def explain_with_shap(model, X: pd.DataFrame, sample: int = 5000) -> dict:
"""
Compute TreeSHAP values and return:
- global_importance: mean(|shap|) per feature
- sample_explanations: per-row contributions for `sample` rows
- interaction_top: top-5 feature interactions by mean abs value
"""
explainer = shap.TreeExplainer(model)
X_sample = X.sample(min(sample, len(X)), random_state=0)
shap_values = explainer.shap_values(X_sample)
if isinstance(shap_values, list):
shap_values = shap_values[1]
global_imp = pd.Series(
np.abs(shap_values).mean(axis=0),
index=X_sample.columns,
).sort_values(ascending=False)
return {
"global_importance": global_imp,
"sample_explanations": pd.DataFrame(shap_values, columns=X_sample.columns, index=X_sample.index),
"expected_value": explainer.expected_value,
}把 global_importance
接到监控系统上,每周对比 SHAP 漂移;把
sample_explanations
接到「持仓归因」面板上,每只股票今日加仓的贡献都能逐因子分解。
主流程串联
def run_pipeline(close, factor_df, industry, log_mcap):
# 1. labels
vol = get_daily_vol(close, span=20)
events = pd.DataFrame({
"t1": close.index.shift(10, freq="B"),
"trgt": vol,
"side": 1.0,
}, index=close.index)
events = events.dropna()
labels = apply_triple_barrier(close, events, pt_sl=(2.0, 1.0))
y = (labels["label"] > 0).astype(int)
# 2. features (cross-sectional neutralization per date)
feats_neutral = factor_df.groupby(level="date").apply(
lambda df: df.apply(lambda col: neutralize_factor(col, industry.loc[df.index], log_mcap.loc[df.index]))
)
feats_z = feats_neutral.groupby(level="date").apply(
lambda df: df.apply(cross_sectional_zscore)
)
# 3. align labels & features
X, y_aligned = feats_z.align(y, join="inner", axis=0)
t1 = labels.loc[y_aligned.index, "t1_hit"]
# 4. hyperparameter search
study = run_search(X, y_aligned, t1, n_trials=30)
best_params = study.best_params
best_params.update({"objective": "binary", "metric": "auc", "verbosity": -1, "n_estimators": 2000})
# 5. final model with purged CV
final = train_lgb_purged(X, y_aligned, t1, best_params, n_splits=5)
# 6. explain
explanation = explain_with_shap(final["models"][-1], X)
return {
"study": study,
"cv_result": final,
"shap": explanation,
}这个 pipeline 还远不能直接上实盘,缺少:
- 数据接入与 PIT 校验;
- 行业 / 市值 / 风格暴露的目标约束;
- 组合优化器(mean-variance 或 risk parity);
- 交易成本模型;
- 在线监控与告警。
但作为研究端的最小骨架,它已经覆盖了本文涉及的所有关键工程动作:Triple-Barrier 标签、截面中性、Purged CV、Optuna 搜索、SHAP 解释。
十、几个常见的工程坑
把过去几年自己和同事踩过的几个坑列出来,供参考。
坑一:用
train_test_split(shuffle=True)
划分时序数据。 sklearn 默认
shuffle=True,初学者复制示例代码上来直接用,把未来日的样本混进训练集。验证
AUC 0.95,上线 0.51。强制 shuffle=False 或者用
TimeSeriesSplit。
坑二:在所有截面上拼接样本后再做
z-score。 这种「全局
z-score」用了所有时间点的均值方差,等价于把未来信息融进训练数据。必须
groupby(date).apply(zscore)
做截面内的标准化。
坑三:fillna 用的是
mean() 而不是
groupby(date).mean()。
同上,全局均值含未来信息。截面均值是合法的;如果连截面均值都不够稳,就用
0 或者 -1(后者作为「特殊编码」让 GBDT 自己学)。
坑四:把 IC 当成模型评估的唯一指标。 IC 只衡量「相对排序」,不衡量「极端段的命中率」。一个 IC 0.05 的模型可能在 top-decile 完全没有超额收益,因为 IC 由中间分位贡献。要同时看 top/bottom 分位的实际收益分布。
坑五:忘记除权除息。 训练用后复权价、回测用前复权、实盘订阅不复权——这是行业里最常见的一类生产事故。统一为「后复权连续价」做一切收益计算。
坑六:把 Triple-Barrier 用错。 经常看到把 \(\sigma\) 设成全市场常数(如 0.02),完全失去了波动率自适应的意义。\(\sigma\) 必须按个股按时间动态计算。
坑七:忽视样本权重。 GBDT
默认每个样本权重相等,但金融数据里不同时期的「信息含量」差异很大(高波动期信号更强)。sample_weight
可以按 \(\text{trgt}^{-1}\)
或 \(|\text{ret}|\)
加权,给信号强的样本更大权重。
坑八:直接用 SHAP 的 force plot 给客户看。 SHAP force plot 信息密度极高,非技术读者看不懂。生产化的「持仓归因」要把 SHAP 值聚合到因子大类(动量、估值、质量、流动性、情绪),输出几条人能读的句子。
坑九:没有监控就上线。 模型上线第一天,监控系统也必须上线,而且要能定位到具体股票、具体特征、具体时间段。否则一旦衰减,根本不知道是哪一环出了问题。
坑十:把 GBDT 当神。 一个调好的 LightGBM 在 A 股选股上比线性提升大约 IC 0.01 到 0.03,年化超额提升 2 到 5 个百分点。这不是革命性的提升,跟整个流程的工程纪律相比,模型本身的边际贡献有限。
坑十一:训练集时间过短。 用 1 至 2 年数据训练,几乎一定会过拟合到当期市场风格。A 股选股的训练集底线是 5 年,10 年更稳。短训练集的另一个症状是模型在熊市样本下的预测严重偏向历史「均值水平」,回撤期间的择时完全失效。
坑十二:把停牌当 0 收益处理。
停牌期内股票没有交易日 K 线,简单的
pct_change() 会得到 NaN 或 0。Triple-Barrier
在停牌期的「未触发」并不等于「平稳穿越」,复牌后第一根 K
线可能直接跳过止损沿——这一类情况要单独处理:要么把停牌事件直接剔除,要么用复牌跳空价做模拟成交。
坑十三:因子之间相关性过高。 五个动量因子塞进模型,等价于把同一个信号放大五倍。GBDT 在这种情况下并不会自动归并,反而会在多个因子之间随机切换分裂依据,解释性大幅下降。预处理阶段就应该做相关性矩阵的层次聚类,每个簇里只保留 1 至 2 个代表性因子。
坑十四:用未来的行业分类。 A 股的中信一级、申万一级行业分类会随时间调整(如计算机/通信合并、金融分拆),如果训练时用的是 2024 年最新分类去回算 2015 年的样本,本身就是一种轻度的未来函数。要么使用 PIT 行业分类,要么用 GICS / Wind 一级这样长期稳定的口径。
十一、几个被反复问到的细节
把研究员、工程师、面试官在过去几年里反复问到的一些具体问题集中在这里回答。这些问题在前面章节的某个角落都有提到,但单独拎出来更便于查阅。
标签的 H 应该取多少
标签持仓窗口 \(H\) 的选择,本质上是「信号速度」与「噪声率」的权衡。
- \(H = 1\) 日:标签噪声极大(单日收益的信噪比通常低于 0.1),模型容易学到日内噪声;但好处是换手快、容量大。
- \(H = 5\) 日:A 股最常用的设定。一周尺度的预测对周报、宏观事件、基本面更新都比较友好。
- \(H = 20\) 日:基本面驱动的策略常用。月度尺度让信号更稳,但回测的「等价独立样本」少了一个数量级,过拟合风险陡增。
- \(H = 60\) 日及以上:通常需要专门的「中长期 alpha」研究框架,不再适合用 GBDT 直接拟合。
经验法则:先在 \(H \in \{5, 10, 20\}\) 三个值上各跑一次,看 IC 与 IR 的衰减曲线,挑信号衰减最慢的那个。
是否要做 Sample Weight
de Prado 在书中花了整整一章讲样本权重。核心观点是:金融数据的样本之间相互依赖,有效信息含量因样本而异,不该一视同仁地加权。
最常用的两种权重:
- Uniqueness weight:把每个样本的「时间唯一性」纳入考虑——重叠多的样本权重低、独立的样本权重高。
- Return attribution weight:用 \(|r_i|\) 或 \(r_i^2\) 加权,给绝对收益大的样本更大权重。
实践中,Uniqueness weight 对降低过拟合最有用;Return attribution weight 收益不一致,要看具体策略是否更看重大波动事件。
类别不均衡怎么处理
二分类版本的 Triple-Barrier 标签,正负类比例可能在 \(\{0.6 : 0.4\}\) 到 \(\{0.3 : 0.7\}\)
之间波动。LightGBM 提供 is_unbalance=True
或者显式 class_weight={0: w0, 1: w1}
两种方式自动调整。
但不要做 SMOTE 或随机过采样。SMOTE 在表格数据上经常制造出无意义的合成样本;时序数据上更糟,会破坏样本之间的依赖结构。
多分类 vs 二分类 vs 回归
| 任务 | 标签 | 适用场景 |
|---|---|---|
| 二分类 | \(\{0, 1\}\)(前后 30% 分位) | 信号清晰、容量小、希望降低噪声 |
| 多分类 | \(\{-1, 0, +1\}\) | Triple-Barrier 三态标签 |
| 回归 | 连续收益 | 信号要直接进入 mean-variance 优化 |
| 排序 | rank(或 LambdaRank) | 直接优化截面排序质量 |
工业界更常用的是「二分类 + Top/Bottom 截取」与「Rank
回归」两种。LightGBM 的 lambdarank
目标函数对截面排名优化很自然。
怎么判断模型是否过拟合
四个互相印证的信号,单独看任何一个都不够:
- 训练集 vs 验证集的指标差:训练 AUC 0.95、验证 0.55,几乎一定过拟合。差值大于 0.05 就要警觉。
- CV 折间方差:5 折 IC 分别为 \(\{0.10, 0.02, 0.08, -0.01, 0.06\}\),标准差比均值还大,说明模型在不同时间段表现严重不稳——本质上还是过拟合到了某些段。
- 特征重要性的稳定性:每折都跑一遍特征重要性,看 top-10 特征的一致性。如果不同折的 top-10 几乎不重合,模型在每折上学到的是不同的「噪声偏好」。
- 样本外退化速度:训练截止日 2023-12-31 的模型,2024 Q1 表现 IC 0.06、Q2 跌到 0.01、Q3 转负——这是典型的过拟合衰减曲线。健康的模型应该平稳衰减而非雪崩。
IC、Rank IC、IR 的区别
经常被混用的三个指标:
- IC(Information Coefficient):\(\text{Corr}(\hat{y}, y)\),皮尔逊相关,受异常值影响。
- Rank IC:\(\text{Corr}(\text{rank}(\hat{y}), \text{rank}(y))\),斯皮尔曼相关,工业界默认使用。
- IR(Information Ratio):\(\text{IC 均值} / \text{IC 标准差}\),衡量信号稳定性。IR > 0.5 算稳,> 1.0 算优秀。
报告模型表现时,至少同时给出 Rank IC 均值、Rank IC 标准差、IR,单独看 IC 均值是不够的。
在线增量学习能用吗
LightGBM 支持 init_model
加载已有模型继续训练,但不建议作为生产做法。原因:
- 增量训练对学习率与数据顺序非常敏感,容易产生不稳定的「最近偏好」;
- 模型的特征顺序、超参等元信息一旦不一致就会出错;
- 监管、复盘需要明确的「模型版本」边界,增量训练让边界模糊。
更稳的做法:每周或每月全量重训练,用 walk-forward 的方式逐步推进。新数据加入训练集,老数据按时间或权重淡出。
集成多个模型有用吗
简单的等权或 Rank-Average 集成几乎一定会让 IC 提升 5% 至 15%,原因是不同模型在不同段噪声不一样,加权平均能消掉一部分。常见集成方式:
- 多种子集成:同一份代码、同一份数据,用 5 至 10 个不同 random seed 训练,再取预测均值。最简单也最有效。
- 多窗口集成:训练数据起点不同(如 2015 起、2017 起、2019 起),适配不同的「市场记忆长度」。
- 多模型集成:LightGBM、XGBoost、CatBoost 三家集成。结果稳定但工程复杂度上升。
- Stacking:把多个一阶模型的输出作为二阶模型的输入。在金融数据上要小心,二阶模型很容易过拟合到一阶模型的噪声相关。
经验:先做多种子集成,性价比最高。Stacking 留到最后再考虑。
模型上线后多久重训练一次
没有标准答案,取决于策略持仓周期与市场风格变化速度。常见档位:
- 每日:高频或日内策略。模型轻、特征更新快、衰减明显。
- 每周:日频选股策略的常见做法。每周末重训,周一开盘前生成新预测。
- 每月:中长期持仓策略。模型稳定,但要警惕节假日、季报披露窗口的特征突变。
- 每季:基本面驱动的低换手策略。
无论哪种档位,重训练之前都要跑一遍模型行为对齐校验:新模型在最近 N 天的预测分布、SHAP 值分布与旧模型差异是否在阈值内。差异过大时不应直接全量切换,应当走灰度。
十二、与下一篇的衔接
本文讨论的是「截面 + 短中期」的机器学习选股,模型主力是 GBDT。但金融数据的另一面——时间序列结构——并没有被 GBDT 充分利用。分钟线、Tick、订单簿、新闻、研报,这些数据天然带着时间维度,需要专门处理时序的模型。
下一篇会讨论时序深度学习在量化中的应用:从最早的 LSTM 到 Temporal Fusion Transformer、PatchTST、TimesNet 等近几年的工作,重点放在「为什么很多论文里漂亮的结果在金融数据上复现不出来」「时序深度网络的过拟合比 GBDT 严重多少」「在哪些具体场景下深度模型确实显著优于 GBDT」三个问题上。
衔接的两条线索值得提前点出:第一,本文反复强调的 Purged CV 与 Embargo,在序列模型上同样适用——只是要把「样本」换成「序列窗口」,约束变得更复杂;第二,本文没有展开的 SHAP 在序列模型上的对应工具是 Integrated Gradients、Attention Rollout、Saliency Map 等,思路类似但工程实现差别很大。
十三、参考文献
- López de Prado, M. (2018). Advances in Financial Machine Learning. Wiley. 第 3 章(标签)、第 4 章(样本权重)、第 7 章(Cross-Validation in Finance)、第 8 章(Feature Importance)、第 11 章(The Dangers of Backtesting)。
- López de Prado, M. (2018). “The 10 Reasons Most Machine Learning Funds Fail.” Journal of Portfolio Management, 44(6).
- Lundberg, S. M., & Lee, S.-I. (2017). “A Unified Approach to Interpreting Model Predictions.” NeurIPS 2017.
- Lundberg, S. M., et al. (2020). “From Local Explanations to Global Understanding with Explainable AI for Trees.” Nature Machine Intelligence, 2(1).
- Ke, G., et al. (2017). “LightGBM: A Highly Efficient Gradient Boosting Decision Tree.” NeurIPS 2017.
- Chen, T., & Guestrin, C. (2016). “XGBoost: A Scalable Tree Boosting System.” KDD 2016.
- Akiba, T., Sano, S., Yanase, T., Ohta, T., & Koyama, M. (2019). “Optuna: A Next-generation Hyperparameter Optimization Framework.” KDD 2019.
- Bailey, D. H., Borwein, J. M., López de Prado, M., & Zhu, Q. J. (2014). “Pseudo-Mathematics and Financial Charlatanism: The Effects of Backtest Overfitting on Out-of-Sample Performance.” Notices of the AMS, 61(5).
- Harvey, C. R., Liu, Y., & Zhu, H. (2016). “…and the Cross-Section of Expected Returns.” Review of Financial Studies, 29(1).
- Gu, S., Kelly, B., & Xiu, D. (2020). “Empirical Asset Pricing via Machine Learning.” Review of Financial Studies, 33(5).
- Israel, R., Kelly, B. T., & Moskowitz, T. J. (2020). “Can Machines ‘Learn’ Finance?” Journal of Investment Management, 18(2).
- Fischer, T., & Krauss, C. (2018). “Deep Learning with Long Short-Term Memory Networks for Financial Market Predictions.” European Journal of Operational Research, 270(2).
- LightGBM 官方文档:https://lightgbm.readthedocs.io/
- SHAP 官方文档:https://shap.readthedocs.io/
- Optuna 官方文档:https://optuna.readthedocs.io/
- Bergstra, J., & Bengio, Y. (2012). “Random Search for Hyper-Parameter Optimization.” JMLR, 13.
- Strumbelj, E., & Kononenko, I. (2014). “Explaining prediction models and individual predictions with feature contributions.” Knowledge and Information Systems, 41(3).
- Krauss, C., Do, X. A., & Huck, N. (2017). “Deep neural networks, gradient-boosted trees, random forests: Statistical arbitrage on the S&P 500.” European Journal of Operational Research, 259(2).
- Asness, C. S., Moskowitz, T. J., & Pedersen, L. H. (2013). “Value and Momentum Everywhere.” Journal of Finance, 68(3).
- Pesaran, M. H., & Timmermann, A. (1995). “Predictability of Stock Returns: Robustness and Economic Significance.” Journal of Finance, 50(4).
系列导航
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【量化交易】量化交易全景:从信号到订单的工程链路
量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。
【量化交易】回测陷阱:前视偏差、过拟合、数据窥视
回测引擎只能保证「语法对」,但真正杀死策略的是「逻辑错、数据脏、推断不严」三件事。本文系统拆解前视偏差(lookahead bias)、过拟合(overfitting)、数据窥视(data snooping)三大陷阱,介绍 Bonferroni、BH-FDR、Family-Wise Error 的多重检验修正,给出 Deflated Sharpe 与概率 Sharpe(PSR)的可运行 Python 实现,配一份 30 条上线前自检清单。
量化交易
从因子研究到生产执行的量化交易全栈工程。覆盖市场微结构、数据管线、因子构造、组合优化、回测方法论、执行算法、做市策略、高频架构到生产运维。面向策略研究员与工程师。
【量化交易】市场结构:交易所、做市商、暗池、ECN
系统梳理全球市场结构(Market Structure)的工程图景:从证券交易所、衍生品交易所、加密交易所,到做市商、暗池、ECN/ATS,再到 Maker-Taker 收费、PFOF、Reg NMS 与 MiFID II 的监管影响;给出量化策略选择交易场所的判断框架与基于 ccxt 的多交易所行情聚合代码。