1. 项目缘起:从“预测不准”到“周期叠加”的实战思考
最近在复盘一个老项目的销售预测模块时,遇到了一个典型问题:模型在大多数月份表现尚可,但每逢节假日或特定季度,预测值就和实际值“分道扬镳”,偏差大得离谱。这让我重新审视了当时使用的经典ARIMA模型——它确实能捕捉趋势和简单的年度周期,但对于更复杂的、嵌套在一起的周期性波动,就显得力不从心了。比如,零售数据同时受“星期几”(7天周期)、“月度促销”(约30天周期)和“季节性大促”(365天周期)的影响,这种多重周期叠加的信号,正是传统时间序列模型的“盲区”。
这次,我们就来彻底搞懂如何应对这类问题。核心就是两个模型:季节性时间序列模型(Seasonal Time Series Model)和多重季节性模型(Multiple Seasonal Model)。前者是处理单一、固定周期(如一年四季)的基石,后者则是前者的进化,专门用来“降维打击”那些拥有多个、不同长度周期的复杂数据。网上能找到的代码片段往往只给个函数调用,背后的参数选择、调优逻辑、结果分析却语焉不详。这篇文章,我将结合一个模拟的电商日频销售数据集,手把手带你走完从数据理解、模型选型、代码实现到结果分析的完整闭环。你会发现,模型本身并不神秘,关键在于理解数据背后的“节奏”,并选用合适的工具去捕捉它。
2. 理解核心:季节性模型与多重季节性模型的本质区别
在动手写代码之前,我们必须先厘清概念。很多人会把“季节性”简单理解为“四季变化”,但在时间序列分析中,它的定义更广泛:任何具有固定周期、重复出现的模式,都称为季节性(Seasonality)。
### 2.1 季节性时间序列模型:捕捉单一主旋律
最常见的季节性模型是季节性自回归综合移动平均模型(Seasonal ARIMA,即 SARIMA)。你可以把它理解为经典ARIMA模型的“季节增强版”。一个SARIMA模型通常用SARIMA(p, d, q)(P, D, Q, s)来表示,其中:
(p, d, q)与非季节性ARIMA模型一样,分别代表自回归阶数、差分阶数和移动平均阶数,用于处理趋势和随机波动。(P, D, Q, s)是季节性部分:P是季节性自回归阶数,D是季节性差分阶数,Q是季节性移动平均阶数,而s是最关键的参数,代表季节周期的长度。
例如,对于月度数据,年度周期s=12;对于日度数据,若只考虑周周期,则s=7。SARIMA的核心假设是数据只存在一种主导的、固定的季节性周期。它通过季节性差分(D次)来消除这种周期性,使其平稳化,然后再用季节性自回归和移动平均项来建模周期性模式本身。
它的局限性也很明显:当你的数据同时存在“每周重复”和“每年重复”的模式时,无论你设置s=7还是s=365,SARIMA都只能捕捉其中一种,另一种周期会作为噪声或趋势残留,严重影响预测精度。
### 2.2 多重季节性模型:编织复杂的节奏网
为了解决多重周期问题,学者们提出了多种模型,其中最著名、最实用的当属TBATS 模型。这个奇怪的名字其实是其核心组件的缩写:Trigonometric seasonality(三角季节性)、Box-Cox transformation(Box-Cox变换)、ARMA errors(ARMA误差)、Trend(趋势)和Seasonal components(季节性分量)。
TBATS的强大之处在于,它可以同时指定多个不同的季节周期参数。比如,你可以告诉模型:seasonal_periods=[7, 30.44, 365.25],让它同时去拟合周、月、年周期(使用小数是为了考虑闰年等细微变化)。其季节性部分使用傅里叶级数(三角函数的和)来拟合,这种方式非常灵活,能处理非整数周期,并且对长周期(如365天)的计算效率比传统的SARIMA季节性差分要高得多。
简单来说,SARIMA像是一个只擅长演奏一种固定节奏的乐手,而TBATS则像一个能同时协调鼓点(周周期)、贝斯线(月周期)和主旋律(年周期)的指挥家。
3. 实战准备:构建一个具有多重季节性的模拟数据集
理论说得再多,不如一行代码。为了清晰地展示差异,我们不使用敏感的真实商业数据,而是自己动手生成一个包含趋势、周季节性、月季节性和年季节性的模拟日度销售数据。这样,数据的“真相”我们完全掌握,便于评估模型的拟合效果。
import numpy as np import pandas as pd import matplotlib.pyplot as plt from statsmodels.tsa.statespace.sarimax import SARIMAX from tbats import TBATS import warnings warnings.filterwarnings('ignore') # 过滤掉模型拟合中的常规警告 # 设置随机种子保证结果可复现 np.random.seed(42) # 生成日期范围:3年的日度数据 date_range = pd.date_range(start='2020-01-01', end='2022-12-31', freq='D') n = len(date_range) # 1. 趋势项:缓慢的线性增长 + 小幅随机波动 trend = np.linspace(100, 150, n) + np.random.normal(0, 5, n) # 2. 周季节性(s=7):模拟周末效应 # 假设周六销量最高,周一次之,周三最低 day_of_week = date_range.dayofweek # Monday=0, Sunday=6 weekly_effect = np.zeros(n) weekly_effect[day_of_week == 5] = 25 # 周六 weekly_effect[day_of_week == 0] = 15 # 周一 weekly_effect[day_of_week == 2] = -20 # 周三 weekly_effect += np.random.normal(0, 3, n) # 加入噪声 # 3. 月季节性(s≈30.44):模拟月末/月初效应 day_of_month = date_range.day # 假设每月1号和15号有小高峰 monthly_effect = 10 * np.sin(2 * np.pi * day_of_month / 30.44) + np.random.normal(0, 4, n) # 4. 年季节性(s=365.25):模拟年度大周期,如夏季旺季、冬季淡季 day_of_year = date_range.dayofyear yearly_effect = 40 * np.sin(2 * np.pi * day_of_year / 365.25) + np.random.normal(0, 6, n) # 5. 合成最终序列 sales = trend + weekly_effect + monthly_effect + yearly_effect # 确保销售额为正数 sales = np.abs(sales) # 创建DataFrame df = pd.DataFrame({ 'date': date_range, 'sales': sales.astype(int) # 转换为整数,更贴近真实销售数据 }) df.set_index('date', inplace=True) # 可视化 fig, axes = plt.subplots(3, 1, figsize=(14, 10)) df['sales'].plot(ax=axes[0], title='模拟日度销售额(完整序列)', alpha=0.7) df['sales'].iloc[:100].plot(ax=axes[1], title='前100天细节(展示周周期)', marker='o') df['sales'].iloc[:365].plot(ax=axes[2], title='第一年数据(展示年周期)') plt.tight_layout() plt.show()这段代码生成了1096天(3年)的数据。通过子图,你可以清晰地看到:在长达3年的视图中,年度的波浪形周期(年季节性)是主导;放大到前100天,以7天为单位的起伏(周季节性)变得明显;如果再结合月度效应,数据就构成了一个典型的多重季节性序列。这就是我们接下来要攻克的目标。
4. 模型一:用SARIMA捕捉单一季节性
我们先尝试用SARIMA模型,假设我们只认为年度周期是最主要的。我们将数据按8:2划分为训练集和测试集。
# 划分训练集和测试集 train_size = int(len(df) * 0.8) train, test = df.iloc[:train_size], df.iloc[train_size:] print(f"训练集样本数:{len(train)}, 测试集样本数:{len(test)}") print(f"测试集起始日期:{test.index[0].date()}") # 使用SARIMA模型,只考虑年周期 (s=365) # 注意:由于是日度数据,s=365会导致模型非常大,拟合极慢。实践中对于长周期,常使用s=7(周周期)或通过其他方式处理年周期。 # 这里为了演示,我们先用s=7(周周期)来拟合,看看效果。 order = (1, 1, 1) # (p, d, q) 非季节性部分,这里是一个简单的设置 seasonal_order = (1, 1, 1, 7) # (P, D, Q, s) 季节性部分,s=7代表周周期 print("开始拟合SARIMA(1,1,1)(1,1,1,7)模型,这可能需要一些时间...") model_sarima = SARIMAX(train['sales'], order=order, seasonal_order=seasonal_order, enforce_stationarity=False, enforce_invertibility=False) result_sarima = model_sarima.fit(disp=False) # disp=False减少输出信息 print("模型拟合完成!") print(result_sarima.summary())拟合完成后,我们进行预测并评估效果。
# 进行样本内预测(拟合值)和样本外预测(测试集) forecast_sarima = result_sarima.get_forecast(steps=len(test)) forecast_series = forecast_sarima.predicted_mean confidence_intervals = forecast_sarima.conf_int() # 计算评估指标 from sklearn.metrics import mean_absolute_error, mean_squared_error mae_sarima = mean_absolute_error(test['sales'], forecast_series) rmse_sarima = np.sqrt(mean_squared_error(test['sales'], forecast_series)) print(f"SARIMA模型在测试集上的表现:") print(f" MAE (平均绝对误差): {mae_sarima:.2f}") print(f" RMSE (均方根误差): {rmse_sarima:.2f}") # 可视化对比 plt.figure(figsize=(14, 6)) plt.plot(train.index[-100:], train['sales'].iloc[-100:], label='训练集(后100天)', alpha=0.7) plt.plot(test.index, test['sales'], label='测试集真实值', color='green', alpha=0.7) plt.plot(test.index, forecast_series, label='SARIMA预测值', color='red', linestyle='--') plt.fill_between(test.index, confidence_intervals.iloc[:, 0], confidence_intervals.iloc[:, 1], color='pink', alpha=0.3, label='95%置信区间') plt.title('SARIMA模型预测效果对比 (s=7,仅考虑周周期)') plt.xlabel('日期') plt.ylabel('销售额') plt.legend() plt.grid(True, alpha=0.3) plt.show()结果分析:你会看到,SARIMA(s=7)的预测曲线确实呈现出了明显的“锯齿状”周周期波动,这与我们数据中真实的周模式是吻合的。然而,预测序列的整体水平(基线)与测试集真实值相比,可能存在系统性的偏高或偏低。这是因为模型只捕捉到了周周期,却把年周期的上升或下降阶段误判成了趋势的一部分,或者直接当成了噪声。因此,在测试集(包含了训练集未出现过的年周期相位)上,预测的基线就产生了偏移。MAE和RMSE值会量化这个误差。
实操心得:对于日度数据,直接将s设为365来拟合SARIMA模型在计算上通常是不可行的,因为会导致巨大的状态空间,内存和计算时间都无法承受。这是SARIMA处理长周期、多重周期时的一个致命弱点。常见的变通方法是:1)先使用其他方法(如移动平均、STL分解)提取年周期,将其从原数据中移除,然后用SARIMA(s=7)对残差建模;2)使用周度或月度聚合数据。但这都增加了流程的复杂性。
5. 模型二:用TBATS征服多重季节性
现在,请出我们的“多重周期杀手”——TBATS模型。我们将直接告诉它我们怀疑存在的所有周期。
print("开始拟合TBATS模型,指定多重季节周期:[7, 30.44, 365.25]...") # 创建TBATS模型评估器 estimator = TBATS(seasonal_periods=[7, 30.44, 365.25], use_arma_errors=True, # 使用ARMA结构来建模误差项 use_box_cox=True) # 使用Box-Cox变换稳定方差 # 拟合模型 model_tbats = estimator.fit(train['sales']) print("TBATS模型拟合完成!") # 查看模型参数摘要(TBATS的summary不如statsmodels详细,但关键信息都有) print(model_tbats.summary()) # 进行预测 forecast_tbats = model_tbats.forecast(steps=len(test)) forecast_series_tbats = pd.Series(forecast_tbats, index=test.index) # 计算评估指标 mae_tbats = mean_absolute_error(test['sales'], forecast_series_tbats) rmse_tbats = np.sqrt(mean_squared_error(test['sales'], forecast_series_tbats)) print(f"\nTBATS模型在测试集上的表现:") print(f" MAE (平均绝对误差): {mae_tbats:.2f}") print(f" RMSE (均方根误差): {rmse_tbats:.2f}") # 与SARIMA模型对比 print(f"\n模型对比 (数值越低越好):") print(f" MAE RMSE") print(f" SARIMA: {mae_sarima:>8.2f} {rmse_sarima:>8.2f}") print(f" TBATS: {mae_tbats:>8.2f} {rmse_tbats:>8.2f}")关键参数解读:
seasonal_periods=[7, 30.44, 365.25]:这是TBATS模型的灵魂。我们明确指定了三个待检测的季节周期。模型会为每个周期自动拟合一组傅里叶项(三角函数的和)来刻画其形态。使用30.44和365.25而非整数,是更严谨的做法,能更好地对齐日历。use_box_cox=True:让模型自动判断是否需要进行Box-Cox变换,以稳定时间序列的方差(即让波动幅度不随时间变化),这能提升模型稳健性。use_arma_errors=True:在模型拟合后,允许对残差(误差项)再用一个ARMA模型进行“精加工”,以捕捉可能未被季节性部分解释的短期自相关,这通常能进一步提升预测精度。
接下来,我们进行更全面的可视化对比。
# 综合可视化对比 fig, axes = plt.subplots(2, 1, figsize=(16, 10)) # 图1:整体预测对比 axes[0].plot(test.index, test['sales'], label='真实值', color='black', linewidth=2, alpha=0.8) axes[0].plot(test.index, forecast_series, label=f'SARIMA预测 (MAE:{mae_sarima:.1f})', color='red', linestyle=':', alpha=0.8) axes[0].plot(test.index, forecast_series_tbats, label=f'TBATS预测 (MAE:{mae_tbats:.1f})', color='blue', linestyle='--', alpha=0.8) axes[0].set_title('模型预测结果整体对比') axes[0].set_ylabel('销售额') axes[0].legend() axes[0].grid(True, alpha=0.3) # 图2:预测误差(残差)对比 error_sarima = test['sales'] - forecast_series error_tbats = test['sales'] - forecast_series_tbats axes[1].plot(test.index, error_sarima, label='SARIMA预测误差', color='red', alpha=0.6) axes[1].plot(test.index, error_tbats, label='TBATS预测误差', color='blue', alpha=0.6) axes[1].axhline(y=0, color='black', linestyle='-', linewidth=0.5) axes[1].set_title('模型预测误差对比') axes[1].set_xlabel('日期') axes[1].set_ylabel('误差(真实值 - 预测值)') axes[1].legend() axes[1].grid(True, alpha=0.3) plt.tight_layout() plt.show()结果深度分析:
- 精度对比:几乎可以肯定,TBATS模型的MAE和RMSE会显著低于SARIMA模型。这是因为TBATS成功分离并建模了周、月、年三个周期的效应。预测曲线不仅跟随了每周的起伏,其整体基线也随着年周期的相位(测试集所在的时间点在年周期中的位置)正确移动。
- 误差图分析:SARIMA的误差图会显示出明显的周期性或趋势性结构(例如,误差连续为正或为负一段时间),这表明有系统性的模式未被模型捕捉(即年周期和月周期)。而TBATS的误差图应该更接近于白噪声(随机、无规则地分布在0附近),这意味着模型已经提取了数据中大部分可预测的结构。
- 模型复杂度与过拟合风险:TBATS模型参数更多,理论上过拟合风险更高。但在时间序列预测中,只要使用合理的周期参数且样本量足够(我们用了近3年数据),过拟合风险相对可控。一个检查方法是观察样本内拟合的“平滑度”,如果拟合曲线完美地穿过了每一个数据点的噪声,那就要小心了。TBATS的傅里叶项拟合方式本身具有一定的平滑性,有助于防止过拟合。
6. 进阶:模型诊断、调优与生产环境考量
跑通一个模型只是第一步。要让模型真正可靠,还需要进行诊断和调优。
### 6.1 模型诊断——检查残差
一个好的时间序列模型,其残差(预测误差)应该近似于白噪声(均值为0,方差恒定,且无自相关)。我们可以用统计检验和自相关图来诊断。
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf from statsmodels.stats.diagnostic import acorr_ljungbox # 计算TBATS模型的样本内残差 residuals_tbats = train['sales'] - model_tbats.y_hat fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # 1. 残差序列图 axes[0, 0].plot(train.index, residuals_tbats) axes[0, 0].axhline(y=0, color='r', linestyle='--') axes[0, 0].set_title('TBATS模型残差序列') axes[0, 0].set_ylabel('残差') # 2. 残差直方图与Q-Q图(检查正态性) from scipy import stats import seaborn as sns sns.histplot(residuals_tbats, kde=True, ax=axes[0, 1], stat='density') axes[0, 1].set_title('残差分布') stats.probplot(residuals_tbats, dist="norm", plot=axes[1, 0]) axes[1, 0].set_title('Q-Q图') # 3. 残差自相关图(ACF) plot_acf(residuals_tbats, lags=40, ax=axes[1, 1], title='残差自相关函数 (ACF)') axes[1, 1].set_xlabel('滞后阶数') plt.tight_layout() plt.show() # Ljung-Box检验(原假设:残差在滞后阶数内无自相关) lb_test = acorr_ljungbox(residuals_tbats, lags=[10, 20, 30], return_df=True) print("Ljung-Box检验结果 (p值):") print(lb_test)诊断解读:
- 残差序列图:应围绕0随机波动,无明显的趋势或周期性。如果存在“U”型或倒“U”型,说明有非线性趋势未捕捉。
- 残差分布与Q-Q图:理想情况是接近正态分布。严重偏离正态可能影响置信区间的准确性,但对点预测影响不大。
- 自相关图(ACF):理想情况下,所有滞后阶数(除了0阶)的自相关系数都应落在蓝色置信区间内(即不显著)。如果在某些滞后阶(如滞后7、14、21)出现显著相关,说明还有未被捕捉的周周期信息。
- Ljung-Box检验:如果p值大于0.05(例如0.1),则不能拒绝原假设,认为残差是白噪声,模型是充分的。
### 6.2 模型调优——TBATS的参数探索
TBATS的主要调优参数就是seasonal_periods列表。如何确定这些周期?
- 业务知识:这是最可靠的来源。周(7)、月(30或30.44)、季度(91或91.31)、年(365或365.25)是常见周期。
- 数据可视化:绘制序列的周期图(Periodogram)或使用STL分解,观察在哪些频率上功率谱密度最高。
- 自动检测:可以编写一个简单的网格搜索,尝试不同的周期组合,选择在验证集上表现最好的。但要注意,周期是物理含义明确的参数,不宜盲目组合。
# 示例:尝试不同的周期组合 periods_to_try = [ [7], # 仅周周期 [7, 30], # 周+月周期(整数) [7, 30.44], # 周+月周期(精确) [7, 365], # 周+年周期(整数) [7, 30.44, 365.25] # 周+月+年周期(精确) ] best_mae = float('inf') best_periods = None best_model = None # 使用时间序列交叉验证或简单的hold-out验证 # 这里为了演示,简单地将训练集的后20%作为验证集 val_size = int(len(train) * 0.2) train_sub = train.iloc[:-val_size] val_sub = train.iloc[-val_size:] for periods in periods_to_try: print(f"尝试周期组合:{periods}") try: estimator = TBATS(seasonal_periods=periods, use_arma_errors=True, use_box_cox=True) model = estimator.fit(train_sub['sales']) forecast = model.forecast(steps=len(val_sub)) mae = mean_absolute_error(val_sub['sales'], forecast) print(f" 验证集MAE: {mae:.2f}\n") if mae < best_mae: best_mae = mae best_periods = periods best_model = model except Exception as e: print(f" 拟合失败: {e}\n") print(f"\n最佳周期组合:{best_periods}, 对应验证集MAE: {best_mae:.2f}")### 6.3 生产环境部署的注意事项
- 计算效率:TBATS拟合包含长周期(如365)的模型比SARIMA快得多,但依然比简单模型耗时。对于需要高频(如每小时)更新的预测,需评估训练时间。可以考虑定期(如每周)重新训练,每日仅用现有模型进行预测。
- 模型更新:随着时间的推移,季节性模式可能缓慢变化(“季节性漂移”)。生产系统需要设计模型重训策略,例如:使用滚动时间窗口(如始终用最近2年数据训练),或当预测误差连续多日超过阈值时触发重训。
- 外生变量:本文演示的是纯时间序列模型。实际业务中,促销活动、天气、节假日(如“双十一”)等外生因素影响巨大。SARIMAX(SARIMA with eXogenous variables)和TBATS的扩展版本(如支持回归项的
TBATS)可以纳入这些变量,这是提升预测精度的关键一步。 - 不确定性量化:
get_forecast和forecast方法都提供了置信区间,这对于库存管理、风险评估至关重要。要理解这是基于模型假设(如误差正态分布)的统计区间,真实世界的不确定性可能更大。
7. 总结与选择指南:SARIMA vs. TBATS
经过完整的代码实战和深度分析,我们可以清晰地看到两者的定位:
选择 SARIMA 当:
- 你的数据只有一种主导的、固定的季节性周期(例如,只有年度周期的月度数据,s=12)。
- 季节性周期长度较短(s <= 7 或 12),计算资源有限。
- 你需要一个高度可解释的模型,ARIMA系列的参数(p,d,q,P,D,Q)有明确的统计意义。
- 你希望使用成熟、广泛集成的库(如
statsmodels),并且需要详细的统计诊断报告。
选择 TBATS 当:
- 你的数据明确存在多个不同的季节性周期(如日度数据的周、月、年周期)。
- 季节性周期中包含长周期(如s=365),SARIMA难以处理。
- 季节性周期的长度可能不是整数(如日度商业数据中,月周期≈30.44天,年周期≈365.25天)。
- 你更关注预测精度,并且可以接受模型相对复杂、可解释性稍弱。
- 你愿意使用一个专门为复杂季节性设计的库(如
tbats)。
从我个人的项目经验来看,对于现代商业场景下的日度、小时度高频数据,TBATS及其同类模型(如Facebook Prophet,它也内置了傅里叶级数处理多重季节性)正在成为更主流的选择。它们开箱即用地解决了多重周期问题,极大简化了建模流程。而SARIMA更像一把精准的手术刀,在周期单一明确、需要极致统计解释的场景下依然不可替代。理解它们的核心差异和适用边界,就能在面临“季节性预测”问题时,做出最合适的技术选型。