☰
PyTimeTK时间序列预测:数据预处理、窗口切分与防泄露实践
2026/10/27 20:01:44 网站建设 项目流程

做时间序列相关的活儿,PyTimeTK 这个名字第一次出现在我视野里的时候,我其实是有点警惕的。这些年见过太多"一站式时间序列分析库",宣称从数据清洗到建模到可视化全包,结果装完之后发现每个环节都只做了 60 分,剩下的 40 分还得自己补,最后代码里多了一层没必要的依赖。所以我拿到一个时间序列分析库的第一反应通常是:它到底替我干了哪几件实事,又刻意没干什么。PyTimeTK 让我留下来的原因恰恰在于它比较克制——它没有把精力砸在堆模型上,而是把时间序列从一张普通表格变成模型能吃的东西这一段流程做薄了、做稳了。这篇文章我想把它的定位、适用边界,以及我在实际项目里用它踩过的那些坑,完整讲一遍。适合已经写过几个时序预测脚本、但每次开新项目都觉得在重复劳动的人看。

1. 时间序列项目里最没技术含量却最耗时的三件事

1.1 从"脚本能跑"到"结果可信"之间隔着一条预处理暗河

我统计过自己过去几年做过的时序项目,真正花在选模型、调超参上的时间,大概只占三成。剩下的七成,全部消耗在三件听起来极其无聊的事情上:把时间列变成长度一致、间隔均匀的索引;把缺失值处理到模型不会直接报错或者悄悄输出 NaN;以及把长序列切成 (样本, 特征, 目标) 这种形状正确的张量。这三件事每一件单拎出来都不难,难的是它们要组合在一起,而且每一步都有十几种写法,每种写法在验证集上表现差不多,但在生产环境里差别巨大。

举个最常见的例子。你拿到一张订单表,时间是精确到秒的下单时刻,一天几千条,但业务方要的是"每小时的成交量预测"。中间要做的事情是:去重、聚合、补全没有订单的小时、处理跨天时的时区问题、确认聚合用的是左闭右开还是左开右闭。任何一步马虎,你得到的序列就是错的,而错误的时间序列喂给再好的模型,训练损失照样能降下去,只是预测出来的东西和现实没有关系。这就是我说的"暗河"——它不报错,它只是让你在两周后的复盘会上被问住。

PyTimeTK 在这件事上的价值,是把这些步骤固化成一套有明确语义的流程,而不是让你每次凭记忆手写。它把数据接入、频率对齐、窗口切分这几件事收拢到一起,你不需要在 pandas、numpy、torch 之间反复倒腾形状。用不用它是一回事,但理解它为什么这么组织,对你自己写代码是有直接帮助的。

1.2 PyTimeTK 选择把复杂度压在数据层而不是模型层

很多时序库的思路是"我给你 30 个模型,你挨个试"。这个思路在论文复现场景里成立,在工程场景里基本不成立。原因很实在:真实业务里决定预测效果上限的,往往是特征构造的质量和评估方式是否诚实,而不是你用了 Transformer 还是梯度提升树。我做过对比,同一份数据,把滞后特征和滚动统计量做对了,线性模型的误差能压掉一大截;反过来,特征做得潦草,换成再复杂的网络也只是把过拟合做得更精致。

所以 PyTimeTK 把抽象放在数据层是合理的:统一的序列容器、统一的窗口生成、统一的切分逻辑。模型那边它给的是封装好的训练流程和一些常见结构的实现,够用就行。你完全可以只把它当数据管道用,模型换成自己熟悉的任意框架,这个用法我认为是最实用的。

环节常见做法PyTimeTK 的思路实际收益
索引对齐每次手写 resample + ffill内置频率推断与重采样封装少写 30 行,少踩时区坑
缺失处理直接 ffill 了事提供插补策略接口,保留掩码推理期行为可复现
窗口切分手写 for 循环向量化索引生成大数据量下快一个数量级
训练切分sklearn 随机切强制时间顺序切分避免信息泄漏
评估单一指标多指标并行 + 滚动回测结论更可信

