时间序列分解:从噪声中提取有效信号

0 阅读10分钟

时间序列数据里,趋势、周期和随机波动经常是混在一起的。

直接看原始曲线,很容易被短期波动带偏,比如把一次促销当成长期增长,或者把季节性回落当成业务衰退。

时间序列分解要解决的就是这个问题,把一条看起来乱七八糟的曲线拆成几个更有解释性的部分,让我们分别看:

  • 长期方向
  • 重复周期
  • 剩下的随机噪声。

本文的目的不是讲一堆公式,而是把时间序列分解这件事讲清楚,并用 Python 和 matplotlib 一步一步演示怎么把有效信号从噪声里拆出来。

希望能讲清楚趋势、季节性、残差分别是什么,知道加法分解和乘法分解怎么选,也能自己跑一遍 seasonal_decompose 并看懂输出图。

1. 为什么要拆开看

时间序列和普通表格数据不太一样,它有时间顺序,当前值往往和过去值有关系。

很多时间序列还会同时受到几种力量影响:整体在缓慢上升或下降,某些月份或星期有固定规律,另外还有一堆随机因素在干扰。

如果直接拿原始序列去建模,模型很可能把季节性波动当成趋势的一部分,或者把噪声当成真实信号。

分解的核心思路很直接:假设原始序列由几个部分叠加或相乘而成,那我们就把它们拆出来。

拆开之后,趋势可以看长期方向,季节性可以看周期规律,残差可以看异常和随机波动。

这样再去做预测、异常检测或者业务判断,会更有依据。

下面这个图,我构建了一个测试数据,直观的演示拆开看与不拆开看的区别。

2. 时间序列分解到底在拆什么

最常见的时间序列分解会把序列拆成三部分:趋势、季节性和残差。

用公式写就是:Yt=Tt+St+RtY_t = T_t + S_t + R_t

这里的 YtY_t 是原始时间序列,TtT_t 是趋势项,StS_t 是季节项,RtR_t 是残差项。

这个公式是加法模型,意思是几部分直接相加。

趋势项代表长期方向。它不关心某个月突然涨了还是跌了,而是看整体是在上升、下降还是基本平稳。

季节性代表固定周期内重复出现的模式。比如月度数据里,每年夏天或每年年底都有类似变化,这种重复规律就是季节性。

残差则是去掉趋势和季节性之后剩下的部分,通常被叫做噪声。但残差并不是完全没用的东西,很多异常点、突发事件、模型没捕捉到的结构,都会留在残差里。

所以时间序列分解不是单纯为了把数据变好看,而是为了把不同来源的变化分开,让我们知道当前波动到底来自长期趋势、固定周期,还是随机干扰。

3. 加法还是乘法

刚才的公式是加法模型。它假设季节性波动的幅度基本稳定,不随趋势水平变化。

比如不管销售额是 500 还是 1000,季节性上下波动的绝对值都差不多,那就适合加法分解。

另一种常见模型是乘法模型:

Yt=Tt×St×RtY_t = T_t \times S_t \times R_t

乘法模型假设各部分是相互影响的,季节性波动的幅度会随着趋势水平成比例变化。

比如销售额越高,季节性波动也越大,旺季更旺、淡季也更明显,这种情况用乘法模型更合适。

实际判断时,可以看原始序列的波动幅度是不是随着整体水平一起变大。

如果是,优先考虑乘法;如果波动幅度比较稳定,用加法。

还有一种常见做法是先对数据取对数,把乘法关系转成加法关系,再做加法分解。

4. 代码实战

下面进入实战。这里我们生成一组可控的月度销售数据,方便验证分解结果。

数据里包含三个部分:线性上升的趋势、按月份重复的季节性,以及随机噪声。

这样做的好处是,我们提前知道信号长什么样,拆完之后更容易判断分解是否合理。

后面的代码是接着一步步运行的,不是一次性全部跑完。每跑完一小段,我们就先看一眼当前拆出来的东西。

4.1 先把原始序列画出来

先导入库,生成数据,然后只看原始序列。这个阶段趋势、季节性、噪声全都混在一起。

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,对应趋势项。

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。

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 个月重复同样的模式。它不再有长期上升或下降,只剩下固定周期规律。

为了更直观地看它有没有抓准周期,可以再把“原始序列减趋势”的结果和季节项放在一起:

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。

这部分可以理解成随机噪声,也可以用来找异常点。

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 把拆出来的部分汇总到一张图

前面是分步看,最后再把原始序列、趋势、季节性、残差放到一张四联图里,方便整体对比。

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. 怎么读分解结果

分解完成之后,最重要的不是图好不好看,而是每一部分能不能解释得通。

趋势项回答的是“长期往哪走”。如果趋势持续上升,说明整体需求或业务规模在增长;如果趋势走平甚至下降,就要进一步看是周期问题还是结构问题。

季节项回答的是“固定周期内怎么重复”。它可以帮助做季节性调整。

比如把原始序列减去季节项:Yt−St=Tt+RtY_t - S_t = T_t + R_t

得到的就是季节调整后的序列。

这个序列去掉了固定周期影响,更适合比较不同月份或季度的真实变化。

残差项回答的是“还有什么没被解释”。残差接近随机噪声,说明趋势和季节性已经把主要结构提取出来了。

如果某些点的残差特别大,可能就是异常值。

实际使用时,可以算残差的标准差,把超过两倍或三倍标准差的点标出来重点检查。

需要强调的是,时间序列分解本身不是预测模型。

它更像一个诊断工具,帮助你理解数据结构。理解清楚之后,再去做 ARIMA、指数平滑、Prophet 或者机器学习预测,会更有方向。

6. 总结

时间序列分解说到底就是把原始序列拆成趋势、季节性和残差三部分,分别理解长期方向、固定周期和随机波动。

实际用的时候,有几点需要注意:

  1. 周期长度一定要设对,月度用 12、季度用 4、周度用 52,这是最基本的
  2. 数据最好有两个以上完整周期,否则趋势和季节性的估计会不稳。
  3. 缺失值也要先处理,因为 seasonal_decompose 对缺失值比较敏感。
  4. 至于加法还是乘法,主要看波动幅度是不是随整体水平一起放大,拿不准就两种都试,比较残差是不是更接近随机噪声。
  5. seasonal_decompose 适合入门和快速诊断,数据复杂时可以考虑 STL 或 MSTL。

整体来说,时间序列分解不是预测模型,而是一个诊断工具。它不复杂,但很实用。

把原始曲线拆开之后,很多之前看不明白的波动,就能分清是趋势、周期还是噪声。