时间序列数据里,趋势、周期和随机波动经常是混在一起的。
直接看原始曲线,很容易被短期波动带偏,比如把一次促销当成长期增长,或者把季节性回落当成业务衰退。
时间序列分解要解决的就是这个问题,把一条看起来乱七八糟的曲线拆成几个更有解释性的部分,让我们分别看:
- 长期方向
- 重复周期
- 剩下的随机噪声。
本文的目的不是讲一堆公式,而是把时间序列分解这件事讲清楚,并用 Python 和 matplotlib 一步一步演示怎么把有效信号从噪声里拆出来。
希望能讲清楚趋势 、季节性 、残差 分别是什么,知道加法分解和乘法分解怎么选,也能自己跑一遍 seasonal_decompose 并看懂输出图。
1. 为什么要拆开看
时间序列和普通表格数据不太一样,它有时间顺序,当前值往往和过去值有关系。
很多时间序列还会同时受到几种力量影响:整体在缓慢上升或下降,某些月份或星期有固定规律,另外还有一堆随机因素在干扰。
如果直接拿原始序列去建模,模型很可能把季节性波动当成趋势的一部分,或者把噪声当成真实信号。
分解的核心思路很直接:假设原始序列由几个部分叠加或相乘而成,那我们就把它们拆出来。
拆开之后,趋势可以看长期方向,季节性可以看周期规律,残差可以看异常和随机波动。
这样再去做预测、异常检测或者业务判断,会更有依据。
下面这个图,我构建了一个测试数据,直观的演示拆开看与不拆开看的区别。

2. 时间序列分解到底在拆什么
最常见的时间序列分解会把序列拆成三部分:趋势 、季节性 和残差。
用公式写就是: Y_t = T_t + S_t + R_t
这里的 Y_t 是原始时间序列, T_t 是趋势项, S_t 是季节项, R_t 是残差项。
这个公式是加法模型,意思是几部分直接相加。
趋势项代表长期方向。它不关心某个月突然涨了还是跌了,而是看整体是在上升、下降还是基本平稳。
季节性代表固定周期内重复出现的模式。比如月度数据里,每年夏天或每年年底都有类似变化,这种重复规律就是季节性。
残差则是去掉趋势和季节性之后剩下的部分,通常被叫做噪声。但残差并不是完全没用的东西,很多异常点、突发事件、模型没捕捉到的结构,都会留在残差里。
所以时间序列分解不是单纯为了把数据变好看,而是为了把不同来源的变化分开,让我们知道当前波动到底来自长期趋势、固定周期,还是随机干扰。
3. 加法还是乘法
刚才的公式是加法模型。它假设季节性波动的幅度基本稳定,不随趋势水平变化。
比如不管销售额是 500 还是 1000,季节性上下波动的绝对值都差不多,那就适合加法分解。
另一种常见模型是乘法模型:
Y_t = T_t \\times S_t \\times R_t
乘法模型假设各部分是相互影响的,季节性波动的幅度会随着趋势水平成比例变化。
比如销售额越高,季节性波动也越大,旺季更旺、淡季也更明显,这种情况用乘法模型更合适。
实际判断时,可以看原始序列的波动幅度是不是随着整体水平一起变大。
如果是,优先考虑乘法;如果波动幅度比较稳定,用加法。
还有一种常见做法是先对数据取对数,把乘法关系转成加法关系,再做加法分解。
4. 代码实战
下面进入实战。这里我们生成一组可控的月度销售数据,方便验证分解结果。
数据里包含三个部分:线性上升的趋势、按月份重复的季节性,以及随机噪声。
这样做的好处是,我们提前知道信号长什么样,拆完之后更容易判断分解是否合理。
后面的代码是接着一步步运行的,不是一次性全部跑完。每跑完一小段,我们就先看一眼当前拆出来的东西。
4.1 先把原始序列画出来
先导入库,生成数据,然后只看原始序列。这个阶段趋势、季节性、噪声全都混在一起。
python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.seasonal import seasonal_decompose
# 设置中文字体,避免图中中文乱码
plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "Arial Unicode MS"]
plt.rcParams["axes.unicode_minus"] = False
np.random.seed(42)
date_range = pd.date_range(start="2015-01-01", periods=120, freq="MS")
t = np.arange(len(date_range))
# 真实趋势:整体从 200 左右上升到 1000 左右
trend = 200 + 6.7 * t
# 真实季节性:按月份重复的正弦波动
seasonality = 100 * np.sin(2 * np.pi * (date_range.month / 12))
# 随机噪声:均值为 0、标准差为 50
noise = np.random.normal(0, 50, len(date_range))
# 原始序列 = 趋势 + 季节性 + 噪声
sales = trend + seasonality + noise
data = pd.DataFrame({"Sales": sales}, index=date_range)
plt.figure(figsize=(12, 4))
plt.plot(data["Sales"])
plt.title("原始序列:趋势、季节性和噪声混在一起")
plt.xlabel("日期")
plt.ylabel("销售额")
plt.tight_layout()
plt.show()

运行后你会看到一条整体向上、同时带有周期性起伏的曲线。
它确实有规律,但长期趋势和季节波动叠在一起,直接看不方便。
4.2 提取趋势项
接下来执行分解,但先只把趋势项拿出来看。
seasonal_decompose 会一次性算好趋势、季节性和残差,我们这里先取 decomposition.trend,对应趋势项。
python
decomposition = seasonal_decompose(
data["Sales"],
model="additive",
period=12
)
data["Trend"] = decomposition.trend
plt.figure(figsize=(12, 4))
plt.plot(data["Sales"], label="原始序列", alpha=0.6)
plt.plot(data["Trend"], label="趋势项", color="orange", linewidth=2)
plt.legend()
plt.title("原始序列 vs 趋势项")
plt.xlabel("日期")
plt.ylabel("销售额")
plt.tight_layout()
plt.show()