2. 读懂 PyTimeTK 的设计取舍:它为什么不给你一堆模型

2.1 "简单有效"在时间序列语境下的真实含义

这个库的名字里带"简单有效",容易被理解成营销话术。但放在时间序列这个领域,"简单"其实是一个非常具体的工程判断。时序建模有一个很反直觉的特点:简单模型的基线极其顽固。移动平均、季节朴素法(seasonal naive,就是"上周同一时刻的值")这两类方法,在大量真实业务数据上能打赢不少精心调参的复杂模型,尤其是预测步长较短、序列噪声大的场景。

所以就"有效"这件事来说,一个时序库最该做的不是提供最强的模型,而是让你能在一分钟内跑出可靠的基线,然后拿复杂模型去和基线比。如果你的复杂模型打不赢季节朴素法,那问题不在模型,在你的特征或者数据。PyTimeTK 的流程设计是支持这个思路的:它的评估环节默认会让你拿到一组基线对照,而不是只给你一个孤零零的损失值。

我自己养成了一个习惯,任何新序列进来,先跑三个基线:最后值延续(naive)、季节周期延续、滚动均值。这三个数出来之后,我才知道这份数据的"难度水位"在哪里。如果某个序列的季节朴素法 MAPE 已经只有 4%,那我基本不会在上面投入太多精力去搞深度模型,收益太小了。

提示:不要小看基线。我见过不止一个项目,最终上线的是"上周同期值 + 一个修正系数",效果比折腾了两个月的模型稳定得多,运维成本还低。

2.2 一个库的能力边界比能力清单更重要

判断一个库值不值得长期用,我一般不看它能做什么,而看它明确不做什么。PyTimeTK 的边界大致是这样的:它擅长单变量或多变量的规则频率序列的监督式预测,也就是说你的数据能整理成"每 5 分钟一行、每行若干列"这种形态,那它很合适。它不擅长的是事件序列、不规则采样、变长序列的序列到序列建模这类场景。硬套的话,你会发现自己一直在和它的窗口切分逻辑对抗,那就说明用错工具了。

我把这个边界整理成一张判断表,你们可以对照自己的场景看一下:

你的场景是否适合原因
服务器每 5 分钟采集的指标预测适合规则频率、多变量、步长固定
门店日销量预测(含促销标记)适合外生变量可以直接作为特征列
用户行为事件流(时间不规则)不适合需要先聚合到固定窗口,否则窗口逻辑失效
超长序列(几十万步以上)单序列谨慎需要先做下采样或分段,否则内存吃紧
需要在线增量更新的实时预测谨慎训练流程偏批处理,在线部署要自己做适配层

这个表不是绝对的,但它能帮你省掉很多"试了三天发现方向不对"的时间。

3. 数据接入环节:索引对齐与缺失值处理的实际做法

3.1 时间索引不规范是所有错误的源头

我在代码 review 里见过最多的时序 bug,源头都是时间索引。具体表现有几种:索引不是单调递增的(数据合并之后顺序乱了)、索引有重复(同一时刻多条记录没聚合)、索引有时区但混了本地时间和 UTC、索引是字符串类型而不是 datetime 类型。这四种情况里有任何一种,后面的窗口切分都会切出莫名其妙的结果,而且不会报错。

PyTimeTK 在数据接入时会做基础校验,但你不能指望库替你做业务判断。我的做法是在喂进去之前,自己先跑一遍体检:

import pandas as pd def check_time_index(df, time_col): ts = pd.to_datetime(df[time_col], errors="coerce") report = { "解析失败条数": int(ts.isna().sum()), "是否单调递增": bool(ts.is_monotonic_increasing), "重复时间点数": int(ts.duplicated().sum()), "时区": str(ts.dt.tz) if hasattr(ts.dt, "tz") else "无", "相邻间隔种类数": int(ts.sort_values().diff().dropna().nunique()), } return report

最后那一项"相邻间隔种类数"经常被我用来判断频率。如果这个数字是 1,说明序列是严格等间隔的,直接可以用。如果是 2 到 3,通常是跨夏令时或者有少量漏采,重采样能修。如果是几十上百,说明数据本身是不规则的,必须先聚合,否则后面的窗口长度没有物理意义。

