4、模型过拟合诊断:样本内与样本外表现差异、交叉验证方法、滚动窗口验证、正则化技术(L1/L2)
做轮动模型最怕什么?不是市场暴跌,而是模型在历史回测里漂亮得像艺术品,一上实盘就崩给你看。说白了,这就是过拟合——模型把历史噪音当成了规律,样本内跑得飞起,样本外直接翻车。
我这些年踩过的坑,十有八九都跟过拟合有关。今天咱们就把这个话题聊透,从诊断到预防,一套组合拳打下来。
4.1 样本内与样本外:最朴素的照妖镜
判断过拟合最简单的方法,就是对比样本内和样本外的表现。如果样本内年化收益30%,样本外直接变成-5%,那基本可以断定——模型在背历史数据。
我个人习惯把数据按时间切三份:训练集(60%)、验证集(20%)、测试集(20%)。注意,时间序列数据不能随机打乱,必须按时间顺序切分,否则就犯了「未来信息泄露」的大忌。
关键指标对比:
| 指标 | 样本内 | 样本外 | 过拟合信号 |
|---|---|---|---|
| 年化收益率 | 25% | 8% | 差距 > 10% |
| 夏普比率 | 2.1 | 0.6 | 差距 > 1.0 |
| 最大回撤 | -8% | -22% | 样本外翻倍以上 |
我曾经见过一个团队,样本内夏普做到3.5,兴奋得不行。结果实盘三个月,夏普直接掉到0.2。后来一查,模型里塞了50多个因子,其中一半是纯噪音。这就是典型的「数据挖掘偏差」——你试了100个因子,总能找到几个历史表现好的。
4.2 交叉验证:时间序列版有讲究
普通机器学习里的K折交叉验证,在金融时间序列上直接套用会出大问题。为什么?因为未来数据不能用来预测过去。你想想看,如果用2023年的数据训练,去预测2020年的行情,这不就开天眼了吗?
正确的做法是使用「时间序列交叉验证」——也叫前向链式验证。具体来说:
- 第一步:用第1-12个月的数据训练,预测第13个月
- 第二步:用第1-13个月的数据训练,预测第14个月
- 第三步:以此类推,窗口逐步向前滚动
# 时间序列交叉验证示例
from sklearn.model_selection import TimeSeriesSplit
import numpy as np
# 假设我们有60个月的数据
n_splits = 5
tscv = TimeSeriesSplit(n_splits=n_splits)
for train_idx, test_idx in tscv.split(X):
print(f"训练集: {train_idx[0]}-{train_idx[-1]}, 测试集: {test_idx[0]}-{test_idx[-1]}")
# 训练模型并记录样本外表现
我的经验:交叉验证的折数不要太多,5-6折就够。折数太多会导致训练集之间高度重叠,评估结果会偏乐观。另外,每次滚动时最好留出「缓冲期」,比如训练集和测试集之间隔一个月,避免数据泄露。
4.3 滚动窗口验证:最贴近实战的检验
如果说交叉验证是「体检」,那滚动窗口验证就是「实战演习」。它的逻辑很简单:固定一个训练窗口长度,不断向前滚动,每次重新训练并预测下一期。
我个人在实盘中最常用的就是这种方法。比如用过去24个月的数据训练,预测下一个月,然后窗口向前滑动一个月,重复这个过程。这样得到的样本外表现,基本能反映模型在真实交易中的水平。
# 滚动窗口验证伪代码
window_size = 24 # 24个月训练窗口
for start in range(0, len(data) - window_size - 1):
train = data[start:start + window_size]
test = data[start + window_size]
model.fit(train)
pred = model.predict(test)
# 记录预测结果
results.append(pred)
注意:滚动窗口验证的计算量比较大,特别是当数据量大、模型复杂的时候。我建议先用小规模数据跑一遍,确认逻辑没问题再全量跑。另外,窗口长度的选择也很关键——太短会欠拟合,太长又跟不上市场风格变化。我个人习惯用24-36个月作为窗口。
4.4 正则化技术:L1与L2的实战选择
正则化是预防过拟合的「硬核武器」。它的核心思想很简单:在损失函数里加一个惩罚项,让模型不敢把权重设得太大。说白了,就是给模型戴上「紧箍咒」。
L1正则化(Lasso)
L1的惩罚项是权重的绝对值之和。它的特点是能把不重要的特征权重直接压到0,实现特征选择。我在做因子筛选时特别喜欢用L1——几十个因子往里一扔,跑完Lasso,留下的就是真正有解释力的。
from sklearn.linear_model import Lasso
lasso = Lasso(alpha=0.01) # alpha控制惩罚强度
lasso.fit(X_train, y_train)
print("系数:", lasso.coef_) # 很多系数会变成0
L2正则化(Ridge)
L2的惩罚项是权重的平方和。它不会把系数压到0,但会让所有系数都变小。当你的因子之间相关性较高时,L2比L1更稳定。我记得有一次做行业轮动模型,因子之间多重共线性严重,换成Ridge之后,模型立马稳了。
from sklearn.linear_model import Ridge
ridge = Ridge(alpha=1.0)
ridge.fit(X_train, y_train)
print("系数:", ridge.coef_) # 系数变小但不会为0
弹性网络:取长补短
如果L1和L2各有千秋,那为什么不把它们结合起来?弹性网络(Elastic Net)就是L1和L2的混合体,通过一个参数控制两者的比例。我个人在大多数项目中都用弹性网络,因为它既做特征选择,又保持稳定性。
from sklearn.linear_model import ElasticNet
elastic = ElasticNet(alpha=0.01, l1_ratio=0.5) # l1_ratio=1是纯L1,=0是纯L2
elastic.fit(X_train, y_train)
正则化参数调优建议:
- alpha值越大,惩罚越强,模型越简单
- 用交叉验证选择最优alpha,比如
LassoCV或RidgeCV - 先粗调(10的幂次),再细调(线性搜索)
- 注意:正则化前一定要标准化特征,否则量纲大的特征会被惩罚得更狠
嗯,说到标准化,我踩过一个坑。有一次做Lasso回归,忘了标准化,结果量纲大的因子系数被压到0,量纲小的反而留下了。后来花了半天才排查出来——从那以后,标准化成了我代码里的固定步骤。
总结一下今天的核心:过拟合诊断不是一次性工作,而是贯穿模型开发全流程的「体检」。样本内外对比是初筛,交叉验证是深度检查,滚动窗口是实战模拟,正则化是预防手段。把这四招用熟了,你的轮动模型才能真正经得起市场考验。
公众号:蓝海数据掘金营,微信deep3321