橘智橘智
偶是大橘子哇
预计阅读时间:10分钟35秒

序列数据的高级交叉验证方法:避免数据泄露指南

在提高泛化性的同时保持数据的序列。

0
0

本文原作者:Kuriko Iwai,喜欢原作者的文章可以访问博客,点击这里


引言

交叉验证(Cross-validation)是一种有效的技术,可以防止过拟合,并为模型在未见数据上的表现提供更可靠的估计。

当应用于**序列数据(sequential data)时,交叉验证存在诸如数据泄漏(data leakage)自相关(autocorrelation)**等风险,这些问题可能导致过于乐观的性能评估结果。


在本文中,我将探讨时间序列数据的交叉验证方法,包括专门为时间序列设计的交叉验证技术,并结合 PyTorch GRUScikit-Learn SVR 的实践实现进行说明。


什么是交叉验证(CV)

交叉验证(Cross-validation, CV)是一种用于评估机器学习模型泛化能力的统计技术。

CV 首先将原始数据集划分为训练集验证集(或测试集)

然后,它在不同的训练子集上反复训练模型,并在独立的验证集上评估性能,从而比单次训练-测试划分(train-test split)更可靠。


何时使用交叉验证

通过重复训练-验证过程,CV 可以在减轻模型偏差的同时防止过拟合。

应用 CV 的最佳实践包括:

  • 模型选择或参数调优:通过小规模交叉验证提高选择/调参结果的可信度
  • 序列数据分析:当单次随机划分可能导致数据泄漏时
  • 高度类别不平衡的分类问题:随机单次划分可能造成类别极度不平衡,从而产生模型偏差。除了多种数据增强技术外,交叉验证也能缓解模型性能评估中的偏差

另一方面,在以下情况中,CV 并非必要:

  • 拥有大型、非序列训练数据集,模型偏差风险较低
  • 模型已经表现出稳定的损失曲线,具备良好的泛化能力
  • 需要节省计算成本,例如进行快速初步实验


主要交叉验证方法

存在多种 CV 方法,每种方法都有其独特的数据划分方式。

下图比较了常见的 K-fold、Stratified K-fold 和 LOOCV 方法:

image.png

图 A:基于 K-fold 的交叉验证数据划分比较


图中说明:

  • K-Fold CV(左):基于 K 个等大小折(fold)的平均结果评估模型性能
  • Stratified K-Fold CV(中):K-fold 的变体,用于分类问题,保证每个折中的类别比例一致
  • Leave-One-Out CV(LOOCV)(右):另一种 K-fold 变体,其中 K 等于样本总数,使每个样本恰好作为一次验证集

这些 K-fold 的概念也可应用于序列数据,但其有效性取决于核心原则。

我们将在下一节进一步探讨。


序列数据交叉验证的核心原则

在对序列数据进行交叉验证时,最关键的考虑因素是避免数据泄漏


数据泄漏(Data Leakage)

数据泄漏是机器学习中的一个问题,即模型在训练过程中使用了来自训练数据集之外的信息。

这种信息泄漏会使模型在训练或验证阶段“作弊”,但在真正的未见数据上却无法达到相同的表现。

数据泄漏主要分为两种类型:

1️⃣ 直接泄漏(Direct Leakage)

一种简单情况:将目标变量本身作为特征包含在训练数据中。

例如:
为了预测房屋售价,模型在训练数据中包含了真实的房屋售价作为特征。


2️⃣ 间接泄漏(Indirect Leakage)

包含一个与目标变量高度相关、但在预测时未知的特征。

例如:
预测房价的模型将“当前房产税率”作为特征。

问题在于:
未来的房产税率在预测销售价格时是未知的。


数据泄漏示例流程

image.png

(图 B:房价预测中的数据泄漏示意图)


流程如下:

  1. 模型使用包括“房产税”在内的特征进行训练
  2. 模型预测房价为 $450,000
  3. 实际成交价为 $500,000
  4. 政府根据成交价更新房屋评估价值
  5. 基于新的评估价值计算新的房产税
  6. 模型再次使用新的税率作为特征进行训练
  7. 模型错误地学习到“高税率意味着高房价”

这种相关性是错误的,因为税率是在成交后计算的。

因此,税率不应被纳入训练数据,模型应专注于预测税前销售价格


在序列数据中防止数据泄漏

在序列数据中,数据泄漏通常发生在:

未来信息(验证数据)直接或间接与过去信息(训练数据)混合。

为避免泄漏,我们需要:

