做预测这么多年,我越来越有个体会:非平稳这个词,不是教科书里一个高高在上的假设,而是每个做业务预测的人每天都要面对的现实。你今天要预测的销量,上周还是平稳爬坡的,这周因为一场大促直接翻了四倍;你预测的城市车流,平时晚高峰的形态基本固定,但一场演唱会散场,就能让原计划里的午夜流量曲线直接起飞。这些场景背后其实都藏着一个共同点:序列本身是非平稳的,而且这种非平稳往往不是随机噪声,是由某个外部事件触发的。
这就是我今天想聊的:事件感知的非平稳时序预测,一个方便叫法的代号是EVENTTSF。它不是某个神秘的商业黑盒,而是一套把外部事件显式建模进预测流程的方法论。它能解决的问题很具体:当你的时间序列因为节假日、促销、极端天气、大型活动等原因出现均值突变、趋势切换、方差跳变时,如何让模型不再“事后反应”,而是知道“接下来要发生什么”。这篇文章适合正在做销量预测、流量预测、能源负荷预测的算法工程师和数据科学家,也适合那些已经被非平稳数据折磨了很久、想换一个切入点的同学。我会从问题拆解、建模思路、实操细节到踩坑经验,把整个链条讲透。
1. 非平稳时序预测:先看清问题长什么样
1.1 非平稳不是异常,而是常态
简单说,平稳序列的统计性质不随时间改变:均值大致恒定,方差大致恒定,序列自身的相关性结构也大致恒定。比如很多白噪声和部分周期性弱信号可以近似这么理解。但真实业务数据几乎都不满足这些条件,而且恰恰是那些“均值会跳变”的片段,才是预测最有价值的时刻。
我举个例子。一个电商平台日常日订单量可能在5万到8万之间波动,这个波动基本能靠周期性模式解释。但双十一当天订单量直接冲到50万,凌晨一小时的数据形态完全不是“日常模式的外推”。如果你用差分、用ADF检验把序列“调整平稳”再预测,会发生很尴尬的事情:平稳化过程会把这些结构性突变当成需要抹平的东西。差分一阶之后,大促当天会出现一个巨大的脉冲,模型学到的是“历史上有这么一次大跳”,却不知道“明天会有一次大跳”因为大促日历年历上都写着。
这就是非平稳预测的核心矛盾:传统的统计处理方法希望把序列“变平稳”来套用模型,而业务上你要预测的恰恰就是那些非平稳的“跳变点”。你不能把跳变当噪声消掉,你要把跳变当信号去建模。
1.2 传统平稳化处理丢掉了什么
ARIMA这套经典框架,核心思想是用差分去掉趋势和季节成分,让残差序列近似平稳,再用AR和MA项拟合。这个方法在弱非平稳场景很好用,但它有两个盲区。
第一,差分是“无差别抹平”。它不区分这个变化是正常的周期波动、是持续的趋势切换,还是外部事件导致的短期冲击。差分之后这些成分全部混在一起,模型只能看到一个脉冲,无法回答“这个脉冲是为什么”。第二,传统方法的外生变量支持很弱。就算ARIMAX能加外生回归项,它也只是把节假日变量当一个0-1哑变量丢进去,处理不了“事件的影响有时间累积效应、有衰减过程、有强度差异”这回事。
所以后来深度学习方法流行起来,大家觉得LSTM能学非线性关系,Transformer能抓长期依赖,非平稳问题应该缓解了吧。但实际测试下来,如果你只是把原始序列灌进一个时序模型,而不告诉它“明天有大促”“后天有寒潮”,模型只能从历史相似模式里硬推测。它可能学会了“每年12月会涨”,但它不知道“今年的12月,因为促销策略变了,涨的方式不一样了”。数据里缺的不是模型复杂度,是外部事件这个信息通道。
1.3 事件与非平稳之间的关联
我们得理解非平稳的来源。经验看,非平稳大体三类:一是内在趋势,比如用户量持续增长带来的销量爬坡;二是周期性变化,比如季节性、周末效应;三是事件冲击,比如促销、天气突变、大型活动。前两类的建模手段已经很成熟,趋势项可以拟合并外推,季节项可以用季节分解或者周期编码。真正难处理的是第三类,因为它不是连续函数,不是正弦波,它是在某个时间点突然叠加上来的东西,形状还各有不同。
EVENTTSF的核心判断就在这里:很多非平稳波动,本质上不是“序列自己发疯了”,而是“外部事件在序列上留下了脚印”。与其用复杂的变点检测算法事后去发现这些脚印,不如把事件的信息在预测前就告诉模型。也就是说,我们不是要在纯序列上单打独斗,而是要把事件当作预测问题里的一等公民。这个思想,我今天会用一整套实操细节来讲清楚。
2. 事件感知建模:EVENTTSF的核心思路
2.1 什么是事件感知
事件感知(event-aware)最简单的理解:在模型的输入特征里,显式加入“未来会发生什么”以及“正在发生什么”的信号。注意,这个和事后分析不一样。很多做归因分析时也会用事件标签,但那是用来解释历史波动的;预测里用事件,是把事件日历当作先验知识,在预测时就把未来事件的影响注入进去。
事件可以分为几类。点事件,比如某天突然发生一次服务器故障导致订单下跌,这类事件持续时间短、影响可能几天;段事件,比如大促从11月1日到11月11日,促销强度还有爬坡和冲刺阶段;还有周期性事件,比如每周五晚上固定有直播活动,它和季节项有重叠,但强度会变。构建事件感知系统时,类型划分很重要,因为不同事件的建模方式完全不同。
这里可以类比成开车。纯时间序列预测就像你在一条空旷的路上凭直觉开:你根据昨天的车速、前几天的路况去猜今天要开多久。事件感知模型等于给你一个导航地图,告诉你前方3公里有收费站、再过5公里有事故拥堵。你依然要自己控制油门刹车,但对未来的路况有了提前的掌握。非平稳就是那些收费站和拥堵点,事件信号就是导航播报。
2.2 事件特征的编码方式
把事件变成模型能用的特征,工程上有几个层次的做法,效果天差地别。
最简单的层次:0-1哑变量。明天是双十一,标记为1;不是,标记为0。这个做法的缺点是:事件影响没有持续时间吗?节后要修复三天,双十一后流量会回弹但可能回不到正常水平,哑变量表达不了这些。而且多个事件叠加时怎么办?双十一当天还叠加了降温,两个哑变量并列丢进去,模型只能学线性叠加,但实际影响可能是非线性放大。
进阶层次:影响强度+衰减曲线。给每个事件设置影响强度(大促强度5,普通周末直播强度1),同时计算距离事件发生时刻的时间差,用指数衰减函数把事件影响按时间铺开。比如促销前1天开始预热,影响从0.2逐渐升到峰值;促销结束后进入衰减期,影响按半衰期递减。这样每个时间点得到的是一个连续的事件影响强度值,而不是一个简单的是否标记。
再高阶一点:事件嵌入。把事件类型做成embedding,让模型自己学不同类型事件之间的相似性——高温红色预警和暴雨橙色预警虽然标签不同,但它们对出行需求的影响可能共享底层结构。这个做法在深度模型里很自然,但在树模型里不好直接做,通常需要用目标编码或事件嵌入预训练来替代。这个后面实操部分会展开。
2.3 事件信息如何进入预测模型
EVENTTSF在模型形态上并不挑剔。树模型(LightGBM、XGBoost、CatBoost)可以直接把事件强度特征、事件距今天数、事件类型编码当作普通特征放进去;RNN和Transformer可以把事件特征拼接在每一时刻的输入向量上,或者通过一个小的事件编码器产生事件表示,再和序列表示做融合。
我个人的经验是,不要一上来就设计过于复杂的融合机制。先用“特征拼接”方式把事件信息加进去,在基线模型上验证事件信息是否真的能提升预测效果。如果提升了,再考虑更复杂的交互建模。很多项目翻车,不是因为模型不够强,而是因为事件特征本身做得稀烂,然后换了一个复杂模型,照样翻车。
这里还要提一个关键点:事件特征必须是“预测时已知”的。比如促销日历是排期好的,节假日是固定的,天气是气象预报给的。你不能把未来事件当成随机变量混进模型,也不能依赖那种事后才能拿到的标签。好的事件特征在训练集和测试集上都是可获得的,只是测试集上来自日历和预报,训练集上也来自日历和预报,两者要保证一致性。
3. 实操流程:从数据到模型的一整套做法
3.1 事件数据准备与对齐
我先讲整个实操流程中最容易翻车的一步:事件表和业务数据的时间粒度对齐。
比如你预测的是小时级订单量,事件表却是按天登记的“有大促”和“无大促”,这就是典型粒度不匹配。大促的影响往往是小时级的:凌晨0点到2点可能因为是前几小时抢购高峰,影响强度是平日的20倍;白天增长反而稳定。到时候你有两个选择:把订单数据聚合到天,损失小时维度的信息;或者把事件表细化到小时,很多时候业务同事嘴上说“有活动”,但系统里根本没有每小时活动强度的数据。
我建议优先做细化。方法也不复杂:先确认每天活动的起止时间点和预估的高峰时段,把这些映射成小时级强度序列。哪怕一开始用拍脑袋的强度系数,也比不分时段的哑变量强。模型会自己学习强度数值和实际影响之间的映射,你只需要把时间结构给对。
事件表的格式最好统一成一张长表:event_id、event_type、start_time、end_time、strength(基础强度)、scope(影响范围,是全网还是某个品类、某个区域)。然后通过时间窗口匹配,把它join到业务数据表上。这个步骤容易忽略的事件是:事件的影响可能有前置预热期和后置衰减期,join的时候要把影响窗口拉宽,不能只覆盖事件区间本身。
3.2 特征工程细节
事件特征的设计有几个值得写进笔记的点。
第一个是事件距今天数。不要只给“是否处于事件期”,要给今天距离事件开始还有几天、事件已经持续了几天。这个特征能让模型学会“预热期”和“尾声期”的不同影响模式。计算公式很简单:days_to_event = event_start_date - current_date;days_in_event = current_date - event_start_date。负数和正值代表不同阶段。
第二个是影响强度的时间衰减。可以用指数衰减,也可以用分段线性。我实践中常用的是分段指数:事件前T1天开始预热,强度按0.2、0.4、0.8、1.0爬坡;事件结束后T2天内,强度按0.8、0.5、0.3、0.1衰减。这些数值不需要精细化调参,模型会把强度映射关系学出来,但要保证它包含“相对大小”的信息量。
第三个是多事件叠加的处理。当多个事件同时发生,比如周五+大促前夜+暴雨,直接把多个强度特征拼进去,树模型会自动找交互关系。如果样本足够,可以加一个“事件数量”“最大强度”“事件组合id”特征,帮助模型区分组合效应。
第四个是事件类型编码。树模型里用目标编码比较有效:统计每种事件类型下的历史平均偏离率(实际值除以基线预测值)。这个偏离率本质上就是事件类型的事后平均效应,模型看到的是“这类事件历史上平均能推高多少个点”。但目标编码有泄漏风险,要做嵌套交叉验证或者至少用历史窗口滚动计算,不能用全时段均值。
3.3 模型选型与训练要点
事件感知的核心不是某个模型,而是特征,但模型选型会影响你如何把特征用起来。我的经验分三档。
如果数据量不大,几万到几十万行,或者你希望快速上线、要可解释性,用LightGBM/CatBoost。把时间特征(小时、星期、月份)和事件特征全部作为普通特征丢进去,训练一个回归模型。这类模型的好处是能自动处理事件特征之间的非线性交互,而且你不用担心理论上复杂的序列依赖结构,lag特征可以做,周期特征可以做,事件特征也可以做。
如果数据量很大,且序列的时间依赖结构确实复杂,用GRU或LSTM。把滞后值、时间周期编码作为序列输入,事件特征既可以拼在每个时刻的输入上,也可以通过一个全连接层变换后和隐状态做相加。我不建议在最开始用Transformer,除非你有大量数据和明显收益,否则训练成本和调试难度会让你很痛苦。
还有一个关键操作:预测窗口的匹配。如果你的目标是未来24小时做24步预测,训练时就要用等长的多步损失;事件特征在每一步都要对应生成“未来第k小时的预测日历”。这个细节如果写错,等于告诉模型“今天的信息”去预测“昨天”,泄露或不一致都会让模型学歪。
3.4 一个可落地的代码骨架
下面给一个极端简化的处理逻辑,说明事件特征如何参与预测。以小时级订单量预测为例,特征包括历史滞后量、时间周期和事件强度。
import pandas as pd import numpy as np # df: 业务数据,包含 hour, orders # events: 事件表,包含 event_id, event_type, start_time, end_time, strength df["date"] = df["hour"].dt.date def build_event_features(df, events): # 把事件表展开到小时级事件窗口 event_rows = [] for _, e in events.iterrows(): # 扩展预热和衰减窗口 window_start = e["start_time"] - pd.Timedelta(hours=e.get("preheat_hours", 6)) window_end = e["end_time"] + pd.Timedelta(hours=e.get("decay_hours", 6)) timestamps = pd.date_range(window_start, window_end, freq="H") stage = np.ones(len(timestamps)) # 简单起见,用线性衰减标记强度系数 intensity = e["strength"] * np.ones(len(timestamps)) tmp = pd.DataFrame({ "hour": timestamps, "event_id": [e["event_id"]] * len(timestamps), "event_type": [e["event_type"]] * len(timestamps), "event_intensity_raw": intensity, }) event_rows.append(tmp) if not event_rows: return df.assign(event_strength=0, days_to_event=0, days_in_event=0) event_df = pd.concat(event_rows) # 多个事件合并时,这里做分组聚合成最强/累计强度 event_agg = ( event_df.groupby("hour") .agg( event_strength=("event_intensity_raw", "sum"), event_count=("event_id", "nunique"), ) .reset_index() ) merged = df.merge(event_agg, on="hour", how="left") merged["event_strength"] = merged["event_strength"].fillna(0) merged["event_count"] = merged["event_count"].fillna(0) return merged这段代码的核心是:把事件表通过起止时间+扩展窗口展开成小时级强度序列,然后merge到业务数据上。实际项目里你还要加days_to_event、days_in_event、事件类型目标编码等字段。模型训练直接把这些特征作为X的一部分。
提示:不要把事件特征做标准化后再丢进树模型,树模型对数值尺度不敏感;也不要在深度模型里把事件强度和非事件特征一起做统一的batch归一化,事件强度往往是稀疏的,归一会让它失去“是否为0”的信息量。
4. 非平稳性能再进一步的关键:趋势、漂移与在线更新
4.1 趋势分解与事件信号分离
加了事件特征之后,非平稳问题就解决了吗?我的经验是,解决了一半。事件特征能解释“已知冲击”,但序列本身还有内在的趋势切换和周期性漂移。如果这些内在成分没有处理好,事件特征和它们会互相打架。
一个有效做法:在做预测之前,先把序列拆成趋势项、季节项、残差项三部分,对事件影响主要作用于残差项这个假设进行训练。这样事件特征不用去背负解释长期趋势的责任,模型也能更专注。当然,事件强度本身会影响趋势,比如连续大促可能会把整个销售水平抬高到一个新台阶,所以这个分离不是绝对的,但在训练阶段作为辅助任务或者multi-task的一支,效果通常不错。
具体实现上,可以用STL分解或者局部加权回归做趋势分离,把趋势项当作一个分支预测目标,把残差项当作另一个分支预测目标,然后再把两个分支的预测值相加。事件特征主要喂给残差分分支。这样的结构设计比把所有东西混在一个模型里更容易训练,也更容易诊断问题:如果事件分支表现不佳,说明事件特征质量不行;如果趋势分支误差大,说明核心预测逻辑有问题。
4.2 在线归一化和模型更新
非平稳问题另一个绕不开的是“序列漂移”。事件特征能帮模型理解“为什么漂移”,但模型本身的参数如果一直不更新,训练时学到的事件影响系数也可能过时。比如大促在2023年是全平台流量的状态,到了2024年直播渠道分流,同样的活动强度带来的销量增长可能不再一样。
应对手段主要有两个。一个是预测时做在线归一化:用最近的数据(比如过去24周)计算均值和方差,对序列做局部标准化。这个做法能消除长期漂移带来的尺度变化。注意这个统计量必须在预测时刻之前计算,不能混入未来数据。另一个是周期性重新训练或增量更新:每隔一段时间用最近数据微调模型。我在实际项目里比较推荐“周级全量重训+日级增量微调”的节奏,不要追求过于频繁的在线更新,容易引入噪声,而且会让模型对近期偶然波动过拟合。
4.3 评估方法:别用固定验证集欺骗自己
非平稳预测的评估是最容易被低估的环节。如果你用随机切分的训练集/验证集来评估事件感知模型,你会得到一个乐观到不真实的结果。因为随机切分会让训练集里“见过”的事件类型也出现在验证集里,模型只要记住事件ID和大致影响倍数,就能在验证集上表现得很好。
正确做法是滚动时间窗口:训练集用[1, T]周的数据,验证集用[T+1, T+2]周,然后不断向后滚动。这样才能测试出模型在“未来事件”上的真实泛化能力。另一个被很多人忽略的点是:评估指标不能只看MAE或RMSE,还要看方向准确率和峰值时段误差。非平稳场景里,峰值时刻预测偏差半个小时的损失,往往比整天平均误差小一点重要得多。
注意:滚动评估时,事件特征的未来值必须来自“事件日历”,而不是训练集里的事件均值。否则你会把“未来事件强度”这个信息在无形中泄漏给模型,评估结果虚高得很严重。
5. 常见问题与避坑实录
5.1 事件标注噪声与粒度问题
事件感知系统里最大的坑,不是模型,是事件标注本身。业务同学给你一张excel,里面写着“6月18日到6月20日大促”,但实际大促开始时间可能是6月17日晚上8点就开抢了。差值几个小时的强行标注,会让模型学习到错误的映射关系。
处理办法:宁可事件数据稀疏,也不要脏数据。如果拿不准具体开始时间,用事件当日和前后一天做了mean shift检测,反向校准事件起止点。另外,所有事件特征生成逻辑要留一份可审计的文档,谁在什么时间改了什么事件定义,都要能追溯。
5.2 事件特征与时间特征的共线性
事件日期和星期、月份、节日之间存在天然共线性。比如大促通常安排在周五或周六,模型可能分不清“涨的原因是周五效应”还是“大促效应”。这不是事故,但会导致事件强度的系数解释失真。
缓解方式:把时间特征(星期、小时)也作为显式特征,让模型同时看到。不要因为担心共线性就删掉其中一个。树模型对共线性不那么敏感,能学到复杂的条件组合;如果是深度学习模型,可以做特征交叉或者直接用残差分支来分离。
5.3 未来事件不可知的情况
天气预报也有不准的时候,促销活动可能临时取消,演唱会可能延期。这时事件感知模型的输入变成了“预测的事件”,如果事件没发生,模型会表现得像被突然抽走了支撑。我的经验是:事件特征拆成两部分,确定事件(节假日、排期活动)和预测事件(天气、突发新闻),对后者额外加一个置信度特征。模型学会在置信度低的时候自动降低对该特征的依赖,比硬编码“可能下雨=0.3强度”要灵活。
5.4 问题排查速查表
如果模型加事件特征后效果反而变差,按下面这个顺序排查,能省下大量时间。
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 训练集提升明显,验证集崩了 | 事件特征泄漏,未来事件信息混入历史 | 检查事件表join逻辑,确认没有使用未来事件值做历史特征 |
| 加了事件特征后MAPE变差 | 事件特征粒度太粗,哑变量化严重 | 检查事件特征是否只有0/1,是否有强度、距离、衰减信息 |
| 事件影响远小于预期 | 事件数据对齐错误,join的时间窗口不对 | 手工挑几个已知事件,核对模型对该时段的影响输出 |
| 冷启动新事件类型效果差 | 事件类型历史样本为零 | 用跨类型事件的特征迁移,或用影响强度做连续特征替代类型孤热 |
5.5 几个实操心得
这里分享三个常规文档里不会写的东西。第一,事件特征做得好,浅层模型也能打赢复杂深度学习模型。我曾经在一个小时级预测项目里,只在LightGBM上加了精心设计的事件强度+距离衰减特征,就把某Transfomer基线的SoTA模型从MAPE 6.8%打到了5.4%。事件信息的编码方式,往往比模型结构更值钱。
第二,事件强度数值不要拍脑袋拍得太精细。一开始用1、3、5这样的离散档位,效果往往比精确的带小数的强度还好。因为模型学到的是“档位之间的相对差异”,而不是绝对的数值含义。如果你把强度调成2.7、3.1、5.9,反而可能引入噪声。
第三,预测模型上线之后,要持续追踪事件特征的实际分布变化。我见过项目上线三个月后,业务已经改了促销规则,但特征工程代码还在用旧的强度系数,导致预测慢慢失准。及时的模型监控不只是看误差,还要看输入特征分布有没有漂移,特别是事件强度的分布漂移。
结尾:一点个人体会
把EVENTTSF这套事件感知方法实践过几轮之后,我有一个很深的体会:时序预测项目的天花板,往往不在算法创新上,而在你对业务事件的理解深度上。你花一周做特征清洗、事件对齐、消除泄漏,模型的提升比换一版更复杂的网络结构来得快得多。非平稳序列不需要被“暴力平稳化”,它需要的是被解释。而事件,就是解释非平稳最好的那把钥匙。如果你也被某些阶段性跳变的序列折磨过,不妨先把手头的事件日历整理好,把它变成连续、有强度、带衰减的特征,再重新审视你的模型。很可能会发现,很多原先想不通的波动,其实早就有了答案,只是你忘了告诉模型。