注意:pd.to_datetime遇到格式混杂的列会静默产生 NaT,不会抛异常。我一般会显式传errors="coerce"然后把 NaT 数量打出来,宁可多看一行日志,也不要让脏数据混进去。

3.2 缺失值填充的三种策略与它们的代价

时序缺失值的处理是所有环节里最容易"看起来对了其实错了"的一步。我按代价从低到高列一下常见策略。

前向填充(ffill)是最常用的,逻辑是"用最近一次观测值顶上"。它便宜、直观,但在两种情况下会骗你:一是缺失段很长的时候,你实际上是在用很久以前的信息做预测,模型会误以为这段时间是平稳的;二是它会造成训练期和推理期的行为不一致——训练时缺失被填上了,推理时如果也缺失,你填的方式必须完全一致,否则分布漂移。

线性插值在缺失段短、序列平滑的时候表现不错,代价是它会"偷看"后面的值。如果你在训练集上做插值,然后又在测试集边界附近做插值,实际上是把未来信息泄漏进了过去。我的处理方式是把插值和填充严格限制在训练窗口内部,测试期只允许使用单向(前向)信息。

模型插补成本最高,通常不值得。除非缺失比例超过 20% 且缺失本身有业务含义,否则用前向填充配合一个缺失掩码特征,效果和复杂插补差不多。

这里有个我反复验证过的小技巧:不管用哪种填充,都额外加一列"距离上次有效观测的步数"作为特征。这一列在缺失密集的区域会显著变大,模型能自己学出"这段时间我的判断不太可靠"。加了这一列之后,我在几个场景里的误差都有可感知的下降,成本几乎为零。

def add_missing_features(df, cols): out = df.copy() for c in cols: is_na = out[c].isna() out[c] = out[c].ffill() # 距离上次有效观测的步数,用累计计数实现 grp = is_na.ne(is_na.shift()).cumsum() out[f"{c}__gap"] = is_na.groupby(grp).cumsum() return out

3.3 重采样与聚合的对齐陷阱

重采样看起来是最机械的一步,其实坑很多。最典型的是边界归属问题:一个时间戳为 10:59:30 的订单,属于 10:00-11:00 这个桶还是 11:00-12:00?resample的closed和label两个参数决定了答案,默认值在不同版本的 pandas 里改过,所以我会显式写出来,不给后人留隐患。

# 显式声明归属,避免版本差异 hourly = df.resample("1h", closed="left", label="left").agg({ "amount": "sum", "user_id": "nunique", })

还有一个更隐蔽的问题:空桶。如果某个小时完全没有数据,resample会给你一行 NaN,而不是 0。对于"成交量"这种语义上是计数的字段,正确的值应该是 0,而不是缺失。这两种处理后续的走向完全不同——填 0 表示"确实没交易",填 NaN 再前向填充表示"我们不知道,就当作和上一小时一样"。业务含义天差地别,代码上只差一个fillna(0),但结论可能完全相反。

最后一个坑是时区。跨时区的数据如果在本地时区做重采样,再转成 UTC,跨天的桶会错位。我的原则是:存储层统一用 UTC,展示层再转本地,重采样一律在 UTC 上做。这条规则帮我省掉了至少两次深夜排查。

4. 窗口构造:泄露未来信息最隐蔽的地方

4.1 滑动窗口的因果性约束

窗口构造这件事,核心约束只有一条:在时刻 t 生成样本时,你能看到的只能是 t 及之前的数据。听起来是废话,但实际代码里违反它的方式多得惊人。最常见的两种:一是先对整条序列做归一化再切窗口,归一化用了全局均值和方差,等于把未来的统计信息掺进去了;二是先对整条序列做差分或者滚动标准化,再切窗口,同样的问题。

正确的顺序永远是:先切窗口,再在窗口内部做变换。或者退一步,用"扩展窗口"(expanding window)的统计量,也就是每个时刻只用它之前的数据算均值和方差。后者在工程上更常见,因为可以一次性向量化算完。

