简介:围绕时间序列预测中的趋势与季节性分析,这份实战资源包面向具备一定基础的数据分析与气候预测学习者,通过真实案例掌握基于自回归移动平均族模型的预测建模方法,全程使用Python实现。资源共九个文件,包含三个Jupyter笔记本、一个气温数据集、四个工程配置以及一个模块描述文件,总体积约二点九五兆,轻量易用。笔记本分别基于合成数据集和真实每日最低气温数据集进行建模,完整覆盖趋势与季节性分解、模型选择与训练、误差评估及结果可视化等环节,并示范了自回归与季节自回归模型的使用差异;气温数据可直接替换输入,配套工程配置便于在编辑器中还原开发环境。已有五百二十八人学习使用,适合希望将时间序列理论落到代码实践、并能进一步拓展到温度与降雨量等气候指标预测场景的读者。
1. 时间序列预测,难点从来不在模型而在数据拆解
做时间序列预测的同行应该都有同感:模型本身翻不出太多花,真正决定预测上限的,是你能不能把趋势、季节性、循环和噪声四类成分从原始序列里干净地拆出来。这份「基于趋势和季节性的时间序列预测实战.rar」压缩包,走的正是这条路线——用一套合成数据集和一份真实的最低温度日数据,从 Pandas 的滑动窗口开始,一路走到 STL 分解、SARIMA 建模和网格寻参,全程用 Python 和 statsmodels 落地。适合刚入门气候预测、想把时间序列四成分搞清楚的新手,也适合已经用 ARIMA 做过几个项目、但始终没把季节性拆明白的熟手。压缩包里除了两个 .ipynb 主笔记,还带了 daily-minimum-temperatures.csv 这份经典温度数据和 .idea 工程配置,打开就是能跑的完整工程。
2. 先看懂数据再谈建模:两个数据集的差异与可视化
2.1 压缩包里的核心文件,分别负责什么
解压后先别急着跑代码,把文件结构过一遍。.ipynb两个笔记是主角:modeling_synthetic_dataset.ipynb用构造出来的合成序列讲原理,modeling_temperature_dataset.ipynb用真实温度数据走完整流程。.idea目录、workspace.xml、misc.xml这类是 PyCharm 的工程配置,和算法无关,直接忽略;.ipynb_checkpoints是 Jupyter 的自动备份目录,也跳过。
我一般拿到这类包的习惯是:先打开daily-minimum-temperatures.csv确认数据规模,再分别扫一眼两个笔记的 cell 结构。合成数据集的笔记重点看「构造逻辑」——它是用np.random叠加趋势项、季节项和噪声项生成的,理解这段构造代码,就等于拿到了拆解真实数据的钥匙。温度数据集的笔记则更接近实战,里面出现了pd.to_datetime、按年/月重采样、滚动均值这些操作,这些才是气候预测里天天要用的基本功。
2.2 用 Pandas 加载温度数据,先查索引再可视化
温度数据是典型的日粒度单变量序列,第一列是日期,第二列是最低温度。加载代码不长,但有个习惯建议从一开始就养成:日期列转成DatetimeIndex后用sort_index()排序,再做任何操作。
import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv("daily-minimum-temperatures.csv", header=0, names=["date", "temp"]) df["date"] = pd.to_datetime(df["date"]) df = df.set_index("date").sort_index() print(df.shape) print(df.index.min(), df.index.max()) df["temp"].plot(figsize=(12, 4), title="Daily Min Temp") plt.show()header=0, names=[...]是显式指定列名,避免原始 CSV 第一行被误读为表头;set_index之后所有按时间的切片、重采样、滞后操作才能正常工作。print出首尾日期,目的是确认数据覆盖范围——这份数据从 1981 年到 1990 年,十年日粒度,足够观察年季节性。直接 plot 全量数据,密集的锯齿形曲线会淹没细节,但这张图本身就有价值:你能直观看到每年的波峰波谷基本对齐,这就是季节性的原始证据。
2.3 趋势和季节性分开看:滚动均值与重采样的配合
全量曲线太密,肉眼很难同时判断趋势和季节性。常见做法是用 Pandas 的滚动窗口算出趋势线,再用按月重采样把季节性压平。
trend = df["temp"].rolling(window=365, center=True).mean() df["temp"].plot(figsize=(12, 4), alpha=0.4, legend=True) trend.plot(figsize=(12, 4), linewidth=2, legend=True) plt.title("Raw vs 365-day Rolling Mean") plt.show() monthly = df["temp"].resample("M").mean() monthly.plot(figsize=(12, 3), marker="o", markersize=3) plt.title("Monthly Mean Temperature") plt.show()rolling(365).mean()是把一年当作窗口求局部平均,窗口内的高频波动被抹平,剩下的曲线就是趋势成分的直观体现——注意这份温度数据的趋势并不明显,十年里基本在水平线附近震荡,靠肉眼只能看出季节波动。resample("M").mean()把日数据压成月均值,每个月一个点,季节性就成了一年内的正弦型起伏。走到这一步,你已经完成了「人工版 STL 分解」:滚动均值充当趋势提取器,月度重采样充当季节性观察器。这也解释了为什么压缩包里的笔记要把这两段放在建模之前——先确认数据里存在可建模的季节模式,再去谈 SARIMA 参数。
3. 趋势与季节性分解:STL 才是真正干活的工具
3.1 为什么不用简单加法分解,而要用 STL
经典加法分解把序列拆成趋势 + 季节性 + 残差,statsmodels 里的seasonal_decompose一行就能跑。但它的季节性假设是「每个周期的季节模式固定不变」——对温度数据来说,这意味着每年冬天的形状都一模一样,这显然和真实气候不符。STL(Seasonal and Trend decomposition using Loess)则允许季节性随时间缓慢变化,周期形状可以逐年微调。这份压缩包的温度笔记选用 STL,背后是经过考量的:真实气象数据受厄尔尼诺、城市热岛等因素干扰,季节模式并非严格重复,STL 的稳健拟合能把这些异常值对趋势的拉扯降到最低。
用 STL 做分解时,周期参数必须写对。日数据的周期是365,月度数据是12,周数据是7。写错周期,分解结果会很难看:周期设小了,季节性分量里会混入高频噪声;周期设大了,季节性会把趋势的拐点吃掉一半。
from statsmodels.tsa.seasonal import STL res = STL(df["temp"], period=365, robust=True).fit() fig = res.plot() fig.set_size_inches(12, 10) plt.show()robust=True是 STL 的内置稳健模式:迭代拟合时给极端残差降权,温度记录里偶尔出现的异常低温日不会把趋势线拽偏。fit()返回的对象包含trend、seasonal、resid三个分量,plot()直接画出四宫格,从上到下是原始序列、趋势、季节性、残差。看这组图要学会读:趋势分量如果接近直线,说明长期没有明显升温或降温;季节性分量是逐年重复但幅度略有变化的波形;残差分量如果还残留明显的峰谷,说明季节周期没设对,或者数据里还有未建模的周内效应。
3.2 从分解结果里读信号:残差才是后续建模的告密者
分解不是终点,读完图之后要落到三个判断上。第一,趋势是平稳的还是带斜率的——这决定 SARIMA 里差分的阶数d;第二,季节性波形的振幅是恒定还是逐年放大——这决定要不要对季节分量再做一次差分;第三,残差里有没有明显的自相关——如果残差在滞后 7 天或 30 天处仍有尖峰,说明日粒度数据里还存在周效应或月度效应,模型季节项没抓全。
温度数据分解后的残差通常还会有点「记忆性」:今天的异常低温,明天大概率还是偏低。这是因为气温是一个惯性系统,冷空气团不会一天就消散。这种情况下直接对原始序列建模也行,但把残差的自相关结构交给 ARIMA 去拟合,SARIMA 就会变得很臃肿。更稳的路线是:先 STL 分解,对趋势项做线性或差分建模,对季节性项用傅里叶项或虚拟变量拟合,最后对残差单独建一个低阶 AR 模型。这种「拆开分别建模」的思路,正是从seasonal_decompose到STL再到 SARIMA 的进阶路径,也是这份压缩包最想演示的思维方式。合成数据集笔记里大量随机序列测试,目的就是训练你读残差图的能力。
4. SARIMA 建模与参数寻优:从 ACF/PACF 到网格搜索
4.1 差分阶数 d 的判定,以及 ACF/PACF 怎么读
SARIMA 是 ARIMA 的季节扩展,完整参数是(p, d, q) × (P, D, Q, m)。d是普通差分阶数,D是季节差分阶数,m是季节周期长度。拿到一组数据,先判断d:STL 分解出的趋势分量如果是水平的,d=0;有明显斜率,d=1;温度数据做一阶差分后还要看方差是否稳定,如果波动幅度跟着季节走,那大概率还需要D=1。
判定p和q靠自相关图(ACF)和偏自相关图(PACF)。差分后的序列要已经平稳,ACF 拖尾、PACF 在滞后 k 阶截断,p=k;反过来 ACF 截断、PACF 拖尾,q=k。
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf diff_series = df["temp"].diff().dropna() fig, axes = plt.subplots(2, 1, figsize=(12, 6)) plot_acf(diff_series, ax=axes[0], lags=120) plot_pacf(diff_series, ax=axes[1], lags=120) plt.show()lags=120是为了看到 365 天季节周期附近的峰值——只画 30 个滞后,周期信息会被截断。温度数据一阶差分后,ACF 在滞后 7、14、21 附近会有小尖峰,这是周内气象节奏的痕迹;在滞后 365 附近出现显著正峰,说明季节差分D=1是必要的。PACF 在低阶截断得比较干净,通常p取 1 或 2 就够。记住一个原则:ACF/PACF 只是初筛,给出的参数是搜索范围的下限,不要把它当精确答案。
4.2 拟合 SARIMA 模型,参数怎么传
statsmodels 的SARIMAX是当前主力接口,支持外生变量和状态空间表示。温度数据集上用(2, 1, 2) × (1, 1, 1, 12)是月度重采样后的常用起点;如果保持日粒度,季节周期就得写 365,状态空间矩阵会非常大,拟合极慢。这份压缩包的温度笔记实际建议先按月聚合,再上 SARIMA,否则训练时间会是灾难。
from statsmodels.tsa.statespace.sarimax import SARIMAX monthly = df["temp"].resample("M").mean() model = SARIMAX( monthly, order=(2, 1, 2), seasonal_order=(1, 1, 1, 12), enforce_stationarity=False, enforce_invertibility=False, ) fit = model.fit(disp=False) print(fit.summary())enforce_stationarity=False和enforce_invertibility=False这两个参数容易被忽略,实际上很重要。模型拟合时 statsmodels 默认会把 AR 和 MA 多项式强制限制在可逆域内,一旦数据本身接近非平稳,这个限制会导致优化器在边界上反复卡住,拟合时间暴涨。关掉约束让优化器自由探索,拟合稳定后最终结果一般会自然落回可逆区域。disp=False关掉迭代日志,否则终端会被刷屏。运行后先看fit.summary()里的P>|z|列——某个系数显著大于 0.05,说明这个参数可删,对应缩小模型维度。
4.3 用滚动预测替代一次性切分,再用网格搜索收敛参数
时间序列切分和普通机器学习不一样:不能用随机打乱,必须按时间顺序切。这份压缩包里提到的split_time_series_sets,本质上就是让前面的数据训练、后面的数据验证,但我更推荐「滚动预测」:每次推进一个时间步,模型用截至当前的全部数据重拟合并预测下一步。这样更贴近真实使用场景,异常点不会被一次性切分躲过去。
import numpy as np from statsmodels.tsa.statespace.sarimax import SARIMAX history = monthly.iloc[:100] y_true = monthly.iloc[100:120] predictions = [] for i in range(len(y_true)): model = SARIMAX(history, order=(2, 1, 2), seasonal_order=(1, 1, 1, 12)) fit = model.fit(disp=False) pred = fit.forecast(steps=1).iloc[0] predictions.append(pred) history = pd.concat([history, pd.Series([y_true.iloc[i]], index=[y_true.index[i]])]) print(pd.DataFrame({"pred": predictions, "true": y_true.values}).head())滚动预测的每一步都在重新拟合模型,开销大但结果可信。把predictions与真实值对齐,算 RMSE 和 MAE。网格搜索则是在参数候选集上循环,候选集来自 ACF/PACF 的初判范围——比如p取 1 到 3,q取 1 到 2,P取 0 到 1,D取 0 到 1,组合数控制在 30 组以内,每组用滚动预测评估一次。别一上来就全排列几十万组,时间序列模型的拟合成本远高于普通机器学习,搜索范围太大容易把机器拖死。
5. 避坑手册:时间序列预测最容易翻车的五个位置
5.1 日期索引没排序,滞后计算全部错乱
现象:df.shift(1)算出的滞后值和昨天对不上,ACF 图上出现诡异的快速衰减。
原因:CSV 读取后日期顺序并不保证严格递增,set_index后如果没有sort_index(),Pandas 会按原始行序计算滞后。差一天的数据被当成滞后一期,自相关结构直接失真。
解决:统一在加载后立即执行df = df.set_index("date").sort_index(),然后肉眼检查一次df.index.is_monotonic_increasing。这一步应该在所有数据预处理之前完成,做成一个固定的函数也行。从那以后我每次拿到任何时间序列数据,第一件事永远是排序索引,哪怕 CSV 看起来是排好的也不跳过。
5.2 季节周期 m 写错,模型速度和效果双输
现象:日粒度温度数据,直接把seasonal_order写成(1, 1, 1, 365),模型拟合耗时从几秒变成几十分钟,甚至内存溢出。
原因:SARIMA 的季节周期越大,状态空间表示中的状态向量维度越高。365 这个数字对应的是日粒度的年周期,如果你的数据已经重采样成月,m 必须改成 12。最典型的低级错误是月度数据用了 365,或者日数据用了 12——前者慢到怀疑人生,后者模型根本抓不住年周期。
解决:先明确数据的采样频率,再换算季节周期。日数据建议先用月度重采样把问题缩小,SARIMA 里写m=12;必须使用日粒度时,也不要直接上 SARIMA,改用带傅里叶项的SARIMAX,或者干脆换 STL 分解后对残差建模。
5.3 ACF/PACF 在非平稳序列上直接读,参数取错
现象:PACF 图第一根柱子几乎等于 1,后面全部贴着 0,p看着像 0,模型拟合后残差仍然显著自相关。
原因:序列还没做差分,趋势成分残留在自相关函数里,ACF 会呈现典型的「下降极慢」模式,PACF 则被趋势主导。这时候读出的p和q全是假信号。
解决:先对原始序列做diff(),差分一次后重画 ACF/PACF,再读参数。期间配合adfuller做 ADF 检验,p-value大于 0.05 就继续差分,直到平稳为止。注意差分次数别贪多,d超过 2 的模型基本无法解释,也容易过拟合。
5.4 一次性切分数据集,忽视了时间顺序的泄露
现象:随手用train_test_split(test_size=0.2)切分时间序列,测试集的 RMSE 比验证集低一截,上线后预测误差大很多。
原因:普通切分是随机打乱的,时间序列一旦打乱,模型在预测「未来」时其实已经偷看了「未来」的数据分布。短期来看拟合指标虚低,长期来看模型对突变毫无准备。
解决:强制使用按时间顺序切分或滚动预测。最简单的是按位置切分,train = df.iloc[:int(len(df)*0.8)],test = df.iloc[int(len(df)*0.8):];严格场景用滚动预测,每个预测点只使用该点之前的数据,这份压缩包里的split_time_series_sets就是干这个的。
5.5 不看残差白噪声检验,置信区间只是摆设
现象:模型的点预测看起来还行,但预测区间窄得离谱,95% 置信区间几乎贴着预测曲线。
原因:模型把残差里的自相关信息也当成噪声忽略掉了,也就是说模型没有完全提取数据里的信息。违反模型假设的情况下,forecast()给出的区间估计没有意义。
解决:拟合后对残差做acorr_ljungbox检验,p-value 小于 0.05 说明残差仍有时间结构,模型欠拟合,回去调参数或换模型。这是压缩包里最容易漏掉的一步,跳过的话后续所有预测区间都不能信。
6. 一个值钱的进阶技巧:用残差反推模型升级方向
拟合完 SARIMA,常规操作是看残差图,觉得平了就算完事。但残差里往往还藏着一层信息:如果 Ljung-Box 检验明确告诉你自相关还存在,那就别直接调p、q了,先画残差的频谱图。
把残差做一次快速傅里叶变换,看频谱在哪里出尖峰。如果尖峰集中在 365 天、182 天这样的年周期倍数位置,说明季节项没拆干净,最直接的应对是给SARIMAX加上傅里叶项,而不是硬把P从 1 调到 3。傅里叶项能拟合任意形状的周期,比 SARIMA 固定周期的季节差分更灵活。
from statsmodels.tsa.statespace.sarimax import SARIMAX import numpy as np # 把年周期拆成傅里叶项,作为外生变量传入 t = np.arange(len(monthly)) seasonal_fourier = np.column_stack([ np.sin(2 * np.pi * k * t / 12) for k in range(1, 3) ] + [ np.cos(2 * np.pi * k * t / 12) for k in range(1, 3) ]) model = SARIMAX(monthly, exog=seasonal_fourier, order=(2, 1, 2)) fit = model.fit(disp=False) print(fit.summary())这段代码把月周期拆成 4 列傅里叶特征,由模型自己学习季节形状。好处是省掉季节差分项D之后模型状态更小,训练速度更快,对不规则季节模式的适应也比固定周期的 SARIMA 好。加完项之后重跑 Ljung-Box,你会发现残差自相关被明显吃掉一截——如果还残留,再补更高阶的傅里叶项。
这是一个几乎所有时间序列项目都能复用的通用技巧:用残差去指示模型欠拟合的方向,而不是盲调参数。合成数据集笔记里构造的那些带相位漂移的季节信号,就是为了验证这套「STL 拆解 → SARIMA 抓残差 → 傅里叶收尾」的组合拳。从那以后我每次建模都强制自己走一遍残差白噪声检验加频谱分析,这个习惯帮我避开过至少三次「模型指标漂亮、上线即翻车」的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取