4. 交叉验证:让数据说话更可靠

做回测最怕什么?怕过拟合。

模型在历史数据上跑得风生水起,一上实盘就原形毕露。这种痛,我经历过太多次了。说白了,过拟合就是模型把历史数据里的噪音当成了规律,学了一堆没用的细节。

那怎么防?交叉验证就是一把利器。

4.1 为什么普通回测不够用?

传统的回测方式,就是拿全部历史数据跑一遍。然后看夏普比率、最大回撤这些指标。看起来挺合理,对吧?

但这里有个坑:你其实是在用同一份数据既训练模型,又评估模型

我打个比方。这就像你拿着答案去考试,分数再高也不能说明你真学会了。模型记住了历史数据里的每一个波动,但未来不会简单重复过去。

核心问题:单次回测无法区分「真规律」和「假噪音」。

你想想看,如果模型只是碰巧拟合了某段特定行情,那换一段数据它就会露馅。交叉验证要解决的,正是这个问题。

4.2 时序交叉验证的核心思想

标准的K折交叉验证,在金融数据上不能用。为什么?因为时间序列有顺序依赖。

你不能拿未来的数据去预测过去,这违反了因果关系。我在项目里见过有人犯这个错,回测结果漂亮得不像话,实盘直接亏到怀疑人生。

正确的做法是:时序交叉验证

它的逻辑很简单:

  • 把数据按时间顺序切分成多个窗口
  • 每次用前面的窗口训练,后面的窗口验证
  • 窗口不断向前滚动

这样既保证了时间顺序,又充分利用了数据。

时序交叉验证示意图(滚动窗口) Fold 1 训练集 验证集 Fold 2 训练集 验证集 Fold 3 训练集 验证集 Fold 4 训练集 验证集 时间 → 每次验证集都在训练集之后,保证不会用到未来数据

4.3 几种实用的交叉验证方法

我这些年用过不少方法,挑几个最实用的说说。

4.3.1 滚动窗口法

这是最基础的方法。固定一个训练窗口长度,不断向前滚动。

def rolling_window_cv(data, train_size, test_size, n_splits):
    """
    滚动窗口交叉验证
    
    参数:
        data: 完整数据集
        train_size: 训练窗口大小
        test_size: 验证窗口大小
        n_splits: 拆分次数
    """
    for i in range(n_splits):
        train_start = i * test_size
        train_end = train_start + train_size
        test_start = train_end
        test_end = test_start + test_size
        
        train_data = data[train_start:train_end]
        test_data = data[test_start:test_end]
        
        yield train_data, test_data

这个方法的好处是简单直观。但有个问题:越早的数据,被用来训练的次数越少。

4.3.2 扩展窗口法

这个方法我更喜欢。训练集不断累积,越往后数据越多。

def expanding_window_cv(data, initial_train_size, test_size, n_splits):
    """
    扩展窗口交叉验证
    
    参数:
        data: 完整数据集
        initial_train_size: 初始训练集大小
        test_size: 验证窗口大小
        n_splits: 拆分次数
    """
    for i in range(n_splits):
        train_end = initial_train_size + i * test_size
        test_start = train_end
        test_end = test_start + test_size
        
        train_data = data[:train_end]
        test_data = data[test_start:test_end]
        
        yield train_data, test_data

扩展窗口更贴近真实交易场景。你想想看,随着时间推移,你能用的历史数据确实越来越多。

我的建议:高频交易中,我一般用扩展窗口法。因为市场结构在变,早期数据可能已经不太适用了,但完全丢弃又可惜。扩展窗口给了早期数据「参与但不主导」的位置。

4.3.3 组合交叉验证

有时候单一方法不够用。我会把多种方法组合起来。

方法 适用场景 优点 缺点
滚动窗口 市场结构稳定 计算量小 早期数据利用率低
扩展窗口 市场结构变化 数据利用率高 计算量大
组合验证 不确定市场状态 结果更稳健 实现复杂

4.4 交叉验证的坑与避坑指南

方法讲完了,说说实战中容易踩的坑。

坑1:数据泄露

我曾经犯过一个低级错误。在计算技术指标时,用了整个数据集来归一化。结果训练集里混进了验证集的信息,回测结果虚高。

解决方法:所有数据预处理必须在每个fold内部独立完成。用训练集的统计量去变换验证集。

坑2:窗口大小选择

窗口太小,模型学不到足够信息。窗口太大,计算成本高,而且可能包含过时的市场结构。

解决方法:我一般会试3-5种不同的窗口大小,看结果是否稳定。如果不同窗口下结果差异很大,说明模型本身就不靠谱。

坑3:只看平均分

很多人只关注交叉验证的平均表现。但方差同样重要。

解决方法:记录每一折的结果。如果某几折表现特别差,说明模型在某些市场状态下会失效。这比平均夏普比率下降0.1要严重得多。

4.5 实战:一个完整的交叉验证流程

最后,我分享一个实际项目中用过的流程。

import numpy as np
from sklearn.metrics import sharpe_ratio

def cross_validate_strategy(data, strategy, cv_method, n_splits=5):
    """
    策略交叉验证完整流程
    """
    results = []
    
    for fold, (train, test) in enumerate(cv_method(data, n_splits)):
        # 1. 在训练集上训练/优化参数
        strategy.fit(train)
        
        # 2. 在验证集上回测
        test_returns = strategy.backtest(test)
        
        # 3. 计算评估指标
        sharpe = sharpe_ratio(test_returns)
        max_dd = max_drawdown(test_returns)
        
        results.append({
            'fold': fold,
            'sharpe': sharpe,
            'max_dd': max_dd,
            'returns': test_returns
        })
        
        print(f"Fold {fold+1}: Sharpe={sharpe:.2f}, MaxDD={max_dd:.2%}")
    
    # 4. 汇总结果
    avg_sharpe = np.mean([r['sharpe'] for r in results])
    std_sharpe = np.std([r['sharpe'] for r in results])
    
    print(f"\n平均夏普: {avg_sharpe:.2f} ± {std_sharpe:.2f}")
    
    return results

这个流程看起来简单,但每个细节都很关键。嗯,这里要注意:千万不要在交叉验证循环外面做任何数据预处理。我吃过这个亏,希望你别重蹈覆辙。

核心要点:交叉验证不是万能的,但它能帮你筛掉至少70%的过拟合策略。剩下的30%,需要结合样本外测试和实盘模拟来进一步验证。

说白了,交叉验证就是让数据自己说话。它告诉你:这个策略是真的找到了规律,还是只是在背答案。我做了这么多年回测系统,最深的体会就是——不要相信任何一次回测的结果,要相信多次交叉验证的共识

专注资料整理