ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

POA优化BP神经网络的时间序列单步预测与MATLAB实现

POA优化BP神经网络的时间序列单步预测与MATLAB实现 去年我接了一个时间序列预测的小需求单列数据一百多个点客户想预测后面几期的走势。我一开始图省事直接写了个BP网络去拟合结果同一份代码每次跑出来的结果都不一样。有一回测试集拟合得特别漂亮我正准备交付又跑了一次指标直接掉了三成。问题不在BP的前向传播和反向传播机制而在BP的初始权值阈值是随机生成的梯度下降对起点极其敏感换一组随机数就换一个局部极小点。后来我把鹈鹕优化算法POA拉进这条链路让POA去搜一组靠谱的初始权阈值BP在好起点附近做精调测试集误差一下子就稳定了同一份数据连续跑十次结果波动被压在一个很小的范围内。这篇文章就把这套“POA优化BP做时间序列单输入单输出预测”的完整思路、MATLAB代码、以及单列数据的替换方法一次性整理清楚适合手里有Excel单列时间序列、想快速搭一个预测模型并且能稳定复现结果的人。1. 单输入单输出时序预测为什么不能直接把BP抡上去1.1 BP的“随机起点”问题是时序预测的致命伤BP神经网络本身的机制不复杂输入层、隐含层、输出层前面做一个前向传播后面根据损失对权重求梯度做反向传播反复迭代直到误差收敛。听起来没问题但真正跑起来你会发现它是个极度依赖初始参数的方法。newff创建网络时默认会随机初始化所有权值和阈值这个随机初始点的位置直接决定了梯度下降会滚进哪个局部极小值。在普通分类或回归任务里训练数据量通常很大样本独立同分布多个局部极小值之间差距不会特别夸张。但时序预测不一样样本之间天然存在序列相关性你要预测的数据点往往和上一时刻、上几个时刻强相关这会使得损失曲面非常复杂到处都是平坦区域和窄谷。随机初始点一不小心落在某个陡峭但错误的谷里训练出来就是一个“看起来很努力、实际预测变成一条歪线”的网络。还有一个容易被忽略的问题初始权重绝对值过大的话隐含层激活函数容易进入饱和区梯度会非常小BP迭代几乎推不动参数。这也是为什么很多人直接拿默认参数跑BP前几十次迭代误差纹丝不动然后突然开始乱飘。归根到底一句话BP的局部搜索能力很强但它没有一个靠谱的“起点”。1.2 POA在预测链路里的真实位置只干“找好起点”这一件事先说清楚一个概念免得很多人被“POA结合BP”这个说法误导。鹈鹕优化算法POA在这里不是用来替代BP训练的也不是用来专门优化学习率或隐含层节点数的它的任务只有一个在BP开始反向传播之前先搜出一组比较好的初始权值和阈值。整个过程可以理解成先粗选再精修。POA在[-1,1]的搜索空间里撒一批种群个体每个个体就是一组BP的初始权阈值。它通过模拟鹈鹕捕猎的两阶段行为不断迭代更新这些个体用训练集的前向传播误差作为评价标准最后挑出误差最小的一组参数。把这组参数写进BP网络之后再用标准的反向传播算法去训练。这样做的好处很直接BP不再依赖一个完全随机的起点而是从一个已经被POA验证过“方向正确”的位置出发收敛稳定性和最终精度都会明显提升。1.3 这个方案适合谁、数据长什么样这套方案最适合的场景是单变量时间序列预测也就是你手里只有一列数值没有额外特征想根据历史值预测未来值比如未来一天的销量、未来一小时的温度、未来一周的用电量。数据量也不需要特别大一百到几百个点就能跑出比较稳定的结果不像深度学习那种动辄需要几千上万条样本。如果你是第一次做时序预测或者之前用过BP、LSTM但结果忽高忽低那这套POABP可以作为基线方案先跑通。它有明显的优点实现难度不高、计算量适中、结果对随机性不敏感。当然也有局限性比如如果序列本身没有明显的自相关性或者数据是纯随机游走那任何模型都很难预测这不是算法的问题是数据本身可预测性就差。在动手之前先画个折线图看看数据是否有趋势和周期这个习惯比调参重要得多。2. 鹈鹕优化算法POA的两段式寻优机制拆解2.1 探索阶段鹈鹕发现猎物后的“俯冲式”全局搜索鹈鹕优化算法是2022年提出的一种元启发式算法灵感来自鹈鹕在水面捕食的行为。捕猎过程被简化成两个阶段对应两个数学更新公式。第一阶段是探索对应鹈鹕在水面上空发现猎物后开始俯冲。数学表达为x_new x rand × (P - I × x)其中P是在搜索空间内随机生成的一个“猎物”位置rand是0到1之间的随机数I会随机取1或2。注意I这个参数很有意思它让鹈鹕的移动幅度出现“冲过头”和“没冲够”两种可能性这个随机过冲机制让种群在早期不容易聚在一起能够尽可能覆盖更大的搜索空间。每个个体更新后如果新位置的适应度更好就保留否则不更新这保证了每一代都不会变得更差。这一步的关键作用是维持种群多样性。元启发式算法最怕的一个问题就是过早收敛也就是所有个体很快就挤在同一个区域后面再怎么迭代都是原地打转。POA用随机猎物加随机系数I本质上是强制给种群注入扰动让它们有更多机会跳出去看更远的地方。2.2 开发阶段翅袋收拢时的局部精细翻找第二阶段是开发对应鹈鹕张开翅膀形成喉囊在水面把鱼群向自己嘴巴方向驱赶的过程。数学表达为x_new x R × (1 - t/T) × (2×rand - 1) × x这里R是一个固定系数论文和多数实现里取0.2效果最好t是当前迭代次数T是最大迭代次数(1 - t/T)是一个随时间衰减的权重因子。整个式子可以理解为围绕当前个体位置做一个邻域扰动扰动幅度随着迭代进行越来越小。为什么这个算法能把开发和探索分得比较清楚就是因为这个衰减因子。算法早期t/T很小衰减因子接近1扰动幅度大个体还在比较宽的范围内搜索到了后期t/T接近1衰减因子趋近0扰动幅度明显收窄每个个体相当于在原地附近精细打磨。这种机制很像人做手工活先用粗砂纸磨形状再换细砂纸抛光而不是从头到尾用同一种力度。每次扰动之后同样要做边界处理越界的维度拉回边界值然后比较适应度变好了才接受。这个“只接受更好的”策略保证了收敛过程整体向下虽然牺牲了一点点跳出局部极小值的几率但换来了更稳定的收敛曲线。2.3 与PSO、GA对比POA在时序优化里赢在哪很多人在选优化算法时会在PSO粒子群、GA遗传算法和POA之间纠结。我在这类BP初始参数寻优问题上都实跑过简单说下差异。算法核心机制主要参数我的实测感受PSO速度和位置更新靠个体最优和全局最优牵引惯性权重、c1、c2收敛快但容易早熟后期多样性不足多跑几次结果波动大GA选择、交叉、变异交叉率、变异率、编码方式参数多调起来麻烦二进制编码还要解码工程上手慢POA探索阶段随机猎物I系数开发阶段衰减扰动R、I、种群数、迭代次数参数少前期探索充分后期衰减细腻在几十维连续优化问题上更稳POA胜出主要是因为它把探索和开发分成了两个显式的阶段而且实现极其简单。PSO的核心牵引机制虽然经典但在五六十维的目标函数上经常会把自己锁死在一个较差区域GA能跳出局部极小但需要配置的参数太多对于工程师来说把交叉率、变异率、编码精度都调到合理范围要花不少精力。POA几乎不用怎么调R固定0.2I随机取1或2剩下的就是种群数和迭代次数哪怕全给默认值也能跑出像样的结果这对做工程的人来说太友好了。3. POA-BP模型的完整建模链路与关键参数设计3.1 时间序列如何变成“单输入单输出”训练样本单变量时间序列本身是一个一维数组x1, x2, x3, …, xN。要喂给BP这种监督学习模型必须把它重组为有“输入—输出”配对的数据集。做法是滑动窗口也叫滞后序列构造。假设我用过去5个时刻预测未来1个时刻那么第一条样本是[x1,x2,x3,x4,x5]映射到x6第二条是[x2,x3,x4,x5,x6]映射到x7依次类推。这里numIn5numOut1。样本总数是N - numIn - numOut 1。如果数据是100个点就能得到95条样本其中按时间顺序取前76条做训练集后19条做测试集。这里有个硬性规则必须强调时序数据的训练集和测试集划分严禁随机打乱必须按时间顺序切。随机打乱会引入未来信息到训练集测试指标会虚高实际上线时会直接崩溃。这个错误在初学者里出现频率极高我在代码里也都是顺序切分。numIn的选取也很关键。太小模型看不到足够的历史上下文预测容易滞后太大输入维度增加模型参数变多对中小样本容易过拟合。没有先验经验时可以先取5到8跑完看测试集误差再调。如果有精力可以做ACF/PACF自相关分析辅助判断选自相关系数显著非零的滞后阶数范围。3.2 个体编码、目标函数和三层结构的参数传递POA优化BP核心是把一组BP初始权阈值编码成一个POA个体。网络结构选择是三层输入层、隐含层、输出层。输入层节点数等于numIn输出层节点数等于numOut隐含层节点数hiddenNum一般取经验值比如输入输出层节点之和开根号再加1到10或者干脆从8到12之间试。编码长度的计算方式非常重要它等于所有权值和阈值的总数dim inputNum × hiddenNum hiddenNum hiddenNum × outputNum outputNum其中第一部分是输入层到隐含层的连接权重第二部分是隐含层阈值第三部分是隐含层到输出层的连接权重第四部分是输出层阈值。以numIn5、hiddenNum10、numOut1为例dim 5×10 10 10×1 1 71也就是每个POA个体是一个71维的向量。个体取值范围设为[-1,1]就好因为后续训练数据会归一化到[0,1]这个范围内的初始权阈值不会让激活函数轻易饱和。如果范围设得太大比如[-10,10]POA搜出来一组参数赋给BP后tansig激活函数很容易进入饱和区再训练也很难救回来。适应度函数我用的是训练集前向传播的均方误差MSE表达式为mse mean((y_pred - y_true)^2)这里有个很实际的性能问题有些人写POA优化BP时每次评估适应度都直接把BP完整训练一遍然后拿训练后的误差当作适应度。这种做法逻辑上说得通但算起来非常慢POA要评估pop×T次网络也就是30×501500次每次都跑几百上千轮反向传播跑完天都黑了。正确做法是只做一次前向传播计算当前这组初始权阈值下的误差。为什么前向传播的误差可以用来评估初始权阈值好不好因为POA真正要找的是一个“好的起点”这个起点本身应该具备低误差特性这样BP从它出发能走得更远。用前向传播误差作为代理指标既快又能有效区分个体好坏属于工程上的合理简化。3.3 从寻优到训练的全流程时间线把完整流程拆成一张时间线来看每一步的输入输出会更清楚加载单列数据清洗缺失值转成列向量对原始数据做min-max归一化保存归一化参数ps_in和ps_out用滑动窗口构造输入输出矩阵input和output按8:2的比例顺序切分训练集和测试集设置网络结构inputNum、hiddenNum、outputNum计算编码长度dim初始化POA种群每个个体是dim维向量范围[-1,1]进入POA主循环每次迭代包含探索阶段和开发阶段逐个体计算适应度、择优保留迭代结束后取历史最优个体解码为BP的初始权阈值把最优个体通过setwb写入BP网络用train函数进行反向传播精调对训练集和测试集做预测反归一化还原为原始量纲计算RMSE、MAE、MAPE、R²画收敛曲线和测试集预测对比图第9步是整个方案的落点。POA负责“全局粗选”BP负责“局部精调”两者是串联关系而不是并行关系。把这个逻辑理解透了以后换成别的优化算法比如灰狼GWO、鲸鱼WOA、麻雀SSA思路完全一样只换更新公式就行。4. MATLAB完整代码加载单列数据直接跑4.1 主脚本参数定义、数据构造、POA寻优、BP训练我习惯把所有逻辑写在一个主脚本加两个子函数里结构清晰方便替换数据。先看主脚本%% POA-BP时间序列单输入单输出预测主脚本 clear; clc; close all; rng(2024); % 固定随机种子保证实验可复现 %% 1. 数据加载data.xlsx的A列单列纯数值 data xlsread(data.xlsx, A:A); data data(~isnan(data)); % 去掉空白行 data data(:); % 确保列向量 N length(data); %% 2. 滑动窗口构造单输入单输出样本 numIn 5; % 用过去5个时刻预测 numOut 1; % 预测未来1个时刻 [input, output] create_io_matrix(data, numIn, numOut); sampleNum size(input, 2); trainNum floor(sampleNum * 0.8); % 前80%训练 testNum sampleNum - trainNum; % 后20%测试 %% 3. 归一化 [input_n, ps_in] mapminmax(input, 0, 1); [output_n, ps_out] mapminmax(output, 0, 1); %% 4. 顺序切分训练集测试集 train_x input_n(:, 1:trainNum); train_y output_n(:, 1:trainNum); test_x input_n(:, trainNum1:end); test_y output_n(:, trainNum1:end); train_y_raw output(:, 1:trainNum); test_y_raw output(:, trainNum1:end); %% 5. BP网络结构 inputNum numIn; hiddenNum 10; outputNum numOut; %% 6. POA参数设置 pop 30; % 种群数 T 50; % 最大迭代次数 dim inputNum*hiddenNum hiddenNum hiddenNum*outputNum outputNum; lb -ones(1, dim); ub ones(1, dim); %% 7. POA主循环 X lb rand(pop, dim) .* (ub - lb); fit zeros(pop, 1); bestFitness zeros(T, 1); for t 1:T for i 1:pop fit(i) calc_fitness(X(i,:), inputNum, hiddenNum, outputNum, train_x, train_y); end [bestFit, idx] min(fit); bestFitness(t) bestFit; bestX X(idx, :); % 探索阶段 for i 1:pop prey lb rand(1, dim) .* (ub - lb); I randi([1 2]); Xnew X(i,:) rand(1, dim) .* (prey - I * X(i,:)); Xnew max(min(Xnew, ub), lb); fnew calc_fitness(Xnew, inputNum, hiddenNum, outputNum, train_x, train_y); if fnew fit(i) X(i,:) Xnew; end end % 开发阶段 R 0.2; for i 1:pop Xnew X(i,:) R * (1 - t/T) .* (2*rand(1, dim) - 1) .* X(i,:); Xnew max(min(Xnew, ub), lb); fnew calc_fitness(Xnew, inputNum, hiddenNum, outputNum, train_x, train_y); if fnew fit(i) X(i,:) Xnew; end end end %% 8. 最优个体写入BP网络并精调训练 net newff(train_x, train_y, hiddenNum, {tansig, purelin}, trainlm); net.trainParam.epochs 1000; net.trainParam.goal 1e-6; net.trainParam.lr 0.01; net setwb(net, bestX); net train(net, train_x, train_y); %% 9. 预测与反归一化 train_pred_n sim(net, train_x); test_pred_n sim(net, test_x); train_pred mapminmax(reverse, train_pred_n, ps_out); test_pred mapminmax(reverse, test_pred_n, ps_out); %% 10. 测试集指标评估 test_rmse sqrt(mean((test_pred - test_y_raw).^2)); test_mae mean(abs(test_pred - test_y_raw)); test_mape mean(abs((test_y_raw - test_pred) ./ test_y_raw)) * 100; ss_res sum((test_y_raw - test_pred).^2); ss_tot sum((test_y_raw - mean(test_y_raw)).^2); test_r2 1 - ss_res / ss_tot; fprintf(测试集 RMSE: %.4f\n, test_rmse); fprintf(测试集 MAE: %.4f\n, test_mae); fprintf(测试集 MAPE: %.2f%%\n, test_mape); fprintf(测试集 R2: %.4f\n, test_r2); %% 11. 绘图 figure; plot(1:T, bestFitness, LineWidth, 1.5); xlabel(迭代次数); ylabel(适应度(MSE)); title(POA收敛曲线); grid on; figure; plot(1:length(test_y_raw), test_y_raw, b-o, LineWidth, 1); hold on; plot(1:length(test_pred), test_pred, r-*, LineWidth, 1); legend(真实值, 预测值); xlabel(测试样本序号); ylabel(值); title(测试集预测对比); grid on;4.2 子函数样本矩阵构造与适应度计算主脚本依赖两个子函数create_io_matrix用来做滑动窗口calc_fitness用来计算个体的适应度。把它们放在单独m文件里或者在脚本末尾作为局部函数都可以但注意老版本MATLAB脚本不支持局部函数建议直接保存为同名m文件。function [input, output] create_io_matrix(data, numIn, numOut) n length(data); sampleNum n - numIn - numOut 1; input zeros(numIn, sampleNum); output zeros(1, sampleNum); for k 1:sampleNum input(:, k) data(k:knumIn-1); output(:, k) data(knumIn:knumInnumOut-1); end endfunction mse calc_fitness(ind, inputNum, hiddenNum, outputNum, train_x, train_y) w1_len inputNum * hiddenNum; b1_len hiddenNum; w2_len hiddenNum * outputNum; w1 reshape(ind(1:w1_len), hiddenNum, inputNum); b1 reshape(ind(w1_len1:w1_lenb1_len), hiddenNum, 1); w2 reshape(ind(w1_lenb1_len1:w1_lenb1_lenw2_len), outputNum, hiddenNum); b2 ind(end); h tansig(w1 * train_x repmat(b1, 1, size(train_x, 2))); y w2 * h repmat(b2, 1, size(train_x, 2)); mse mean(mean((y - train_y).^2)); endcalc_fitness里的repmat是为了兼容旧版MATLAB的维度广播问题新版自动广播也能跑但写repmat更保险。这里没有任何BP反向传播只有三层矩阵乘法和激活函数计算速度非常快1500次评估几秒钟就能完成。4.3 为什么训练函数和隐藏层节点这样选主脚本里newff用的训练函数是trainlm即Levenberg-Marquardt算法它在中小样本回归问题上收敛非常快利用近似二阶梯度信息几步就能达到不错的精度。如果你的数据量超过几百条或者trainlm在内存上吃不消可以改成traingdx配学习率0.01也可以跑。hiddenNum取10这是针对numIn5、numOut1的经验值隐含层节点太少拟合不了序列中的非线性关系太多会放大过拟合风险。这里给一个保守的调参思路从5开始试逐步加2观察测试集RMSE是否持续下降如果测试集误差上升而训练集继续下降说明已经过拟合退回上一个值。5. 数据替换手册把你的单列数据接进模型5.1 数据格式要求与三步替换流程这个方案设计成专门适配单列时序数据所以数据接入是非常关键的一步。格式要求不算苛刻但有几条硬规矩要求项具体要求文件格式data.xlsx或data.csv均可建议xlsx数据位置Excel的A列从A1开始连续排列数据内容纯数值不能有表头、日期列、序号列时间间隔必须等间隔比如每日、每小时、每周缺失值不能有NaN有空行要先删除数据量建议50条以上太少模型没有统计意义替换数据的步骤非常简单。第一步用Excel打开data.xlsx把自变量的数值列复制到A列如果原来A列有日期或其他内容先清空。第二步从B列开始检查是否有残留数据全部删除保证程序只读A列。第三步确认数据没有空行后保存直接运行主脚本即可。如果你的原始数据有多列比如一列日期加一列数值两个办法处理。要么在Excel里只复制数值列到data.xlsx的A列要么修改主脚本里的读取范围比如xlsread(data.xlsx, B:B)直接读B列。我个人推荐前者因为改文件比改代码更直观不容易出错。5.2 替换后最容易翻车的5个报错与解法我帮同事排查数据替换问题时常见报错集中在这么几个地方这里列出来可以少走弯路。报错现象根本原因解决方案Error using xlsread无法读取xlsxMATLAB版本太老不支持xlsx在Excel里另存为xls或用readmatrix替代Undefined function create_io_matrix子函数没有单独保存或版本不支持脚本局部函数把两个子函数保存成独立m文件Error using reshape维度乘积不匹配改过隐藏层节点数后没有同步修改dim只要改网络参数dim必须重新计算mapminmax报错输入含NaN或全为常数数据有空行、缺失值或序列本身就是常量清洗数据常量序列不适合预测换数据测试集RMSE很大且预测为一条水平线numIn太小序列信息不足或过拟合增大numIn试跑到8~10减少hiddenNum第5个报错经常被误判为代码问题实际上更可能是数据本身的规律太弱模型只能学到均值回补。遇到这种情况先用plot(data)看看原始序列如果走势像白噪声一样上下乱跳任何时序模型都很难给出有效预测。不是所有数据都适合做预测这需要有一个心理预期。6. 实测效果、指标解读与调参避坑心得6.1 训练集好、测试集崩先分清过拟合还是欠拟合模型跑通之后第一件事不是看曲线好不好看而是对比训练集和测试集的指标。如果训练集RMSE很低但测试集RMSE很高百分百过拟合。时序模型的过拟合和普通回归还不一样因为序列数据有自相关性模型很容易把训练集里某个特定波动模式背下来换到测试集就失效。过拟合的调整方向有几个减小hiddenNum、增大numIn让模型看更多上下文避免死记硬背、数据量允许时增加训练集比例到85%。如果训练集本身RMSE就高那是欠拟合说明模型容量不够或数据模式没抓到需要增加hiddenNum或者检查numIn是否过小。还有一个容易忽略的问题MAPE指标在真实值接近0的时候会爆炸因为除以了一个极小值这时候改用RMSE或MAE做主要参考更合理。6.2 收敛曲线和预测图应该怎么看POA收敛曲线能暴露很多信息。正常的收敛曲线应该是前10代快速下降之后进入平缓下降区间50代结束时曲线趋于水平。如果曲线到第50代还在明显单边下降说明迭代次数不够可以加大T到80或100。如果曲线在后期还在上下剧烈震荡说明开发阶段的扰动幅度还是太大可以检查R是不是被改过了R0.2是一个平衡点不要为了追求变化擅自调大到0.5。测试集预测对比图主要看两点。第一预测曲线和真实曲线的走势是否一致比如上升段、下降段的趋势是否跟住第二相位是否明显滞后如果预测曲线总是比真实曲线慢半拍说明numIn太小或序列变化太快增加滞后阶数通常能减轻。很多时序模型预测看起来“差不多”其实仔细看是平移了一条线这种情况实际应用价值很低要特别注意。6.3 我踩过的坑参数组合、耗时问题和可复现性最后分享几个我多次踩坑换来的经验。关于耗时POA主循环里最怕的不是迭代次数而是在适应度函数里误写成了train(net, ...)。第一次这么写跑了一个多小时没出结果我还以为是算法问题实际是嵌套了完整的BP训练计算量爆炸。后来改成纯前向传播同样的配置几秒钟就完事。如果你发现自己的POA-BP跑得极其慢先查calc_fitness里有没有不该出现的train、sim。关于可复现性实测过很多次MATLAB不设置随机种子的话同样的代码两次结果完全不同。这个在时序预测里特别坑因为BP的随机初始权阈值经过POA优化后虽然已经大幅收敛但trainlm的随机性还在最后结果可能还有微小波动。所以我习惯在脚本第一行写rng(2024)把随机种子固定下来这样交付给同事、客户时任何人跑都是同一组结果沟通成本低很多。关于R参数和I参数我实际对比过R0.1和R0.20.2在多数序列上的收敛深度更好0.1后期过于保守容易卡在次优解。I的随机取值是POA保持探索性的核心不要改成固定值固定成1会让种群太早同质化。如果你手头数据量很小比如只有五六十个点建议把hiddenNum降到6或7防止训练集被快速记住。这套流程在我做完第一个稳定版本后又陆续替换过好几组不同领域的单列数据从气温序列到销量序列都只需要改Excel数据文件和numIn就能直接跑。对于想做时间序列单输入单输出预测、又不想在调参上花太多时间的人来说POABP是一个性价比非常高的起点跑通之后再去尝试LSTM、Transformer这些更复杂的模型也不迟。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进