# 错误:全局标准化后再切窗口 scaled = (series - series.mean()) / series.std() # 正确:扩展窗口标准化,只使用历史信息 exp_mean = series.expanding(min_periods=1).mean() exp_std = series.expanding(min_periods=1).std().replace(0, 1) scaled = (series - exp_mean) / exp_std

这个细节在很多教程里被忽略了,但它对评估结果的影响非常大。用全局标准化,你的验证集误差会虚低,因为模型在训练时"见过"未来的分布范围。上线之后真实误差会比验证集高出一截,而且你很难定位原因。

4.2 用向量化方式生成样本索引

很多人切窗口是写一个 for 循环,把每个窗口 copy 出来堆成数组。小数据量没问题,几十万步、多特征的时候,内存直接爆掉。PyTimeTK 内部用的是索引视图的思路,也就是说它不复制数据,只记录每个窗口的起止位置,真正取数的时候才按需索引。这个思路你自己实现起来也不难:

import numpy as np def make_windows(series, lookback, horizon): """series: 一维 np.ndarray;返回 (样本数, lookback) 和 (样本数, horizon)""" n = len(series) n_samples = n - lookback - horizon + 1 if n_samples <= 0: return np.empty((0, lookback)), np.empty((0, horizon)) base = np.arange(n_samples)[:, None] x_idx = base + np.arange(lookback)[None, :] y_idx = base + lookback + np.arange(horizon)[None, :] return series[x_idx], series[y_idx]

这个函数返回的是通过花式索引复制出来的数组,比 for 循环快得多。如果连这份复制都想省掉,可以用np.lib.stride_tricks.sliding_window_view,它返回的是视图,不占额外内存,代价是你不能直接对它做原地修改。我在数据量超过千万级元素的时候会用这种方式,普通场景用上面的花式索引就够了。

还有一个参数需要想清楚:horizon大于 1 的时候,样本之间会有重叠,训练时如果按样本随机打乱,同一个时间段的信息会同时出现在训练和验证里。所以多步预测场景下,切分必须按时间块切,不能按样本切。

4.3 训练/验证/测试切分不能随机打乱

这条在时序领域是老生常谈,但我还是要写,因为我见过太多人栽在这里。随机切分在时序上制造的是"未来预测过去"的样本,模型在验证集上的表现会好得离谱,上线之后一塌糊涂。正确做法是按时间顺序切三段:训练集在最前,验证集居中,测试集在最后,三段之间留出至少一个horizon的间隔,避免边界处的窗口跨段。

def time_split(n, ratios=(0.7, 0.15, 0.15), gap=0): n_train = int(n * ratios[0]) n_val = int(n * ratios[1]) train_end = n_train val_start = train_end + gap val_end = val_start + n_val test_start = val_end + gap return (0, train_end), (val_start, val_end), (test_start, n)

gap这个参数容易被忽略。假设你的horizon是 24,训练集最后一个样本的目标值实际上取到了训练集边界之后 24 步的数据。如果验证集紧挨着训练集,这部分数据就重叠了。把gap设成horizon是最保守的做法,实际项目里我一般至少设成horizon。

5. 特征工程的取舍:不是特征越多越好

5.1 滞后特征与滚动统计量的构造

时序特征里性价比最高的两类,一是滞后(lag),二是滚动统计量(rolling mean/std/min/max)。这两类特征加起来通常能解释大部分可预测性。问题在于滞后阶数怎么选。我的经验是先看自相关函数(ACF)和偏自相关(PACF),把显著不为零的滞后阶挑出来,一般取 3 到 5 个就够,不要一股脑把 lag1 到 lag100 全塞进去——特征维度上去了,模型容易在噪声上过拟合,训练时间还翻倍。

滚动统计量的窗口长度要和业务周期对齐。做日频数据,窗口取 7 和 28 是有道理的;做小时频数据,窗口取 24 和 168。我曾经图省事用了 10、20、30 这种"整数好看"的窗口,结果模型学不到周期性,效果比不加还差。窗口长度这件事,宁可少而准,不要多而乱。

