时间序列数据预处理:滑动窗口构建、平稳性检验、差分处理、归一化与反归一化

各位同学,欢迎来到数据预处理这一章。

说实话,很多做Transformer时间序列预测的朋友,模型调得再花哨,最后效果不好,八成是数据预处理没做好。我见过太多人直接把原始数据扔进模型,然后跑来问我「为什么预测结果全是直线?」——嗯,大概率是数据没处理好。

这一章,咱们就把预处理这块硬骨头啃下来。我会把我在工业项目里踩过的坑、总结的经验,全都抖出来。

1. 滑动窗口构建:把时间序列变成监督学习

Transformer吃的是序列数据,但原始时间序列是一长串连续的点。我们需要把它切成一个个「输入-输出」对。这就是滑动窗口。

核心思想:用过去 window_size 个时间步,预测未来 horizon 个时间步。

关键参数

  • window_size(窗口大小):用多长的历史数据。我一般建议至少包含一个完整周期。比如日度数据有周周期,那就至少设7。
  • horizon(预测步长):预测未来几步。Transformer通常做多步预测,horizon可以设成1到几十不等。
  • stride(滑动步长):窗口每次移动多少步。通常设1,数据量不够时可以设大一点做数据增强。

举个例子。假设我们有10个时间步的数据 [t0, t1, ..., t9],window_size=3,horizon=1,stride=1:

输入: [t0, t1, t2] → 输出: t3
输入: [t1, t2, t3] → 输出: t4
输入: [t2, t3, t4] → 输出: t5
...

代码实现其实很简单:

def create_sliding_windows(data, window_size, horizon, stride=1):
    X, y = [], []
    for i in range(0, len(data) - window_size - horizon + 1, stride):
        X.append(data[i:i+window_size])
        y.append(data[i+window_size:i+window_size+horizon])
    return np.array(X), np.array(y)

我的经验:窗口大小别拍脑袋定。我习惯先做自相关分析(ACF),看看数据在多少步之后相关性衰减到0附近,那个值就是窗口大小的上界。另外,如果数据量不够,可以用stride=1做数据增强,但注意别引入太多冗余样本。

2. 平稳性检验:你的数据「稳」吗?

Transformer虽然比RNN稳定,但它本质上还是假设数据分布相对一致。如果数据趋势忽上忽下、方差忽大忽小,模型学起来会很吃力。

什么是平稳性? 简单说,就是统计性质(均值、方差、自协方差)不随时间变化。

检验方法我常用两种:

  • ADF检验(Augmented Dickey-Fuller):原假设是「序列非平稳」。p值小于0.05,就拒绝原假设,认为序列平稳。
  • KPSS检验:原假设是「序列平稳」。跟ADF反过来。

我一般两个都跑一遍,交叉验证。如果ADF说平稳、KPSS也说平稳,那基本稳了。如果两个打架,那就得小心了。

from statsmodels.tsa.stattools import adfuller, kpss

def check_stationarity(series):
    # ADF检验
    adf_result = adfuller(series)
    print(f'ADF p-value: {adf_result[1]:.4f}')
    
    # KPSS检验
    kpss_result = kpss(series)
    print(f'KPSS p-value: {kpss_result[1]:.4f}')
    
    if adf_result[1] < 0.05 and kpss_result[1] > 0.05:
        print('结论:序列平稳')
    else:
        print('结论:序列非平稳,需要差分处理')

我曾经踩过的坑:有一次做电力负荷预测,ADF检验p值0.03,我以为数据平稳了,直接丢进模型。结果验证集上误差大得离谱。后来仔细一看,数据里有个隐藏的周期性突变——每年春节那几天负荷骤降。这种结构性变化,常规检验很难捕捉。所以我的建议是:检验通过后,最好再可视化看一眼,确认没有明显的「异常段」。

3. 差分处理:让数据变「乖」

如果检验发现数据不平稳,别慌。差分是最常用的处理方法。

一阶差分diff(t) = y(t) - y(t-1)。说白了,就是看每个时间点比上一个点变化了多少。

