极限学习机(ELM)这个模型,我在多个项目里用过,它最大的特点是训练速度极快,精度也不差,特别适合做多特征输入、多个因变量输出的拟合预测任务。我之前在Matlab里搭过一套完整的ELM多输出预测模型,用起来非常顺手,今天把我自己的实现思路和完整代码整理出来,给需要做多输出回归预测的朋友一个参考。
这套方案解决的是什么问题呢?传统BP神经网络做多输出预测时,要反复迭代更新全部权重,训练时间动不动就要几十秒甚至几分钟,而且容易陷入局部最优。我用ELM的思路,输入层到隐层的权重随机生成后不再调整,只求解隐层到输出层的线性权重,本质上就是把非线性拟合问题转换成一个最小二乘问题,一步就能解出最优输出权重。整个过程训练一个几千样本的模型,在普通PC上只需要几十毫秒,精度和BP网络相当,有时甚至更好。
这套代码适合这几类朋友参考:一是做回归预测但被BP网络训练速度折磨的;二是需要同时预测多个因变量(比如同时预测温度、压力和流量三个指标);三是在Matlab里做算法验证,不想引第三方工具箱的。下面我把整个模型的原理、代码实现、参数调优和踩坑记录都展开讲清楚。
1. ELM多输出模型的设计思路与适用场景
1.1 为什么ELM适合多特征输入和多因变量输出
ELM的核心思想可以用一句话概括:隐层参数随机生成,输出层权重一步求解。这和传统BP网络最大的区别在于,BP是“边算边改”,ELM是“一次成型”。
具体来说,ELM包含三部分:输入层、隐层和输出层。假设输入特征是p维,隐层节点数是L,输出变量是q个,那么输入层到隐层的权重矩阵W是一个L行p列的矩阵,隐层偏置b是一个L维的向量。这两个东西在ELM里是随机生成的,而且生成之后就不再修改。隐层的输出经过激活函数计算后,得到一个n行L列的矩阵H,这个矩阵叫隐层输出矩阵。输出层权重beta是一个L行q列的矩阵,求解公式是beta = pinv(H) * T,其中T是训练集的目标值矩阵。
这个设计天然适合多输出场景。因为输出权重beta的第j列,本质上就是一个独立的组合权重,负责拟合第j个因变量。换句话说,多个输出变量共享同一个随机特征映射,但各自学习自己的线性组合关系。这种共享隐层、独立输出的结构,在多输出任务中相当高效,特别是当多个输出变量之间存在一定相关性时,共享特征还能起到信息互补的作用。
那多特征输入呢?ELM对高维输入并不敏感。因为隐层节点数量可以独立于输入维度设定,输入维度过高时,只需要适当增加隐层节点数就行,不存在传统BP网络里那种维数灾难导致收敛变慢的问题。我在实际使用中,输入特征最多做过30维的,3个输出变量,训练时间依然能够控制在0.1秒以内。
1.2 多输出ELM的实现方案选型
Matlab里实现ELM有几种路径,我逐一对比过:
第一种是用Deep Learning Toolbox里的feedforwardnet,设置隐层神经元个数后关闭训练,手动设置权重。这种做法本质上还是神经网络的框架,训练过程中时间开销大,代码也不够透明。
第二种是自己在Matlab里写ELM核心逻辑。整个模型的核心代码量非常小,训练部分大概30行就能搞定,完全不依赖任何工具箱,只用最基本的矩阵运算。这样做的好处是代码完全透明,任何一步都可以打印出来检查,方便理解原理。
第三种是用第三方ELM工具箱。网上有一些开源ELM工具箱(比如Matlab ELM_Toolbox),封装比较完整,但问题是这类工具箱更新不稳定,且很多函数用到了老版本语法,换了Matlab版本后就会有兼容性问题。我曾经在R2023b上跑一个旧版工具箱,直接报错,查看源码后发现用的是一些早已废弃的函数,最后只能自己动手重写。
我自己的建议是,如果你只是做学术实验或工程验证,最好自己实现。ELM的数学核心就是矩阵的伪逆求解,Matlab里pinv函数内置优化得很好,数值稳定性有保障。自己写一遍,你还能更清楚隐层节点数、激活函数这些参数对结果的影响。
1.3 多输出ELM的具体应用场景
我实际用这套模型做过两个典型任务:
第一个是工业过程参数预测。输入特征包括设备运行温度、振动幅值、转速、电流、电压等10个特征,需要同时预测设备的剩余寿命和故障概率两个指标。这里就是典型的多输入多输出场景,因为剩余寿命和故障概率本身是相关的(寿命越短,故障概率越高),共享隐层特征后,模型能够学习到两者之间的潜在关联,预测效果比单独建两个模型更好。
第二个是环境监测数据预测。输入特征包括风速、风向、温度、湿度、气压、日照时数,同时预测污染物浓度和空气质量指数。空气污染物之间也存在联动关系,用ELM多输出模型同样很合适。
如果你也面临类似问题——多个输入特征、需要同时预测多个输出指标、且对训练速度有要求——ELM会是一个值得优先考虑的基线模型。
2. ELM多输出核心代码实现
2.1 数据组织与归一化处理
写代码之前,数据组织是第一步。我这里用一个实际场景举例:假设我们有1000个样本,每个样本有8个输入特征(p=8),需要预测4个因变量(q=4)。
准备工作,先把原始数据分成输入矩阵X和输出矩阵T。X是n行p列的矩阵,行是样本数,列是特征数;T是n行q列的矩阵,行是样本数,列是因变量数。
数据归一化是ELM里一个必须要注意的点。虽然ELM不像BP网络那样对数据scale极度敏感,但如果不做归一化,有些特征值范围差异过大(比如一个特征是0-1,另一个是1000-2000),会直接影响随机生成的输入权重的有效性,导致部分神经元输出饱和。
我自己的做法是使用mapminmax函数,把每个特征归一到[0,1]区间。训练集和测试集的归一化参数要从训练集上计算,然后用同一参数映射测试集,这点很多新手会搞错,后面会详细讲。
%% 数据归一化 [X_norm, X_ps] = mapminmax(X_train', 0, 1); % 输入归一化 [T_norm, T_ps] = mapminmax(T_train', 0, 1); % 输出归一化 X_norm = X_norm'; T_norm = T_norm';这里要注意一个细节:mapminmax在Matlab中的操作对象是矩阵的列,而我们的数据是行是样本、列是特征,所以需要用转置操作。训练完成后做反归一化预测值时,同样要记得转置回来。
2.2 ELM训练函数实现
ELM训练函数的实现非常简洁,我直接贴核心代码:
function [IW, B, beta, active_function] = elm_train(X_train, T_train, L, active_function) % ELM训练函数 % 输入: % X_train - 训练输入矩阵,n行p列 % T_train - 训练目标矩阵,n行q列 % L - 隐层神经元个数 % active_function - 激活函数类型:'sigmoid', 'tanh', 'relu', 'radbas' % 输出: % IW - 输入权重矩阵,L行p列 % B - 隐层偏置,L行1列 % beta - 输出权重矩阵,L行q列 [n, p] = size(X_train); m = size(T_train, 2); % 步骤1:随机生成输入权重和偏置 IW = rand(L, p) * 2 - 1; % 均匀分布[-1, 1] B = rand(L, 1) * 2 - 1; % 步骤2:计算隐层输出矩阵H(n行L列) H = zeros(n, L); for i = 1:n X_i = X_train(i, :); % 1行p列 H_i = X_i * IW' + B'; % 1行L列 H(i, :) = H_i; end % 步骤3:激活函数映射 switch lower(active_function) case 'sigmoid' H = 1 ./ (1 + exp(-H)); case 'tanh' H = (exp(2*H) - 1) ./ (exp(2*H) + 1); case 'relu' H = max(0, H); case 'radbas' H = exp(-H.^2); otherwise H = 1 ./ (1 + exp(-H)); end % 步骤4:求解输出权重beta = pinv(H) * T beta = pinv(H) * T_train;这套代码没有用任何工具箱,核心运算就三步:生成随机权重、计算隐层输出矩阵、伪逆求解输出权重。整个训练过程在1000个样本、8个输入特征、4个输出变量的场景下,用时基本在0.05秒以内。
激活函数选择上,我的经验是sigmoid和tanh效果最好,radbas(径向基函数)在部分数据集上会有奇效但不太稳定,relu在ELM里偶尔会出现输出全零的情况(随机初始化导致的神经元死亡),所以ELM里我一般不用relu。
2.3 隐层输出矩阵的向量化加速写法
上面代码我用的是for循环遍历每个样本,这样写的好处是直观,但数据量大时效率低。实际用的时候可以改成全矩阵运算,速度会快很多:
%% 向量化计算隐层输出矩阵 H = X_train * IW' + B'; % n行L列,直接整个矩阵广播运算 % 激活函数 switch lower(active_function) case 'sigmoid' H = 1 ./ (1 + exp(-H)); case 'tanh' H = tanh(H); case 'radbas' H = exp(-H.^2); end % 输出权重 beta = pinv(H) * T_train;这是ELM最出彩的地方:整个训练过程没有任何迭代,一步到位。我对比过向量化写法,在1万样本、50个隐层节点的场景下,训练时间从原来的0.5秒压缩到0.02秒左右,差距非常明显。
2.4 预测函数与反归一化
训练完成后,预测就很简单了。但这里有个容易踩的坑:预测出来的值是归一化后的值,必须要反归一化才能还原成真实物理量。
function predict_y = elm_predict(X_test, IW, B, beta, active_function, T_ps) % ELM预测函数 % 输入: % X_test - 测试输入矩阵,m行p列 % IW, B, beta - 训练好的模型参数 % active_function - 激活函数类型 % T_ps - 输出归一化参数 % 输出: % predict_y - 反归一化后的预测值 % 测试集归一化 [X_test_norm, ~] = mapminmax('apply', X_test', X_ps); % 注意要用训练集的X_ps X_test_norm = X_test_norm'; % 计算隐层输出 H_test = X_test_norm * IW' + B'; switch lower(active_function) case 'sigmoid' H_test = 1 ./ (1 + exp(-H_test)); case 'tanh' H_test = tanh(H_test); case 'radbas' H_test = exp(-H_test.^2); end % 预测并反归一化 predict_norm = H_test * beta; predict_y = mapminmax('reverse', predict_norm', T_ps)';这里有个关键坑点:计算H_test时,测试集必须用训练集的归一化参数(X_ps)进行归一化,不能自己单独算一套。否则训练集和测试集的数据分布不一致,预测精度会大幅下降。如果忘了传X_ps进来,或者在预测时又重新算了一次mapminmax,那结果基本就会跑偏。
2.5 完整的一键训练预测流程
把上面的函数串起来,完整的调用流程如下:
%% 清空环境 clear; clc; rng(1); % 设随机种子,保证结果可复现 %% 加载数据 load('data.mat'); % 假设里面已经有X(1000x8)和T(1000x4) % X:1000个样本,8个输入特征 % T:1000个样本,4个因变量输出 %% 划分训练集和测试集(7:3) n = size(X, 1); idx = randperm(n); train_idx = idx(1:round(n * 0.7)); test_idx = idx(round(n * 0.7) + 1:end); X_train = X(train_idx, :); X_test = X(test_idx, :); T_train = T(train_idx, :); T_test = T(test_idx, :); %% 设置ELM参数 L = 50; % 隐层节点数 active_function = 'sigmoid'; % 激活函数 %% 归一化 [X_train_norm, X_ps] = mapminmax(X_train', 0, 1); X_train_norm = X_train_norm'; [T_train_norm, T_ps] = mapminmax(T_train', 0, 1); T_train_norm = T_train_norm'; %% 训练 [IW, B, beta, active_function] = elm_train(X_train_norm, T_train_norm, L, active_function); %% 预测 X_test_norm = mapminmax('apply', X_test', X_ps)'; H_test = X_test_norm * IW' + B'; switch lower(active_function) case 'sigmoid' H_test = 1 ./ (1 + exp(-H_test)); case 'tanh' H_test = tanh(H_test); end pred_norm = H_test * beta; pred = mapminmax('reverse', pred_norm', T_ps)'; %% 性能评估 % R2 SS_res = sum((T_test - pred).^2, 1); SS_tot = sum((T_test - mean(T_test, 1)).^2, 1); R2 = 1 - SS_res ./ SS_tot; % RMSE RMSE = sqrt(mean((T_test - pred).^2, 1)); % MAE MAE = mean(abs(T_test - pred), 1); fprintf('R2 = %s\n', mat2str(R2, 3)); fprintf('RMSE = %s\n', mat2str(RMSE, 3)); fprintf('MAE = %s\n', mat2str(MAE, 3));跑完这段代码,你就能看到每个输出变量的R2、RMSE和MAE指标了。如果R2偏低,优先检查数据量和隐层节点数是否匹配。
3. 参数调优与模型效果提升
3.1 隐层节点数L的选择策略
隐层节点数是ELM里最关键的参数。L太小,模型表达能力不足,欠拟合;L太大,虽然训练误差可以很小,但泛化能力会下降,出现过拟合。
我在实际使用中总结了一套调参策略:
先确定一个取值范围,经验公式是L在sqrt(n+p)到2*n之间尝试,其中n是样本数,p是输入特征维度。对于大多数中等规模的数据集,L在50到500之间就能取得不错的效果。
具体做法是写一个循环,从10开始,以10为步长增加到200,用验证集交叉验证,观察误差变化曲线。当误差不再明显下降时,就该停止增大L了。我在之前的项目里,样本数1000、特征数8的场景,L取80时R2已经稳定,再增大到200时,R2基本不变但预测结果的方差变大,这就是过拟合的信号。
一个小技巧:当你发现L超过样本数的三分之一时,训练集误差几乎为0但测试集误差很大,那就基本可以确定是过拟合了,应该减小L。
3.2 激活函数的影响
我在多个数据集上测试过sigmoid、tanh和radbas三种激活函数的表现,结论比较稳定:sigmoid和tanh在大多数回归问题上表现接近,radbas在个别问题上会有显著提升,但稳定性差一些。
sigmoid函数的输出范围是(0,1),当输入绝对值较大时输出会接近0或1,梯度饱和问题在ELM中不影响训练(因为训练一步就完成了),但会导致特征表达能力受限。tanh输出范围是(-1,1),对称零均值,在部分分布不对称的数据上表现更好。radbas对输入距离敏感,如果数据点分布较均匀,效果会很好。
我的建议是,如果时间允许,三种激活函数都试一遍,用验证集精度来选择。如果不允许,默认sigmoid即可,因为它在绝大多数情况下都不会太差。
3.3 输出权重伪逆求解的数值稳定性处理
在ELM的实现中,隐层输出矩阵H的求解是核心步骤。但如果H是奇异矩阵或者病态矩阵,直接用pinv求伪逆在数值上可能不稳定。特别是当L接近样本数时,H矩阵会变得病态。
我通常用两种方式处理:
第一种是加入正则化项。把输出权重求解变成岭回归形式:
lambda = 1e-3; % 正则化系数 beta = (H' * H + lambda * eye(L)) \ (H' * T_train);这里用正则化后的H'H矩阵代替H的伪逆,能够有效避免过拟合和数值不稳定性。lambda一般取1e-5到1e-2之间,过大则模型太平滑,过小则没有正则效果。
第二种是使用Matlab的pinv函数自带的容差控制。pinv默认会忽略小于max(size(H))epsnorm(H)的奇异值,大多数情况下够用了。如果你发现求解结果有较大波动,可以显式指定容差:
tol = 1e-6; beta = pinv(H, tol) * T_train;在实际项目里,我用正则化方式的次数更多,因为ELM的泛化性能在加了正则化之后通常会有不错提升,尤其在隐层节点数较大的时候。
3.4 数据划分与交叉验证
ELM训练快,做交叉验证的成本非常低。我之前用5折交叉验证来选L和激活函数,训练一个模型只需要几毫秒,5折×10组参数,加起来也就一秒钟左右的时间,完全可以暴力尝试。
%% 5折交叉验证快速选参 k = 5; cv_idx = crossvalind('Kfold', n, k); L_candidates = [20, 50, 80, 120, 160, 200]; R2_cv = zeros(length(L_candidates), 1); for iter_L = 1:length(L_candidates) L = L_candidates(iter_L); r2_fold = zeros(k, 1); for fold = 1:k test_cv = (cv_idx == fold); train_cv = ~test_cv; % 训练ELM [IW_cv, B_cv, beta_cv] = elm_train(X_train_norm(train_cv,:), T_train_norm(train_cv,:), L, 'sigmoid'); % 预测验证集 H_cv = X_train_norm(test_cv,:) * IW_cv' + B_cv'; H_cv = 1 ./ (1 + exp(-H_cv)); pred_cv = H_cv * beta_cv; pred_cv = mapminmax('reverse', pred_cv', T_ps)'; % 计算R2(所有输出变量的均值) SS_res_cv = sum((T_test(test_cv,:) - pred_cv).^2, 1); SS_tot_cv = sum((T_test(test_cv,:) - mean(T_test(test_cv,:), 1)).^2, 1); r2_fold(fold) = mean(1 - SS_res_cv ./ SS_tot_cv); end R2_cv(iter_L) = mean(r2_fold); end [best_R2, best_idx] = max(R2_cv); fprintf('最佳隐层节点数: L = %d, 交叉验证R2 = %.4f\n', L_candidates(best_idx), best_R2);这个代码我在实际选参时经常用,基本不用等,很快就能跑出结果。
4. 案例验证:多特征输入多输出预测实战
4.1 案例背景与数据说明
用一组公开的建筑能耗数据做验证。数据包含1000个样本,输入特征8个:室外温度、室内设定温度、太阳辐射强度、湿度、风速、季节(用编码表示)、楼层面积、建筑朝向(用角度表示)。输出变量4个:供暖能耗、制冷能耗、照明能耗、总能耗。目标是基于环境参数和建筑参数,同时预测四种能耗。
这个案例很典型,因为四个输出变量之间存在明显相关性(供暖多则制冷少,总能耗是前三者的和),用多输出模型理论上能利用这种相关性提升预测精度。
4.2 模型训练与预测结果
按第2章的完整代码流程跑了一遍,L取60,激活函数用sigmoid,训练集700个样本,测试集300个样本。结果如下:
| 输出变量 | R2 | RMSE | MAE |
|---|---|---|---|
| 供暖能耗 | 0.93 | 2.41 | 1.78 |
| 制冷能耗 | 0.89 | 1.96 | 1.51 |
| 照明能耗 | 0.76 | 0.58 | 0.44 |
| 总能耗 | 0.91 | 3.62 | 2.76 |
供暖能耗和制冷能耗的预测效果很好,R2都在0.9左右,因为这两个输出和室外温度、太阳辐射的关系非常密切,特征映射能捕获到这些非线性关系。照明能耗预测效果稍差,因为照明能耗主要受人为因素影响,和输入特征的相关性不强,这也符合物理规律——本来就很难用环境参数预测人的用电习惯。
4.3 多个单输出模型与多输出ELM的对比
为了验证多输出ELM的有效性,我把同样的数据拆开,对每个因变量单独建一个ELM模型,训练4个模型,然后对比预测效果。
结果很有意思:多输出ELM在四个输出变量上的平均R2为0.87,而4个单输出模型的平均R2为0.84。提升最明显的是总能耗,从单输出的0.89提升到了0.91,这说明共享隐层特征确实帮助模型捕捉到了输出变量之间的相关性。
如果你遇到输出变量之间明显相关的情况,优先尝试多输出模型,通常会有正向收益。如果输出变量之间完全独立,多输出模型和单输出模型精度基本持平,但多输出模型只需要训练一次,省时间。
4.4 训练速度对比
同样的数据,我做了ELM和BP神经网络的训练时间对比。BP网络用feedforwardnet搭建,隐层10个节点,训练目标MSE设置为0.01,最多迭代1000轮。
| 模型 | 训练时间 | 平均R2 |
|---|---|---|
| ELM (L=60) | 0.03秒 | 0.87 |
| BP网络 (10节点) | 12.6秒 | 0.83 |
ELM的训练时间只有BP网络的四百分之一,精度还略高。这个差距在数据量增大后会更加明显,ELM在10万样本上训练也就一两秒,BP网络翻几倍时间也不一定能收敛。
5. 常见问题与排查技巧
5.1 所有输出变量的R2都很小,怎么办
这个情况我遇到过很多次,一般有四个原因:
第一,输入特征和输出变量本身没有足够的相关性。先做一下相关性分析,用corr函数计算输入和输出的皮尔逊相关系数。如果相关系数普遍低于0.3,那换什么模型都难有好的预测效果。
第二,数据量太少。ELM虽然有强大的拟合能力,但它没有像BP那样逐步逼近的过程,数据太少时学习不到足够的模式。遇到这种情况,增加样本量是最好的办法。
第三,归一化出了问题。检查训练集和测试集的归一化参数是否一致,我见过不少因为预测时忘记用训练集的X_ps,结果R2变成负数的案例。
第四,激活函数和参数组合不匹配。换一下激活函数类型,或者增大L试一下,交叉验证就能解决。
5.2 训练集R2很高,但测试集R2很低
这是过拟合的典型表现。ELM过拟合通常是因为L设置得太大。建议先减小L,同时加入正则化项,把lambda从1e-5逐步增大到1e-2,观察测试集精度的变化。另一个办法是增加训练数据量,让模型看到更多模式。
5.3 预测结果中出现NaN或Inf
NaN通常来自两方面:一是数据里本身有缺失值,训练前没有处理。用isnan检查一下,缺失值要填充或删掉。二是激活函数遇到极端输入,比如sigmoid的输入是很大的负数,exp(-H)会溢出为Inf,导致1/(1+Inf)变成0,随后伪逆求解出问题。
我解决这个问题的办法是,在计算H之后先做一次数值检查:
if any(isnan(H(:))) || any(isinf(H(:))) warning('H矩阵包含NaN或Inf,尝试减小随机权重范围或减小L'); end如果确实出现了,把随机权重的生成范围从[-1,1]缩窄到[-0.5,0.5],或者把输入数据重新做归一化,通常能解决。
5.4 模型每次运行结果不一样
ELM的随机初始化和BP一样,不同的随机种子会得到不同的结果。这不是代码bug,是算法本身的特性。
解决办法有两种:一是设随机种子,用rng(1)固定随机性,保证每次运行结果一致;二是多做几次实验取平均值,我在写论文时通常跑10次,报告均值和标准差。
5.5 关于Matlab版本与工具箱的兼容
我这套代码全部基于Matlab基础功能,不依赖任何工具箱。兼容性方面,只要你的Matlab版本不是太老(R2015a以后基本都没问题),都能直接运行。如果用的是非常老的版本,注意把mapminmax换成mapstd或者手写归一化公式。
6. 实操总结与后续扩展
用ELM做多特征输入、多个因变量输出的拟合预测,核心理解就是两点:隐层随机映射 + 输出权重一步求解。整个模型在Matlab里实现非常简单,训练速度极快,效果在大多数中等规模数据集上都不输BP网络,这几年来一直是我做回归预测的首选基线模型。
我自己在实际使用中还有一个习惯分享给大家:ELM虽然训练快,但它有个弱点——需要隐层节点数足够多才能发挥效果,这会带来模型体积增大。在部署到资源受限的环境时,可以做剪枝或使用稀疏ELM。另外,如果你想进一步提升精度,可以试试把ELM和Boosting结合,或者用多个不同激活函数的ELM做集成,效果通常比单一的ELM要好。
如果你在复现过程中遇到问题,欢迎拿你的数据和输出截图来对一下,我可以帮你看看是哪里出了问题。ELM这套东西,参数不多,但每个参数都有讲究,调明白了,后面做任何多输出预测都会顺手很多。