时间序列数据,先拆规律还是先查异常
一串按天变化的数字里,趋势、周内规律、假期等等这些起伏本来就该有。
拿整条序列的均值和标准差去查异常,它们全会被算成「离均值远」,跟设备坏掉那天的 0 混在同一个口径里。
拿一年日报把这套拆法走一遍:
- 把时间轴对齐到天
- 算趋势和星期几系数
- 在残差上找孤立单日和成片台阶
- 最后留出一周验收
这样分出来的,是「本来就该有的规律」「结构变了」「一条坏记录」三种东西。
下面用一年的校园快递站日报走一遍: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

浙公网安备 33010602011771号