简介:面向有一定Python基础、希望掌握气候数据预测的时间序列分析初学者,这套实战内容围绕趋势与季节性两个核心维度,结合Pandas、statsmodels、Matplotlib等常用库,系统演示了移动平均提取趋势、STL季节分解、ARIMA/SARIMA建模、预测效果评估等多个关键环节,并强调了数据切分与误差指标的理解。包体约2.95MB,共9个文件,以3个Jupyter Notebook案例为主,配有一个每日最低气温csv数据集和少量IDE配置,打开后可按案例顺序运行,逐步复现时间序列分析流程。目前已有528人学习下载;资源同时覆盖温度数据集与合成数据集,便于对照不同趋势和季节强度下模型的表现。通过动手操作,读者不仅能掌握时间序列预测的通用流程,还能学习SARIMA参数调优、MSE/RMSE/MAE等误差计算,以及网格搜索等优化方法,为气温、降雨等气象预测场景提供可复用的分析模板。
1. 基于趋势和季节性的时间序列预测:这份 rar 里的完整建模路线
拿到一份时间序列预测的实战资源,最怕的是它上来就让你调 LSTM,调完也不知道结果为什么长这样。这份基于趋势和季节性的时间序列预测实战 rar 走的完全是另一条路线:先用合成数据把趋势和季节性两个核心成分讲透,再用一份真实的逐日气温数据跑通建模全流程。它解决的是气候预测里最经典的那类问题——数据有明显年度周期,想预测未来走势,又不希望结果是个黑匣子。适合刚做完入门课程、想动手复现完整流程的人,也适合用统计模型做环境、能源、零售销量预测的从业者。整份资源不依赖深度学习框架,核心工具就是 pandas、statsmodels 和 matplotlib。
2. 拆开资源看门道:两个 Notebook、一份气象数据与趋势/季节性分解
2.1 解压后先分清主次:哪些文件值得看,哪些是 IDE 留下的噪音
一个 rar 解压之后,别急着打开 ipynb 就跑。这份资源里同时混着两类东西:真正要研究的建模代码,和 PyCharm 打开项目时自动生成的工程文件。如果不先分清主次,很容易把注意力浪费在无关文件上。
| 路径 | 类型 | 用途与优先级 |
|---|---|---|
| modeling_synthetic_dataset.ipynb | Jupyter Notebook | 合成数据上演示趋势与季节性建模,核心 |
| modeling_temperature_dataset.ipynb | Jupyter Notebook | 真实气温数据全流程建模,核心 |
| data/daily-minimum-temperatures.csv | CSV | 墨尔本 1981-1990 逐日最低气温,核心数据 |
| .idea/ 、*.iml 、workspace.xml 等 | IDE 配置 | PyCharm 解析项目用的缓存,可忽略 |
| .ipynb_checkpoints/ | Jupyter 缓存 | Notebook 自动保存的副本,可忽略 |
这份资源在结构设计上有个很实用的安排:它刻意用两个 Notebook 把「原理」和「实战」分开。合成数据 Notebook 里,数据是人为构造的,趋势项和季节项在生成时就已知,你在结果里能精确看到分解是否还原了真相;真实数据 Notebook 里再套用同一套流程,从读 CSV 到出预测图全部走一遍。这种「先验已知 → 未知实测」的学习顺序,比直接拿真实数据硬调参稳得多——当结果不对劲时,你能判断题出在模型还是出在数据。
把气象数据读进来,最顺手的方式是这样:
import pandas as pd df = pd.read_csv( "data/daily-minimum-temperatures.csv", parse_dates=["Date"], index_col="Date", ) df.columns = ["temp"] print(df.head()) print(df.index.min(), df.index.max(), len(df))这里parse_dates=["Date"]把日期列解析成DatetimeIndex,index_col让时间直接成为索引,这是 pandas 时间序列管线的第一步。打印出的首末日期和行数能快速确认数据范围与量级,避免后面建模时才发现索引类型不对。如果你的本地 CSV 编码和资源里的不一致,读取时可能报UnicodeDecodeError,这是 Python 项目常见的开局问题,加encoding="utf-8"多半能解决。
2.2 趋势提取:滚动均值并不是万能的趋势线
趋势识别最常见的手段是移动平均,pandas 里一句话就能算:
window = 365 # 窗口对齐到一年,把季节波动平均掉 df["trend_365"] = df["temp"].rolling(window=window, min_periods=30).mean() df["trend_30"] = df["temp"].rolling(window=30).mean()window=365的滚动均值在气温数据里意味着把一整年的日温度取平均,冬季低温和夏季高温在窗口内互相抵消,留下来的就是年际之间的长期变动。min_periods=30允许窗口开头部分不完整时也输出有效值,减少头部 NaN 的损失。
我一般不用窗口太短的均值代表趋势,比如 30 天窗口对日温度来说更多是短期噪声平滑,谈不上趋势。趋势的定义和季节周期是绑定的,趋势窗口至少要覆盖一个完整季节周期,这是新手最容易踩到的概念误区之一。另外滚动均值自带边界问题:序列头尾各会损失约 window/2 长度的数据,而预测任务恰恰关心尾部,所以滚动均值只适合观察趋势形态,不适合直接外推。更推荐的做法是把它当诊断工具,真正建模时用 STL 分解输出的趋势分量,或者把趋势直接交给 ARIMA/SARIMA 去拟合。
2.3 STL 分解:把序列拆成趋势、季节、残差三层
要拆开季节性,statsmodels 的 STL 是当前最常用的选择:
from statsmodels.tsa.seasonal import STL stl = STL( df["temp"], period=365, # 季节周期:逐日气象数据取 365 robust=True, # 对极端气温鲁棒 seasonal=13, # 季节分量平滑窗长(奇数,越大季节曲线越平滑) ) res = stl.fit() res.plot()STL(Seasonal-Trend decomposition using LOESS)用局部加权回归把序列拆成res.trend、res.seasonal、res.resid三个分量。period=365告诉算法季节循环的长度;robust=True让寒潮、热浪这类极端温度值不至于把局部分量拉偏,这在气象数据里很实用。
这里有个细节经常让人绕晕:STL接收的是period而不是seasonal周期数。seasonal参数反而是季节分量的平滑窗长,必须是奇数。窗口太小,季节分量会追着噪声跑;窗口太大,季节分量又会把趋势的渐变吞掉。对逐日气温数据,一般从seasonal=13试到seasonal=31。分解完成后,res.resid的长度会因 LOESS 拟合的边界效应而缩短,拿去跑检验统计量时自由度对不上是正常的,不必惊慌。STL 对缺失值很敏感,分解前务必确认序列没有 NaN,否则fit()会直接报错或给出异常分量。
3. 从平稳性到 SARIMA:把日最低气温预测跑通的核心三步
3.1 平稳性检查:ADF 检验通过不代表万事大吉
ARIMA 类模型的基本前提,是把序列处理成平稳的,至少是趋势平稳的,所以第一步是检验平稳性。
from statsmodels.tsa.stattools import adfuller res_adf = adfuller(df["temp"].dropna()) print(f"ADF statistic = {res_adf[0]:.3f}, p-value = {res_adf[1]:.4f}") if res_adf[1] < 0.05: print("拒绝单位根原假设,可视为平稳序列") else: print("无法拒绝单位根,需要差分")adfuller的 p 值小于 0.05,说明在 5% 显著性水平下可以拒绝「存在单位根」的零假设,序列可视为平稳。但这条结论在气温数据上特别容易误导人:日最低气温有很强的年度季节性,ADF 检验只检测普通单位根,并不会因为季节循环就必然判非平稳。你完全可能得到 p=0.00 的结果,然后直接去拟合一个不带季节项的 ARIMA,最后预测出一条缓慢漂移的直线。
所以我的习惯是:对气象数据这类有明显周期的序列,ADF 检验只用来决定差分阶数 d,季节性阶数 D 要单独判断。判断方法有两个——一是看 STL 分解出来的 seasonal 分量振幅是否稳定;二是对序列做季节性差分后再跑一次 ADF。若季节性差分后显著平稳,说明D=1是有必要的。
3.2 时间序列分割:不要碰随机 shuffle
训练模型之前,先把数据切分好。时间序列切分和普通机器学习最大的差别,是必须保证时间连续性,不能用随机抽样,否则未来信息会泄漏进训练集,让指标虚高。
def split_time_series_sets(df, train_ratio=0.8, valid_ratio=0.1): n = len(df) train_end = int(n * train_ratio) valid_end = int(n * (train_ratio + valid_ratio)) train = df.iloc[:train_end] valid = df.iloc[train_end:valid_end] test = df.iloc[valid_end:] return train, valid, test这个函数按行占比切出训练、验证、测试三段,默认 8:1:1。注意参数含义:train_ratio=0.8不是随机抽 80% 的数据,而是取前 80% 的时间范围;valid_ratio=0.1是紧邻其后的 10%。我一般会把验证集和测试集都设成最后一段连续时间,因为预测业务天然关心的是「最近的未来」,而不是「中间的某一段」。
如果你在别人的代码里看到对时间序列用train_test_split(..., shuffle=True),要警惕指标虚高。时间序列的自相关结构决定了相邻样本高度相似,随机切分会让模型在训练时提前见到验证集附近的点,验证 RMSE 会明显比真实滚动预测乐观。
3.3 拟合 SARIMA:日粒度数据别硬跑
现在到了核心部分——用 SARIMA 建模。这里有个几乎每个人都会遇到的效率问题:日粒度数据配上seasonal_order里的s=365,状态空间模型的规模会膨胀到难以拟合。温度序列的年度周期是 365,但 SARIMA 的季节周期参数不只是「一个周期长度」,它直接决定模型内部状态的维度。
我一般先把数据降到月粒度再建模,这样 s=12,训练时间和模型规模都在合理范围。
# 重采样到月粒度,用月均最低温 monthly = df["temp"].resample("M").mean().dropna() from statsmodels.tsa.statespace.sarimax import SARIMAX model = SARIMAX( monthly, order=(1, 1, 0), # 非季节 (p, d, q) seasonal_order=(1, 1, 0, 12), # 季节 (P, D, Q, s) enforce_stationarity=False, enforce_invertibility=False, ) res = model.fit(disp=False) print(res.summary())order=(1,1,0)表示非季节部分用 1 阶自回归和 1 阶差分,不含滑动平均项;seasonal_order=(1,1,0,12)表示季节部分同样用 1 阶自回归、1 阶季节差分,季节周期为 12 个月。enforce_stationarity=False和enforce_invertibility=False是给参数估计留余地,避免在搜索边界上直接报错,但正式报告模型时我会改回 True 再跑一遍确认。disp=False关闭拟合过程的迭代日志,做网格搜索时几十个模型的日志会把 Notebook 输出区淹没。
提示:pandas 2.2 之后
resample("M")会给出弃用警告,新写法是resample("ME"),语义相同。
拟合之后,预测分两步:先用get_forecast拿到未来 12 个月的预测和置信区间,再作图。
import matplotlib.pyplot as plt fc = res.get_forecast(steps=12) pred = fc.predicted_mean ci = fc.conf_int(alpha=0.05) plt.figure(figsize=(10, 4)) plt.plot(monthly.index, monthly.values, label="observed", color="tab:blue") plt.plot(pred.index, pred.values, label="forecast", color="tab:red") plt.fill_between( pred.index, ci.iloc[:, 0], ci.iloc[:, 1], color="tab:red", alpha=0.15, ) plt.legend() plt.show()get_forecast(steps=12)产生的是多步滚动预测,注意它不是重新估计模型,而是用已拟合的参数迭代推出 12 个点。conf_int(alpha=0.05)给出 95% 置信区间,温度数据预测 12 个月后区间会很宽,这是正常的——它提醒你点预测只是条件均值,不是确定答案。
有一点要特别说明:如果把steps拉长到 24 或 36,预测曲线会逐渐收敛到序列的条件均值附近,这是 ARIMA 类模型的固有行为,不是代码写错了。想验证模型的真实预测能力,要去看验证集上的滚动预测,而不是盯着一张外推图感叹曲线太平。
4. SARIMA 调参与避坑:网格搜索范围与四条高频踩坑记录
4.1 网格搜索:先固定 D,再把搜索范围压缩到 81 个组合以内
SARIMA 参数表面上看只有 6 个(p,d,q,P,D,Q),但 d 和 D 一旦定了,剩下的 4 个参数每个在 0~2 之间取,就是 81 个组合。如果取 0~3,就是 256 个组合,配合月粒度数据每个模型拟合十几秒,一个网格搜下来一小时就没了。我的习惯是先固定差分阶数,再搜小范围。
import itertools import warnings warnings.filterwarnings("ignore") best_aic, best_cfg = float("inf"), None for p, q, P, Q in itertools.product(range(3), repeat=4): try: m = SARIMAX( monthly, order=(p, 1, q), seasonal_order=(P, 1, Q, 12), enforce_stationarity=False, enforce_invertibility=False, ).fit(disp=False) if m.aic < best_aic: best_aic, best_cfg = m.aic, (p, q, P, Q) except Exception: continue print(f"best AIC = {best_aic:.2f}, best (p,q,P,Q) = {best_cfg}")这里用itertools.product(range(3), repeat=4)遍历 p、q、P、Q 在 0、1、2 之间的所有组合,d 和 D 固定为 1。try/except是为了跳过参数估计时数值发散导致LinAlgError的组合——这不是掩盖问题,而是网格搜索里常见的防御性写法。真正出报告时,我会把所有失败的组合单独列出来,看是不是集中在某个参数区间。
选模型的标准用 AIC 而不是 R²。AIC 惩罚参数数量,倾向更简洁的模型,对预测任务更合适。如果你关心季节周期是否被充分建模,可以额外加一条检查:如果最优模型的季节滑动平均阶数 Q 恒为 0,说明季节自相关已经被季节差分处理掉了,这是正常的。
| 参数 | 含义 | 搜索范围建议 |
|---|---|---|
| d | 非季节差分阶数 | 0~1,ADF 不平稳再取 1 |
| D | 季节差分阶数 | 0~1,季节分量不稳取 1 |
| p / q | 非季节 AR / MA 阶数 | 0~2 |
| P / Q | 季节 AR / MA 阶数 | 0~2 |
| s | 季节周期 | 月数据 12,周数据 7,日数据酌情降采样 |
这个表值得贴在 Notebook 旁边:时间和精力永远优先花在 d 和 D 上,p、q、P、Q 差不多就行,因为它们对预测结果的边际影响远小于差分和季节设定的影响。
4.2 四条高频踩坑记录:现象、原因、对策
坑一:日粒度数据直接拟合 s=365,程序卡死或内存暴涨
现象:SARIMAX(... seasonal_order=(1,1,0,365)).fit()跑了几十分钟没有输出,CPU 持续拉满,部分机器直接报 MemoryError。 原因:季节周期 s 在状态空间模型中直接决定系统状态的维度。s=365 意味着状态空间里要维护 365 个季节相关变量,状态矩阵规模是月粒度(s=12)的数百倍,拟合和滤波的计算量完全不在一个量级。 对策:把数据resample("M").mean()降采样到月度后再建模,s=12;只有非季节部分保留日粒度,或者换用 STL 分解后对季节调整序列做简单 ARIMA。不要跟 s=365 硬刚,这不是调参能解决的问题。
坑二:随机切分训练集导致验证指标虚高,真实预测翻车
现象:用train_test_split(shuffle=True)切数据,验证集 RMSE 很低,模型看起来很好;一上线做真实滚动预测,误差直接翻一倍。 原因:时间序列的相邻样本强相关,随机切分让训练集混入了验证集相邻时段的真实观测,模型无形中提前看到了「未来」的信息。 对策:统一用按时间顺序分割的函数,训练集永远在最前,验证集和测试集在时间上严格靠后;评估时用 walk-forward,每次只用截至当前时刻的数据训练。怕记不住的话,给自己立个规矩:凡是在时间序列上写了shuffle=True的,一律打回重审。
坑三:ADF 检验通过、普通 ARIMA 拟合成功,但残差里还有明显年度波动
现象:ADF p 值小于 0.05,order=(1,1,0)的 ARIMA 也能拟合,但残差序列的时序图在每年同期出现波浪形起伏,预测图则是一条直线。 原因:ADF 检验只识别普通单位根,强季节性序列在包含截距加趋势项的回归设定下也可能被判定为平稳,但它并不告诉你有周期成分。忽略季节性的 ARIMA 无法捕捉年度循环,信息全留在残差里。 对策:建模前先跑 STL 分解,如果 seasonal 分量振幅稳定且明显,就不要把 D 设为 0。用 ADF 决定 d,用分解后的季节分量决定 D,两者分开判断。这比「看 ADF p 值定一切」稳得多。
坑四:多步预测收敛成直线,被当成模型坏了
现象:用predict(dynamic=True)外推 24 个月,预测值慢慢变成一条接近常数的线,和「温度应该有季节波动」的直觉严重不符。 原因:多步预测是无反馈递归,预测点会替代滞后项进入下一次计算,误差逐步累积;当预测步数超过有效记忆长度,模型开始输出条件均值,规律上就表现为直线化。这是 ARIMA 类模型的统计性质,不是 bug。 对策:先检查seasonal_order的 s 是否和数据周期匹配(月度数据用 12,别用 365 这种残留值);再看置信区间,区间快速变宽恰恰是模型在诚实表达不确定性;最后用验证集上的滚动预测对比真实观测,而不是盯着一张外推图判断好差。
5. 建模前的两个固定动作:残差白噪声检查与回头预测
5.1 残差白噪声:Ljung-Box 五分钟体检
SARIMA 拟合完,别急着下结论,先看一眼残差是不是白噪声。如果残差里还有结构,说明模型没把信息提取干净。
from statsmodels.stats.diagnostic import acorr_ljungbox lb = acorr_ljungbox(res.resid.dropna(), lags=[12, 24], return_df=True) print(lb)两行的lb_pvalue都大于 0.05,说明残差在 12 阶、24 阶滞后上没有显著自相关,季节性和趋势信息已经被模型吸收。这两行就是模型可信度的快速体检单。
5.2 回头预测:把「猜未来」变成「回测过去」
预测未来没法立即验证,但可以回头验证:把最后 12 个月藏起来,用前面的数据拟合,逐月滚动预测,再和真实值对比。
history = monthly.iloc[:-12].copy() preds = [] for i in range(12): m = SARIMAX(history, order=(1,1,0), seasonal_order=(1,1,0,12)).fit(disp=False) preds.append(m.forecast(1).iloc[0]) history = pd.concat([ history, pd.Series([monthly.iloc[-12 + i]], index=[monthly.index[-12 + i]]) ]) rmse = ((pd.Series(preds, index=monthly.index[-12:]) - monthly.iloc[-12:]) ** 2).mean() ** 0.5这就是 walk-forward 的最简形态:每次只预测 1 步、把真实值补进历史、重拟合,循环 12 次。RMSE 落在 1~2 摄氏度以内,说明月粒度模型在实战尺度上能打;比训练集指标高出一截才是常态,不高反而要怀疑有没有信息泄漏。
从那以后,我每次做时间序列预测,都把残差白噪声检查和回头预测当成两个固定动作,哪怕只是临时评估一个模型,也要走完这两步才敢说结果可信。血泪经验是:跳过这两步省下的十分钟,会在上线之后用一个不眠之夜还回来。希望这两个动作能帮到你,也帮你少踩几次我当年踩过的坑。
本文还有配套的精品资源,点击获取