最近后台一直有人在问时间序列预测该选什么模型,做课程设计或者工程项目时,既想快速出结果,又想把原理讲得明明白白。我给出的答案里,反复出现一个名字——线性回归。别看深度学习的声势一年比一年大,在时间序列预测这件事上,线性回归(LR)依然是工业界和学术界最常见的baseline,没有之一。尤其是用Matlab来做,核心求解过程真的可以简洁到夸张:一个反斜杠运算符就能把回归系数求出来。这篇内容就把“基于线性回归的时间序列预测”完整讲透,覆盖特征构造、滞后期选择、多步预测策略、完整代码,以及我实际调试中踩过的坑。网上这类教程铺天盖地都是Python版,Matlab版反倒零散,这里一次性补齐。
1. 线性回归做时间序列预测:为什么过了这么多年仍然值得用
1.1 深度学习时代,为什么还要把LR当回事
现在聊时间序列预测,大家开口就是LSTM、Transformer、Informer这些,似乎不用深度模型就落伍了。但实际做项目的时候你会发现,深度学习模型的调参成本和数据需求都远超预期,在没有足够数据、算力和时间的情况下,复杂模型很容易变成“精美的过拟合机器”。
线性回归的价值在于它是一个足够聪明的参照系。
第一,可解释性强。回归系数直接告诉你过去第几天的数据对当前预测影响有多大,是正相关还是负相关,这在业务汇报、论文撰写、课程答辩里都非常好讲。第二,训练成本几乎为零。Matlab里矩阵求逆加一个反斜杠运算符,百万级以下的数据量都是秒出结果。第三,它是检验特征工程是否有效的放大镜。如果你加了某个特征之后,LR的误差显著下降,说明这个特征确实携带了预测信息;如果LR怎么调都不行,那基本可以断定数据里没有线性可分的模式,这时候再上复杂模型才有意义。
所以在我的项目流程里,拿到一条时间序列的第一件事永远是先跑LR,把这个“及格线”画出来。后续无论换什么模型,心里都有底。
1.2 核心思路:把“顺序问题”硬生生变成“表格问题”
时间序列预测和普通回归最本质的区别在于:普通回归的样本之间是独立的,而时间序列的样本不是——它们是按时间顺序排列的,彼此之间存在先后依赖。
怎么让线性回归这种本来不关心顺序的模型来处理顺序数据?
答案是滑窗构造特征。
假设你有一组按小时记录的电力负荷数据 y1, y2, y3, ..., yn,你想预测 yt。线性回归不可能直接理解“t-1时刻在前、t时刻在后”这种顺序关系,但它能理解数字之间的函数关系。所以我们要做的事情是:把过去 p 个时刻的值作为特征,把当前时刻的值作为目标,构造出一张“表格”。
举个例子,假设 p = 3,那么训练数据长这样:
| 特征1(yt-3) | 特征2(yt-2) | 特征3(yt-1) | 目标(yt) |
|---|---|---|---|
| y1 | y2 | y3 | y4 |
| y2 | y3 | y4 | y5 |
| y3 | y4 | y5 | y6 |
这样一构造,问题就彻底变了:原来是一串按时间排列的序列,现在变成一个普通的多元线性回归问题,y ≈ beta0 + beta1 * yt-3 + beta2 * yt-2 + beta3 * yt-1。模型完全不关心时间顺序本身,它只负责学出这组系数。
这就是线性回归做时间序列预测的全部秘密:把顺序问题转化为回归问题。后面所有的操作,包括代码怎么写、滞后期怎么选、多步预测怎么做,都是围绕这张“表格”展开的。
1.3 这类模型到底擅长捕捉哪些模式
搞清楚LR的适用范围非常重要,否则你会被它的失败坑得很惨。
线性回归本质上是学一条“直线”或“超平面”去拟合数据,所以它对以下三类模式表现不错:
第一,线性趋势。数据整体呈现稳定上升或下降,比如月销售额逐步攀升、温室内温度缓慢升高,这类趋势LR几乎白捡。
第二,短期惯性依赖。很多序列在相邻时刻高度相关,比如今天的气温、水位、流量,和昨天、前天往往强相关。只要这种相关是线性的,滞后特征就能很好地捕捉。
第三,叠加了周期成分的均值回归,比如正弦波加上噪声。LR拟合出来的其实是对周期成分的分段线性逼近,能大致跟随波峰波谷,相位上会有轻微延迟。
反过来,如果数据存在明显的非线性突变、指数级增长、状态切换(比如某个系统突然从正常模式切换到故障模式),线性回归就会力不从心。这不是模型写错了,而是线性模型本身表达能力的边界。
还有一个容易忽略的点:LR在“插值”范围之外几乎没有外推能力。测试集的时间点如果超出训练集的时间范围,预测本质上是沿着回归超平面外推,误差会随着预测距离拉远而快速膨胀。这是多步预测误差累积的根本原因,后面细说。
2. 建模前的三个决定:平稳性、滞后阶数、预测步数
2.1 数据拿到手,先做平稳性检查
很多人拿到数据就直接塞进模型,这是时间序列预测里最危险的习惯。
线性回归假设特征和目标之间的关系是稳定的,也就是说,数据的统计性质(均值、方差、自相关结构)不能随时间发生剧烈变化。如果原始序列带长期趋势或者波动幅度不断增大,回归系数会被“带偏”,预测后期基本失效。
拿Matlab操作来说,最简单粗暴的办法是画图观察。plot(1:n, y)之后,看均值是否在某个水平附近震荡、方差是否大致恒定。严谨一点,用Econometrics Toolbox里的adftest做单位根检验:
[h, pValue] = adftest(y);h = 1表示序列平稳,h = 0表示存在单位根,也就是非平稳。如果没有这个工具箱,也可以用差分判断:对序列做一阶差分 diff(y),如果差分后的序列看起来平稳了,那么原始序列大概率是I(1)过程。
如果发现数据带趋势,最常用的两个对策:
- 先做一阶差分,对差分序列建模。预测得到差分值后,再累加回原始尺度。
- 在特征矩阵里增加一列“时间序号”1, 2, 3, ...,让线性回归自己学一个时间趋势项。
个人经验:带明显上升趋势的温度、流量数据,加入时间序号效果立竿见影;对于周期性很强的电力负荷数据,差分往往更管用。两个都试一下,选测试集误差小的方案。
2.2 滞后期怎么定:别拍脑袋,用自相关函数说话
滞后阶数 p 是整个方法里最关键的超参数,没有之一。p 太小,模型学不到足够的“记忆”;p 太大,特征维度膨胀,还容易引入共线性,系数变得极不稳定。
判断 p 的主要工具是自相关函数(ACF)。对序列计算滞后 k 阶的自相关系数,看它衰减到什么程度。Matlab里可以用autocorr直接绘图:
figure; autocorr(y, 30);如果0到2阶自相关系数都很高,3阶开始明显跌到置信区间以内,那 p 取2到3就够了。很多实际场景里,p 取3已经能够覆盖绝大多数短周期依赖。这背后的直觉是:线性模型对过去信息的利用是“直接叠加”的,滞后2阶和滞后3阶之间往往高度相关,所以多加几阶的边际收益很小,反而容易把噪声放进模型。
更系统的做法是网格搜索。在训练集上对 p 从1到10遍历,训练模型并在验证集上算RMSE,画一条折线,选择RMSE开始平台化或开始上升的位置。这套流程在后面的完整代码里我会给出。
2.3 预测策略:单步、直接多步、滚动多步
很多新手做多步预测时的做法是:训练好模型之后,把测试集的全部特征一次性喂进去,得到所有预测值。这在学术评估上勉强说得通,但真实场景里完全不成立。真实预测是站在当前时刻,没有任何“未来的过去值”可用的。
所以多步预测必须明确策略,常见有三种:
单步预测。只预测下一时刻,等真实值出来了再滚动。这是最简单、最可靠的方式,误差最小,但无法提前得到远期预判。
直接多步预测。想预测未来h步,就分别训练h个模型,第1个模型用 yt 的特征预测 yt+1,第2个模型用 yt 的特征预测 yt+2,以此类推。每个模型各学各的目标,互不干扰,不会把预测误差像滚雪球一样传递下去。代价是训练量变成原来的 h 倍。
滚动(递归)多步预测。先用模型预测 yt+1,然后把这个预测值当作新特征的一部分,继续预测 yt+2,一步一步“自己喂自己”。实现简单,但预测误差会叠加,尤其是超过5步之后误差常常呈指数放大。
实际项目里,我用得最多的是直接多步预测和滚动多步预测的结合:短期几步用滚动,中远期用直接模型。代码部分我会把直接多步预测的实现放进去。
2.4 划分训练集和测试集:时间序列不能乱切
这个坑我见人踩过无数次,包括我自己早期也犯过。
普通机器学习的训练集和测试集常常是随机划分,但时间序列绝对不能这么做。原因很简单:你把一条序列顺序打乱之后,每个样本的“历史上下文”就被破坏了,测试集里可能出现“未来数据帮助预测过去”的荒谬情况,最终得到的评估指标虚高得离谱。
正确做法是严格按时间顺序切分。比如前80%的时间段作为训练集,后20%作为测试集:
train_len = floor(0.8 * size(X, 1)); X_train = X(1:train_len, :); y_train = y(1:train_len); X_test = X(train_len + 1:end, :); y_test = y(train_len + 1:end);另外还要提醒一点:如果先对整个序列做归一化,再划分训练测试,统计量(均值、标准差)会用到测试集的信息,这属于特征泄露。严格讲,应该用训练集的均值和标准差去归一化训练集,再用同一组参数归一化测试集。小规模实验很多人嫌麻烦直接整段归一化,问题不大,但你要知道自己做了取巧。
3. Matlab完整实现:从特征矩阵、回归求解到多步预测
3.1 手写滞后特征构造函数
Matlab不像Python的pandas那样方便地做shift操作,但写一个构造滞后特征矩阵的循环并不复杂。这里给出我自己常用的一版:
function [X, y] = lagged_features(data, p) % 构造滞后特征矩阵 % data: 列向量,原始时间序列 % p: 滞后阶数 % X: n-p 行 p 列,第 k 列为滞后 k 阶的数据 % y: n-p 行 1 列,对应的预测目标 data = data(:); n = length(data); if n <= p error('数据长度必须大于滞后阶数'); end m = n - p; X = zeros(m, p); for k = 1:p X(:, k) = data(p - k + 1 : n - k); end y = data(p + 1 : n); end这个函数的核心逻辑是:第 k 列是滞后 k 阶的值,即用 yt-k 作为第 k 个特征。循环里 data(p - k + 1 : n - k) 的意思是,把序列往前平移 k 个位置截取出来,和目标 y = data(p+1:end) 对齐。
如果想把时间趋势项也加进去,调用之后补一列即可:
[X, y] = lagged_features(y_raw, p); trend = (1:size(X, 1))'; X = [X, trend];3.2 核心一行:反斜杠运算符求解线性回归
特征矩阵构造好之后,线性回归的参数估计就是最小二乘问题:beta = argmin ||X*beta - y||²。Matlab里面最干净、最地道的写法是:
beta = X_train \ y_train;反斜杠运算符会自动选择合适的算法(矩阵规模小时走QR分解,大规模稀疏时有专门处理),数值稳定性比手写 inv(X'*X) * X'*y 要好得多。我的原则是:永远不要手写正规方程求逆,直接用反斜杠。
如果你想看到更丰富的统计输出,比如系数的置信区间、残差方差、R²等,可以用regress函数:
[Xn_train, ~, mu, sigma] = zscore(X_train); Xn_train = [ones(size(Xn_train, 1), 1), Xn_train]; [b, bint, r, rint, stats] = regress(y_train, Xn_train);不过需要注意,regress要求特征矩阵是满秩的,如果滞后特征之间严重共线,它会直接报矩阵奇异。而反斜杠运算符在这种情况下通常还能给出一个最小范数解,虽然结果要打个问号。所以我的建议是:先追求简洁用反斜杠,出了诡异结果再回去检查特征是否存在共线性。
3.3 多步预测的实现:以直接多步为例
如果只做单步预测,预测代码就一行:
y_hat = X_test * beta;如果要做未来 h 步的直接多步预测,每个预测步长需要单独训练一个模型。假设滞后期为 p,预测步长从1到h,那么第 s 步模型的输入特征仍然是 yt-p+1 到 yt,目标变成 yt+s:
function [y_hat_multi, beta_list] = direct_multistep(X, y, h, train_len) % 直接多步预测,为每一步训练一个独立模型 % X: 滞后特征矩阵(不含趋势项) % y: 目标向量 % h: 最大预测步数 % train_len: 训练集长度 [m, p] = size(X); beta_list = cell(h, 1); y_hat_multi = zeros(h, 1); for s = 1:h % 第 s 步的目标是 y_{t+s} ys = y(1 + s - 1 : m + s - 1); % 这里按函数外部截断方式构造,需保证索引不越界 % 通常更安全的做法是在函数外预先扩展目标矩阵 X_s = X(1 : m - s + 1, :); ys_cut = y(s + 1 : m + 1); % 取前 train_len - s + 1 行训练 beta_s = X_s(1:train_len - s + 1, :) \ ys_cut(1:train_len - s + 1); beta_list{s} = beta_s; % 用训练集最后一个窗口做递推输入 last_x = X(end, :); y_hat_multi(s) = last_x * beta_s; end end这段代码我写出来主要是想说明“每个步长单独建模”的结构。实际工程里我会把它整理得更干净:预先构造一个目标矩阵 Y_steps,第 s 列为 y_{t+s},然后对每一列分别训练。
滚动多步预测的代码更短,核心就是把预测值回填进特征:
function y_hat = rolling_predict(beta, initial_x, h) % initial_x: 1×p 行向量,最新的 p 个观测值 % h: 预测步数 % beta: 回归系数向量,长度 p 或 p+1 p = length(initial_x); x_input = initial_x; y_hat = zeros(h, 1); for s = 1:h if length(beta) == p + 1 y_hat(s) = x_input * beta(1:p) + beta(p + 1); else y_hat(s) = x_input * beta; end x_input = [x_input(2:end), y_hat(s)]; end end实测下来,滚动预测在1到3步内效果尚可,5步之后误差增长非常明显。所以不要指望这个极简模型能做超长周期预测,它更适合做短期预警。
3.4 评估指标:四个数看清模型水平
预测完必须量化评估。我通常同时看RMSE、MAE、MAPE和R²,四者各有侧重:
rmse = sqrt(mean((y_test - y_hat).^2)); mae = mean(abs(y_test - y_hat)); mape = mean(abs((y_test - y_hat) ./ y_test)) * 100; ss_res = sum((y_test - y_hat).^2); ss_tot = sum((y_test - mean(y_test)).^2); r2 = 1 - ss_res / ss_tot;RMSE对大误差敏感,如果你想惩罚那些离谱的预测,主要盯它;MAE反映平均绝对偏差,量纲直观;MAPE是百分比,适合和业务方沟通,但y_test里有接近0的值时会爆炸,这时候要谨慎使用;R²反映模型相比“直接用均值预测”提升了多少,接近1说明模型抓住了大部分波动,接近0或为负说明预测还不如无脑用历史均值。
注意反归一化的问题。如果训练前做了zscore归一化,预测结果是归一化尺度,必须还原到原始尺度再算指标:
y_hat_raw = y_hat * sigma + mu;3.5 完整可运行脚本
把上面几块串起来,一个完整的单步预测脚本如下:
%% 1. 准备数据 clear; clc; close all; rng(42); t = (1:800)'; y_raw = 0.02 * t + 10 * sin(2 * pi * t / 80) + 3 * randn(800, 1); %% 2. 数据预处理 mu = mean(y_raw(1:600)); sigma = std(y_raw(1:600)); y = (y_raw - mu) / sigma; %% 3. 构造滞后特征 p = 10; [X, y_target] = lagged_features(y, p); %% 4. 划分训练集和测试集 train_len = floor(0.8 * size(X, 1)); X_train = X(1:train_len, :); y_train = y_target(1:train_len); X_test = X(train_len + 1:end, :); y_test = y_target(train_len + 1:end); %% 5. 训练 beta = X_train \ y_train; %% 6. 预测与反归一化 y_hat_norm = X_test * beta; y_hat = y_hat_norm * sigma + mu; y_true = y_test * sigma + mu; %% 7. 指标 rmse = sqrt(mean((y_true - y_hat).^2)); mae = mean(abs(y_true - y_hat)); fprintf('RMSE = %.4f, MAE = %.4f\n', rmse, mae); %% 8. 画图 figure; plot(y_true, 'b-', 'LineWidth', 1.2); hold on; plot(y_hat, 'r--', 'LineWidth', 1.2); legend('真实值', '预测值'); xlabel('测试集样本'); ylabel('数值'); title('线性回归时间序列预测结果'); grid on;这里我故意用了带趋势和周期成分的模拟数据,一个p = 10的单步LR就能跟上曲线,读者可以复制下来直接跑,然后换自己的数据试。
4. 诊断与可视化:预测图、残差图、系数解读
4.1 预测图不能只看“贴不贴近”
很多人画完预测图,看到两条曲线缠在一起就以为大功告成,这是典型的自我安慰。正确的做法是先把测试集真实值画出来,再把预测值画出来,同时标注训练集和测试集的切分位置。这样能一眼看出误差主要集中在哪些区域。
我习惯在测试集前段补画一段训练集末尾的预测值,用来观察模型是否出现“切换点崩溃”。很多模型在训练集结尾处表现良好,一进入测试集立刻偏离,说明它过拟合了历史噪声,而不是学到了规律。
figure; plot(1:train_len, y_target(1:train_len), 'k-'); hold on; plot(train_len+1:length(y_target), y_target(train_len+1:end), 'b-'); plot(train_len+1:length(y_target), y_hat_norm, 'r--'); xline(train_len + 0.5, '--', '切分点');4.2 残差分析:真正的诊断核心
残差 = 真实值 - 预测值。一个合格的线性回归时间序列模型,残差应该表现为白噪声:均值接近0,没有明显的自相关性,没有周期性。
如果残差序列里还能看出明显的“波浪”,说明你的模型漏掉了周期性成分,需要把对应周期的特征补进去。比如每24小时一个周期的负荷数据,可以在特征里加入“时刻”的哑变量,或者加入周期项 sin(2pitime/24)、cos(2pitime/24)。
如果残差在某个时间段突然整体偏移,说明序列在那个时间点发生了结构变化,模型已经不适应新状态,这时候建议重新训练或引入状态变量。
如果残差方差越来越大,像喇叭口一样展开,说明数据存在异方差性,线性模型无能为力,至少要换成加权回归或者对目标做对数变换。
4.3 回归系数的业务解读
线性回归最香的地方就在这里:你能把系数拉出来解释。
假设滞后特征是 yt-1、yt-2、yt-3,最后学出的系数是 beta = [0.62, 0.18, 0.05],含义是:当前值主要受上一时刻影响,影响力按时间距离快速衰减,符合直觉。如果beta=[0.7, -0.3, 0.1],说明yt-2对yt有反向作用,常见于周期性序列,比如波峰过去两个时刻之后开始回落。
系数的绝对值大小还能帮助筛选特征。如果一个滞后阶数的系数始终在0附近,且换了训练集后符号不稳定,这个特征基本可以丢掉了。这比单纯看相关系数更贴近预测任务本身。
5. 必踩的坑与排查实录
5.1 预测曲线变成一条水平线
这是最经典的现象:测试集预测值几乎恒等于一个常数,真实值还在上下波动。原因通常是滞后特征的系数都很小,模型学到的截距项明显大于其他项,导致所有预测都向均值回归。
解决办法分三步排查:
- 检查数据是否被错误归一化。如果训练集和测试集的归一化参数不一致,预测值会被压扁。
- 检查滞后期是否太小。p = 1时模型只能捕捉“昨天的值直接决定今天”,一旦测试集波动形态改变,模型就只能输出平均水平。
- 检查训练集是否包含足够多的“极端值”样本。如果训练集里目标值分布非常集中,回归模型会把所有预测都推到均值附近。
5.2 系数异常大,甚至互相抵消
滞后特征之间天然强相关,yt-1和yt-2的相关系数经常超过0.9,这会导致多重共线性。症状是系数数值巨大、符号一正一负、看上去毫无解释力,但预测结果又似乎还行。
最直接的解决方法是改用岭回归,Matlab里一行调用:
lambda = 0.1; beta_ridge = ridge(y_train, X_train, lambda); y_hat_ridge = [ones(size(X_test, 1), 1), X_test] * beta_ridge;ridge会牺牲一点训练集拟合度来换取系数的稳定性,实际效果常常比普通最小二乘更稳。lambda可以用crossval网格搜索,我个人经验是从0.01开始,每10倍往上加,看测试集RMSE的“浴缸曲线”找到最优值。
5.3 序列有趋势,却忘了处理
如果你的序列整体在上升,而你没加趋势项也没做差分,线性回归学出来的其实是一条“平均趋势直线”,对局部波动的预测基本无效。
我的建议是先差分再建模,把趋势彻底去掉。差分之后的预测值是“变化量”,最后累加回原始值。这套组合在金融、气象、流量数据上都比直接对原始值建模稳定。还有一种混合方案,对差分序列和趋势项同时建模,相当于让模型自己决定是用“惯性”还是用“趋势”。
5.4 训练集完美,测试集崩盘
这种情况十有八九是过拟合。滞后阶数取得太大,模型把训练集里的噪声当作规律,一遇到测试集的新噪声就乱套。
一个重要的实操原则:滞后期宁少勿多。我在调试中反复验证,很多数据p取2到6就足够,超过10通常只是把问题复杂化。先把p设成3跑一遍基线,再逐步增加,观察测试集误差是否真的在下降。若误差不降反升,果断回退。
5.5 多步预测误差像滚雪球
滚动多步预测中的误差累积是数学上注定的结果,不是你的代码写错了。每预测一步,误差会作为“输入噪声”进入下一步特征,形成自我放大的恶性循环。
应对策略:
- 预测步数控制在3步以内,把任务设计成短期预警而不是长期预报。
- 改用直接多步预测,每个步长单独建模,切断误差链。
- 每步都做一次特征更新:真实观测一旦到手,立即用真实值替换预测值,重新构造特征窗口再预测下一步。这种方法叫做“校准的滚动预测”,在工程上非常实用。
以我个人做负荷预测的经验来说,LR模型在1到3步内能给出非常可靠的参考值,超过6步就开始明显偏差。如果你需要的是更长周期的预测,我的建议是放弃LR,转用带时间注意力机制的模型,或者在LR基础上引入外生变量(天气、节假日、事件标记),把预测问题从“纯时间序列外推”变成“带辅助信息的回归问题”,效果往往比换个复杂模型更立竿见影。
最后分享一个我自己的习惯:任何时间序列项目,无论最终决定用多高级的模型,第一版永远是线性回归。它像一个诚实的老伙计,不会给你惊喜,但也不会给你幻觉。把这条基准线跑明白,你对自己数据的理解会提升一个档次。往后加特性、换模型,也都有一个清晰的比较对象。这套Matlab代码从头到尾都是可以逐行跑的,你可以把它当模板,换成自己的数据,跑通之后再按上面的思路做诊断和优化。