简介:这是一份面向时间序列预测与毕业设计场景的完整且可直接运行的Python工程,基于TensorFlow实现CEEMDAN、VMD、GRU与ARIMA的组合建模,覆盖信号分解、特征提取、神经网络预测和误差修正的完整流程,适合需要开展组合预测研究或课程设计的学生直接参考。压缩包共3个文件、大小仅52KB,包含2个CSV格式的数据文件和1个Python源码,可直接作为算法验证与预测实验的输入样例。目前已有387人学习下载,是同类组合预测资源中较受关注的方案。源码采用参数化编程,几乎一行一注释,从环境配置到参数调整都有清晰指引;整体结构按数据处理、分解、预测、评估分段组织,初学者可逐模块学习,也可以快速替换为自己的数据复现实验,代码注释详细,便于二次开发与改进。
1. 为什么 CEEMDAN-ISOS-VMD-GRU-ARIMA 值得复现
看到“Python实现CEEMDAN-ISOS-VMD-GRU-ARIMA时间序列预测”这个标题,第一反应往往不是兴奋,而是被三个信号分解算法和两个预测模型的长串组合吓到。但拆开看,它解决的是单模型很难啃下的非平稳时序预测难题:实际数据里趋势、周期、噪声和局部突变往往挤在一起,单独用ARIMA会被非线性成分带偏,单独用GRU又容易在长记忆上失效。做法是把原始信号先做CEEMDAN分解,再用ISOS优化过的VMD做二次分离,最后让GRU处理高频成分、ARIMA处理低频趋势,再把预测结果叠加回最终序列。这套流水线在负荷预测、流量预测、水位预测里被反复证明有效,适合已经会用Python跑sklearn和TensorFlow、但想突破时序精度瓶颈的工程师。本文不贴完整项目源码,而是把这条管道的实现思路、关键代码和坑讲清楚。
2. 把 CEEMDAN、ISOS、VMD、GRU、ARIMA 的流水线拆开
2.1 CEEMDAN:为什么不用 EMD 或 EEMD
以EMD为代表的经验模态分解,把原始序列按局部极值包络逐层拆成多个固有模态函数。它不预设基函数,看起来适合非平稳数据,但最大问题是模态混叠:一个IMF里经常同时出现多个频带,物理意义不干净。EEMD通过在每次分解前加入高斯白噪声,让混叠在不同试验中被平均掉,代价是重构结果里残留噪声。CEEMDAN的核心改进是在每一层分解时都加入自适应白噪声,并且分解完成后再去除噪声分量,最终得到的IMF既能有效分离频带,又能几乎无损重构原序列。
在Python里,PyEMD库提供了现成的CEEMDAN实现。基本调用如下:
from PyEMD import CEEMDAN import numpy as np t = np.linspace(0, 1, 1000) signal = np.sin(2 * np.pi * 5 * t) + 0.5 * np.sin(2 * np.pi * 30 * t) + 0.2 * np.random.randn(1000) ceemdan = CEEMDAN(trials=50, noise_width=0.05) imfs = ceemdan(signal) print(imfs.shape) # 输出形状为 (分量数量, 样本数)trials表示添加白噪声后平均的次数,noise_width是噪声幅值占信号标准差的比例。imfs里面最后一行是残差,不是真正的IMF。判断分解是否合格,比较简单的方法是检查重构误差:
reconstructed = imfs.sum(axis=0) rmse = np.sqrt(np.mean((signal - reconstructed) ** 2))rmse越小说明分解的可靠性越高,一般量级应低于信号标准差的百分之一。如果这个误差很大,先检查trials是否太小,再看noise_width是否过大。工程上我一般会把trials设为100起步,因为单次CEEMDAN运行时间并不长,多加几次平均能明显稳定IMF形状。
2.2 VMD 与 ISOS 之间的关系
VMD是另一种完全不同的分解思路。它通过构造变分问题,把输入信号拆成指定个数的带限模态,每个模态的中心频率和带宽在迭代中被自动分离。VMD的优点是对模态混叠的抑制能力强,缺点是需要预设两个关键参数:模态个数K和惩罚因子alpha。K设少了,混合频带散不开;K设多了,会出现虚假模态。alpha控制模态的带宽约束,过大会让模态过度光滑,过小又会产生噪声泄露。
工程上很难靠一次两次试验确定这两个参数,于是就有了ISOS这类群体智能优化算法的用武之地。ISOS是在SOS基础上改进得到的,SOS通常指共生生物搜索,模拟生物之间互利共生、共栖和寄生三种关系。ISOS在不同论文里更新公式差别很大,但共同点是种群个体携带一组待优化参数,通过适应度函数评估好坏,不断迭代逼近最优解。
这里需要明确一个观点:ISOS并不是负责预测的模型,而是VMD的“参数搜索引擎”。它搜索的空间是[K, alpha],适应度函数则是某个衡量VMD分解质量的指标。许多人在复现这类标题时把重点放在GRU和ARIMA上,却忽视了ISOS的适应度函数设计,导致最终效果还不如手动选参数。
2.3 为什么高频给 GRU、低频给 ARIMA
GRU是LSTM的简化版本,只有更新门和重置门,参数更少、训练更快,在处理高频、非线性、短记忆成分时表现比较稳定。ARIMA则是典型的线性统计模型,对趋势项和平稳成分拟合效果很好,且预测结果有可解释性。一个时间序列经过CEEMDAN和VMD分解后,高频模态往往是非平稳的局部波动,适合GRU去捕捉模式;低频模态和残差则呈现缓慢变化,用ARIMA拟合回归部分更合理。
如果把这个分工调反,局面会变得很难看。ARIMA处理高频成分时,滞后阶数难以确定,预测误差会被迅速放大;GRU处理低频长期趋势时,又容易因为样本量不足而发生过拟合。所以在这类混合模型的复现中,先观察各IMF的频谱和方差贡献,再决定哪些送给GRU、哪些送给ARIMA,是很重要的一步。
| 组件 | 处理对象 | 主要参数 | 常用Python库 |
|---|---|---|---|
| CEEMDAN | 原始非平稳序列 | trials, noise_width | PyEMD |
| ISOS | VMD参数寻优 | 种群数、迭代次数、K范围、alpha范围 | 自写 |
| VMD | 混频或宽频带IMF | K, alpha, tau, init | vmdpy |
| GRU | 高频非线性成分 | hidden_size, dropout, time_step | TensorFlow/Keras |
| ARIMA | 低频趋势和残差 | p, d, q | statsmodels |
2.4 一个容易忽略的分量选择策略
不是所有CEEMDAN分解出来的IMF都值得送到ISOS-VMD里进行二次分解。有些IMF本身频带已经很窄,再跑VMD只会浪费时间并可能引入虚假模态。常见做法是依次计算每个IMF和原始信号的Pearson相关系数,并观察IMF频谱的峰值个数。相关系数低的分量,通常与主体信号关系不大,可以直接送到残差组;相关系数高且频谱主峰分散的分量,才进入ISOS-VMD流程。
我一般使用这样一个判断逻辑:
from scipy import signal as sp_signal def needs_second_decompose(imf, original, corr_threshold=0.1): corr = np.abs(np.corrcoef(imf, original)[0, 1]) if corr < corr_threshold: return False freqs, powers = sp_signal.welch(imf) peaks = np.sum(powers > np.median(powers)) return peaks > 1corr_threshold控制进入VMD的IMF数量。如果设置过高,许多有效信息被忽略;设置过低,又会增加VMD的无效计算。peaks > 1说明频谱不是单峰,存在混频风险。这个选择策略写起来很简单,但它决定了整个管道的计算开销和最终精度,值得在复现时单独写一页测试。
3. 把 ISOS-VMD 跑通得到可预测的子序列
3.1 vmdpy 的调用与参数
VMD在Python里的常用实现是vmdpy库,核心接口是一个VMD()函数。它输入原始信号和一组参数,返回分解后的模态数组、频域表示和中心频率。调用代码非常精简:
from vmdpy import VMD def vmd_decompose(signal, K, alpha): tau = 0 DC = 0 init = 1 tol = 1e-7 u, u_hat, omega = VMD(signal, alpha, tau, K, DC, init, tol) return u, omegaK是模态个数,alpha是惩罚因子,tau是噪声容忍度,DC表示是否需要提取直流分量,init控制中心频率初始化方式,tol是迭代停止阈值。u的形状是(K, 样本数),也就是K个模态;omega则是每个模态的中心频率轨迹。复现时先固定其余参数,只让ISOS去接力搜索K和alpha,这样搜索空间只有二维,优化器不容易发散。
3.2 ISOS 适应度函数怎么写
要让ISOS驱动VMD,必须定义适应度函数。一个经典方案是计算每个VMD模态的包络熵,包络熵越小说明模态的稀疏性和正则性越好,分解效果越接近理想。包络可以通过希尔伯特变换求瞬时幅值得到:
from scipy.signal import hilbert def envelope_entropy(modal): analytic = hilbert(modal) amplitude = np.abs(analytic) p = amplitude / (amplitude.sum() + 1e-12) p = p[p > 0] return -np.sum(p * np.log(p)) def fitness_individual(individual, original_signal): K = int(round(individual[0])) alpha = individual[1] u, _ = vmd_decompose(original_signal, K, alpha) entropy_list = [envelope_entropy(u[i]) for i in range(K)] corr_list = [abs(np.corrcoef(u[i], original_signal)[0, 1]) for i in range(K)] return np.mean(entropy_list) - 0.01 * np.mean(corr_list)这里减去平均相关系数,是为了避免优化器把“熵最小”理解为把所有能量拆到极窄频段、与原始信号失去关联。系数0.01是一个经验值,当你发现优化结果出现大量无效模态时,适当调大这个系数。注意alpha在搜索空间中一般不做对数缩放,但在ISOS初始化时可以设置取值范围为[200, 5000],避免数量级差距让优化器走很多冤枉路。
3.3 ISOS 优化器最小运行模板
接下来用一个可以实际运行的最小SOS框架来模拟ISOS。ISOS与标准SOS的核心差别通常在第三阶段的寄生更新方式,我会在代码注释里标出哪里是替换点:
class ISOSOptimizer: def __init__(self, original_signal, bounds, pop_size=10, max_iter=20): self.signal = original_signal self.bounds = bounds # [(K_min, K_max), (alpha_min, alpha_max)] self.pop_size = pop_size self.max_iter = max_iter self.pop = np.array([ [ np.random.randint(bounds[0][0], bounds[0][1] + 1), np.random.uniform(bounds[1][0], bounds[1][1]), ] for _ in range(pop_size) ], dtype=float) def run(self): for _ in range(self.max_iter): fits = np.array([ fitness_individual(ind, self.signal) for ind in self.pop ]) best_index = np.argmin(fits) # 阶段一:互利共生 for i in range(self.pop_size): j = np.random.choice( [x for x in range(self.pop_size) if x != i] ) mutual_vector = (self.pop[i] + self.pop[j]) / 2 r1, r2 = np.random.rand(), np.random.rand() self.pop[i] += r1 * (mutual_vector - r2 * mutual_vector) self.pop[j] += r2 * (mutual_vector - r1 * mutual_vector) # 阶段二:共栖 for i in range(self.pop_size): j = np.random.choice( [x for x in range(self.pop_size) if x != i] ) r3 = np.random.rand() self.pop[i] += r3 * (self.pop[best_index] - self.pop[j]) # 阶段三:寄生,ISOS 改进点通常在这里 parasite_host = np.random.randint(0, self.pop_size) parasite_vector = self.pop[parasite_host].copy() parasite_vector += np.random.uniform(-0.1, 0.1, 2) if fitness_individual(parasite_vector, self.signal) < fits[parasite_host]: self.pop[parasite_host] = parasite_vector # 边界修正 self.pop[:, 0] = np.clip(self.pop[:, 0], self.bounds[0][0], self.bounds[0][1]) self.pop[:, 1] = np.clip(self.pop[:, 1], self.bounds[1][0], self.bounds[1][1]) best_index = np.argmin([ fitness_individual(ind, self.signal) for ind in self.pop ]) K_best = int(round(self.pop[best_index][0])) alpha_best = self.pop[best_index][1] return K_best, alpha_best这份代码把生物启发算法简化成三段交互,第一阶段的r1 * mutual_vector是让个体向种群公共信息靠近,第二阶段是用最优个体引导其他个体移动,第三阶段是随机扰动。寄生虫阶段每次只改一个宿主,所以运行速度较快。使用时要根据你的alpha取值范围调整幅度,如果alpha最大值是5000,那么±0.1的扰动幅度会显得过小,可以改成±200。
3.4 VMD 结果和 ISOS 结果怎么验证
ISOS每轮都会调用VMD,VMD本身是一次迭代求解,所以整体计算量并不小。为了验证优化结果,可以拿ISOS给出的K和alpha再做一次VMD,然后检查两点:第一,模态重构误差是否足够小;第二,omega的最后一步是否收敛到稳定的中心频率。如果相邻两个模态的中心频率相差过近,说明K偏大,下次搜索时应该把K的上界调低。
另一个可视化技巧是画出模态的频谱。把每个VMD模态做FFT后叠加在同一张图里,如果相邻模态频谱有显著重叠,说明该组参数并不理想。这时候优先调大alpha而不是调小K,因为带宽约束变严后,重叠压力会降低。
4. 用 GRU 和 ARIMA 完成预测与重组
4.1 数据准备:滑窗与归一化
得到子序列后,先决定哪些子序列进入GRU,哪些进入ARIMA。高频成分通常需要做滑动窗口处理,用过去一定步长预测下一点。这一步要注意归一化方式:只能在训练集上做归一化,然后把参数应用在测试集上,否则会把未来的统计量泄漏进模型。
from sklearn.preprocessing import MinMaxScaler def make_window_data(series, time_step=12): X, y = [], [] for i in range(len(series) - time_step): X.append(series[i:i + time_step]) y.append(series[i + time_step]) return np.array(X), np.array(y) scaler = MinMaxScaler() train_scaled = scaler.fit_transform(train.reshape(-1, 1)).flatten() test_scaled = scaler.transform(test.reshape(-1, 1)).flatten() X_train, y_train = make_window_data(train_scaled, time_step=12) X_test, y_test = make_window_data(test_scaled, time_step=12)time_step是滑窗长度,它决定了GRU能看到多久的历史。选太小会丢失模式,选太大会引入噪声。从我的复现经验看,12到24是比较稳妥的初始区间。
4.2 Keras 中的 GRU 模型结构
GRU模型的结构不宜过于复杂。高频成分往往只是原序列的一部分,样本量有限,网络太深反而容易过拟合。两层GRU加一个输出层是多数项目的起步模板:
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import GRU, Dropout, Dense model = Sequential([ GRU(32, return_sequences=True, input_shape=(12, 1)), Dropout(0.2), GRU(16), Dropout(0.2), Dense(1) ]) model.compile(optimizer='adam', loss='mse') history = model.fit( X_train.reshape(-1, 12, 1), y_train, epochs=30, batch_size=16, validation_split=0.1, verbose=0 )input_shape=(12, 1)里的12对应time_step,1是特征数。第一层GRU设置return_sequences=True,是为了把完整的隐藏状态序列传给第二层。Dropout放在两层GRU之间,用于降低小样本过拟合。epochs和batch_size需要按序列长度调整,序列短就减小batch_size并放大epochs。
4.3 ARIMA 拟合低频与残差部分
低频成分和总残差送到ARIMA前,先做ADF平稳性检验。如果不平稳,做一阶差分后再拟合。这里容易犯的一个错误是差分后忘记反差分,导致最后预测曲线整体偏移。
from statsmodels.tsa.stattools import adfuller from statsmodels.tsa.arima.model import ARIMA def fit_arima(series, d_max=2): d = 0 diff_series = series.copy() while adfuller(diff_series)[1] > 0.05 and d < d_max: diff_series = np.diff(diff_series) d += 1 model = ARIMA(diff_series, order=(2, d, 2)).fit() return model, dadfuller返回元组,第二个元素是p值。order=(2, d, 2)并不是固定最优,实际项目中可以用pmdarima的auto_arima自动搜索。注意ARIMA的预测结果通常会返回置信区间,这里只取预测均值即可。
4.4 分量预测怎么叠加回最终结果
GRU和ARIMA各自完成后,所有分量需要相加得到最终的预测值。直接相加是最常见的操作,但前提是所有分量的长度已经对齐。如果在构造滑窗时截掉了前12个点,那么ARIMA的输出也要保持相同的时间偏移。
gru_pred = model.predict(X_test.reshape(-1, 12, 1)).flatten() arima_pred = arima_model.forecast(len(y_test)) # 反归一化 gru_pred_original = scaler.inverse_transform(gru_pred.reshape(-1, 1)).flatten() arima_pred_original = scaler.inverse_transform(arima_pred.reshape(-1, 1)).flatten() final_pred = gru_pred_original + arima_pred_original如果你在训练GRU前分别对每个子序列做了归一化,那么反归一化时也要使用对应子序列的scaler。这一点非常容易在代码迭代中搞混,建议把每个分量的scaler存成字典,方便后续回溯。若求稳,可以先用训练集的误差计算各分量的组合权重,再把这个权重应用到测试集。
5. CEEMDAN-ISOS-VMD 的参数边界与验证方法
5.1 三组关键参数的经验值
CEEMDAN部分需要关心的参数有两个:trials和noise_width。推荐trials=100,noise_width=0.05起步。如果你的数据信噪比很低,把noise_width提高到0.2,并适当增加trials到200,才能稳定分解结果。ISOS优化VMD时的K建议在3到10之间搜索,alpha在200到5000之间。GRU的time_step可以从12开始,如果预测目标是对未来24个点做预测,那么time_step至少要与预测步长同量级。
5.2 验证方法的两个关键点
第一,不要只用一步预测评估。实际预测往往会做滚动多步预测,也就是把前一步的预测值重新作为输入喂回模型。误差会随步数累积,这一步才是模型真实能力的写照。测试时至少用50个连续测试点做递归多步预测,计算累积RMSE。第二,分解算法的边界效应很容易被忽略。CEEMDAN和VMD在序列首尾都存在端点效应,预测时如果直接使用最后一段序列的分解结果,端点误差会直接引入预测。建议在训练集和测试集之间保留一段缓冲数据,让分解算法在边界处有更多有效样本。
5.3 复现时最容易被忽视的三件事
第一,ARIMA在预测低频成分前,必须检查低频成分是否平稳。如果不平稳,先差分,预测后反差分。第二,GRU随机性较大,需要在代码顶层设置随机种子,否则精度对比没有意义。第三,不要对整个序列一次性做归一化,而是先用训练集fit,再transform测试集,减少未来信息泄漏。
import random import numpy as np import tensorflow as tf random.seed(42) np.random.seed(42) tf.random.set_seed(42)5.4 怎么判断整套方案是否真的有效
最简单的做法是做一个消融对比:一组只用CEEMDAN加GRU,另一组完整跑CEEMDAN-ISOS-VMD-GRU-ARIMA。如果第二组在测试集上的RMSE下降不明显,不要立刻怀疑ISOS,先检查VMD分解出的模态是否真的比CEEMDAN结果更合理。画出模态频谱,确认没有模态混叠后再看预测误差。如果某条IMF被ARIMA拟合后残差仍然很大,这条IMF可能不适合进入ARIMA分支,需要把它调整到GRU分支或直接做重构权重修正。通过这种逐个分支验证的方式,才能让这个长标题名副其实地跑出可复现的效果。
本文还有配套的精品资源,点击获取