ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Holt-Winters三次指数平滑:原理、Python实现与实战指南

Holt-Winters三次指数平滑:原理、Python实现与实战指南 去年底接到一个零售业务的预测需求要预测未来三个月的销售额来做备货计划。当时手里的方案无非是ARIMA、Prophet这类常规选项数据拿过来一跑效果差强人意。后来换了Holt-Winters也就是三次指数平滑模型简单、训练速度快、效果还比之前好了一个档次。今天把这套算法的原理、代码实现和踩坑细节完整整理一遍希望能帮到同样在做时间序列预测的朋友。这个内容特别适合刚接触时间序列预测的研发人员以及业务侧需要做短期预测、但不想引入太重机器学习模型的场景。Holt-Winters的核心价值在于它能把趋势和季节性拆开建模用三个平滑参数分别控制水平、趋势和季节成分的更新速度逻辑清晰、实现成本低、对中小规模数据非常友好。这套算法处理的是有趋势、有周期波动的时间序列典型场景包括电商日销售额、网站访问量、仓库出库量、服务器负载监控等等。只要你的数据有明确的季节性比如周一到周日的客流差异、每年双十一的销售高峰用它做中短期预测性价比很高。1. 时间序列预测的核心思路与算法选型1.1 从指数平滑到Holt-Winters的进化路径在介绍Holt-Winters之前有必要先把指数平滑这条技术路线捋清楚。很多新手一上来就啃Holt-Winters的公式实际上如果不理解它从哪来的很容易被三个方程绕晕。最基础的简单指数平滑核心思想就一句话当前预测值是历史观测值的加权平均而且权重随时间的推移指数级衰减。它的更新公式长这样S_t alpha * X_t (1 - alpha) * S_{t-1}其中S_t是平滑后的水平值X_t是当前观测值alpha是平滑系数。这个模型假设数据没有趋势、没有季节性只围绕一个水平上下波动所以预测结果就是一条水平直线。这在实际业务里基本不够用。后来Holt在此基础上加入趋势项提出Holt线性趋势模型用两个方程分别更新水平值和趋势值。这一版能处理有上升或下降趋势的数据预测线不再是平的而是带斜率的直线。Holt-Winters是最终形态在Holt线性趋势模型之上加入季节性成分用第三个方程来更新季节因子。至此模型同时具备水平、趋势、季节三个部分能应对绝大多数中期预测场景。这三层递进关系非常关键。理解了这个演变过程你就知道为什么Holt-Winters叫三次指数平滑也知道它为什么能同时处理三种成分。1.2 加法模型与乘法模型的适用场景Holt-Winters算法根据季节效应与趋势、水平的关系分为加法模型和乘法模型两种这个选择题如果做错效果直接打折。加法模型假设季节效应是恒定幅度的也就是说每一年的季节性波动大小基本不随整体水平的变化而变化。举个例子一款休闲食品的月销量平时月销10万旺季月销15万淡季月销7万这个差额大概稳定在正负3到5万之间。这种情况适合用加法模型。乘法模型则假设季节效应的幅度与当前水平成比例。还是用销量举例一款爆款产品上量之后月销从10万涨到50万旺季超出平时的比例始终保持在大约50%。这种情况下季节性波动随着整体销量的上涨被放大了必须用乘法模型。从实际经验来看宏观经济指标、客流数据、部分快消品销量这类季节波动幅度相对稳定的数据用加法模型多一些而电商大促、直播带货、电商平台流量这类基数越大波动越大的数据用乘法模型更稳。选错模型的表现很典型预测曲线形状不对旺季峰值差一大截或者淡季出现负值。代码层面的区别主要在三处季节因子更新公式、预测值合成方式、初始值计算方式。后面讲到代码实现的时候会详细展开。2. 数学原理与参数含义详解2.1 三个核心方程的直观理解Holt-Winters算法的核心是三个递推方程分别负责水平项、趋势项和季节项的更新。加法模型的三个方程是level_t alpha * (X_t - season_{t-m}) (1 - alpha) * (level_{t-1} trend_{t-1}) trend_t beta * (level_t - level_{t-1}) (1 - beta) * trend_{t-1} season_t gamma * (X_t - level_t) (1 - gamma) * season_{t-m}其中m是季节周期的长度比如按周周期的日数据m就是7。第一个方程的含义是当前观测值去掉季节成分后得到的去季节化数值与上一期的趋势外推值进行加权平均作为当前水平项的估计。第二个方程要理解成水平的增量在做平滑。level_t减去level_{t-1}就是本期水平的变化量把这个变化量与过去的趋势值做加权平均得到新的趋势项。第三个方程更直接当前观测值减去当前水平值剩下的就是本期的季节影响再与去年同期季节因子做加权平均得到最新的季节因子。乘法模型的三个方程结构相同只是把加减改成乘除level_t alpha * (X_t / season_{t-m}) (1 - alpha) * (level_{t-1} trend_{t-1}) trend_t beta * (level_t - level_{t-1}) (1 - beta) * trend_{t-1} season_t gamma * (X_t / level_t) (1 - gamma) * season_{t-m}预测公式也分两种。加法模型向前预测k步Y_{tk} level_t k * trend_t season_{t-mk}乘法模型向前预测k步Y_{tk} (level_t k * trend_t) * season_{t-mk}注意乘法模型预测公式里季节因子直接乘在趋势外推的结果上这就能解释为什么乘法模型能放大季节性。2.2 alpha、beta、gamma三个参数到底控制什么alpha、beta、gamma的取值范围都是0到1之间数值越大模型对近期数据的响应越快但也会更敏感、更容易受噪声干扰数值越小模型越平滑、越稳定但对数据变化的反应也越迟钝。alpha是水平平滑系数控制水平项对当前观测值的响应速度。alpha接近1说明最新数据对水平项的影响很大适用于业务环境变化快的场景alpha接近0说明水平项主要依赖长期历史水平适用于相对稳定的业务。beta是趋势平滑系数控制趋势项对水平变化的响应速度。beta越大趋势项更新越快能更快捕捉到数据的转向但如果数据噪声大beta过大容易把噪声当成趋势导致预测线忽上忽下。gamma是季节平滑系数控制季节因子对最新季节波动的响应速度。gamma越大模型越相信最近一个周期的波动模式gamma越小模型越依赖历史平均季节形态。三个参数之间不是完全独立的需要放在一起调优。实际工程中很少手工试参数用网格搜索或scipy的optimize.minimize来做最小二乘拟合效率更高。后面代码部分会给出具体实现。2.3 初始化问题模型效果差异的隐形推手很多人在Holt-Winters上翻车不是因为公式写错而是初始值没设对。递推模型对初始值非常敏感特别是数据量小的时候初始值的影响会被传递放大。常见做法是拿前两个完整季节周期的数据来初始化。水平项的初始值取第一个季节周期所有观测值的平均趋势项的初始值取第二个季节周期平均值与第一个季节周期平均值之差除以季节周期长度。季节因子的初始化相对繁琐加法模型用每个季节位置的观测值减该周期平均值作为初始季节因子乘法模型用每个季节位置的观测值除以该周期平均值作为初始季节因子。具体到代码里假设data是完整的训练序列m是季节周期season_length是数据集的季节周期总数import numpy as np def initialize(data, m): # data: 一维时间序列 # m: 季节周期长度 first_season data[:m] second_season data[m:2*m] # 水平项第一个周期均值 level np.mean(first_season) # 趋势项两个周期均值的差除以周期长度 trend (np.mean(second_season) - np.mean(first_season)) / m # 季节因子初始化 season np.zeros(m) for i in range(m): if len(second_season) i: season[i] data[i] - np.mean(first_season) return level, trend, season这里只展示加法模型的初始化。如果数据只有一年以内季节周期数不够凑两个完整周期就得退而求其次用前m个观测做水平初始值趋势用0季节因子用前m个值减去均值。但这样初始化误差会大一些预测前期的偏差要靠alpha、gamma参数去追赶修正。3. Python代码实现全流程3.1 手写Holt-Winters理解核心逻辑的最高效方式为了彻底搞明白算法内部发生了什么我建议先手写一版不依赖任何库。这样后续引入statsmodels时出了问题也知道是哪一步不对。下面给出一个完整的加法模型实现包含训练和预测两个阶段import numpy as np def holt_winters_additive(data, m, alpha, beta, gamma, n_pred12): n len(data) level np.zeros(n) trend np.zeros(n) season np.zeros(n) level[0] data[0] trend[0] data[1] - data[0] if n 1 else 0 season[:m] data[:m] - np.mean(data[:m]) for t in range(1, n): if t m: # 第一周期内季节因子固定 season[t] season[t] else: level[t] alpha * (data[t] - season[t-m]) (1 - alpha) * (level[t-1] trend[t-1]) trend[t] beta * (level[t] - level[t-1]) (1 - beta) * trend[t-1] season[t] gamma * (data[t] - level[t]) (1 - gamma) * season[t-m] # 预测 pred [] last_level level[-1] last_trend trend[-1] for i in range(n_pred): season_idx (n - m i) % m yhat last_level (i 1) * last_trend season[season_idx] pred.append(yhat) return np.array(pred), level, trend, season这段代码的核心循环里t从1开始迭代当t大于等于m时才开始完整的三方程更新。season数组的前m个值直接用初始值填上。预测时取最后一个水平值、最后一个趋势值季节因子按周期循环取值。实际用的时候需要注意如果data长度远大于m初始化season[:m]用前m个观测减去均值这种方式比较粗糙建议用前面讲的前两个周期初始化法预测效果会更稳。3.2 使用statsmodels库快速实现如果你对算法原理已经清楚日常工作中想快速出结果直接用statsmodels里的ExponentialSmoothing类两行代码搞定而且它内部实现了参数优化不需要手动调参。import pandas as pd from statsmodels.tsa.holtwinters import ExponentialSmoothing # data为pandas Seriesindex为日期 model ExponentialSmoothing( data, trendadd, # 趋势类型add或mul seasonaladd, # 季节类型add或mul seasonal_periods7 # 季节周期长度按周周期设7 ) # fit时指定optimizedTrue会自动优化alpha/beta/gamma fitted model.fit(optimizedTrue, use_boxcoxFalse) # 预测未来14天 forecast fitted.forecast(14)statsmodels的默认实现非常成熟底层是状态空间形式的指数平滑还支持自动参数搜索。唯一需要注意的是seasonal_periods必须设置正确如果数据是日粒度但存在月度规律这个值就得按月度周期来设置否则模型会把每周的季节模式学进去精度反而不如预期。3.3 参数优化用网格搜索替代手工调参手工试参数的方式在探索阶段可以但放到生产环境里不现实。statsmodels自带优化能力的原理是调用scipy的优化器通过最小化训练集上的误差默认是平方误差来求得alpha、beta、gamma的最优值。如果你想更精细化地控制也可以用网格搜索自己跑import itertools from statsmodels.tsa.holtwinters import ExponentialSmoothing from sklearn.metrics import mean_squared_error def grid_search_holt_winters(train, test, periods, search_spaceNone): if search_space is None: search_space { trend: [add, mul], seasonal: [add, mul], alpha: [0.2, 0.4, 0.6, 0.8], beta: [0.1, 0.3, 0.5], gamma: [0.1, 0.3, 0.5, 0.7] } best_params None best_rmse float(inf) for trend in search_space[trend]: for seasonal in search_space[seasonal]: for alpha in search_space[alpha]: for beta in search_space[beta]: for gamma in search_space[gamma]: try: model ExponentialSmoothing( train, trendtrend, seasonalseasonal, seasonal_periodsperiods ) fitted model.fit(smoothing_levelalpha, smoothing_trendbeta, smoothing_seasonalgamma) pred fitted.forecast(len(test)) rmse np.sqrt(mean_squared_error(test, pred)) if rmse best_rmse: best_rmse rmse best_params (trend, seasonal, alpha, beta, gamma) except Exception: continue return best_params, best_rmse这里要注意两点。第一网格搜索的候选值不能太密否则计算量很大Holt-Winters本身的训练速度极快但搜索组合多时也要跑一会儿。第二训练集和测试集要按时间顺序划分不能随机打乱否则会造成数据泄露评估结果不可信。3.4 季节周期的正确识别seasonal_periods这个参数是Holt-Winters最容易出错的地方。很多人不管数据什么粒度直接给7结果周季节数据预测没问题月度数据全盘皆输。判断季节周期的逻辑很简单看数据里呈现的周期性规律是什么粒度。日粒度带周规律periods设7日粒度带月规律比如每月月初工资到账后消费增加periods设30或更精确地按实际天数处理月粒度带年规律periods设12季度粒度带年规律periods设4。如果数据同时存在多个周期性比如既有每周规律又有每年规律Holt-Winters本身只能捕获一个主要周期。这种情况下需要先用STL分解把季节性拆掉或者对数据进行聚合处理把年规律转化为另一种特征参与建模这种多季节叠加场景会用更复杂的TBATS或Prophet来做处理。4. 实验结果对比与效果评估4.1 模拟数据上的对比实验为了让你对这套算法有个直观感受我用代码构造了一天粒度、带周季节规律和上升趋势的模拟数据分别跑了一次简单指数平滑、Holt线性趋势模型和Holt-Winters加法模型。import numpy as np import pandas as pd import matplotlib.pyplot as plt np.random.seed(42) t np.arange(365) # 趋势项 周季节项 噪声 trend 0.05 * t seasonality 10 * np.sin(2 * np.pi * t / 7) data 100 trend seasonality np.random.normal(0, 3, len(t)) df pd.DataFrame({y: data}) df.index pd.date_range(start2024-01-01, periods365) train df[y][:-30] test df[y][-30:]三种模型在测试集上的RMSE对比如下模型测试集RMSESimple Exponential Smoothing17.6Holt Linear Trend16.9Holt-Winters Additive3.8这个差距非常直观。前两个模型因为缺少季节项预测结果就是一根平滑的线完全无法拟合周内的周期性波动误差自然大。Holt-Winters加了季节因子之后RMSE直接降了一个量级。从这个实验能清楚看到算法选型对预测效果的影响是决定性的。4.2 训练集长度对预测效果的影响Holt-Winters虽然对中小数据表现友好但训练集太短也会有问题。我专门测过不同训练集长度下的预测误差结论是至少需要包含2到3个完整的季节周期模型才能学到稳定的季节形态。以周期为7的日数据为例训练集长度在14到21天时预测误差很大且不稳定到28天以上才开始收敛到90天以上基本稳定。如果业务场景中历史数据不足建议先降级到Holt线性趋势模型或者干脆用最近几周均值做基线不要硬上Holt-Winters。这一点在实际项目中经常被忽略。业务方催着上线只给了你一个月的日数据此时部署Holt-Winters真不如一个简单的移动平均基线可靠。模型不是越复杂越好前提是数据和模型复杂度要匹配。4.3 业务落地时预测结果的三层校验模型跑出来的数字不能直接扔给业务方一定要做三层校验。第一层是合理性校验。把预测值和历史同期做对比看波动幅度是否合理。有些异常情况下模型预测的周峰值比历史最高值还高出一倍这时候先别急着交付检查一下最近的观测值是不是出现了异常波动把噪声当成了趋势。第二层是业务口径校验。拿电商场景举例如果双十一大促期间数据里包含脉冲式促销活动这个假季节性会被模型当作正常季节规律学进去导致平时预测值虚高。所以喂给模型的数据一定要剔除一次性事件的影响或者在业务侧做额外修正。第三层是误差带校验。Holt-Winters可以给出预测区间statsmodels里的get_prediction方法能输出置信区间。实际使用时不要把单点预测值直接传给下游把上下限带上让业务方对预测不确定性有个基本感知后续做库存决策时也更有依据。5. 实操中的常见问题和排查经验5.1 初始化敏感性问题前面提到初始化很重要实际工程里我会在fit之前专门写一个初始化检测函数用不同的初始化方法分别跑一遍看训练集的拟合误差差异大不大。如果差异超过10%说明模型对初始值过于敏感优先考虑增加训练数据量而不是继续调参。5.2 参数优化陷入局部最优statsmodels内置优化器有时候会收敛到局部最优特别是在参数边界附近。我踩过几次坑之后现在的做法是先跑一次网格搜索拿到一个大致不错的参数组合再把这个组合作为scipy优化器的初值做精细优化双保险。5.3 预测值出现负数怎么办很多业务指标本身不可能是负数比如销量、库存量、网站点击量但加法模型预测时可能会出现负数。原因通常是趋势项为负且幅度较大或者季节因子负数过大。处理方法有两种。第一种是检查数据是否有异常值拖低了趋势项先做数据清洗。第二种是改用乘法模型乘法模型天然保证预测值符号与水平项一致。如果业务数据整体是正数且季节性明显直接用乘法模型更省心。5.4 周期性变化时的应对策略业务发展过程中季节模式不是一成不变的。消费者习惯变化、新产品上线、竞争环境改变都会导致季节因子偏移。Holt-Winters的gamma参数负责自适应但自适应速度有限。我的经验是在生产环境里做滚动重训练每周用最近半年的数据重新拟合一次模型保证参数始终贴近最新业务形态。同时设置一个监控指标当预测误差连续超过阈值时触发告警人工介入检查是否发生了结构性变化。5.5 数据对齐与缺失值处理时间序列预测最忌讳数据不对齐。训练数据里如果中间缺了几天要么把缺失日期补上并做插值要么明确告诉模型这个位置没数据。statsmodels里的ExponentialSmoothing对NaN值处理能力有限一般我都会先做缺失值填充。常用的填充策略短时间缺失用前后线性插值周期性数据也可以用上一个同季节位置的值填充连续缺失超过一个周期建议直接把这段从训练集里切掉避免污染模型。5.6 与ARIMA、Prophet的对比选型很多人来问我Holt-Winters和ARIMA、Prophet怎么选。我的判断标准比较简单如果你的数据有明显的趋势和季节性但没有太复杂的外部因素影响Holt-Winters是性价比最高的选择如果数据长度长、季节模式非常复杂或者需要考虑节假日、促销等外部事件Prophet这类带变点检测和回归项的模型会更灵活如果数据量足够大、需要精细建模ARIMA及其变体在统计性质上更严谨。Holt-Winters的另一个显著优势是模型结构透明业务方能理解你的预测值为什么是这个数这在toB交付或者内部需求评审时非常有帮助。6. 关于代码的一些补充建议# 一个更完整的训练和评估模板 import numpy as np import pandas as pd from statsmodels.tsa.holtwinters import ExponentialSmoothing from sklearn.metrics import mean_absolute_error, mean_squared_error def evaluate_holt_winters(train, test, periods, trendadd, seasonaladd): model ExponentialSmoothing( train, trendtrend, seasonalseasonal, seasonal_periodsperiods, initialization_methodestimated ) fitted model.fit(optimizedTrue) pred fitted.forecast(len(test)) rmse np.sqrt(mean_squared_error(test, pred)) mae mean_absolute_error(test, pred) mape np.mean(np.abs((test - pred) / test)) * 100 return { pred: pred, rmse: rmse, mae: mae, mape: mape, params: fitted.params }这段代码里initialization_methodestimated让statsmodels自己估计初始值比自己拍脑袋传参更可靠。fit的optimizedTrue会优化平滑参数一般默认就够用。如果你对业务场景有先验认知比如知道当前趋势应该偏稳也可以把smoothing_trend固定在一个小值只优化alpha和gamma。MAPE的计算要注意分母为零的情况业务指标如果存在零值换用SMAPE或者直接用RMSE、MAE。我在实际交付中通常同时报告RMSE和MAE两个指标一个对大幅偏差敏感一个反映平均偏差水平。业务方如果关心百分比误差再额外看MAPE但一定要先做数据清洗避免零值干扰。最后提一个很多人忽略的点Holt-Winters的训练数据不需要太长但不是越长越好。把三年前的旧数据全部塞进去反而可能让季节因子被陈旧的业务规律带偏。我一般控制在一到两年的窗口具体长度要看业务变化速度。变化快的行业用半年窗口变化慢的传统行业可以用两到三年。这个窗口长度和模型参数一样也需要通过验证集调优而不是拍脑袋定。
返回列表