时间序列数据,先拆规律还是先查异常

一串按天变化的数字里,趋势、周内规律、假期等等这些起伏本来就该有。

拿整条序列的均值和标准差去查异常,它们全会被算成「离均值远」,跟设备坏掉那天的 0 混在同一个口径里。

拿一年日报把这套拆法走一遍:

  1. 把时间轴对齐到天
  2. 算趋势和星期几系数
  3. 在残差上找孤立单日和成片台阶
  4. 最后留出一周验收

这样分出来的,是「本来就该有的规律」「结构变了」「一条坏记录」三种东西。

下面用一年的校园快递站日报走一遍:365 天、两列数字,时间戳故意不整齐,周内规律、寒暑假、双十一、设备故障都是埋进去的。

完整脚本文末共享。

1. 先把时间轴对齐到天

首先,把原始记录整理成一天一行,哪几天压根没报也一并记下来。

拿到的原始记录是 384 行,顺序乱的,有 22 天早晚各报了一次,还有 3 天压根没报。

先按时间排序、按天聚合。

daily = d[cfg["数值列"]].resample(cfg["频率"]).sum(min_count=1)
gaps = daily.index[daily[cfg["主序列"]].isna()]
daily = daily.interpolate(method="time")
原始记录 384 行 → 对齐后 365 天
一天报了多次的天数: 22
例: 2026-01-03 求和得 671.4 ,误用求均值只有 335.7
无记录的天: ['2026-02-20', '2026-06-30', '2026-10-28'] → 前后两天插值补齐,日期留在这里

输出里 671.4 和 335.7 正好差一半:一天报了两次该求和还是求平均,得先定口径,到件量是件数,当然求和。

误用 mean() 量级就错一半,后面所有系数跟着全错。

min_count=1 这半个参数比看上去重要。

sum() 默认会把「一天记录都没有」补成 0,而 0 看起来像个真实读数,比 NaN 危险得多;

加上它,没记录的三天才会留成空值,也就是上面打印出来的 2026-02-20、06-30、10-28,再用前后两天插值补齐,补过哪几天一直留在报告里。

这里只处理「已经知道缺哪几天」这一种情况;要是数据本来就没有固定频率、连该有哪些时间点都说不清,那是另一类问题,得先造一条期望轴再谈补值。

对齐完 365 天,一天一行,可以往下走了。

2. 把规律拆出来

接下来把原序列拆成趋势、季节、残差三层,把本来就该有的起伏单独量出来。

拆法不用装额外库,三步:趋势用 7 日居中窗口,季节按星期几求系数,残差是原序列除掉前两项。

trend = series.rolling(p, center=True, min_periods=p).median()
fac = (series / trend).groupby(dow_idx).median()
fac = fac / fac.mean()
resid = series / (trend * dow_idx.map(fac))

跑出来是这些数:

低量级段「工作日 − 周末」落差: 376.6 ;高量级段: 486.5 → 选 乘性
星期几季节系数:
星期    系数
周一 1.185
周二 1.140
周三 1.092
周四 1.092
周五 1.108
周六 0.727
周日 0.656
趋势:首 954.9 → 尾 1255.3 ;首尾各 3 天没有趋势值(居中移动平均的代价)
残差标准差: 0.144 ;双十一那几天残差: [0.79, 1.77, 1.65, 1.92, 0.83]

脚本同时打印出这张季节系数表:

星期 一 二 三 四 五 六 日
系数 1.185 1.140 1.092 1.092 1.108 0.727 0.656

趋势用中位数而不是均值,这是第一个坑。设备坏掉那天记成 0,若用 7 日均值,前后三天的趋势会被它一起拉低,一个点的错误扩散成一串。

中位数不吃这一套。

代价就写在输出里:居中窗口首尾各 3 天算不出趋势,残差那几格也是空的,这得认。

加性还是乘性也有个能算的判断:把趋势按高低分成两段,看「工作日减周末」的落差会不会跟着量级一起变大。

低量级段 376.6、高量级段 486.5,明显在涨,所以用比值、选乘性;要是两段差不多,就该用减法。

表里的系数就是「本来就该有的起伏」的答案:周一比平均水平高近两成,周日只有三分之二,趋势从 954.9 走到 1255.3。

还有个边界得说清:周期设成 7,只吸收得了周内规律。双十一那三天残差 1.77、1.65、1.92,扎眼地留在输出里——这不是分解没做好,是周期没覆盖年度事件。

顺带一句,窗口开多大和事件持续多久是耦合的:连着五六天的大促会被 7 日窗口吃进趋势里,那时候该回去改周期或单独加事件标记,而不是把它当异常处理。

3. 在残差上查异常

然后在拆干净的残差上找偏离,并把命中的天分成孤立单日和连续成片两类。

先看直接对整条序列算 z-score 会怎样,再跟残差法的结果摆在一起。

med = r.median()
scale = 1.4826 * (r - med).abs().median()
local_hit = ((r - med) / scale).abs() > k
整条序列算 z-score:命中 5 天; 在残差上用中位数+MAD:命中 17 天
全局法命中日: ['2026-04-11', '2026-05-31', '2026-11-11', '2026-11-12', '2026-11-13']
残差法拆开看:孤立单日 9 天 → ['2026-02-20', '2026-04-11', '2026-05-20', '2026-05-31', '2026-06-12', '2026-08-15', '2026-09-02', '2026-09-06', '2026-09-14']
连续成片 3 段共 8 天 → ['2026-01-18~2026-01-19', '2026-02-26~2026-02-27', '2026-11-10~2026-11-13']
成片的是周期没覆盖的事件或台阶,该回去查分解;孤立的才是要查的记录