def add_lag_roll(df, col, lags=(1, 2, 7), windows=(7, 28)): out = df.copy() for l in lags: out[f"{col}_lag{l}"] = out[col].shift(l) for w in windows: r = out[col].rolling(w, min_periods=max(2, w // 4)) out[f"{col}_mean{w}"] = r.mean() out[f"{col}_std{w}"] = r.std() out[f"{col}_max{w}"] = r.max() return out

注意min_periods的设定。如果设得太严格(比如等于窗口长度),序列开头会有一大段 NaN,白白浪费样本;设得太宽松(比如 1),窗口里只有一个点时算出来的标准差是 0 或者 NaN,会给模型错误的信号。我一般取窗口长度的四分之一。

5.2 日历特征与周期编码

时间本身包含的信息量比很多人以为的大。星期几、是否周末、是否月初月末、是否节假日,这几类特征在零售、流量、能耗这些场景里往往比模型结构更重要。但要注意编码方式。

直接给"星期几"一个 0 到 6 的整数是个坏主意,因为模型会认为 6 比 0 大,而星期天和星期一在业务上通常是连续的、相邻的。正确做法是用正弦余弦编码,把周期性的整数映射到一个圆上:

import numpy as np def cyclical_encode(values, period): sin = np.sin(2 * np.pi * values / period) cos = np.cos(2 * np.pi * values / period) return sin, cos

这样星期天的 (sin, cos) 和星期一的 (sin, cos) 在空间上就是相近的,模型能自然地学到连续性。小时、月份、一年中的第几天都可以这么处理。

节假日特征要小心处理。我的建议是分两层:一层是"是否法定假日"的布尔标记,另一层是"距离最近假日还有几天/过去了几天"的整数特征。第二层的作用经常被低估,因为节前和节后的行为模式和节日当天完全不同。这个特征我一般是自己写一个映射表,按年维护,别指望自动化的节假日库能覆盖所有地区的调休安排。

6. 评估与回测:让指标说实话

6.1 常用指标的坑:MAPE 在接近零时的爆炸

报误差的时候只报一个 MAPE,是我见过最普遍的坏习惯。MAPE 的分母是真实值,当真实值接近 0 的时候,这个比值会炸到几百甚至几千,把整体指标完全带偏。零售场景里销量为 0 的 SKU 大量存在,用 MAPE 算平均,结果基本没有参考价值。

我一般同时算三个指标,各有各的用途:

指标计算方式适用场景主要缺陷
MAE绝对误差均值需要解释成业务单位时对量纲敏感,跨序列不可比
WAPE绝对误差之和 / 真实值之和多序列聚合评估大值序列主导结果
MASEMAE / 朴素法 MAE跨序列比较、含零值需要先算好朴素法基线

MASE 是我最推荐的。它的做法是把你的模型误差,除以"季节朴素法"的误差。小于 1 说明你比基线强,大于 1 说明你不如直接拿上一个周期的值。这个指标无标度、能跨序列比较、不怕零值,非常适合多序列场景。PyTimeTK 的评估环节对这类指标有支持,我在项目里也基本以 MASE 作为主指标。

还有一个细节:多序列场景下,先算每个序列的指标再取平均,和把所有序列的误差混在一起算,结果差别可能很大。前者对小序列公平,后者被大序列主导。我通常会两个都算,报告时说明清楚口径。

6.2 滚动回测(rolling origin)的实施细节

单次切分验证的问题是结论不稳定。你正好切在一个平稳期,模型看起来很好;换个切法可能就差很多。滚动回测的做法是设置多个起点,每次往后推一个固定步长,重复训练和评估,最后看指标的分布而不是单点值。

def rolling_origin(n, initial, horizon, step): """生成 (训练结束位置, 预测起点, 预测终点) 三元组""" splits = [] start = initial while start + horizon <= n: splits.append((start, start, start + horizon)) start += step return splits

实际执行时有几个参数要想清楚。initial是第一次训练用的历史长度,太小模型学不到东西,太大则回测次数少。step是每次往前推的步长,一般取horizon的一半到一个horizon之间,推得太密计算量翻倍但信息增益有限。

还有个选择是"固定窗口"还是"扩展窗口"。固定窗口每次只用最近 N 步训练,适合分布会漂移的场景;扩展窗口每次把新数据加进去,适合数据量不大、希望充分利用历史的场景。我一般两个都跑,如果固定窗口明显更好,说明数据存在显著的分布变化,这时候还得回头看是不是需要加时间衰减权重。

提示:滚动回测的报告不要只给均值,把每折的误差画出来。我遇到过一次,均值看起来正常,但其中两折的误差是其他折的三倍——那两周正好有促销活动,模型完全没有捕捉到。如果只看均值,这个问题就会被掩盖。

7. 工程化落地:性能、复现与版本管理

7.1 内存与速度:从 Python 循环到数组视图

数据处理阶段的性能瓶颈,九成出在 Python 层的循环和 DataFrame 的链式操作上。我的优化顺序是这样的:先把所有能向量化的操作用 numpy 完成,再考虑类型降精度,最后才考虑并行。

类型降精度这一条收益经常被低估。默认的 float64 换成 float32,内存直接减半,特征工程阶段的速度也能提升不少。对于时序预测这种精度要求,float32 的 7 位有效数字完全够用。同样的道理,如果某个特征列的取值只有几十种,用 category 或者 int8 存储,比用 object 或者 int64 省得多。

import numpy as np import pandas as pd def downcast(df): for c in df.select_dtypes(include=["float64"]).columns: df[c] = df[c].astype(np.float32) for c in df.select_dtypes(include=["int64"]).columns: if df[c].min() >= -128 and df[c].max() <= 127: df[c] = df[c].astype(np.int8) elif df[c].min() >= -32768 and df[c].max() <= 32767: df[c] = df[c].astype(np.int16) return df

还有一点:窗口切分之后得到的张量,如果直接送进模型,形状是 (样本数, 窗口长度, 特征数)。这个布局对多数框架是友好的,但如果你后面要做大量的按特征切片操作,转置成 (样本数, 特征数, 窗口长度) 可能更快。这个要实测,不要凭直觉。

7.2 可复现性的几个小习惯

时序实验的复现难度比普通机器学习更高,因为涉及随机种子、数据切分、滑动窗口三重不确定性。我养成了几个固定习惯,成本很低但收益很大。

第一,把切分方案和特征配置写进一个配置文件,而不是散落在代码里。哪怕只是一个 JSON,也比硬编码强。三个月后你要回答"当时那个结果是怎么来的",配置文件的用处就体现出来了。

第二,每次实验固定随机种子,并且在日志里记下来。numpy、torch、python 的 random 是三个独立的种子源,只设一个是没用的。

第三,保存中间结果。特征工程跑一次可能要好几分钟,把处理好的数组存成 npz,调试模型的时候直接加载,能省掉大量等待。这一步的代价是磁盘空间,但对比反复重跑的时间成本,非常划算。

第四,日志里除了损失值,还要记录数据的时间范围和样本数。我曾经因为一份数据的日期范围变了(上游补了历史数据),导致同样的代码跑出完全不同的结果,排查了大半天才发现是数据本身变了。

注意:不要在不同实验之间共用同一个缓存目录。特征定义一改,缓存就失效了,但你不会记得清理。用配置文件的哈希值作为缓存目录名,能自动避开这个问题。

最后说一句我的实际体会。这类库真正的价值不在于它替你写了多少行代码,而在于它把一些容易被忽略的正确做法变成了默认行为——比如强制时间切分、比如窗口的因果性约束、比如多指标评估。你自己写代码的时候,这些地方很容易图省事跳过,而跳过的代价往往要等到上线之后才显现。所以哪怕你最后不用 PyTimeTK,把它这几个设计思路抄进自己的工具函数里,也是稳赚的。至于参数细节和函数签名,不同版本会有出入,照着文档来,别照着我这篇里的示例代码硬套。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询