简介:围绕ARIMA时间序列模型预测新能源汽车销量的专题PDF,面向统计学、数据分析与汽车产业研究方向的初学者及从业者,帮助其理解如何用月度销量数据完成建模、定阶、检验与短期预测。文件共1个,为PDF格式,压缩包约1.11MB,单篇文档便于快速查阅与引用,已有480人学习下载。内容以2014年1月至2019年5月的销量数据为例,梳理ARIMA(p,d,q)(P,D,Q)模型的自回归、差分与移动平均原理,说明季节性分解、ADF单位根检验、ACF与PACF定阶以及SPSS参数估计和拟合评价流程,并给出2019年3月至5月的预测结果与相对误差对比。文中还讨论节假日淡旺季波动、政策变化对销量的影响,以及超过三个月预测精度下降等局限,可作为课程论文、建模练习和车企市场研判的参考材料。
1. 65 个月度数据点,够不够撑起一份销量预测
先说结论:用 ARIMA 做新能源汽车销量预测,模型那一行fit()是最不值钱的部分,真正决定结果的是定阶之前有没有把序列长度、季节周期、差分次数这三件事算清楚。这份资料拿 2014 年 1 月到 2019 年 5 月的月度销量做样本,一共 65 个点,留出 2019 年 3—5 月做验证,相对误差落在 5%~9%,最终定下来的模型是带季节项的 ARIMA(1,0,0)(1,1,0),季节周期取 12。对做产销排期的人来说,这个精度够支撑三个月内的滚动调整;对拿它做课程设计或复现练习的人来说,这是一份把「分解—差分—定阶—检验—回测」五步全部走完的干净样本,不用自己造数据。适合三类人:手上已有月度序列想套模型的从业者、要交一份完整时间序列作业的学生、以及被 SPSS 自动定阶结果和 Python 结果对不上折磨过的工程师。
2. 分解与平稳性:先把 65 个月拆成趋势、季节、残差
拿到一列月度销量,最容易犯的错是直接diff()一下就往模型里塞。季节性数据在差分之前必须先看清结构,否则后面 ACF 图上的每一个峰都会被误读成自回归阶数,定阶从第一步就歪了。
这份资料里有两张图值得反复看:一张是 2014—2019 年的月度销量曲线,整体上升趋势非常明显,同时每年 2—3 月掉下去、10 月到次年 1 月冲上来;另一张是季节性分解出的季节因子序列,数值大致在 0.5 到 2.5 之间围绕 1 上下摆动。这两张图决定了三件事:季节周期取 12、季节项用乘性、差分以季节差分为主。
2.1 乘性季节还是加性季节,看波动幅度跟不跟水平走
判断方法很朴素:把每年同一个月的销量横向比一遍,看淡旺季之间的落差是固定值还是随整体水平放大的比例。2014 年整体月销还在万辆以下,2 月淡季比旺季少个几千辆;到了 2018 年 12 月单月冲到 22.5 万辆,同年淡季月份只有三万多辆,落差已经放大到几万辆的量级。绝对落差跟着整体水平一起涨,这就是乘性季节的典型特征。
选错分解方式的代价是隐性的。用加性分解去拆一份乘性序列,季节项会被压成一个近似常数,被放大的那部分波动全部漏进残差里,结果就是残差 ACF 在 lag 12、lag 24 上出现本不该有的显著峰,让你误以为还需要再加一个季节 MA 项。所以这一步不是走形式,它直接决定后面 P、Q 取几个。
2.2 用 seasonal_decompose 把季节因子抠出来
先把手上的月度数据整理成两列 CSV,列名date和sales,单位统一(万辆或辆都行,全文别混用),日期统一落在每月 1 号。下面这段直接把趋势、季节、残差拆开,并把 12 个月的季节因子单独打出来看。
import pandas as pd import matplotlib.pyplot as plt from statsmodels.tsa.seasonal import seasonal_decompose # date 统一为月初,sales 单位万辆;asfreq("MS") 补齐全月索引,避免缺月导致后面 diff 错位 df = pd.read_csv("nev_sales_2014_2019.csv", parse_dates=["date"]) s = df.set_index("date").asfreq("MS")["sales"] # model="multiplicative":季节波动幅度随销量水平放大,故用乘性 # period=12:月度数据的年周期;extrapolate_trend 让首尾 6 个月的趋势值也能算出来 res = seasonal_decompose(s, model="multiplicative", period=12, extrapolate_trend="freq") # 按月份聚合季节因子:>1 为旺季,<1 为淡季,1 表示无季节效应 season_by_month = res.seasonal.groupby(res.seasonal.index.month).mean().round(3) print(season_by_month) fig, axes = plt.subplots(4, 1, figsize=(10, 9), sharex=True) s.plot(ax=axes[0], title="原始销量") res.trend.plot(ax=axes[1], title="趋势项") res.seasonal.plot(ax=axes[2], title="季节因子") res.resid.plot(ax=axes[3], title="残差") plt.tight_layout() plt.show()asfreq("MS")这一步经常被忽略,但它是后面所有差分、检验、预测对齐的前提——索引里混着月末和月初,diff(12)出来的就不是同月对同月,get_forecast的日期标签也会整体偏一个月。extrapolate_trend="freq"的作用是让序列首尾各 6 个月也有趋势值可用,否则这两年半的数据在分解图上是空白,看不出趋势是否被正确剥离。
跑完先看两件事:季节因子是不是围绕 1 上下、有没有某个月份的因子长期稳定在 1.5 以上;残差项有没有明显的一段一段的台阶。台阶说明趋势没剥干净,通常是分解方式选错了,回去改成加性再试一次对比。
2.3 ADF 检验与差分顺序:为什么最终 d=0、D=1
平稳性检验用 ADF 单位根检验,判断口径就一句话:p 值小于 0.05 拒绝存在单位根,序列可视为平稳;p 值大于 0.05 说明还有趋势或季节结构没消掉。下面这个封装函数把统计量、p 值、滞后阶和三个临界值一次打全,方便连着跑三次做对比。
from statsmodels.tsa.stattools import adfuller def adf_report(x, name): x = x.dropna() # 差分后首部会出现 12 个 NaN,必须先剔除 r = adfuller(x, autolag="AIC") # 用 AIC 自动挑滞后阶,别手写 maxlag print(f"{name}: stat={r[0]:.3f}, p={r[1]:.4f}, lags={r[2]}, n={r[3]}") for k, v in r[4].items(): print(f" 临界值 {k}: {v:.3f}") return r[1] adf_report(s, "原始序列") adf_report(s.diff(12), "仅季节差分 D=1") adf_report(s.diff(12).diff(1), "季节差分 + 一阶差分")参数上autolag="AIC"值得保留,手写滞后阶在只有 65 个点的样本上很容易把自由度吃光。临界值三个档(1%、5%、10%)要一起看:统计量比 5% 临界值更负才算过关,只看 p 值会被样本量小带来的偏差骗过去。
差分顺序上,先季节差分、再看要不要普通差分,是月度季节性数据的常规做法。这份资料最终取 d=0、D=1,也就是只做了一次季节差分(lag=12),普通差分一次都没做——一年一循环的结构在 lag=12 上被消掉的同时,上升趋势也被同步压平了,所以普通差分没必要再叠。要注意的是这条结论对数据很敏感:如果季节差分之后 ADF 的 p 值还在 0.2 以上,就老老实实加 d=1,别为了凑论文里的参数硬压。65 个点做一次季节差分只剩 53 个,再做一次普通差分只剩 52 个,样本越薄,后面定阶和检验的置信度越差,这也是为什么 d 能取 0 就别取 1。
3. 定阶:从 ACF/PACF 走到 ARIMA(1,0,0)(1,1,0)
定阶是整个流程里最像玄学的一段,但只要把「看图 + 自动搜索 + 系数显著性」三道关都过一遍,结果其实相当稳定。这份资料最终落在 (1,0,0)(1,1,0),六个参数里只有 p 和 P 非零,属于相当克制的设定——在只有 65 个观测的序列上,这是优点不是缺点。
3.1 六个参数各自管什么
先把这个记号拆开。ARIMA(p,d,q)(P,D,Q) 里,小写那组管非季节部分,大写那组管季节部分,季节部分的滞后是按 s 的整数倍跳的(这里 s=12,所以 lag 12、24、36)。很多人卡住不是因为不会算,而是没搞清六个参数各自对应的图特征。
| 参数 | 作用 | 本次取值 | 定阶依据 |
|---|---|---|---|
| p | 非季节自回归阶数,用过去 p 个月销量解释当月 | 1 | PACF 在 1 阶后截断,ACF 呈拖尾 |
| d | 非季节差分次数 | 0 | 季节差分后 ADF 已通过,无需再差分 |
| q | 非季节移动平均阶数 | 0 | ACF 无明显尖峰后拖尾 |
| P | 季节自回归阶数,滞后按 12 的倍数跳 | 1 | 季节 PACF 在 lag 12 处有显著峰 |
| D | 季节差分次数 | 1 | 年周期结构明显,一次季节差分足以平稳 |
| Q | 季节移动平均阶数 | 0 | lag 12 处 ACF 无持续显著峰 |
把这张表背下来的价值在于:任何一次定阶失败,你都能立刻定位是哪一项判错了。比如拟合出来残差在 lag 12 上还有大峰,第一反应应该是 D 或 P 不够,而不是去调 p。
3.2 读 ACF/PACF 图,再用 auto_arima 交叉验证
图画出来之后,两条规则够用:ACF 拖尾、PACF 在 k 阶后截断,取 p=k;ACF 在 k 阶后截断、PACF 拖尾,取 q=k;两条都拖尾,说明还缺差分或者需要混合项。季节部分同理,只是把观察位置挪到 lag 12、24。
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf d12 = s.diff(12).dropna() # 在已经平稳的序列上画图,别拿原始序列画 # lags 取 36(3 个完整年周期),不要超过样本量的 1/4,否则尾部全是噪声 fig, axes = plt.subplots(2, 1, figsize=(10, 6)) plot_acf(d12, lags=36, ax=axes[0]) plot_pacf(d12, lags=36, ax=axes[1], method="ywm") plt.tight_layout() plt.show()method="ywm"是偏相关估计的一种,样本量偏小时它比默认方法稳一些;lags=36对应三个年周期,够看出季节峰的衰减节奏,再往后的柱子基本是随机波动,看了也没用。
图看完之后,用自动搜索做一次交叉验证,重点不是让它替你决定,而是看它给的最优解和你看图得出的结论差在哪。
import pmdarima as pm auto = pm.auto_arima( s, seasonal=True, m=12, # 月度数据,季节周期 12 d=0, D=1, # 沿用前面 ADF 的结论,把差分固定住,别让搜索空间乱跑 start_p=0, max_p=3, start_q=0, max_q=2, start_P=0, max_P=2, start_Q=0, max_Q=1, information_criterion="aicc", # 小样本用 AICc,AIC 在 n<100 时容易过拟合 stepwise=True, trace=True, suppress_warnings=True, ) print(auto.summary())把d和D固定住是关键操作。放开让搜索自己挑,它经常在 d=0 和 d=1 之间来回跳,每次跳都对应不同的差分损失,AICc 之间根本没可比性。固定住差分,搜索空间就只剩 p、q、P、Q 四个维度,二十来次拟合就能收敛。
3.3 参数显著性、残差白噪声与两个 R 方的区别
自动搜索给出的只是「AICc 最小的候选」,能不能用还得看系数显著性和残差。用 statsmodels 拟合时有一处必须手动处理:order=(1,0,0)且 d=0 的情况下,SARIMAX默认trend="c"会带上常数项,而这份资料里的模型是不含常数项的,参数对不上十有八九就出在这里。
from statsmodels.tsa.statespace.sarimax import SARIMAX from statsmodels.stats.diagnostic import acorr_ljungbox model = SARIMAX( s, order=(1, 0, 0), seasonal_order=(1, 1, 0, 12), trend="n", # 关键:d=0 时默认加常数项,这里显式关掉 enforce_stationarity=False, # 季节差分后强行约束平稳会拖慢收敛,先放开 enforce_invertibility=False, ) fit = model.fit(disp=False) print(fit.summary()) # 残差白噪声检验:p 值 > 0.05 说明残差里没剩下可提取的自相关 lb = acorr_ljungbox(fit.resid, lags=[12, 24], return_df=True) print(lb)summary()里盯P>|z|一列,所有系数都要小于 0.05,只要有一项不显著,就说明这个参数是多余的,删掉重拟合。acorr_ljungbox的lags=[12, 24]对应年周期位置,这两个位置 p 值都得大于 0.05,模型才算把季节结构吃干净了。
两个 R 方的关系值得单独说一遍,因为这里最容易自我欺骗。资料里给出的平稳 R 方是 0.538,整体 R 方是 0.932,相差接近一倍。整体 R 方的 0.932 里有很大一块是趋势贡献的——销量从万辆级涨到二十万辆级,随便画条斜线都能拟合得不错。平稳 R 方剔除了趋势和季节成分,衡量的是模型对「波动」那部分的解释力,0.538 才是真实水平。只报 0.932 就宣布模型好,等于把趋势的功劳算到模型头上。
4. 回测:留出 2019 年 3—5 月,把误差算到月
拟合好看不代表预测能用。真正有说服力的是留出一段没参与训练的数据,逐月算相对误差,再去看误差集中在哪些月份。
4.1 训练集与验证集的切分位置
资料里写的是用 2014 年 1 月到 2019 年 5 月的数据建模,再用 2019 年 3—5 月做验证。严格按这个口径复现会有一个问题:3 月、4 月、5 月的数据已经进了训练集,模型相当于提前看过答案,回测误差会偏乐观。实操上应该把切点提前到 2019 年 2 月,训练集用 2014-01 到 2019-02 共 62 个点,验证集留 2019-03 到 2019-05 共 3 个点。这样得到的误差才有参考价值,也才能拿去和别人的结果横向比。
train = s.loc[:"2019-02-01"] # 62 个点,2014-01 ~ 2019-02 test = s.loc["2019-03-01":"2019-05-01"] # 3 个点,完全不参与训练 model = SARIMAX(train, order=(1, 0, 0), seasonal_order=(1, 1, 0, 12), trend="n") res = model.fit(disp=False) fc = res.get_forecast(steps=3) pred, ci = fc.predicted_mean, fc.conf_int(alpha=0.05)loc[:"2019-02-01"]依赖索引是按月频率的 DatetimeIndex,前面的asfreq("MS")就是在为这一步铺路。alpha=0.05给出 95% 置信区间,预测值落在区间内说明模型对自己的判断有把握,落在区间外说明这一期有模型没捕捉到的额外冲击。
4.2 预测值、置信区间与相对误差
out = pd.DataFrame({"实际": test.values, "预测": pred.values.round(2)}, index=test.index) out["下界"] = ci.iloc[:, 0].round(2) out["上界"] = ci.iloc[:, 1].round(2) out["相对误差%"] = ((out["预测"] - out["实际"]).abs() / out["实际"] * 100).round(1) print(out) print("MAPE = {:.1f}%".format(out["相对误差%"].mean()))对照资料里的结果,实际值 12.6、9.7、10.1 万辆,预测值 11.5、9.2、9.5 万辆,对应相对误差 9%、5%、6%,三个月 MAPE 约 6.6%。三个误差的分布有一个共同点:预测值全部低于实际值。这不是巧合,是后面 4.3 要讲的均值回归效应在起作用,也是这份模型最需要知道的能力边界。
relative_error用绝对值除以实际值而不是除以预测值,这一点在销售场景里很重要——分母用实际值,误差百分比才对应「这一期实际卖了 100 辆,我猜偏了 9 辆」这种业务上说得通的表述。
4.3 极值月份为什么总是被拉回均值
资料里点得很直白:2018 年 12 月销量冲到 22.5 万辆,最低月份只有 3.4 万辆,量级差 6.6 倍,模型在个别时段拟合和预测误差明显偏大。原因不在于参数没调好,而在于 ARIMA 本质上是一个线性模型,它的预测值永远是历史值的加权组合,天然带均值回归倾向——序列冲高时它预测不足,序列探底时它预测偏高。
这个特性在图上表现为:拟合线整体走势和实际线贴得很近,但所有尖峰都被削平了一截。想缓解只能从三个方向入手。一是对序列取对数再做模型,把乘性波动转成加性,峰值被压缩;二是引入干预变量或虚拟变量,把已知会造成异常高点的月份单独标注;三是换模型,季节性 ARIMA 换成能处理非线性和外生变量的方法。三条路各有代价,取对数会让预测值的反变换变麻烦(要乘回指数并做偏差修正),加虚拟变量在只有 65 个点的样本上自由度很紧张,换模型则丢掉了这份资料的可复现性。
更实用的做法是接受它,并把预测窗口卡死。资料里明确写了:预测主要针对最近 3 个月的销量,超出 3 个月范围的预测结果不具有预测意义。这句话是整份资料里最有工程价值的一句——时间序列的「近大远小」效应决定了信息越远衰减越快,销售旺季之前那两三个月是模型最准的区间,把 12 个月的预测直接拿去做年度排产,翻车只是时间问题。
5. 避坑排查:五条踩过的坑,每条都有现象和解决
这一章是我在复现这类季节性销量模型时反复撞上的地方,按「看到什么现象、什么原因、怎么改」写,对上号了直接改代码就行。
5.1 验证集混进了训练集
现象:回测跑出来的相对误差全在 3% 以内,MAPE 好到不像真的,一比资料的 9%/5%/6% 还优秀。
原因:训练集切到了 2019-05,验证集取的又是 2019-03 到 2019-05,验证样本已经在训练时被模型看过一遍。用get_forecast拿到的所谓「预测值」其实是样本内拟合值。
解决:把切点提前到验证集开始的前一个月,训练集用loc[:"2019-02-01"],验证集用loc["2019-03-01":"2019-05-01"]。跑完顺手打个len(train) + len(test) == len(s)断言,能挡住绝大多数手滑。
5.2 diff() 忘了 lag=12
现象:季节差分做完,ACF 图上 lag 12 和 lag 24 仍然有很高的柱子,怎么调 P、Q 都压不下去,AICc 越搜越大。
原因:s.diff()的默认参数是lag=1,做的是普通一阶差分,年周期结构一点没动。图上那些 lag 12 的峰就是没被消掉的季节成分。
解决:季节差分显式写s.diff(12)。同时检查 D 的取值和代码是否一致——seasonal_order=(1,1,0,12)里的第三个 1 对应 D,别写成(1,0,0,12)。
5.3 拿整体 R 方 0.932 当护身符
现象:模型报告整体 R 方 0.932,看着非常漂亮,直接拿去汇报,结果峰值月份误差超过 15%,被问为什么和拟合优度对不上。
原因:整体 R 方把趋势成分算进去了。销量从万辆级涨到二十万辆级,这条上升趋势本身就能解释掉大部分方差,模型哪怕只画了一条斜线,R 方也不会低。
解决:看平稳 R 方(这份资料里是 0.538),它剔除了趋势和季节成分,反映的是对波动的解释力。再加上残差 Ljung-Box 检验和分月相对误差表,三个口径一起看。任何只报一个 R 方的结果都值得警惕。
5.4 SPSS 定阶结果照搬到 statsmodels 对不上
现象:SPSS 给出的最优模型是 ARIMA(1,0,0)(1,1,0),平稳 R 方 0.538、整体 R 方 0.932;同样的参数在 statsmodels 里跑,系数值、AIC 全都不一样,甚至连正负号都能反过来。
原因:两边默认设定不同。statsmodels 在order=(1,0,0)且 d=0 时会默认加常数项(trend="c"),而这个模型本身不含常数项;SPSS 的平稳 R 方定义和 statsmodels 的rsquared也不是同一个东西;季节周期的设定、是否中心化移动平均,各家实现细节都有差异。
解决:不要试图把两边的数字对齐,只对齐三件事——参数组合、系数显著性、残差白噪声检验。statsmodels 里显式写trend="n"关掉常数项,然后以P>|z| < 0.05和acorr_ljungbox的 p 值为准。跨工具比参数组合可以,比 R 方没有意义。
5.5 把 3 个月预测拉长成 12 个月
现象:三个月内的预测误差还能接受,一旦把steps改成 12 拉全年,后半段的置信区间宽得能塞进两个数量级,点预测也完全脱离实际。
原因:时间序列的「近大远小」效应,加上这份数据本身样本期短(2014 年才开始统计,65 个点),季节差分会持续消耗样本,越往后预测的方差越大。资料自己也写了,超出 3 个月范围的预测结果不具有预测意义。
解决:把预测窗口硬编码成 3,写成一个常量而不是随手传参。需要更长期的时候不要加steps,改成每月重训一次、每次只往前走一步的滚动模式,也就是下一章要写的东西。
6. 进阶:把一次性预测改成每月滚动的重训脚本
一次性切分回测有个天然缺陷:只在某一个时间点上验证了三个月的误差,样本量太小,三个点算出来的 MAPE 说服力有限。真正能拿去做决策的验证方式,是滚动起点回测——每次用一个不断扩张的窗口训练,只预测下一步,然后把所有的「实际 vs 预测」对收集起来统一算误差。这样在 62 个可训练点上能攒出几十组对比,误差分布才看得清形状。
import numpy as np import pandas as pd from statsmodels.tsa.statespace.sarimax import SARIMAX def rolling_one_step(series, order, sorder, min_train=36): """扩张窗口 + 一步预测:每次只往前走一个月,训练集不断变长""" rows = [] for i in range(min_train, len(series)): train, target = series.iloc[:i], series.iloc[i] m = SARIMAX(train, order=order, seasonal_order=sorder, trend="n", enforce_stationarity=False, enforce_invertibility=False) pred = m.fit(disp=False).get_forecast(steps=1).predicted_mean.iloc[0] rows.append({"预测起点": train.index[-1], "目标月": series.index[i], "实际": target, "预测": round(pred, 2), "APE%": round(abs(pred - target) / target * 100, 1)}) return pd.DataFrame(rows) bt = rolling_one_step(s, order=(1, 0, 0), sorder=(1, 1, 0, 12), min_train=36) print(bt.describe()) print("滚动 MAPE = {:.1f}%".format(bt["APE%"].mean()))min_train=36是硬性下限,三年数据才够让一个季节差分后的模型估出稳定的季节 AR 系数,低于这个数拟合出来的东西不能信。enforce_stationarity=False是为了收敛速度,代价是极端情况下 AR 系数可能落在单位圆外,所以跑完必须检查bt["APE%"].max(),出现三位数误差的那一行要单独拎出来看,通常是某个异常月份把模型带偏了。
跑完拿到的这张表,用途比一个 MAPE 数字大得多。按月份聚合一次 APE 均值,能看出模型在哪些月份系统性偏乐观或偏悲观;按预测起点排序看 APE 随时间的变化,能判断模型是不是随着样本增加在变好。
| 检查项 | 计算方式 | 判断标准 |
|---|---|---|
| 整体 MAPE | 所有 APE 的均值 | 月度销量场景下 10% 以内可接受 |
| 分月 MAPE | 按目标月份 groupby 后取均值 | 淡季月份不应显著高于旺季 |
| 误差符号分布 | APE 带符号后统计正负比例 | 单边偏差超过 70% 说明存在系统性问题 |
| 最大单点误差 | APE 的最大值 | 若由已知异常月造成,可加虚拟变量单独处理 |
最后说个习惯。这套流程我一开始也嫌麻烦,直到有一次拿三个月预测去支撑半年的排产计划,被现实按在地上摩擦——前三个月误差 6%,第四个月开始直接跑偏。从那以后我每次拿到月度序列,都强制先跑一遍 ADF、残差 Ljung-Box 和滚动回测这三道关,三个都过了才允许自己谈精度,预测窗口也一律硬编码成 3 个月,需要更远就每月重训。这份资料的 PDF 我放在文末的获取入口,连同我整理好的两列 CSV 模板(date加sales)和一版可跑的复现脚本,拿去把nev_sales_2014_2019.csv换成你自己的序列就能直接跑。希望帮到你。
本文还有配套的精品资源,点击获取