简介:这份资源面向具备一定R语言基础、希望深入掌握时间序列建模的数据分析与预测从业者,聚焦加法与乘法过程两类核心思路,并延伸至广义可加模型(GAM)的非线性建模场景。压缩包内共1个文件,为R脚本,整体约1KB,可直接在R环境中加载运行,便于对照代码理解建模流程。内容围绕时间序列的基本构成展开,涉及趋势、季节性与随机成分的线性相加与相互影响两种假设,并串联ARIMA、SARIMA、STL分解以及基于mgcv包的gam()拟合等实现路径,同时涵盖数据加载、模型选择、参数调优与残差诊断等环节。目前已有618人学习下载,适合作为经济、金融、气象等领域预测任务的实操参考,帮助读者把抽象模型转化为可复用的代码框架,并借助GAM样条项捕捉复杂非线性趋势。
1. 时间序列的加法与乘法:为什么你的预测总在趋势拐点翻车
很多人做时间序列预测时,拿到数据第一件事就是往 ARIMA 或 Prophet 里一塞,跑出来一条平滑曲线,看着拟合得不错,一到真实业务里就发现:趋势一变,预测全废。问题往往不在模型本身,而在于你默认了一个假设——这个序列是「加法结构」还是「乘法结构」。这两个词听起来像统计学课本里的老古董,但在 R 语言里用 GAM(广义可加模型)做时间序列分解和预测时,它直接决定了你的模型是「能跟着趋势走」还是「一到旺季就低估」。
加法模型假设各成分独立叠加:观测值 = 趋势 + 季节 + 残差。乘法模型假设成分之间是比例关系:观测值 = 趋势 × 季节 × 残差。现实业务里,销售额随趋势增长时季节性波动幅度也在放大,这就是典型的乘法结构。如果你硬用加法模型去拟合,残差会呈现明显的异方差,预测区间在高峰期窄得离谱,低谷期又宽得没用。R 语言里的 GAM 通过mgcv包提供了灵活的平滑项,可以让你在同一个框架下处理这两种结构,甚至混合结构。这篇文章面向的是已经会用 R 做基本数据分析、但想在时间序列上把 GAM 用对的从业者。我会把加法与乘法的选择逻辑、GAM 的平滑项设置、以及实际跑通一套分解加预测的流程讲清楚,顺带把那些让我翻过车的参数坑标出来。
2. 加法与乘法结构的判定:先看残差,再谈模型
2.1 从业务场景反推结构类型
拿到一条时间序列,不要急着画图。先问一个业务问题:当趋势上升 10% 时,季节波动的绝对幅度是保持不变,还是也跟着涨 10%?如果是前者,加法结构更合理;如果是后者,乘法结构更合理。这个判断不需要任何统计检验,靠业务常识就能定调。
举个例子,某电商平台的日订单量。过去两年整体趋势从每天 1000 单涨到 3000 单,同时「双十一」当天的峰值从 5000 单涨到 15000 单。峰值与均值的比例大致稳定在 5 倍左右,这就是乘法结构的典型信号。反过来,如果是一个成熟产品的日活用户,趋势基本平缓,周末比工作日固定少 2000 人,那加法结构就够了。
在 R 里,你可以用forecast包的decompose()函数分别跑加法和乘法分解,然后对比残差图。乘法分解的残差如果比加法分解更接近白噪声,且残差绝对值不随趋势增大而膨胀,那就选乘法。
library(forecast) # 假设 ts_data 是一个 ts 对象,频率为 7(周季节) # 加法分解 decomp_add <- decompose(ts_data, type = "additive") # 乘法分解 decomp_mult <- decompose(ts_data, type = "multiplicative") # 对比残差的标准差随趋势的变化 # 如果乘法分解的残差更稳定,选乘法 sd_add <- sd(decomp_add$random, na.rm = TRUE) sd_mult <- sd(decomp_mult$random, na.rm = TRUE) # 更直观的方法:看残差与趋势的相关系数 trend_add <- decomp_add$trend resid_add <- decomp_add$random cor_add <- cor(trend_add, abs(resid_add), use = "complete.obs") trend_mult <- decomp_mult$trend resid_mult <- decomp_mult$random cor_mult <- cor(trend_mult, abs(resid_mult), use = "complete.obs") cat("加法残差与趋势相关系数:", cor_add, "\n") cat("乘法残差与趋势相关系数:", cor_mult, "\n")这段代码的逻辑是:如果残差的绝对值和趋势有正相关,说明波动幅度随趋势增长,加法模型就不合适。参数上,type指定分解类型,frequency在ts()里设定。注意decompose()只适合季节性固定的序列,如果季节模式在变,得用stl()或 GAM。
2.2 用 GAM 的平滑项同时捕捉趋势与季节
GAM 的优势在于它不预设趋势是线性的,也不预设季节是固定的。mgcv包里的gam()函数可以用s()指定平滑项,用te()指定交互项。对于时间序列,我一般这样建:
library(mgcv) # 构造时间索引和季节索引 n <- length(ts_data) time_idx <- 1:n season_idx <- cycle(ts_data) # 1 到 frequency # 加法结构 GAM gam_add <- gam(ts_data ~ s(time_idx, k = 20) + s(season_idx, k = 7, bs = "cc"), family = gaussian()) # 乘法结构:先取对数,再跑加法 GAM gam_mult <- gam(log(ts_data) ~ s(time_idx, k = 20) + s(season_idx, k = 7, bs = "cc"), family = gaussian())这里的关键参数是k,它控制平滑项的最大自由度。k太小会欠拟合,趋势被抹平;k太大会过拟合,把噪声当信号。我一般从 10 到 30 之间试,用gam.check()看残差和k的显著性。bs = "cc"是循环平滑,专门用于季节索引这种周期性变量,不加这个,12 月和 1 月之间会出现断点。
乘法结构取对数后跑加法 GAM,等价于在原始尺度上做乘法分解。预测时记得exp()回来,但要注意偏差修正——直接exp()会低估均值,因为对数正态分布的均值是exp(mu + sigma^2/2)。这个坑我后面会细说。
2.3 模型选择的量化依据:AIC 与残差诊断
加法 GAM 和乘法 GAM 跑完后,不能只看图。AIC()可以比较,但注意乘法模型是在对数尺度上算的,AIC 不能直接和加法模型比。正确做法是:把两个模型的预测值都变换回原始尺度,算 RMSE 或 MAE,用交叉验证比较。
# 样本外交叉验证:留出最后 30 个点 train_n <- n - 30 train_data <- ts_data[1:train_n] test_data <- ts_data[(train_n + 1):n] # 重新拟合加法模型 gam_add_train <- gam(train_data ~ s(1:train_n, k = 20) + s(cycle(train_data), k = 7, bs = "cc")) # 重新拟合乘法模型 gam_mult_train <- gam(log(train_data) ~ s(1:train_n, k = 20) + s(cycle(train_data), k = 7, bs = "cc")) # 预测 pred_add <- predict(gam_add_train, newdata = data.frame( time_idx = (train_n + 1):n, season_idx = cycle(test_data) )) pred_mult_log <- predict(gam_mult_train, newdata = data.frame( time_idx = (train_n + 1):n, season_idx = cycle(test_data) )) pred_mult <- exp(pred_mult_log) # 计算 RMSE rmse_add <- sqrt(mean((test_data - pred_add)^2)) rmse_mult <- sqrt(mean((test_data - pred_mult)^2)) cat("加法 RMSE:", rmse_add, "\n") cat("乘法 RMSE:", rmse_mult, "\n")这段代码的核心是「用样本外误差说话」。参数上,k在训练集上重新选,不要用全量数据调好的k直接套。cycle()提取季节位置,predict()的newdata必须包含和训练时同名的变量。如果乘法模型的 RMSE 明显更低,且残差没有异方差,那就选乘法。
3. 在 R 里跑通 GAM 时间序列:从数据到预测的完整链路
3.1 数据准备与 ts 对象构造
R 里做时间序列,第一步是把数据转成ts对象。很多人从 CSV 读进来直接跑,结果cycle()返回 NULL,季节平滑项直接报错。正确做法是明确频率和起始时间。
# 假设 raw_data 是数据框,date 列是日期,value 列是观测值 raw_data$date <- as.Date(raw_data$date) # 按日期排序 raw_data <- raw_data[order(raw_data$date), ] # 构造 ts 对象,频率为 7(周数据) ts_data <- ts(raw_data$value, frequency = 7, start = c(year(min(raw_data$date)), as.numeric(format(min(raw_data$date), "%j")))) # 检查 cat("频率:", frequency(ts_data), "\n") cat("周期数:", length(ts_data) / frequency(ts_data), "\n") head(cycle(ts_data))frequency = 7表示每周 7 个观测,start参数指定起始年份和年内第几天。如果频率设错,比如日数据设成 365,cycle()会返回 1 到 365,季节平滑项k就得设得很大,计算量爆炸且容易过拟合。常见做法是:日数据用frequency = 7捕捉周内模式,或者frequency = 365.25捕捉年内模式,但后者需要k至少 50 以上,我一般先用周频率跑通再考虑年频率。
3.2 GAM 平滑项的参数设置与 gam.check 解读
gam()函数里最关键的三个参数:k、bs、family。k是基函数维度,决定平滑曲线的最大弯曲次数。bs是基函数类型,"tp"是薄板回归样条(默认),"cc"是循环三次样条(用于周期变量),"cr"是三次回归样条(计算更快)。family指定分布族,高斯用于连续值,泊松用于计数,负二项用于过离散计数。
# 完整模型拟合 gam_fit <- gam(ts_data ~ s(time_idx, k = 25, bs = "tp") + s(season_idx, k = 7, bs = "cc"), family = gaussian(), method = "REML") # 用 REML 估计平滑参数 # 检查 gam.check(gam_fit)gam.check()输出四张图:残差 vs 拟合值、QQ 图、直方图、响应 vs 拟合值。重点看第一张:如果残差呈现漏斗形(随拟合值增大而扩散),说明方差非恒定,加法高斯模型不合适,要么换乘法(取对数),要么换family = Gamma。QQ 图如果尾部偏离,说明残差非正态,预测区间会不准。
method = "REML"是我强烈建议的,默认的 GCV 在样本量小的时候容易过拟合,REML 更稳健。这个参数不设,gam()会用 GCV,残差诊断经常显示k不够,其实换了 REML 就好了。
3.3 预测与置信区间:对数变换后的偏差修正
乘法模型预测时,predict()返回的是对数尺度上的值,exp()回去之后得到的是中位数,不是均值。如果业务要的是均值预测,必须加偏差修正。
# 乘法模型预测(对数尺度) pred_log <- predict(gam_mult, newdata = data.frame( time_idx = (n + 1):(n + 30), season_idx = rep(1:7, length.out = 30) ), se.fit = TRUE) # 偏差修正:exp(mu + sigma^2/2) sigma2 <- summary(gam_mult)$scale pred_mean <- exp(pred_log$fit + sigma2 / 2) # 置信区间(近似) lower <- exp(pred_log$fit - 1.96 * pred_log$se.fit) upper <- exp(pred_log$fit + 1.96 * pred_log$se.fit) # 注意:这个区间是条件均值区间,不是预测区间 # 预测区间还要加残差方差 pred_lower <- exp(pred_log$fit - 1.96 * sqrt(pred_log$se.fit^2 + sigma2)) pred_upper <- exp(pred_log$fit + 1.96 * sqrt(pred_log$se.fit^2 + sigma2))summary(gam_mult)$scale提取的是残差方差估计。偏差修正项sigma2 / 2看起来小,但当sigma2大的时候,不修正会低估均值 5% 到 10%,在库存补货场景里就是真金白银的差距。置信区间和预测区间是两回事:前者是均值的区间,后者是单次观测的区间。业务上要「明天可能卖多少」,用预测区间;要「明天平均卖多少」,用置信区间。
4. 避坑与排查:那些让我重跑模型的参数陷阱
4.1 现象:gam.check 显示 k 不够,调大后过拟合
原因:k的默认值是 10,对于趋势变化剧烈的序列,10 个基函数不够弯曲。但直接调到 50,模型会把噪声也拟合进去,样本外误差反而上升。
解决:先用gam.check()看残差是否还有模式。如果残差已经像白噪声,k就够了,不用管 p 值。如果残差还有趋势,每次加 5,直到残差无模式。同时用method = "REML",它比 GCV 更不容易过拟合。我一般从k = 20开始,趋势项和季节项分开调。
4.2 现象:乘法模型预测值在低谷期为负
原因:对数变换后预测再exp()回去,理论上不会负。但如果用了family = gaussian()且没有取对数,直接跑乘法结构(比如把季节项写成乘积形式),线性预测子可能为负。
解决:乘法结构必须在对数尺度上建模。log(ts_data)后跑加法 GAM,exp()回来。如果原始数据有零或负值,先加一个常数再取对数,或者改用family = Gamma(link = "log"),后者直接在对数链接函数上建模,不需要手动变换。
4.3 现象:季节平滑项 bs = "cc" 报错「A term has fewer unique covariate combinations than specified maximum degrees of freedom」
原因:k设得比季节周期的唯一值数量还大。比如周频率frequency = 7,season_idx只有 7 个唯一值,k设成 10 就报错。
解决:k必须小于等于唯一值数量。周频率用k = 7,月频率用k = 12,日频率用k = 7(周内模式)或k = 365(年内模式,但计算量大)。如果确实需要更大的k,改用bs = "tp"并手动构造傅里叶项。
4.4 现象:预测区间在趋势上升段明显偏窄
原因:GAM 的predict(se.fit = TRUE)只给了平滑项的不确定性,没有包含残差方差。而且如果模型是加法结构但数据实际是乘法结构,残差方差会随趋势增大,区间估计系统性偏窄。
解决:预测区间用sqrt(se.fit^2 + sigma2),不要只用se.fit。同时检查残差是否异方差,如果是,换乘法结构或family = Gamma。我习惯在预测后画残差 vs 拟合值图,确认没有漏斗形才发结果。
4.5 现象:时间索引 time_idx 从 1 到 n,预测时新数据的时间索引对不上
原因:训练时time_idx是 1 到 n,预测时新数据的time_idx必须从 n+1 开始。如果直接用1:30,模型会以为你在预测历史区间。
解决:预测时构造newdata的time_idx用(n+1):(n+h),season_idx用cycle()或手动指定。更稳妥的做法是把时间索引存成数据框的一列,训练和预测用同一套构造逻辑。
5. 进阶技巧:用 te() 捕捉趋势与季节的交互效应
5.1 什么时候需要交互项
加法 GAM 假设趋势和季节是独立作用的:趋势上升不影响季节波动的形状。但现实中,很多序列的季节模式会随趋势变化。比如一个产品刚上市时只有周末卖得好,随着市场成熟,工作日销量也上来了,季节波动的幅度和形状都变了。这时候s(time_idx) + s(season_idx)就不够了,需要用te(time_idx, season_idx)张量积平滑来捕捉交互。
# 张量积交互模型 gam_te <- gam(ts_data ~ te(time_idx, season_idx, k = c(15, 7), bs = c("tp", "cc")), family = gaussian(), method = "REML") # 对比:无交互模型 gam_no_te <- gam(ts_data ~ s(time_idx, k = 15) + s(season_idx, k = 7, bs = "cc"), family = gaussian(), method = "REML") # 用 AIC 比较(同一尺度下) cat("交互模型 AIC:", AIC(gam_te), "\n") cat("无交互 AIC:", AIC(gam_no_te), "\n")te()的参数k是一个向量,分别对应两个变量的基函数维度。bs也是向量,对应各自的基函数类型。交互模型的自由度是k[1] * k[2]的量级,计算量比加法模型大很多,样本量少于 200 时慎用,容易过拟合。我一般先跑加法模型,如果残差在特定时间段(比如旺季)还有系统偏差,再上交互项。
5.2 用 vis.gam 可视化交互效应
mgcv自带的vis.gam()可以画三维透视图或等高线图,直观看到季节模式如何随趋势变化。
# 三维透视图 vis.gam(gam_te, view = c("time_idx", "season_idx"), theta = 30, phi = 20, color = "heat", plot.type = "persp", ticktype = "detailed") # 等高线图 vis.gam(gam_te, view = c("time_idx", "season_idx"), plot.type = "contour", color = "heat")view指定两个变量,theta和phi控制视角,plot.type选"persp"或"contour"。等高线图里,如果等高线是平行的,说明没有交互;如果弯曲或交叉,说明季节模式随趋势变了。这个图我每次做完交互模型都会看,比 AIC 更直观。
5.3 一个我常犯的错误:交互项加了但没做预测对比
刚用te()的时候,我看到 AIC 降了就以为交互模型更好,直接拿去预测,结果样本外 RMSE 反而更高。后来养成习惯:不管 AIC 多低,一定做样本外交叉验证。交互模型的方差更大,样本量不够时,AIC 的惩罚项不足以抵消过拟合。
# 样本外对比 train_n <- n - 30 gam_te_train <- gam(ts_data[1:train_n] ~ te(1:train_n, cycle(ts_data[1:train_n]), k = c(15, 7), bs = c("tp", "cc")), family = gaussian(), method = "REML") pred_te <- predict(gam_te_train, newdata = data.frame( time_idx = (train_n + 1):n, season_idx = cycle(ts_data[(train_n + 1):n]) )) rmse_te <- sqrt(mean((ts_data[(train_n + 1):n] - pred_te)^2)) cat("交互模型样本外 RMSE:", rmse_te, "\n")如果rmse_te比加法模型的样本外 RMSE 还大,说明交互项在拟合噪声,果断回退。这个习惯让我少了很多「AIC 好看但业务翻车」的情况。
5.4 最后的习惯:先画图,再跑模型
我现在拿到任何时间序列,第一件事是plot(ts_data),第二件事是plot(decompose(ts_data))或plot(stl(ts_data, s.window = "periodic"))。图上看一眼趋势和季节的形态,比任何统计检验都快。加法还是乘法,很多时候图上一眼就能定:如果季节波动的幅度随趋势明显放大,乘法;如果幅度稳定,加法。GAM 只是把这个判断量化并给出预测区间。希望帮到你。
本文还有配套的精品资源,点击获取