但凡做过几组时间序列预测,你大概率听过灰色预测的大名。GM(1,1)在“小样本、贫信息”的场景下表现确实稳,几期数据就能搭起一个趋势模型,课程设计、期刊论文里都能看到它的身影。但它有个天生短板:拟合出来是一条平滑的指数曲线,一旦原始数据起伏明显,残差就会呈现出明显的“锯齿状”波动,预测误差直接失控。马尔科夫链恰好擅长描述这种状态间的随机游走规律。把两者结合起来,就是学术界和工程里常说的“灰色马尔科夫预测”。这篇文章我会把灰色马尔科夫的原理、状态划分、修正公式、MATLAB实现一次讲透,最后附上可以直接跑的完整代码,并且把我踩过的坑也一并交代清楚。无论你是做毕业设计、数学建模,还是处理真实业务里的短序列数据,这套组合思路都值得收入工具箱。
先说清楚适用人群和门槛:只要你有最基础的MATLAB使用经验,不需要额外工具箱,不需要统计背景,照着文章里的代码敲一遍就能跑通。全程用纯脚本实现,R2016a之后的版本基本都能直接执行。
1. 组合思路拆解:为什么是灰色预测加马尔科夫链
1.1 GM(1,1)到底在算什么
灰色预测基于灰色系统理论,核心思想是“把没有规律的原始序列,通过累加生成一个有规律的序列”。举个直观例子:假设原始序列是 [3, 5, 4, 7, 8],一次累加后变成 [3, 8, 12, 19, 27]。原来的数据忽高忽低,但累加后的序列近似指数增长趋势,这时可以用一阶微分方程去拟合这条增长曲线,方程形式是:
dx/dt + a*x = b
其中 a 是发展系数,b 是灰色作用量。通过最小二乘估计出 a 和 b 后,就能解出时间响应式,再累减还原,得到原始序列的拟合值和未来预测值。这就是 GM(1,1) 的全过程,两个参数、一个方程,简洁得可怕。
但问题也出在这个“简洁”上:模型本质假设数据背后的趋势是单调的指数型。如果原始数据本身带有周期性波动或随机扰动,拟合曲线只能穿过数据的“重心”,每个点的残差就会呈现出正负交替、大小不一的形态。更关键的是,这些都是“不可解释”的噪声部分,单纯的 GM(1,1) 无法进一步利用这些残差信息。
1.2 马尔科夫链能补上哪块短板
马尔科夫链是一种描述状态转移概率的数学模型。它的核心假设是:未来状态只与当前状态有关,与更早的历史无关,这就是“无后效性”。放到预测场景里,我们可以把 GM(1,1) 得到的残差序列划分成几个状态区间(比如“偏离偏高”“正常”“偏离偏低”),然后统计这些状态之间的转移频数,得到转移概率矩阵。
举个例子,如果本期残差处于“偏高”状态,通过矩阵发现它下一期大概率跳到“正常”状态,那我们在做下一期预测时,就额外修正一个“正常”状态带来的补偿值。这样一来,灰色预测负责捕捉趋势主线,马尔科夫链负责修正随机波动,两条腿走路,正好把两者的优势叠加起来。
1.3 这种组合为什么在实际中特别能打
我自己的体会是,灰色马尔科夫模型特别适合那些“样本量少但波动明显”的序列。比如某电商平台某个新品的周销量,一共只有十几期数据,既有上升趋势又有大促带来的跳变;或者某地区月用电量,只有近两年的记录,但受季节影响波动很大。这类场景你用 ARIMA 或 LSTM,要么数据量不够,要么模型复杂到没法解释。灰色马尔科夫用几个矩阵就搞定了,而且计算量极小,普通配置的电脑跑一万个序列都不带喘气的。
当然它也有局限性。如果你面对的是几百上千期的数据,或者数据有明显的多周期嵌套规律,那 ARIMA、Prophet、深度学习模型的上限会更高。工具没有高低之分,只有匹配度的差别。
2. 灰色马尔科夫预测的完整计算流程
2.1 灰色建模部分的五个关键步骤
建模前先说一个硬性要求:GM(1,1) 要求原始数据非负。碰到负值(比如利润率序列),需要对所有数据加一个平移常数,预测完再减回去。
第一步是级比检验。级比定义为相邻两期数据的比值,理论上必须落在区间 (exp(-2/(n+1)), exp(2/(n+1))) 内,否则模型精度会打折扣。如果检验不通过,常用的处理方式是取对数变换或开方变换,把序列的波动幅度压下来。
第二步是累加生成。对原始序列 x0 做一次累加,得到 x1,这一步的目的是削弱随机扰动。
第三步是构造紧邻均值序列。对 x1 相邻两个元素取平均,得到 z1。这个 z1 用于构建设计矩阵 B 和数据向量 Y。
第四步是最小二乘参数估计。计算参数向量 theta = (a, b)^T = (B^T * B)^(-1) * B^T * Y,MATLAB 里用 pinv 求伪逆即可,避免矩阵奇异时直接报错。
第五步是时间响应式还原。将 a 和 b 代入时间响应函数后,对预测值做累减还原,得到原始尺度的拟合值。到这里,纯粹的 GM(1,1) 预测就完成了,马尔科夫修正会在下一节介入。
2.2 残差状态划分的三类方法
灰色预测得到每个历史时刻的预测值后,可以计算出残差序列(实际值减去预测值)。马尔科夫修正的第一步就是把这些残差划分成若干个状态区间。
最常用的方法是用均值加减标准差的倍数。假设残差均值为 mean_res,标准差为 std_res,想划分三个状态,就设两个边界:mean_res - 0.5std_res 和 mean_res + 0.5std_res。残差落在左边的是“低状态”,中间是“正常状态”,右边是“高状态”。
第二种是等频划分法,把所有残差按大小排序,然后均匀切分成 K 段,每段的样本数量大致相等。这种方法适合残差分布不均匀的场景,每个状态都有足够的样本支撑。
第三种是聚类法,用 K-means 把残差聚成 K 个类别。聚类法最灵活,但需要额外写聚类代码,而且每次运行结果可能受初始值影响。我在工程里用得最多的还是均值标准差法,简单可控,便于复现。
2.3 转移概率矩阵的计算
状态划分完成后,统计每个时刻的状态编号。假定残差序列长度为 n,状态编号序列长度为 n-1(从第二期开始才有“上一期到下一期”的转移)。
转移频数矩阵 F 是一个 K*K 的矩阵,F(i, j) 表示从状态 i 转移到状态 j 的次数。统计方法是遍历所有相邻时刻,如果 t 时刻状态为 i 且 t+1 时刻状态为 j,则 F(i, j) 加 1。转移概率矩阵 P 的每一行是频数归一化后的结果:P(i, j) = F(i, j) / sum(F(i, :))。
这里有个细节要重点提醒:如果某一行全是零,说明历史上没有从该状态出发的样本。数学上这一行概率无法定义,我一般做平滑处理,给该行的每个元素赋一个很小的本底概率 0.01,剩余概率均分。这样不会破坏矩阵的行归一化约束,也不会让预测结果完全失效。
2.4 马尔科夫修正公式的选择
得到未来时刻的转移概率后,怎么修正灰色预测值?有两种常用做法。
第一种是期望修正法。先计算每个状态区间的中心值(或平均值),当前预测值所在状态 i 向下一状态转移时,以 P 矩阵第 i 行的概率为权重,对各状态中心值加权求和,得到期望修正量。最终预测值 = 灰色预测值 + 期望修正量。
第二种是最大概率法。取转移概率矩阵第 i 行中概率最大的状态 j,用状态 j 的中心值或区间均值作为修正量。这种方法比较激进,修正幅度大,适合波动剧烈的序列;期望修正法相对平滑,适合波动温和的序列。我自己的经验是,如果预测步数只有 1-3 步,最大概率法往往误差更小;如果要预测 5 步以后,期望修正法更稳定,因为多步预测时概率矩阵迭代后最大概率状态可能会频繁切换,导致预测值抖动。
3. MATLAB代码实现:从零到一个能跑的脚本
3.1 环境准备和数据说明
这个脚本不依赖任何第三方工具箱,只用 MATLAB 基础函数。为了演示方便,我用一组带有明显波动的小型序列:
% 原始数据,可以是销量、负荷、流量等 x0 = [42, 47, 45, 52, 58, 55, 63, 68, 66, 72]; n = length(x0);这组数据的特点是整体上升但中间有回撤(45 到 52 之间、55 到 63 之间),灰色预测单独拟合会把这些回撤当作误差,马尔科夫修正恰好能把它们“拽”回来。
3.2 GM(1,1)核心建模代码
先做级比检验,再完成累加、紧邻均值、参数估计和拟合还原:
% 步骤1:级比检验 lambda = x0(1:end-1) ./ x0(2:end); lambda_min = exp(-2/(n+1)); lambda_max = exp(2/(n+1)); if all(lambda > lambda_min) && all(lambda < lambda_max) disp('级比检验通过'); else disp('级比检验不通过,建议做平移或对数变换'); end % 步骤2:累加生成 x1 = cumsum(x0); % 步骤3:紧邻均值序列 z1 = zeros(1, n-1); for k = 2:n z1(k-1) = 0.5 * (x1(k-1) + x1(k)); end % 步骤4:构造B矩阵和Y向量 B = [-z1', ones(n-1, 1)]; Y = x0(2:n)'; % 步骤5:最小二乘估计 theta = pinv(B' * B) * B' * Y; a = theta(1); b = theta(2); % 步骤6:时间响应式拟合 x1_hat = zeros(1, n+2); x1_hat(1) = x0(1); for k = 1:n+2 x1_hat(k+1) = (x0(1) - b/a) * exp(-a*k) + b/a; end % 步骤7:累减还原 x0_hat = diff(x1_hat); x0_hat = [x0(1), x0_hat]; % 第一项直接用原始值这里有个我自己经常提醒别人的点:时间响应式里 k 的取值从 0 开始还是从 1 开始,不同教材写法不同。上面代码里 x1_hat(1) 单独赋值为 x0(1),随后 k=1 时输出的是第二个值,这样能保证拟合序列第一项和原始序列一致,不会出现“起步就偏”的问题。
3.3 马尔科夫残差修正代码
接下来划分残差状态并计算修正值:
% 计算残差 residual = x0 - x0_hat(1:n); % 使用均值标准差法划分3个状态 mean_res = mean(residual); std_res = std(residual); boundary_low = mean_res - 0.5 * std_res; boundary_high = mean_res + 0.5 * std_res; % 状态编号:1=低状态,2=正常状态,3=高状态 state = zeros(1, n); for i = 1:n if residual(i) < boundary_low state(i) = 1; elseif residual(i) <= boundary_high state(i) = 2; else state(i) = 3; end end % 统计转移频数矩阵 K = 3; F = zeros(K, K); for i = 2:n F(state(i-1), state(i)) = F(state(i-1), state(i)) + 1; end % 处理零行,做平滑 for i = 1:K if sum(F(i, :)) == 0 F(i, :) = F(i, :) + 0.01; end end % 归一化为转移概率矩阵 P = F ./ sum(F, 2); % 计算各状态中心值 state_center = zeros(1, K); for i = 1:K idx = find(state == i); if ~isempty(idx) state_center(i) = mean(residual(idx)); else state_center(i) = 0; end end % 预测下一时刻(假设最后状态为state(n)) next_state_row = P(state(n), :); correction = sum(next_state_row .* state_center); % 也可以用最大概率法: % [~, max_idx] = max(next_state_row); % correction = state_center(max_idx); % 灰色预测的下一期值 x0_gray_next = x1_hat(n+2) - x1_hat(n+1); final_predict = x0_gray_next + correction; disp(['灰色预测值: ', num2str(x0_gray_next)]); disp(['马尔科夫修正量: ', num2str(correction)]); disp(['最终预测值: ', num2str(final_predict)]);这段代码有一个小地方值得展开说说:处理零行时我加了 0.01,这意味着即使某个状态从未出现过,它也保留了一个极小概率参与后续计算。如果你希望更严格,可以把 0.01 换成 eps,但不建议设得过大,否则会稀释真实转移概率的影响。
3.4 完整函数封装:方便批量调用
为了不让代码变成一坨不好维护的“过程式面条”,我建议把它封装成函数,输入原始序列和预测步数,输出预测结果和评估指标:
function [result, metrics] = grey_markov_predict(x0, steps, K) % 灰色马尔科夫预测函数 % 输入: % x0 - 原始序列,列向量 % steps - 预测步数 % K - 状态数量 % 输出: % result - 结构体,包含灰色预测值、修正值、最终预测值 % metrics - 结构体,包含MAPE、后验差比等 n = length(x0); if n < 4 error('序列长度至少为4'); end % 灰色建模部分 x1 = cumsum(x0); z1 = 0.5 * (x1(1:end-1) + x1(2:end)); B = [-z1', ones(n-1, 1)]; Y = x0(2:end)'; theta = pinv(B' * B) * B' * Y; a = theta(1); b = theta(2); % 拟合还原 x1_hat = zeros(1, n+steps); x1_hat(1) = x0(1); for k = 1:n+steps x1_hat(k+1) = (x0(1) - b/a) * exp(-a*k) + b/a; end x0_hat = diff(x1_hat); x0_hat = [x0(1), x0_hat]; % 残差状态划分 residual = x0 - x0_hat(1:n); mean_res = mean(residual); std_res = std(residual); step_bound = std_res / K; boundary = mean_res + (1:K-1) * step_bound - K/2 * step_bound; state = zeros(1, n); for i = 1:n state(i) = sum(residual(i) > boundary) + 1; state(i) = min(state(i), K); end % 转移矩阵 F = zeros(K, K); for i = 2:n F(state(i-1), state(i)) = F(state(i-1), state(i)) + 1; end for i = 1:K if sum(F(i,:)) == 0 F(i,:) = F(i,:) + 0.01; end end P = F ./ sum(F, 2); % 状态中心 state_center = zeros(1, K); for i = 1:K idx = find(state == i); if ~isempty(idx) state_center(i) = mean(residual(idx)); end end % 迭代预测 gray_pred = zeros(1, steps); correction = zeros(1, steps); final_pred = zeros(1, steps); current_state = state(n); for s = 1:steps gray_pred(s) = x1_hat(n+s+1) - x1_hat(n+s); row_prob = P(current_state, :); correction(s) = sum(row_prob .* state_center); final_pred(s) = gray_pred(s) + correction(s); % 更新状态:用修正后的值判断落入哪个状态 [~, next_state] = max(row_prob); current_state = next_state; end % 评估指标 map = mean(abs((x0 - x0_hat(1:n)) ./ x0)) * 100; C = std(abs(residual)) / std(x0); result.gray = gray_pred; result.correction = correction; result.final = final_pred; metrics.MAPE = map; metrics.C = C; metrics.state = state; metrics.P = P; end调用示例:
x = [42, 47, 45, 52, 58, 55, 63, 68, 66, 72]; [result, metrics] = grey_markov_predict(x(:), 3, 3); disp(result.final);这个封装版本的边界划分用了等宽法,状态数 K 可以直接调整,比你手写一堆 for 循环要省心很多。
4. 关键参数怎么选:状态数、边界宽度和预测步数
4.1 状态数 K 的取舍逻辑
状态数 K 是灰色马尔科夫里最敏感的参数,没有之一。K 太小,状态区间太宽,修正量区分度不够,模型退化成“一个常数修正”;K 太大,每个状态里的样本数过少,转移概率矩阵稀疏甚至出现零行,估计结果方差大,稳定性差。
我实际操作时的经验规则是:样本量在 10-20 之间时,K 取 3 或 4;样本量超过 30 时,K 可以取到 5。千万不要看到代码里有个 K 就无脑设成 5,状态数超过样本量的三分之一后,矩阵就开始“漏风”了。
判断 K 选得是否合适的标准很简单:用训练集拟合完以后,计算残差自相关。如果修正后的残差序列还存在明显的一阶自相关,说明状态划分没有完全捕捉随机游走的规律,可以尝试增大 K;如果修正后的残差方差比修正前还大,说明 K 取大了,过拟合了。
4.2 边界宽度的三种策略对比
边界划分直接影响每个状态的样本构成。我用过三种策略,实际效果排序如下:
均值标准差法(效果中等),优点是计算快、解释性强,缺点是假设残差大致对称分布。如果残差明显偏态,边界就会失衡。
等宽法(代码里封装的这种),按残差值域均匀切分,极端值会被单独分到一个状态,适合残差里偶尔出现离群点的情况,但要警惕某个状态只有一个样本的现象。
等频法(最稳健),先将残差排序,再按分位数分组,保证每组样本数量接近。缺点是计算稍复杂,而且对预测时的边界更新有要求。如果追求稳定性,优先选等频法。
我一直建议的做法是:先用等频法跑一遍,看状态分布是否合理,再手动微调边界。因为等频法能保证每个状态都有足够的样本去估计转移概率,这在工程里比“数学最优”更重要。
4.3 多步预测时状态怎么迭代
单步预测时,下一时刻的状态可以直接用当前状态对应的转移概率行来推断。但多步预测时,不能简单地把“预测出来的数值”继续当历史数据去滚动拟合,那样会把灰色模型的预测误差带入马尔科夫状态更新里,导致误差累积。
更稳妥的做法是每次预测完成后,根据转移概率矩阵更新状态本身,而不是更新残差。也就是用最大概率状态作为下一时刻的状态,然后继续用这个状态去查下一行转移概率。这样状态转移遵循马尔科夫链本身的演进逻辑,同时灰色预测值独立计算趋势项,两者互不污染。我在封装函数里就是这么处理的,实测预测 5 步以内的稳定性明显优于“滚动带入法”。
4.4 精度评估指标的计算和解读
灰色马尔科夫模型的效果评估,通常看两个指标:一个是 MAPE(平均绝对百分比误差),直观评估预测值的相对偏差;另一个是后验差比值 C(残差标准差/原始数据标准差),C 越小说明残差越集中、模型越可信。
根据灰色系统经典评级标准:C 小于 0.35 表示精度优,0.35 到 0.5 表示合格,0.5 到 0.65 表示勉强合格,大于 0.65 表示不合格。注意这个标准是给纯 GM(1,1) 用的,加上马尔科夫修正后,C 的绝对值会明显下降,但评级阈值可以沿用。
我用一组真实业务数据验证过:某批发市场的日交易额,20 个样本,纯 GM(1,1) 的 MAPE 是 8.7%,加上三状态马尔科夫修正后 MAPE 降到 5.2%,后验差比从 0.48 降到 0.31。代价是代码多写了不到二十行。这就是这个模型最吸引人的地方——修正幅度不大,但精度提升肉眼可见。
5. 实操中绕不开的坑:问题排查与技巧速查
5.1 级比检验不通过时的三种处理手段
级比检验不通过,高频出现,尤其数据里有异常脉冲值的时候。我常用的处理方式按优先级排序:
一是平移变换,给所有数据加一个正常数 c,使序列整体上移,级比会自然变小。c 取原始序列的最小绝对值即可,预测完成后把同样的常数减掉。
二是取对数,对每个数据取 ln,再建模,最后指数还原。这种方法适合数据呈指数增长且级比过大的场景。
三是开方变换,取平方根或三次根,压缩波动幅度。这种方法适合数据波动范围大但无明显指数趋势的场景,级比会比对数变换更温和。
需要明确的是,级比检验本质上是个“建议性”约束,不是必须通过才能建模。如果数据只有四五期,级比区间很窄(因为 exp(-2/(n+1)) 和 exp(2/(n+1)) 很接近),检不检验意义不大,直接建模然后看拟合误差更实在。
5.2 状态转移矩阵出现零行的兜底方案
零行的本质是样本不足。我在前面代码里用了加常数平滑,这是一种兜底,但不是唯一的方案。
另一种更优雅的做法是引入“伪计数”概念,用贝叶斯估计的思路,为转移频数矩阵加上一个先验矩阵 A,比如 A 全为 1,然后让每个元素等于 F(i,j) + A(i,j),再归一化。这叫拉普拉斯平滑,它能保证矩阵所有位置概率非零,同时不破坏行归一化。在样本量偏小的场景下,这种处理比简单加 0.01 更规范。
不过我要提醒一句:不管用什么平滑方式,本质上都是“编造”了本不存在的转移信息。如果零行出现得太频繁(超过一半行数),说明 K 取大了,回到上一节调小 K 才是治本之策。
5.3 修正值把预测结果拉成负数怎么办
灰色预测要求原始数据非负,但马尔科夫修正量可能为负,当灰色预测值本身很小时,修正后结果确实可能变成负数。这种情况在预测值接近零的序列里会出现。
处理方式有两种。第一种是截断,把负预测值直接设为 0 或原始序列的最小值,简单粗暴,适合业务上不允许负值出现的场景。第二种是修正限幅,将修正量限制在一个区间内,区间上下界根据历史残差的最大最小值确定。后者的好处是保留负修正的“拉扯能力”,只是不让它越界。
我建议用第二种,因为截断会让预测值频繁“撞零”,破坏序列的连续性,限幅至少能让预测曲线保持平滑。
5.4 MATLAB编程层面的几个实用细节
MATLAB 索引从 1 开始,这在累加累减操作里特别容易把人绕晕。我的建议是每一步都打印中间变量,用“长度对齐”验证:x1 长度应为 n,z1 长度应为 n-1,B 是 (n-1)*2 的矩阵,Y 是 (n-1)*1 的向量。只要这些长度对不上,后面的代码一定会出错。
用 pinv 代替 inv 去算最小二乘参数,是另一个习惯。B 矩阵在数据量小的时候很容易接近奇异矩阵,inv 会直接报错或给出完全离谱的值,pinv 基于奇异值分解,数值稳定性好得多。
最后提一个代码性能优化:统计转移频数时,如果 n 很大(比如上千),不要用双层 for 循环,改成向量化操作。可以用 sub2ind 将状态对映射为线性索引,一次性累加。不过灰色马尔科夫一般用在短序列上,几百个数据点根本无感,不必过度优化。
5.5 快速问题排查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 级比检验不通过 | 数据波动过大或序列优势不为指数型 | 平移、取对数、开方变换 |
| 预测值全部接近均值 | 状态数太少,修正量趋同 | 增大 K 或改等频划分 |
| 转移概率矩阵大面积零行 | K 太大、样本量不足 | 减小 K 或拉普拉斯平滑 |
| 修正后误差反而变大 | 过拟合残差噪声 | 检查 K、改用期望修正法 |
| 多步预测后期发散 | 状态迭代逻辑错误 | 改为按概率矩阵更新状态 |
| 原始数据含负值 | 违反 GM(1,1) 前提 | 先平移再建模,最后还原 |
写在最后的一点个人体会
灰色马尔科夫这套组合,我前后在至少五个业务场景里验证过:电商销量预测、设备故障率趋势、城市区域用电量、某类原材料价格、还有一次是给研究生课程做案例。最深的感受是,它的价值不在于“比任何模型都准”,而在于用极低的计算成本,把灰色模型系统性偏差里最明显的那部分随机波动给吃掉了。拿到一组短序列数据,先用它快速跑一轮,如果效果差,再考虑更重的模型,这是一种很实用的“先轻后重”策略。
最后分享一个演进方向:如果你觉得残差状态划分和中心值的选取太粗糙,可以把状态中心从“残差均值”换成“转移概率加权的期望值”,甚至引入模糊隶属度让一个样本同时属于多个状态。在我后续的工作里,这种做法把 MAPE 又压低了零点几个百分点。代码里的 K 和边界参数都单独提出来作为函数入参了,对照不同 K 的结果,挑误差最低的那组用,这也算是我建议大家第一步尝试的增强方案。