1️⃣ 保持时间顺序:确保事件的时间序列不被破坏
2️⃣ 使用时间序列专用验证方法
3️⃣ 防止自相关影响:避免训练集和验证集中时间接近的数据点相互相关

条件 1 是必须满足的;根据数据类型,还需满足条件 2 和 3。


保持时间顺序

在处理序列数据时,必须保留时间顺序。

即使使用非时间序列专用的简单 CV 方法,也必须对数据排序,避免打乱(shuffle)。


单次训练-测试划分(Holdout)

这是最简单的方法,将数据集分为:

  • 前半部分 → 训练集
  • 后半部分 → 验证集

模型在历史数据上训练一次,并在验证集上评估:

image.png

图 C-1:数据划分示意图


适用场景:

  • 数据量较大
  • 单次评估足以减轻偏差

缺点:

  • 如果验证区间不能代表未来数据,模型表现可能失真


Monte Carlo 交叉验证

Monte Carlo CV 在每个折中随机抽样,前提是假设:

  • 数据点彼此独立
  • 数据统计特性(均值、众数、中位数等)随时间不变化

当这些条件满足时,随机采样不会破坏数据结构,是传统时间序列交叉验证的可行替代方法。

image.png

(图 C-2:数据划分示意图)


适用场景:

  • 平稳数据且无自相关(例如多次抛硬币实验)

缺点:

  • 若应用于非平稳时间序列,会完全破坏时间顺序


Blocked K-Fold 交叉验证

这是 K-fold 的一种改进版本,其中数据不进行打乱(shuffle)

数据被划分为连续的区块(fold),每个区块依次作为验证集使用。

image.png


适用场景:

  • 非平稳数据,但不存在自相关
  • 样本量较小,需要充分利用所有样本进行训练与验证

缺点:

  • 对于存在强自相关的数据,存在数据泄漏风险


时间序列专用验证方法

第二个核心原则是使用时间序列专用的验证方法

这些方法专门设计用于保持时间顺序,重点在于模拟“向前预测”的场景,从而评估模型性能。


时间序列交叉验证(“Growing Window” 增长窗口)

这是一种顺序方法,其中每个折的训练集都会包含此前的全部历史数据。

模型在不断扩大的历史数据上重新训练,并在其后的固定大小数据块上进行验证。

image.png

(图 D-1:数据划分示意图)


适用场景:

  • 模型性能随着数据量增加而提升
  • 需要模拟真实生产环境(模型定期用新数据更新)

缺点:

  • 后期折次计算成本高,因为训练集越来越大


Walk-Forward 验证(“Rolling Window” 或 “Sliding Window”)

Walk-forward 方法与增长窗口方法理念相似,但训练窗口和验证窗口的大小保持不变。

随着验证窗口向前移动,旧的训练数据会被丢弃。

这是序列数据交叉验证中常见的方法。

image.png


(图 D-2:数据划分示意图)


适用场景:

  • 超长时间序列数据
  • 较早的历史数据对未来预测相关性较弱

缺点:

  • 计算成本高,因为每个滚动步骤都需重新训练模型
  • 丢弃旧数据可能会丢失长期趋势或季节性模式


防止自相关

自相关是序列数据中典型的数据泄漏来源。

某些交叉验证方法通过在训练集与验证集之间**刻意加入时间间隔(gap)**来防止自相关。


带间隔的时间序列交叉验证(“Gap”)

该方法是在时间序列专用 CV 的基础上,在训练集与验证集之间加入一个时间间隔。

这个间隔可以增强训练与验证数据之间的独立性。


image.png

(图 E-1:数据划分示意图)


适用场景:

  • 需要严格分离训练和验证数据,以避免数据泄漏

缺点:

  • 间隔会导致部分数据无法用于训练
  • 当数据量较小时,可能导致模型欠拟合


hv-Blocked K-Fold 交叉验证

hv-Blocked K-fold 是带时间间隔的 Blocked 验证的高级形式。


image.png


(图 E-2:数据划分示意图)


适用场景:

  • 数据具有强自相关
  • 需要更严格的阻断式验证以防止泄漏

缺点:

  • 时间间隔导致部分训练数据被丢弃
  • 数据量小时容易欠拟合


Purged & Embargo 交叉验证

Purged & Embargo 方法专门设计用于防止数据泄漏。

它会:

  • Purged(清除):移除与验证期过于接近的训练数据
  • Embargo(禁区):移除验证期之后可能受到未来信息影响的训练数据

适用场景:

  • 时间序列具有高自相关
  • 严格防止数据泄漏至关重要(例如金融领域)