全局法那 5 天里,4/11 是设备记 0、5/31 是重复上报记成 3 倍,另外 11/11~11/13是大促。

两个真错和三天规律混在同一张清单里,光看它分不开。

残差已经去掉趋势和季节,理论上平稳,连滚动窗口都不用了。

但阈值仍得用中位数和 MAD(绝对中位差),不能用均值和标准差——异常点自己会把标准差撑大,反而把自己藏起来。

很多人查异常时习惯「多跑几种方法、看结论一不一致」,时间序列里更该先问的是「在哪一层查」:直接查原序列,还是在拆干净的残差上查,结果完全是两回事。

残差法命中 17 天,比全局法还多,关键是别急着当成 17 个问题。

输出把它拆成了 9 个孤立单日和 3 段共 8 天的连续成片:成片的是 1/18–1/19、2/26–2/27 两处寒暑假台阶和 11/10–11/13 大促前后,属于结构没拆干净或周期没覆盖,该回去查分解;

孤立的 9 天里,4/11 和 5/31 正是埋进去的两个真错,剩下的是噪声和台阶边缘。阈值 3 只是初筛,删不删由人定。

回头看,拆干净之后判断反而简单了:不只是「哪天偏离了」,还能分清「这是规律、是结构变了,还是一条坏记录」。

4. 谁领先谁

再看看两条序列的先后:把其中一条前后挪几天,用相关峰值落在哪边定方向。

另一列是取件量:三成当天取走,七成次日取。

两条序列放一起,普通相关只回答「同一天像不像」,看不出谁在前。

for lag in range(-3, 4):
    rows.append({"滞后天数": lag,
                 "相关系数": round(x.corr(y.shift(-lag)), 3)})
 滞后天数  相关系数
   -3 0.172
   -2 0.193
   -1 0.331
    0 0.721
    1 0.930
    2 0.497
    3 0.204
峰值在 lag = 1 ,即 到件量 领先 取件量 1 天

把其中一条按 lag 前后挪,逐个算相关,峰值明明白白落在 1,跟造数据时「七成次日取」对得上。

这里不摆完整的相关矩阵。

Pearson、Spearman 回答的都是「同一时刻像不像」,时间序列多出来的是「滞后」这一层:同一对数字,挪一天关系能差 0.2。

用的时候留意方向,y.shift(-lag) 挪反了会得到完全相反的领先关系。

5. 留出一周验收

最后做一次验收:只用训练段重新分解,再拿三种基线比预测误差,看拆出来的规律有没有用上。

留出最后 7 天(12/25–12/31)当答卷。

parts = decompose(train, cfg, verbose=False)   # 只喂训练段
level_series = train / dow.map(parts["季节系数"])
pred_vals = (level + slope * np.arange(1, h + 1)) * np.array(
    [fac[d] for d in test.index.day_of_week])

最容易出错的一行是第一行:分解必须只用训练段重做一遍。

拿整年(含留出期)分解出来的趋势和系数去预测最后一周,等于偷看了答案,误差会虚低。

基线给三个:照抄昨天、上周同一天、季节基线(去季节后的水平加斜率外推,再乘星期几系数)。

只用训练段重新分解,模型: 乘性
                实际    季节基线    上周同天    照抄昨天
时间                                        
2026-12-25  1255.0  1321.0  1349.0  1272.0
2026-12-26   849.0   867.0   829.0  1255.0
2026-12-27   755.0   783.0   785.0   849.0
2026-12-28  1427.0  1413.0  1465.0   755.0
2026-12-29  1394.0  1366.0  1340.0  1427.0
2026-12-30  1226.0  1311.0  1293.0  1394.0
2026-12-31  1256.0  1313.0  1272.0  1226.0

留出 7 天的误差:
         基线   MAE
季节基线(用分解结果)  42.2
      上周同一天  45.6
       照抄昨天 202.9
基线 MAE
季节基线(用分解结果) 42.2
上周同一天 45.6
照抄昨天 202.9

表里只要用上周内规律,误差就从 202.9 掉到 50 以内。输出里 12/26 那行更直观:这天是周六,照抄昨天给 1255,实际只有 849,一个周末就差出四百多件,这就是不用规律的代价。

季节基线和上周同天几乎打平(42.2 对 45.6),这点不必掩饰:分解的价值在看清结构、分清什么是规律,不在于换一个更炫的基线。最简单的「上周同一天」本来就把大部分周内规律吃掉了。

6. 串成一个入口

把五步收成一个可重复运行的入口,跑完直接得到一份数值报告。

五步各是一个函数,run_analysis 按序跑完,也就是这么几行:

daily = align_daily(df, cfg)
parts = decompose(daily[cfg["主序列"]], cfg)
find_anomalies(daily[cfg["主序列"]], parts, cfg)
leading_lag(daily, cfg)
holdout_check(daily[cfg["主序列"]], cfg)

直接打印一份数值报告——不画图、不落盘,分解、异常、滞后、验收四处的结论全用表和数字给出来。

完整的代码共享在:ts_pipeline.py https://url11.ctfile.com/f/45455611-17569912363609-78bf2b?p=6872

posted @ 2026-11-15 18:21  wang_yb  阅读(7)  评论(0)    收藏  举报