为什么要做差分?因为很多非平稳序列的「趋势」和「季节性」可以通过差分消除。比如股价,原始序列一路飙升,但每日涨跌幅(一阶差分)往往在0附近波动,相对平稳。

def difference(series, order=1):
    diff_series = series.copy()
    for _ in range(order):
        diff_series = np.diff(diff_series)
    return diff_series

# 一阶差分示例
diff_data = difference(original_data, order=1)

差分阶数怎么选?我一般从1开始试,每差分一次就做一次平稳性检验。如果一阶差分后还不平稳,再试二阶。但说实话,二阶以上很少用——差分太多会丢失原始信息,而且Transformer对高阶差分的「记忆」能力有限。

注意:差分后的数据预测完了,别忘了「反差分」还原。很多新手做完差分训练模型,预测结果直接输出差分值,然后说「这预测的什么鬼?」——因为你没还原啊!反差分公式很简单:y_pred_original = y_pred_diff + y_last_original

4. 归一化与反归一化:把数据塞进模型能消化的范围

Transformer对输入数据的尺度非常敏感。如果特征A的取值范围是[0, 1],特征B是[1000, 10000],模型会天然偏向特征B,因为它的数值大、梯度大。这会导致训练不稳定。

常用归一化方法

方法 公式 适用场景
Min-Max归一化 (x - min) / (max - min) 数据有明确上下界,如温度、百分比
Z-score标准化 (x - mean) / std 数据分布近似正态,或有异常值
Robust缩放 (x - median) / IQR 数据有较多异常值

我个人习惯用Min-Max归一化,因为它的输出范围固定为[0, 1],跟Transformer的激活函数(如GELU)配合得比较好。但要注意:必须用训练集的min和max来归一化验证集和测试集,不能混在一起算。否则就是数据泄露。

from sklearn.preprocessing import MinMaxScaler

# 只拟合训练集
scaler = MinMaxScaler()
train_scaled = scaler.fit_transform(train_data.reshape(-1, 1))

# 用训练集的参数转换验证集和测试集
val_scaled = scaler.transform(val_data.reshape(-1, 1))
test_scaled = scaler.transform(test_data.reshape(-1, 1))

# 反归一化
train_original = scaler.inverse_transform(train_scaled)

我曾经犯过的错:有一次做多变量预测,我把每个特征单独归一化到[0,1],然后直接拼接成向量喂给模型。结果模型学出来的注意力权重全乱套了——因为不同特征的尺度虽然一样,但分布形态完全不同。后来我改成每个特征独立归一化,但保留各自的scaler,预测时分别反归一化。效果好了很多。

5. 完整预处理流程:一张图说清楚

下面这张图总结了整个数据预处理的流程。我建议你把它存下来,每次做项目前对照着走一遍。

时间序列数据预处理流程 原始时间序列 平稳性检验(ADF + KPSS) 是否平稳? 差分处理 再次检验 归一化(Min-Max/Z-score) 滑动窗口构建 模型输入数据 预测后:反归一化

这张图里有个关键点我想强调一下:差分和归一化的顺序。我建议先做平稳性检验,如果不平稳就先差分,差分后再归一化。为什么?因为差分后的数据范围可能跟原始数据完全不同,先归一化再差分,归一化的参数就白算了。

6. 实战中的几个小建议

  • 保存所有预处理参数:scaler的min/max、差分阶数、窗口大小,全部存成文件。模型上线推理时要用同样的参数处理新数据。
  • 多变量预处理要小心:每个特征独立归一化,但滑动窗口要同步滑动。别把不同特征的时间对齐搞乱了。
  • 验证集上不要做任何「学习」:包括归一化参数的拟合、差分阶数的选择。这些都要基于训练集。

最后分享一个我自己的习惯:每次做完预处理,我都会把处理后的数据画个图,跟原始数据对比一下。如果发现处理后出现了「尖刺」或者「断层」,那八成是哪里出了问题。可视化是最好的调试工具,没有之一。

好了,数据预处理这部分就讲到这里。下一章我们会把这些预处理好的数据喂进Transformer模型,看看它到底是怎么「注意」到时间序列中的模式的。


公众号:蓝海资料掘金营,微信deep3321