缺点:

  • 由于间隔和清除机制,会丢弃大量训练数据
  • 相比普通 K-fold 方法,可能更容易欠拟合


以上就是关于序列数据交叉验证方法的全部内容。

方法的选择高度依赖于数据类型。

在下一节中,我将探讨模型的敏感性如何在交叉验证之后影响模型表现。


模拟实验(Simulation)

在本节中,我将探讨不同交叉验证方法在以下两种模型上的表现:

  • 基于 PyTorch 构建的 GRU(门控循环单元)网络
  • 基于 Scikit-Learn 的较简单模型 SVR(支持向量回归)

所有代码均可在我的 GitHub 仓库中找到:GRU / SVR


构建原始数据集

首先,我从 UC Irvine 机器学习仓库加载并处理了 CSV 数据:


image.png

(图 F:变量表截图)


在本项目中,我将使用时间序列数据(如 rain_1h, date_time 等)来预测 traffic_volume

import pandas as pd

# 加载数据
file_path = 'data/Metro_Interstate_Traffic_Volume.csv'
df = pd.read_csv(file_path, sep=',')

# 添加与时间相关的特征(对 GRU 至关重要)
df['date_time'] = pd.to_datetime(df['date_time'])
df['year'] = df['date_time'].dt.year
df['month'] = df['date_time'].dt.month
df['hour'] = df['date_time'].dt.hour
df['day_of_week'] = df['date_time'].dt.dayofweek  # 类别变量 (0 到 6)
df['is_weekend'] = df['day_of_week'].isin([5, 6])
df['is_holiday'] = df['holiday'].notna()

# 删除不必要列
df = df.drop(columns=['holiday', 'weather_description', 'date_time'], axis=1)

# 创建输入和目标变量
target_col = 'traffic_volume'
y = df[target_col]
X = df.drop(target_col, axis=1)


划分训练集与测试集

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, shuffle=False
)

⚠ 为了保持时间顺序,在创建训练集和测试集时不能打乱数据。

X_test 用于评估泛化能力。在训练或验证阶段必须完全隔离,以避免数据泄漏。


特征转换

from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from category_encoders import BinaryEncoder

首先区分数值特征和类别特征:

cat_cols, num_cols = [], []
for col in df.columns.to_list():
    if col == target_col:
        pass
    else:
        if df[col].dtype == 'object' or df[col].dtype == 'bool':
            cat_cols.append(col)
        else:
            num_cols.append(col)

定义列转换器:

num_transformer = Pipeline(steps=[
    ('imputer', SimpleImputer(strategy='median')),
    ('scaler', StandardScaler())
])

cat_transformer = Pipeline(steps=[
    ('encoder', BinaryEncoder(cols=cat_cols))
])

preprocessor = ColumnTransformer(
    transformers=[
        ('num', num_transformer, num_cols),
        ('cat', cat_transformer, cat_cols)
    ],
    remainder='passthrough'
)

转换数据:

X_train = preprocessor.fit_transform(X_train)
X_test = preprocessor.transform(X_test)

最终训练集包含:

  • 38,563 个样本
  • 16 个输入特征


定义 GRU 模型

接下来,在 PyTorch 中定义 GRU 类:

import torch.nn as nn

class GRU(nn.Module):
    def __init__(self, input_size=X_train.shape[1], hidden_size=64, output_size=1):
        super(GRU, self).__init__()
        self.gru = nn.GRU(
            input_size=input_size,
            hidden_size=hidden_size,
            batch_first=True
        )
        self.fc = nn.Linear(hidden_size, output_size)

    def forward(self, x):
        h_gru, _ = self.gru(x)
        o_final = self.fc(h_gru[:, -1, :])
        return o_final

这是一个简单的 many-to-one 架构

  • GRU 输出每个时间步的隐藏状态
  • 最终时间步的输出用于生成单一预测结果


运行交叉验证

接下来定义 train_and_validate 函数。

该函数会根据指定的交叉验证方法,将 X_train 分成 5 个折,并在这些折上训练与验证模型。

按照交叉验证最佳实践:

  • 每个 fold 都会重新初始化模型和优化器


实验统一参数

所有方法使用相同参数:

  • num_epochs = 300
  • lr(学习率)= 0.001
  • num_folds = 5
  • test_size = 0.2


支持的交叉验证方法

在函数中,根据 validation_method 选择不同划分方式:

  • Holdout
  • Monte Carlo
  • K-Fold
  • Blocked K-Fold
  • Growing Window
  • Sliding Window
  • Gap
  • hv-Blocked K-Fold


训练与验证流程

