ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

趋势与季节性时间序列预测:STL分解实战与Python实现

2026/9/28 5:41:43 拓冰建站 浏览量
趋势与季节性时间序列预测:STL分解实战与Python实现 简介面向Python气候预测与时间序列分析学习者的实战压缩包聚焦趋势与季节性两个核心成分帮助解决温度、降雨等气象指标的可解释预测与科学决策问题也适合作为相关课程或竞赛的入门练习。压缩包共9个文件以3个Jupyter Notebook为核心另含1个CSV格式的每日最低气温数据集与5个XML/IML工程配置文件整体仅2.95MB轻量易下载。Notebook从合成数据集与真实温度数据集双线展开完整覆盖移动平均提取趋势、STL季节性分解、ARIMA/SARIMA建模、训练验证集切分、MSE/RMSE/MAE指标评估、网格搜索调参与Matplotlib可视化并附有.idea工程配置便于复现调试可直接在Jupyter中运行。目前已有528人学习下载适合具备Python基础、希望快速上手气候时间序列预测的初学者和中阶数据分析者对农业规划、能源管理和灾害预警也有参考价值。1. 从一份“实战.rar”说起趋势与季节性不是玄学是可分解的工程打开这份“基于趋势和季节性的时间序列预测实战.rar”你大概率会看到几段 Python 脚本、一份业务数据可能是电商日销售额、机房流量或工厂能耗以及一份勉强能跑的预测结果。我第一次接触这种项目时也以为“趋势”和“季节性”是两个需要靠感觉去“悟”的概念后来才发现它们只是加性模型和乘性模型里的两个可计算项——趋势是长期单调方向季节性是以固定周期重复的波动。把这两项拆出来预测就不再是黑匣子。这篇文章按我自己的落地路径来写先讲清楚 STL 分解和趋势/季节项怎么算再给出能直接抄的 Python 实现最后把真正耽误时间的几个坑周日效应、节假日突变、窗口长度一次性说透。适合刚接手预测需求的数据分析师也适合想把 statsmodels 用明白的 Python 工程师。2. 把序列拆成趋势、季节和残差为什么 STL 是首选以及什么情况该换模型2.1 加法模型与乘法模型先判断你的序列是“绝对波动”还是“相对波动”任何趋势季节性的预测第一步不是建模而是判断序列的波动形态。常见做法是把序列拆成三个部分趋势项、季节项、残差项。加法模型写作y Trend Seasonal Residual适用于季节波动幅度不随趋势水平变化的情况乘法模型写作y Trend × Seasonal × Residual适用于波动幅度随水平成比例变化的情况——比如客单价 100 元时节日波动 20 元、客单价 500 元时节日波动 100 元这就是乘性。判断方法我一般不看统计检验直接看趋势图和残差图。先画原始序列的移动平均线再看不同年份同月份的波动宽度。如果峰谷高度随整体水平抬升而变大优先用乘法或 Box-Cox 变换后再走加法。一个更省事的替代方案是直接对原始序列取 log多数乘性序列在 log 域里变成可加性的然后用加法模型处理。实际项目中电商日销售额我 90% 的情况会取 log因为大促日的销量波动确实和平时不是一个量级。提示不要一上来就上 Prophet 或 LSTM。趋势季节性的预测先做分解看残差残差若接近白噪声简单模型就够了残差里还有结构再考虑上复杂模型。2.2 STL 分解的原理与关键参数Loess 平滑、内循环与外循环STLSeasonal-Trend decomposition using Loess是 Cleveland 在 1990 年提出的方法也是我个人处理日粒度数据的第一选择。它比 X-13ARIMA-SEATS 更灵活能处理任意周期长度不限于月度、季度而且对缺失值容忍度较高。核心思路是用 Loess 局部加权回归做两次平滑一次提取趋势一次提取季节成分再通过内循环迭代让二者收敛外循环则用来降低异常值对分解的影响。参数决定成败。period是季节周期长度日数据我给 7周季节或 365年季节月数据给 12。seasonal是季节项平滑窗口它控制季节成分随时间的演变速度默认值 7 意味着季节项在约 7 个周期内可以发生明显变化——如果你的业务季节模式稳定这个值可以调到 15 或 21。trend是趋势项的平滑窗口经验值是period的 1.5 到 2 倍但不能超过序列长度的一半。low_pass是低通滤波窗口取大于等于seasonal的最小奇数。import pandas as pd import numpy as np from statsmodels.tsa.seasonal import STL # 假设 df 是日粒度数据含 date 和 sales 两列 df df.set_index(date).asfreq(D).fillna(methodffill) # 取 log 将乘法模型近似转成加法模型 df[log_sales] np.log1p(df[sales]) stl STL( df[log_sales], period7, # 周季节周期 seasonal15, # 季节项平滑窗口季节模式较稳定时调大 trend13, # 趋势项平滑窗口至少大于 period low_pass17, # 低通窗口取奇数且通常 seasonal robustTrue # 外循环开启降低节假日等异常点的影响 ) result stl.fit() df[trend] result.trend df[seasonal] result.seasonal df[resid] result.resid这段代码里几个参数值得展开说。robustTrue是我在电商数据上的固定配置因为大促日生成的极端值如果不做降权会让趋势项在促销日附近出现一个不自然的尖峰。fillna(methodffill)预处理缺口的理由很简单STL 对缺失值不友好虽然它不报错但会在缺口处产生扭曲的平滑结果。如果缺口超过 3 天我倾向先把缺口天数记录下来用插值补一段而不是直接往前填充——前面填充会把一段平台期硬画成水平线。2.3 读懂分解图判断趋势、季节与残差是否“干净”的三个指标分解完成后验证工作看三张图原始序列与 trend 叠加图、seasonal 周期剖面图、resid 直方图与自相关图。第一张图里trend 线应当光滑且不跟随季节波动。如果 trend 线里出现锯齿说明trend参数取小了如果 trend 把季节峰谷也吞进去了则取大了。第二张图把 seasonal 序列按周期切片叠加应该看到 7 条曲线日数据周周期时形状一致、振幅相差不大。振幅不一致说明季节模式在随时间漂移需要调小seasonal/trend窗口来增强局部适应性。第三张图里resid 的均值应接近 0直方图大致对称ACF 图中 lag-7 和 lag-14 如果还有显著相关性说明季节项没抽干净需回查参数。import matplotlib.pyplot as plt from statsmodels.graphics.tsaplots import plot_acf fig, axes plt.subplots(3, 1, figsize(12, 10)) # 趋势 原始序列叠加 axes[0].plot(df[sales], colorgray, alpha0.6, labelraw) axes[0].plot(np.expm1(df[trend]), colorred, labeltrend) axes[0].legend() # 季节项按周切片叠加 seasonal_pivot pd.DataFrame({ i: df[seasonal].iloc[i::7].values for i in range(7) }) axes[1].plot(np.expm1(seasonal_pivot), alpha0.5) axes[1].set_title(Weekly seasonal profiles) # 残差自相关 plot_acf(df[resid].dropna(), axaxes[2], lags28) plt.tight_layout()# 残差基本统计 print(df[resid].describe()) print(ACF at lag 7:, df[resid].autocorr(lag7)) print(ACF at lag 14:, df[resid].autocorr(lag14))注意我回代到原始量纲时用的是np.expm1()而不是np.exp()因为建模时用了log1p。这是新手最容易弄错的地方log 变换的对偶操作必须一一对应否则预测结果会整体偏移一个常量。ACF 的 lag-7 显著说明周季节项抽取有残留常见原因是周五和周一的季节形态差异过大一个固定季节项不足以描述这时可以考虑把周末单独建一个虚拟变量模型。3. 用 statsmodels 做趋势季节预测从分解到未来 N 天预测的完整链路3.1 趋势项建模线性回归、Hodrick-Prescott 滤波还是局部加权回归趋势项提取出来后需要对 trend 序列做外推。三种常见做法各有利弊线性回归最简单但趋势线只在单调区间内可信Hodrick-Prescott 滤波对端点敏感最近几个点的趋势值会被压平或抬高Loess 本身不支持外推只能用于内插。我一般这样选如果趋势项在最近 90 天近似线性直接做带鲁棒性的线性回归如果趋势明显呈 S 型或增长放缓则先用分段线性回归仅在最后一段上做外推。from sklearn.linear_model import HuberRegressor # 只取最近 180 天的趋势项做拟合 trend_series df[trend].dropna().iloc[-180:] X np.arange(len(trend_series)).reshape(-1, 1) y trend_series.values # Huber 回归对趋势项里的残余毛刺不敏感 huber HuberRegressor(epsilon1.35, alpha0.0001) huber.fit(X, y) # 预测未来 30 天的趋势值 future_idx np.arange(len(trend_series), len(trend_series) 30).reshape(-1, 1) future_trend huber.predict(future_idx)epsilon1.35是 Huber 回归的默认阈值含义是残差超过 1.35 倍标准差就按线性损失处理相当于给异常点降权。alpha0.0001是 L2 正则系数调大趋势线会更平滑但可能欠拟合。这里刻意不直接对未来预测覆盖全序列而是把趋势建模和季节建模分开——原因是趋势项对未来 30 天的外推只需要最近一段的局部信息太早的历史反而会把早期增长期拉进来导致预测偏平。3.2 季节项外推直接用周期拼接还是滚动再分解季节项的外推最简单也最可靠的方案是向后取模拼接如果预测未来 30 天且周期为 7就把最近一个完整周期的季节值复制 4 周再截断。这个方案的前提是季节模式在短期内稳定适用大多数滚动预测场景。另一种方案是每到一个新周期就重新跑一次 STL让季节项动态更新但计算开销大且延迟高不适合逐日批处理。实际项目里我常用下面的函数。它取最近一个周期的季节项均值作为未来季节值并对周内各天做一次轻量平滑。这样做的好处是避免把最近某天的异常季节值原样复制到下个月。def extrapolate_seasonal(seasonal_series: pd.Series, periods_ahead: int, period: int 7): # 取最近一个完整周期的季节项 last_cycle seasonal_series.iloc[-period:].values # 未来季节项按周期重复 n_repeat int(np.ceil(periods_ahead / period)) future_seasonal np.tile(last_cycle, n_repeat)[:periods_ahead] return future_seasonal future_seasonal extrapolate_seasonal(df[seasonal], periods_ahead30, period7)np.tile是向量化复制效率高且实现清晰。这里不推荐对季节项做 ARIMA 建模因为季节项已经被 STL 抽取过一轮残差里再做 ARIMA 的边际收益很低反而增加模型复杂度。真遇到季节形态漂移的业务优先处理方式不是改进外推而是缩短训练窗口——比如只用最近 8 周的数据做 STL而不是用全历史。3.3 最终预测合成与区间估计点预测为什么必须加残差把未来趋势和未来季节加回得到 log 域的点预测后必须加回残差的分布信息才能给业务输出区间。残差通常不是正态的尤其是电商日销数据右尾明显偏长。我常用分位数法从历史残差序列里取 10% 和 90% 分位数加到点预测上做上下界。# log 域的点预测 future_log future_trend future_seasonal # 残差分位数区间 resid df[resid].dropna() lo_q np.percentile(resid, 10) hi_q np.percentile(resid, 90) # 回代原始量纲 future_sales np.expm1(future_log) future_lower np.expm1(future_log lo_q) future_upper np.expm1(future_log hi_q) # 输出未来 30 天预测表 forecast_df pd.DataFrame({ date: pd.date_range(df.index[-1] pd.Timedelta(days1), periods30), forecast: future_sales, lower_10: future_lower, upper_90: future_upper })分位数区间用 10% 和 90% 而不是一个标准差原因是残差若有偏标准差区间会把结果做得过于对称而业务侧更关心“最差能差到哪”和“最好能好到哪”。另一个细节是区间是加法叠加的如果序列是乘性主导的log 变换已把乘性摊平成加法所以这个方法在 log 域做区间再回代是自洽的。业务侧如果要求“最大可能值”我会再单独用历史同期去年同周的对应值做一次加权而不是直接调高分位数。4. 给真实业务数据建模时最容易被忽略的三个准备环节4.1 时间索引与缺失日期你以为的空值其实是整行消失真实业务数据最常见的坑不是 NaN而是日期跳过去了。某天没有销量ETL 可能干脆不产出这一行pandas 做分解时不会报错但 STL 把“消失的一天”当成了连续序列中的正常值导致周日和周一之间直接被拉平。解决方法是重索引到连续日期再显式处理缺失。# 将数据按自然日重索引 full_idx pd.date_range(startdf.index.min(), enddf.index.max(), freqD) df df.reindex(full_idx) # 缺失值拆成两类工作日缺失异常和周末缺失正常 df[is_weekend] df.index.dayofweek.isin([5, 6]) # 工作日缺失用前后 3 天中位数插补 working_days df[~df[is_weekend]] working_days[sales] working_days[sales].fillna( working_days[sales].rolling(7, centerTrue, min_periods1).median() ) # 周末缺失用同星期几的历史均值补 for dow in [5, 6]: mask (df.index.dayofweek dow) df[sales].isna() dow_mean df.loc[df.index.dayofweek dow, sales].mean() df.loc[mask, sales] dow_mean这段代码体现了一个原则缺失值处理必须尊重业务周期。周一的数据丢了和周六的数据丢了原因完全不同——周一可能因为上游报表延迟周六则可能是门店休息日。混在一起统一填充会污染季节模式。rolling(7, centerTrue)用前后共 7 天的中位数填充比均值更抗异常值。周末用同日均值填充等于把“周末季节项”当作先验知识用上了。4.2 趋势/季节分解前先验一遍日历效应法定节假日和大促日不全是残差STL 的robustTrue能降低异常值权重但它只能让异常值不污染分解结果不能把这种“可解释的异常”从残差里清理出去。大促日、春节、双十一这类事件的效应是结构性的会年复一年出现。正确的做法是先在原始序列上把这些日期做回归剔除再对剔除后的序列做分解。# 构造节假日虚拟变量 holiday_dates [2023-06-18, 2023-11-11, 2024-06-18, 2024-11-11] df[is_holiday] df.index.isin(holiday_dates).astype(int) # 用线性回归估计节假日效应并从原始序列中扣除 from sklearn.linear_model import LinearRegression X_holiday df[[is_holiday]].values y_sales df[sales].values lr LinearRegression().fit(X_holiday, y_sales) holiday_effect lr.predict(X_holiday) df[sales_adjusted] df[sales] - holiday_effect * df[is_holiday]回归系数本身即节假日的平均效应量乘回is_holiday虚拟变量得到每个节假日当日的效应值。用这个方法的训练数据要覆盖至少两轮节假日否则系数估计偏小——只经历一次双十一的序列会把这个 2 倍日销量的事件当成随机波动回归系数会被残差拉跑偏。节假日效应扣完后残差里应该只剩真正的随机噪声这时再做 STL 分解会得到更纯粹的趋势与季节项。4.3 滚动训练与参数更新频率多久重跑一次模型才算合理静态模型拟合完就丢到生产环境里的做法在趋势季节性场景里活不过一季度。业务季节模式会漂移节假日的规模也会变化。我比较常用的是滚动窗口训练每次预测前只取最近 180 天数据重新做 STL 分解并更新参数模型重训频率与业务节奏挂钩——电商按周重训制造业按月重训。def rolling_forecast(df, date, horizon30, history_days180): train df.loc[:date].iloc[-history_days:] if len(train) history_days: return None stl STL(np.log1p(train[sales]), period7, robustTrue).fit() trend_fit HuberRegressor().fit( np.arange(len(train[trend].dropna())).reshape(-1, 1), train[trend].dropna().values ) # 在 train.seasonal 上执行外推 last_seasonal train[seasonal].dropna().iloc[-7:].values future_seasonal np.tile(last_seasonal, 5)[:horizon] future_trend trend_fit.predict( np.arange(len(train[trend].dropna()), len(train[trend].dropna()) horizon).reshape(-1, 1) ) return np.expm1(future_trend future_seasonal) # 对每个周一执行滚动预测 forecast_dates df.resample(W-MON).size().index for fd in forecast_dates: fc rolling_forecast(df, fd, horizon30) # 业务侧取数或入库resample(W-MON)按周一对齐预测节点因为多数周预测在周一启动、周五看数。history_days180是我经验上的平衡点短于 90 天趋势外推方差太大长于 365 天会把旧年份的季节形态带进来。5. 实战避坑趋势、季节与 STL 的 5 个高频翻车点5.1 现象预测值整体偏低且越低的值偏差越大原因几乎可以锁定在 log 变换后直接用了np.exp()而非np.expm1()。这个错误的典型症状是销量越大回代偏差越明显。原因是np.log1p(x)在 x 较大时接近np.log(x)而在 x 较小时差异显著回代时如果用错逆变换整体预测系统性地向下偏移。解决方法是严格对称使用变换对建模前用np.log1p回代时用np.expm1。代码审查时我会直接搜索两个函数名检查是否成对出现。另一个更隐蔽的版本是训练时用np.log(series 1)但回代时用np.exp(pred) - 1这种写法在小值域时也有细微偏差统一换成log1p/expm1即可。5.2 现象周季节项在周一总是“算低了”原因要追溯到 ETL 的时区截断。日颗粒数据如果按 UTC0 划分自然日中国的周一夜里 0 点到 2 点的销量会被归属到“上周日”导致周日被抬升、周一被压低。STL 提取的季节项体现的其实是这个错位后的节奏并非真实业务节奏。解决分两步先在数据源把日期字段按业务所在地时区重对齐再在季节项分析切片时观察一周各天的均值。如果周日和周一呈现出“镜像反差”周日偏高而周一偏低大概率就是时区截断问题。代码上可用df.index df.index.tz_convert(Asia/Shanghai)统一时区后重新生成自然日标签。5.3 现象趋势项在年底附近突然下弯或上翘STL 的趋势平滑在序列端点处依赖 Loess 的边界处理数据结束时最后一个窗口内的局部回归会被尾部几个点带偏。如果当年 12 月恰好有异常高销量趋势项会被抬出一个“假拐点”外推时线性回归顺着这个拐点给出偏高的未来预测。解决方法是判断最近一个季度趋势线的斜率并与前两个季度对比若斜率方向改变超过 30%则启用稳健回归HuberRegressor代替普通线性回归。另一种做法是把预测起点前移 7 天用一周前的趋势值做外推绕开端点抖动。这个方法从结果上看更粗糙但有时比精细调参更省事。5.4 现象残差 ACF 在滞后 7 和 14 处明显超标这是季节项抽取不充分的信号。常见原因有两种一是seasonal窗口设置过大比如 15导致季节项变化缓慢无法捕捉快速演变的周模式二是周期设定错误比如业务是“周二到次周一”结算周期但数据按自然周对齐。解决时先验证周期按不同周期长度6、7、8分别做 STL比较各周期下残差的 ACF 绝对值之和取最小者。这里有个反直觉的经验周期的选择不只看数据还要看业务结算节奏。如果仓库补货按周一到周日滚动、门店结算按周二到次周一那么按自然周做模型本质上低估了“周末峰值在周一的滞后效应”。5.5 现象模型节假日后的第一周预测误差异常放大节假日当天的效应被robustTrue降权后残差里仍保留着“节后恢复日”的效应。比如双 11 之后连续 3 天销量回落这 3 天在季节项里没有对应模式又不足以被趋势项吸收于是全堆进残差。直接叠加残差分位数区间时节后会被过度乐观地估计。解决方法是把节前节后各 N 天的哑变量一并建进回归方程而不是只做当天。节假日效应往往是“前后各 3 天”的窗口效应尤其春节和双 11。我通常对每个节假日生成[t-3, t3]共 7 个哑变量一并回归估计后再剔除。这样做会让趋势和季节的分解更干净同时预测区间也会在节假日窗口附近合理放大。问题现象典型原因解决手段预测整体偏低log/exp 逆变换不配对统一 log1p/expm1周一系统性低估时区截断导致归属错位重对齐业务时区再聚合年末趋势出现假拐点Loess 端点效应改用稳健回归或前移起点残差 lag-7 ACF 超标周期设错或季节窗口过大按残差 ACF 选周期节后第一周误差偏大只建模节假日当天用前后窗口哑变量6. 更进阶的做法用时间序列交叉验证迭代趋势季节模型让上线前的评估不靠“感觉”6.1 滚动起源点交叉验证是检验超额预测能力的唯一标准趋势季节模型的最大风险不是拟合差而是把历史拟合得很好、对未来一无是处。评估套路用 K 折交叉验证不合适——时间序列不能随机打乱会泄露未来信息。正确做法是滚动起源点验证每次只保留前 N 天做训练预测后 M 天记录误差然后向前滚动一个步长。def ts_cv_forecast(df, model_func, horizon30, step7, min_train180): errors [] for train_end in range(min_train, len(df) - horizon, step): train df.iloc[:train_end] y_true df[sales].iloc[train_end:train_end horizon].values # model_func 返回未来 30 天的预测向量 y_pred model_func(train, horizon) # 用 MAPE 评估电商场景下比 RMSE 更直观 errors.append(np.mean(np.abs((y_true - y_pred) / y_true)) * 100) return np.mean(errors) mape ts_cv_forecast(df, rolling_forecast) print(f滚动验证平均 MAPE: {mape:.2f}%)上面框架里的model_func我会封装成一个完整流程STL 分解 → 趋势外推 → 季节拼接 → 残差区间 → 返回点预测数组。交叉验证的步长step7意味着每周评估一次这样 365 天的数据能得到约 25 个验证点样本量足以判断模型里趋势外推是否跑偏。6.2 一个值得长期维护的预测基线朴素季节模型与你的模型的对比评估模型值不值得上线光看自己模型的误差绝对值不够——要和基线模型比。基线是“今年同期等于去年同周”即直接取去年同周的销量作为预测值。如果 STL趋势模型连这个基线都打不过说明季节模式没什么可预测的结构或者模型参数设置有问题。def naive_seasonal_baseline(train, horizon): # 训练集取去年同周数据 last_year train[sales].iloc[-365:-365 horizon].values return last_year mape_baseline ts_cv_forecast(df, naive_seasonal_baseline) print(f基线模型 MAPE: {mape_baseline:.2f}% (趋势季节模型: {mape:.2f}%))从项目经验看趋势季节模型对基线的 MAPE 改善通常能到 10 到 25 个百分点。如果你的改善低于 5 个百分点先排查训练窗口长度和节假日处理而不是换更复杂的模型。另一个实用方式是把基线误差按星期几分组展示若周六、周日的基线误差显著大于工作日说明你模型的季节项提取对周末的敏感度还不够。6.3 我对这个方向最终的使用建议趋势季节性拆解永远是你该先跑通的基准方案最后一章里我想留一个自己的教训。早期做时间序列我总想着从 LSTM 起步——看了不少“lstm时间序列预测 python”相关文章觉得深层网络才算“实战”。后来被现实教育了几次才明白趋势季节性分解的价值不是替代 LSTM而是给任何复杂模型提供一个结构化的起点和比对基线。即使最终 LSTM 表现更好分解出的趋势项和季节项也能帮助判断 LSTM 学到了什么、哪里在硬背。趋势和季节性的预测技巧不在模型多复杂而在分解得干净、残差解释得清楚、验证做得严格。先分解、后建模、再滚动验证这套流程我用了四年希望帮到你。本文还有配套的精品资源点击获取