4. 时间序列切分原则

时间序列数据与横截面数据在训练集切分上存在本质差异。横截面数据可以随机打乱后划分,而时间序列数据必须严格遵循时间顺序,否则会引入未来信息泄露,导致模型在回测中表现虚高、实盘失效。本节系统阐述时间序列切分的特殊性、核心避坑原则以及前向验证与回测的关键区别。

4.1 时间序列数据切分的特殊性

时间序列数据具有时间依赖性和序列自相关性,相邻样本之间并非独立同分布。切分时必须保留时间顺序,不能使用随机划分或 K 折交叉验证的默认方式。

对比维度 横截面数据切分 时间序列数据切分
样本独立性 样本间独立,可随机打乱 样本间存在时间依赖,不可打乱
划分方式 随机抽样 / K 折 按时间窗口顺序切分
验证集位置 可位于训练集之前或之后 验证集必须严格在训练集之后
信息泄露风险 低(随机划分即可避免) 高(未来数据可能被用于训练)
常用方法 train_test_split / StratifiedKFold 前向验证 / 滚动窗口 / 扩展窗口
核心原则:时间序列切分的第一要义是时间顺序不可逆。训练集必须全部发生在验证集之前,测试集必须全部发生在验证集之后。

4.2 避免未来信息泄露

未来信息泄露是时间序列建模中最常见且最隐蔽的错误。当模型在训练阶段接触到了未来时刻的数据特征或标签时,就会产生泄露,导致回测收益虚高、实盘收益崩塌。

常见泄露场景

  • 特征泄露:使用未来时刻的价格、成交量、财务数据作为当前时刻的特征。例如,用 t+1 日的收益率作为 t 日的预测特征。
  • 标签泄露:训练集和验证集的时间窗口重叠,导致验证集标签被训练集间接学习。
  • 预处理泄露:在切分前对整个数据集进行标准化、归一化或缺失值填充,导致训练集使用了验证集的统计量。
  • 回测泄露:在回测中使用未来信息进行调仓、止损或再平衡决策。
⚠️ 避坑提示:任何涉及“全局统计量”的操作(如均值、标准差、分位数)都必须在每个时间窗口内重新计算,且只使用该窗口训练集的数据。切勿在切分前对整个数据集做一次性预处理。

4.3 前向验证与回测的区别

前向验证(Forward Validation)和回测(Backtesting)是时间序列模型评估中两个紧密相关但概念不同的环节。下表从目的、执行方式和输出结果三个维度进行区分。

对比维度 前向验证 回测
核心目的 评估模型在不同时间窗口的泛化能力,选择最优超参数 模拟模型在历史数据上的真实交易表现,验证策略有效性
执行方式 滚动窗口或扩展窗口,逐窗口训练-预测-评估 固定模型参数,在完整历史区间上模拟交易流程
输出结果 各窗口的预测误差(如 RMSE、MAE、AUC) 收益曲线、夏普比率、最大回撤、胜率等交易指标
是否包含交易成本 通常不包含 必须包含(滑点、佣金、冲击成本)
使用阶段 模型开发与调参阶段 模型选定后的策略验证阶段
实践建议:先通过前向验证筛选出最优模型和超参数,再用回测评估该模型在历史数据上的真实交易表现。两个阶段使用相同的时间切分原则,但回测阶段必须加入交易成本模拟。

4.4 前向验证的两种主流切分方式

前向验证中,最常用的两种切分方式是滚动窗口和扩展窗口。下面通过 SVG 流程图展示两者的结构差异。

前向验证:滚动窗口 vs 扩展窗口 滚动窗口(固定长度) 训练集 1 验证 1 训练集 2 验证 2 训练集 3 验证 3 扩展窗口(递增长度) 训练集 1 验证 1 训练集 2(含验证1) 验证 2 训练集 3(含验证1、2) 验证 3 滚动窗口:训练集长度固定,窗口整体向后滑动;扩展窗口:训练集不断累积,仅验证集向后滑动。 两种方式均保证验证集始终在训练集之后,避免未来信息泄露。

4.5 时间序列切分的代码实现示例

以下 Python 代码展示了使用扩展窗口进行前向验证的典型实现。注意每次切分时只使用历史数据,且预处理操作在窗口内重新执行。

import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error

def forward_validation_expanding_window(df, feature_cols, target_col, min_train_len=252):
    """
    扩展窗口前向验证
    df: 按时间排序的 DataFrame
    min_train_len: 最小训练集长度(例如 252 个交易日)
    """
    predictions = []
    actuals = []
    timestamps = []

    for i in range(min_train_len, len(df)):
        # 训练集:从起点到 i-1
        train = df.iloc[:i]
        # 验证集:第 i 个样本
        test = df.iloc[[i]]

        # 在训练集内重新计算均值和标准差(避免未来信息泄露)
        train_mean = train[feature_cols].mean()
        train_std = train[feature_cols].std()

        X_train = (train[feature_cols] - train_mean) / train_std
        y_train = train[target_col]

        X_test = (test[feature_cols] - train_mean) / train_std
        y_test = test[target_col]

        # 训练模型
        model = LinearRegression()
        model.fit(X_train, y_train)

        # 预测
        y_pred = model.predict(X_test)[0]

        predictions.append(y_pred)
        actuals.append(y_test.values[0])
        timestamps.append(df.index[i])

    # 计算整体误差
    rmse = np.sqrt(mean_squared_error(actuals, predictions))
    return pd.DataFrame({'timestamp': timestamps, 'actual': actuals, 'predicted': predictions}), rmse
关键点:上述代码中,train_mean 和 train_std 在每个窗口内重新计算,且只使用训练集数据。验证集样本从未参与训练集的统计量计算,从而避免了预处理泄露。

4.6 时间序列切分常见错误与纠正

常见错误 错误后果 正确做法
随机打乱后划分训练/验证集 未来数据进入训练集,模型学到虚假规律 按时间顺序切分,验证集必须在训练集之后
全局标准化后再切分 训练集使用了验证集的统计量 在每个窗口内重新计算标准化参数
使用未来特征(如 t+1 数据) 模型在回测中表现完美,实盘失效 只使用 t 时刻及之前可获取的数据
回测时不考虑交易成本 收益虚高,实盘无法复现 回测中必须加入滑点、佣金等成本
前向验证与回测使用不同切分逻辑 模型选择与最终评估不一致 两个阶段使用相同的切分原则
⚠️ 最终提醒:时间序列切分没有“默认正确”的方法。每次切分前,必须问自己三个问题:
1. 训练集是否全部发生在验证集之前?
2. 预处理是否在每个窗口内重新计算?
3. 特征是否只包含当前时刻及之前的信息?
只有三个答案都是“是”,切分才是安全的。