运行后,橙色趋势线会比原始序列平滑很多,它基本沿着长期上升方向走。
趋势项首尾会有一点缺失值,这是 seasonal_decompose 用居中移动平均估计趋势时的正常现象。
到这里,长期信号先从原始序列里露出来了。
4.3 提取季节项
趋势拿走之后,剩下的应该主要是周期波动和噪声。现在把季节项取出来,对应 decomposition.seasonal。
python
data["Seasonal"] = decomposition.seasonal
plt.figure(figsize=(12, 4))
plt.plot(data["Seasonal"], color="green")
plt.title("季节项:按 12 个月重复的规律")
plt.xlabel("日期")
plt.ylabel("季节波动")
plt.tight_layout()
plt.show()

运行后,季节项会按 12 个月重复同样的模式。它不再有长期上升或下降,只剩下固定周期规律。
为了更直观地看它有没有抓准周期,可以再把"原始序列减趋势"的结果和季节项放在一起:
python
detrended = data["Sales"] - data["Trend"]
plt.figure(figsize=(12, 4))
plt.plot(detrended, label="原始序列减趋势", alpha=0.6)
plt.plot(data["Seasonal"], label="季节项", color="green")
plt.legend()
plt.title("去趋势后的波动 vs 季节项")
plt.xlabel("日期")
plt.ylabel("波动")
plt.tight_layout()
plt.show()

运行后你会发现,去趋势后的波动和绿色季节项基本对齐。这说明季节项已经把固定周期那部分抓出来了。
4.4 提取残差项
趋势和季节性都拿掉之后,剩下的就是残差,对应 decomposition.resid。
这部分可以理解成随机噪声,也可以用来找异常点。
python
data["Residual"] = decomposition.resid
plt.figure(figsize=(12, 4))
plt.plot(data["Residual"], color="red")
plt.title("残差项:去掉趋势和季节性后剩下的部分")
plt.xlabel("日期")
plt.ylabel("残差")
plt.tight_layout()
plt.show()

运行后,残差应该围绕 0 上下波动,没有明显上升趋势,也没有固定周期。
如果残差里还能看出规律,通常说明周期设错了,或者模型选得不太合适。
如果某个点特别大,那可能就是异常值。
4.5 把拆出来的部分汇总到一张图
前面是分步看,最后再把原始序列、趋势、季节性、残差放到一张四联图里,方便整体对比。
python
fig, axes = plt.subplots(4, 1, figsize=(12, 10), sharex=True)
axes[0].plot(data["Sales"])
axes[0].set_title("原始序列")
axes[0].set_ylabel("销售额")
axes[1].plot(data["Trend"], color="orange")
axes[1].set_title("趋势项")
axes[1].set_ylabel("趋势")
axes[2].plot(data["Seasonal"], color="green")
axes[2].set_title("季节项")
axes[2].set_ylabel("季节性")
axes[3].plot(data["Residual"], color="red")
axes[3].set_title("残差项")
axes[3].set_ylabel("残差")
axes[3].set_xlabel("日期")
plt.tight_layout()
plt.show()

这张图就是时间序列分解的标准输出。
- 第一张是原始信号
- 第二张是长期趋势
- 第三张是固定周期
- 第四张是剩余噪声
到这一步,原始序列已经被拆成了几个更有解释性的部分。
5. 怎么读分解结果
分解完成之后,最重要的不是图好不好看,而是每一部分能不能解释得通。
趋势项回答的是"长期往哪走"。如果趋势持续上升,说明整体需求或业务规模在增长;如果趋势走平甚至下降,就要进一步看是周期问题还是结构问题。
季节项回答的是"固定周期内怎么重复"。它可以帮助做季节性调整。
比如把原始序列减去季节项: Y_t - S_t = T_t + R_t
得到的就是季节调整后的序列。
这个序列去掉了固定周期影响,更适合比较不同月份或季度的真实变化。
残差项回答的是"还有什么没被解释"。残差接近随机噪声,说明趋势和季节性已经把主要结构提取出来了。
如果某些点的残差特别大,可能就是异常值。
实际使用时,可以算残差的标准差,把超过两倍或三倍标准差的点标出来重点检查。
需要强调的是,时间序列分解本身不是预测模型。
它更像一个诊断工具,帮助你理解数据结构。理解清楚之后,再去做 ARIMA、指数平滑 、Prophet 或者机器学习预测,会更有方向。
6. 总结
时间序列分解 说到底就是把原始序列拆成趋势 、季节性 和残差三部分,分别理解长期方向、固定周期和随机波动。
实际用的时候,有几点需要注意:
- 周期长度一定要设对,月度用 12、季度用 4、周度用 52,这是最基本的
- 数据最好有两个以上完整周期,否则趋势和季节性的估计会不稳。
- 缺失值也要先处理,因为
seasonal_decompose对缺失值比较敏感。 - 至于加法还是乘法,主要看波动幅度是不是随整体水平一起放大,拿不准就两种都试,比较残差是不是更接近随机噪声。
seasonal_decompose适合入门和快速诊断,数据复杂时可以考虑 STL 或 MSTL。
整体来说,时间序列分解不是预测模型,而是一个诊断工具。它不复杂,但很实用。
把原始曲线拆开之后,很多之前看不明白的波动,就能分清是趋势、周期还是噪声。