每个 fold 内:

  1. 初始化 GRU 模型
  2. 初始化 Adam 优化器
  3. 使用 MSELoss
  4. 创建 DataLoader
  5. 运行训练循环
  6. 运行验证循环
  7. 记录 loss

训练阶段:

model.train()
for X_batch, y_batch in train_loader:
    X_batch = X_batch.unsqueeze(1)
    y_batch = y_batch.unsqueeze(1)

    outputs = model(X_batch)
    loss = criterion(outputs, y_batch)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

验证阶段:

model.eval()
with torch.inference_mode():
    ...


完成交叉验证后

完成所有 folds 后:

  • 使用整个 X_train / y_train
  • 再次训练模型

最后返回:

{
    'model': model,
    "fold_train_losses": fold_train_losses,
    "fold_val_losses": fold_val_losses,
    "average_train_loss": np.mean(fold_train_losses),
    "average_val_loss": np.mean(fold_val_losses)
}


这一部分展示了:

  • 不同时间序列交叉验证方法
  • 在深度学习模型(GRU)上的实现
  • 与传统机器学习(SVR)的对比框架


进行推理(Performing Inference)

训练完成后,模型会在新的、未见过的数据(X_test)上进行推理。

通过记录损失值来评估模型的泛化能力

# 将 X_test(numpy)转换为 torch 张量
X_test_float = torch.from_numpy(X_test).float()
y_test_float = torch.from_numpy(y_test.values).float()

# 创建测试数据加载器
test_dataset = torch.utils.data.TensorDataset(X_test_float, y_test_float)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=32, shuffle=False)

# 执行推理
model.eval() 
test_loss = 0
criterion = nn.MSELoss()

with torch.inference_mode():
    for X_batch, y_batch in test_loader:
        X_batch = X_batch.unsqueeze(1)
        y_batch = y_batch.unsqueeze(1)
        outputs = model(X_batch)
        test_loss += criterion(outputs, y_batch).item()

# 计算平均损失(MSE)
ave_test_loss = test_loss / len(test_loader)


实验结果


GRU

在所有方法中,Blocked K-Fold 取得了最佳的泛化损失(MSE)。

下图展示了:

  • 所有折的 CV 损失曲线
  • 平均 CV 损失(蓝线)
  • X_test 上的泛化损失(红色竖线)

彩色区域表示模型学习的泛化能力(面积越小越好)。

当**平均 CV 损失(蓝线)反超泛化损失(红线)**时,说明出现了过拟合。


image.png(图 G:不同交叉验证方法下的 GRU 损失曲线对比)


结果分析:

  • Blocked K-Fold 泛化表现最佳
  • 带 Gap 的 K-Fold 次之
  • 两者在约第 150 个 epoch 后开始过拟合
  • 若加入 Early Stopping(早停机制),结果可能进一步优化

Growing Window

  • 表现较为平衡
  • 能较好避免过拟合
  • 同时保持较低泛化误差

Holdout 和 Monte Carlo 方法

  • 过拟合最严重
  • 在测试集上误差极高(粉色区域大)
  • 对该数据集和 GRU 模型而言,这两种方法不适合


SVR

在所有方法中,Sliding Window(滚动窗口) 取得最佳表现。

图中展示:

  • 五折 CV 损失
  • 平均 CV 损失(蓝线)
  • 泛化损失(红线)

与 GRU 类似:

蓝线与红线差距越大,说明过拟合越严重。


image.png

图 H:不同交叉验证方法下的 SVR 损失曲线对比


结果分析:

Sliding Window

  • 最低平均 MSE(0.6149)
  • 稳定性高
  • 说明对于 SVR,固定大小的训练窗口更适合处理本数据中的局部自相关问题

Growing Window 和 hv-Blocked K-Fold

  • 某些折出现最差误差
  • 模型容易过度拟合历史数据

其他标准方法(Holdout、Monte Carlo、K-Fold)

  • 泛化能力尚可
  • 但整体损失较高
  • 相比 Sliding Window,学习效果有限


总结(Wrapping Up)

交叉验证是评估时间序列模型的强大工具。

通过模拟原始数据结构,它可以:

  • 提高模型泛化能力
  • 有效避免过拟合

本次模拟实验表明:

  • 为特定模型与数据类型选择合适的交叉验证方法至关重要
  • 不同模型(GRU vs SVR)对 CV 方法的敏感性不同

最终目标是:

构建一个在真实生产环境中表现最佳的模型,而交叉验证正是通过模拟现实数据结构来实现这一目标的关键步骤。


评论