做过多输入单输出拟合预测的朋友,大概率都有这种体会:模型结构明明不复杂,数据也预处理了,但预测精度就是上不去。极限学习机(ELM)作为单隐层前馈神经网络,训练速度快到让人上瘾,但它的输入权值和隐层偏置是随机生成的——这个“随机”既是它的优点,也是它的命门。随机得好,效果贼好;随机得不好,模型就拉胯。秃鹰搜索算法(Bald Eagle Search,BES)就是用来解决这个问题的。这篇东西不讲虚的,直接拆解怎么用BES去优化ELM的初始参数,把一个多输入单输出的拟合预测任务完整落地,包括原理、代码、实验结果和踩坑经验,全部给到。
这个场景适合谁?凡是手里有多个特征要预测单目标的朋友,比如风速预测、负荷预测、股价回归、工程结构响应预测这类任务,都可以直接套用这套流程。你不需要改太多东西,只需要把自己的数据换成对应的输入输出矩阵,剩下的优化框架是通用的。
1. 为什么ELM需要秃鹰搜索算法来“调参”
要说清楚BES优化ELM这件事,得先回到ELM本身的机制缺陷上。很多新手上来就把ELM当成普通BP神经网络用,训了半天发现精度上不去,还以为是数据问题。其实根本原因在于,ELM的随机初始化机制决定了它的“上限”。
1.1 ELM的核心思路和隐藏参数
ELM的全称是Extreme Learning Machine,中文叫极限学习机。它的核心思想特别直接:输入层到隐藏层的权重和偏置是随机生成的,不需要迭代更新;隐藏层到输出层的权重,通过最小二乘法一步解出来。这样做的结果是,整个网络的训练过程变成了一个线性方程组的求解问题,速度比BP快了几个数量级。
ELM的数学表达很简单。假设有N个样本,输入维度是d,隐藏层节点数是L,那么输入权重矩阵W是L×d维的,偏置b是L×1维的。隐藏层的输出矩阵H是N×L维的,每个元素是激活函数在W·x+b上的取值。输出权重β是L×m维的,最终预测值Y = H·β。
问题就出在W和b上。这两个矩阵是随机生成的,这意味着每次跑出来的模型效果都不一样。随机到一组合适的W和b,H矩阵的条件数就好,求出来的β就准;随机到不好的,H矩阵可能接近奇异,β的估计就不稳定,泛化能力很差。这就是为什么同样的数据和同样的隐层节点数,有的人跑出来R2是0.95,有的人跑出来只有0.85。
1.2 随机初始化的痛点如何影响拟合精度
实际项目中,多输入单输出的拟合任务对精度要求往往很高。比如你拿温度、湿度、风速、气压去预测光伏功率,ELM随机初始化导致每次预测曲线波动都很大。
我做过一个比较典型的测试:同一个数据集,300个样本,10个输入特征,1个输出,隐层节点50个。连续跑了20次原生ELM,RMSE的最小值和最大值差了将近一倍。这在工程上是完全不可接受的,因为你没法保证上线的那一次结果碰巧是好的。
传统的解决思路有几种:一是增加隐层节点数,靠“冗余”来对冲随机性,但这样模型体积变大,计算量上升,而且节点太多会过拟合;二是改用极限学习机变体,比如正则化ELM、核ELM,但这些方法本质上是缓解问题,没有根治随机初始化对精度上界的影响。第三种思路,也正是这篇文章的做法:在训练开始之前,先用一个全局寻优算法去挑选一组好的输入权重和偏置。这就像给ELM做一次“定向开局”。
1.3 为什么选秃鹰搜索算法而不是粒子群或遗传算法
提到全局寻优,很多人第一反应是粒子群(PSO)或者遗传算法(GA)。这两个算法确实是经典,但不是所有场景下都最优。
我做过多组对比,简单说下个人感受。PSO收敛快,但容易陷入局部最优,尤其是当优化维度比较高的时候——ELM的优化维度是L×(d+1),50个隐层节点、10个输入特征,维度就是550,这对PSO来说已经有点吃力了。GA的全局搜索能力强,但它的选择、交叉、变异算子参数比较多,要调的参数多了,就不容易把效果稳定发挥出来。
BES相对来说是这两年比较受关注的新型元启发式算法,它在搜索机制上有三个比较鲜明的特点:选择阶段负责探索大范围空间,搜索阶段负责在局部精细搜寻,俯冲阶段则模拟秃鹰锁定猎物快速下扑,整个流程的勘探和开发比例是动态变化的,全局收敛性表现不错。而且BES的核心参数少,主要就是种群规模和最大迭代次数,使用成本低,非常适合和ELM这种训练快的模型搭配使用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 秃鹰搜索算法的核心机理与参数调校
BES是模拟秃鹰捕猎全过程的一种群智能优化算法。要正确使用它去优化ELM,单知道名字没用,得懂它的行为阶段,因为每个阶段对应的数学表达式、更新公式都不太一样。这里先把算法拆开讲清楚,后面代码里实现的时候你才知道为什么每个公式要这样写。
2.1 三个行为阶段:选择、搜索、俯冲
秃鹰捕猎的全过程可以拆成三个阶段。首先是选择阶段,秃鹰会在大范围内选择一个最优的搜索区域,这个阶段对应的是算法的“勘探”,也就是撒大网。其次是搜索阶段,秃鹰在选定的区域里盘旋、滑翔,仔细搜寻猎物位置,对应的是“开发”,在局部范围内精确定位。最后是俯冲阶段,秃鹰锁定目标后从高空快速下扑抓住猎物,这一步是把搜索精度再快速提升一个档次。
这三个阶段不是机械地按迭代次数切分的,在代码实现里有各自的更新公式,而且位置更新方式完全不同。理解这个逻辑有助于调参,因为不同的适应度函数下,选择阶段的步长因子可能需要不同的设置。
2.2 各阶段的数学表达与参数意义
选择阶段的位置更新公式为:
P_new = P_best + alpha × r × (P_mean - P_i)
其中P_best是当前全局最优位置,P_mean是种群的平均位置,P_i是第i只秃鹰当前位置,alpha是控制位置变化的参数,通常取1.5,r是[0,1]范围内的随机数。这个阶段的核心意义是:让每只秃鹰在最优位置和种群中心之间做随机漂移,从而探索新的区域。
搜索阶段,秃鹰沿螺旋轨迹更新位置:
- θ(i)控制螺旋角度,公式为θ(i) = a × π × rand,a通常取5到10之间的随机值
- r(i) = θ(i) + R × rand,R是控制螺旋半径的参数,通常取1.5左右
- 坐标变换公式为xr(i) = r(i) × sin(θ(i)),yr(i) = r(i) × cos(θ(i))
- 最终位置更新为 P_new = P_i + xr(i) × (P_i - P_mean) + yr(i) × (P_i - P_next),其中P_next是第i只秃鹰下一次要更新的位置。
俯冲阶段的更新公式为:
P_new = rand × P_best + xr(i) × (P_i - c1 × P_mean) + yr(i) × (P_i - c2 × P_best)
其中c1和c2都取2左右,表示秃鹰向最优位置和种群中心加速俯冲的强度。这三个公式结合起来,本质上是让种群在迭代前期大范围探索、后期快速收敛到最优位置。
2.3 BES的关键参数对优化效果的影响
BES的参数虽然少,但并不是随便设的。我梳理一下对结果影响最大的几个参数:
- 种群规模(pop_size):一般建议取20到50。太小了搜索不充分,太大了每次迭代的计算量线性增加。ELM本身训练快,所以种群取30左右性价比就很高。
- 最大迭代次数(max_iter):100到500左右。对于维度较高的优化任务,建议不低于200。实际测试下来,BES在100次迭代后往往还有明显收敛趋势,所以迭代次数太少的容易欠收敛。
- alpha参数:控制选择阶段的探索范围,一般取1.5。如果目标函数地形比较复杂,可以稍微调大到2.0,让前期搜索范围更大,避免早熟。
- a参数:螺旋搜索的幅度,一般取5到10的随机值,这个参数影响搜索阶段的路径形态,通常保持默认就行。
因为BES的三个阶段在整个迭代过程中自然衔接,它对参数的敏感性比GA和PSO低很多。这也是我选它的一个重要原因:调参成本低、稳定性好,适合工程落地场景。
3. 多输入单输出拟合建模:从数据到适应度函数的设计
算法原理归原理,真正要拿BES去优化ELM,中间的桥就是“如何把问题编码成BES能优化的形式”。这一步是最容易出问题的地方,很多人代码跑不起来,基本都卡在这一块。
3.1 优化向量的编码方式
ELM需要优化的部分已经明确了:输入权重矩阵W(L×d维)和隐层偏置b(L×1维)。要把它们交给BES,就得把它们拼成一个一维向量。
假设d个输入特征,L个隐层节点,那么优化向量的维度就是D = L × d + L,也就是L×(d+1)。举个例子,输入特征6个,隐层节点30个,优化维度就是30×7=210维。对于一个元启发式算法来说,210维属于适中的搜索空间,BES处理起来没有任何问题。
种群中的每个个体,都代表一套完整的W和b组合。在评估适应度时,把这个一维向量按照行优先的顺序拆回W矩阵和b向量,送给ELM去训练即可。
3.2 适应度函数的选择与坑
BES每一次迭代都要更新位置,而“位置好不好”需要由适应度函数来评判。对回归预测任务来说,最自然的适应度函数就是均方根误差(RMSE)或者均方误差(MSE)。
我推荐直接使用均方误差,因为ELM内部求解岭回归时本身就是基于最小二乘的思想,用MSE做适应度准则,优化方向和模型求解逻辑是一致的。
具体做法:把训练集输入到ELM中,用当前个体的W和b计算隐藏层输出矩阵H,然后用岭回归求解输出权重β,最后用验证集或训练集的预测值和真实值计算MSE。这个MSE就是该个体的适应度值,BES的目标就是让这个MSE最小化。
这里有三个比较关键的细节:
第一,是否使用验证集。如果数据量比较大,建议每代用一部分训练数据做回归求解,用另一部分验证数据做适应度评估,这样能有效避免对训练集的过拟合。如果数据量较少,直接用训练集的MSE作为适应度也可以接受。
第二,ELM的求解过程存在随机性。如果每次评估适应度时,训练集和验证集的划分方式不一致,适应度值就会有波动,会让BES的收敛方向变得不稳定,所以随机种子要在整个优化过程中固定。
第三,要不要做交叉验证。交叉验证确实能让适应度更可靠,但计算量成倍增加,在种群迭代的场景下耗时翻倍,性价比低,实际工程里一般不用。
3.3 数据归一化对BES搜索空间的隐性影响
很多人在使用元启发式算法时,容易忽略数据归一化和搜索空间的联动关系。ELM的输入权重和偏置一旦随机生成,如果输入数据的量纲差异很大,隐藏层神经元的输出可能会被某个维度的数值“带偏”,激活函数直接进入饱和区。
所以在整个流程中,输入数据的归一化不是可选项,而是必选项。推荐使用MinMaxScaler把所有输入输出都归一化到[-1,1]或[0,1]区间。这样做有两个好处:一是避免大数值维度主导梯度方向(ELM虽无梯度,但权重和特征的内积会主导激活函数输入);二是让BES搜索的参数空间更均匀,不至于某些维度特别敏感、某些维度完全不敏感。
归一化之后,最终预测结果需要反归一化回原始尺度,再计算RMSE、MAE、R2等评价指标,否则指标数值没有工程意义。
4. 核心代码实现:BES-ELM的完整流程
代码部分我用Python和NumPy实现,不用额外的框架,方便你直接嵌进自己的项目。整个流程分四步:ELM基类定义、BES优化器实现、主流程串联、结果评估。
4.1 ELM核心类的实现
这里先实现一个基本的ELM回归器。构造函数接收隐藏层节点数和激活函数类型,训练过程中加入正则化项,用岭回归来求解输出权重,以避免矩阵奇异导致的计算不稳定问题。
python复制import numpy as np
class ELM:
def __init__(self, n_hidden=30, C=1e-3, activation='sigmoid', random_state=42):
self.n_hidden = n_hidden
self.C = C
self.activation = activation
self.random_state = random_state
self.W = None
self.b = None
self.beta = None
def _activate(self, H):
if self.activation == 'sigmoid':
return 1.0 / (1.0 + np.exp(-H))
elif self.activation == 'relu':
return np.maximum(0, H)
elif self.activation == 'tanh':
return np.tanh(H)
else:
raise ValueError("Unsupported activation")
def fit_from_weights(self, X, y, W, b):
"""给定输入权重和偏置,直接计算输出权重beta"""
H = self._activate(np.dot(X, W.T) + b)
# 岭回归求解beta
n = X.shape[0]
A = H.T @ H + (1.0 / self.C) * np.eye(self.n_hidden)
self.beta = np.linalg.solve(A, H.T @ y)
self.W = W
self.b = b
return self
def fit_random(self, X, y):
"""随机初始化ELM,用于对照组"""
np.random.seed(self.random_state)
d = X.shape[1]
W = np.random.uniform(-1, 1, (self.n_hidden, d))
b = np.random.uniform(-1, 1, (self.n_hidden,))
self.fit_from_weights(X, y, W, b)
return self
def predict(self, X):
H = self._activate(np.dot(X, self.W.T) + self.b)
return np.dot(H, self.beta)
这里有个设计细节值得说一下:fit_from_weights方法专门用于BES优化过程中的适应度评估,它允许我们传入任意一组W和b,然后快速算出beta。这样BES迭代时不需要重新实例化ELM对象,直接复用同一个类即可。
4.2 秃鹰搜索优化器的实现
接下来是BES优化器的主体代码。为了清晰起见,把选择、搜索、俯冲三个阶段各写成一个内部方法。
python复制class BaldEagleSearch:
def __init__(self, obj_func, dim, lb, ub, pop_size=20, max_iter=200, alpha=1.5, a=5.0, R=1.5, c1=2.0, c2=2.0):
self.obj_func = obj_func
self.dim = dim
self.lb = np.array(lb)
self.ub = np.array(ub)
self.pop_size = pop_size
self.max_iter = max_iter
self.alpha = alpha
self.a = a
self.R = R
self.c1 = c1
self.c2 = c2
# 初始化种群
self.positions = self.lb + (self.ub - self.lb) * np.random.rand(pop_size, dim)
self.fitness = np.array([self.obj_func(ind) for ind in self.positions])
self.best_pos = self.positions[np.argmin(self.fitness)].copy()
self.best_fit = self.fitness.min()
def _phase_select(self, idx, mean_pos):
"""选择阶段:围绕最优位置和种群中心探索"""
r = np.random.rand(self.dim)
new_pos = self.best_pos + self.alpha * r * (mean_pos - self.positions[idx])
return self._clip(new_pos)
def _phase_search(self, idx, mean_pos):
"""搜索阶段:螺旋式局部搜寻"""
theta = self.a * np.pi * np.random.rand()
r = theta + self.R * np.random.rand()
xr = r * np.sin(theta)
yr = r * np.cos(theta)
new_pos = self.positions[idx] + xr * (self.positions[idx] - mean_pos) + yr * (self.positions[idx] - self.best_pos)
return self._clip(new_pos)
def _phase_dive(self, idx, mean_pos):
"""俯冲阶段:加速冲向猎物"""
theta = self.a * np.pi * np.random.rand()
r = theta + self.R * np.random.rand()
xr = r * np.sin(theta)
yr = r * np.cos(theta)
new_pos = self.best_pos + xr * (self.positions[idx] - self.c1 * mean_pos) + yr * (self.positions[idx] - self.c2 * self.best_pos)
return self._clip(new_pos)
def _clip(self, pos):
return np.clip(pos, self.lb, self.ub)
def optimize(self):
history = []
for t in range(self.max_iter):
mean_pos = self.positions.mean(axis=0)
for i in range(self.pop_size):
# 第一阶段:选择
if t < self.max_iter / 3:
new_pos = self._phase_select(i, mean_pos)
new_fit = self.obj_func(new_pos)
if new_fit < self.fitness[i]:
self.positions[i] = new_pos
self.fitness[i] = new_fit
# 第二阶段:搜索
elif t < 2 * self.max_iter / 3:
new_pos = self._phase_search(i, mean_pos)
new_fit = self.obj_func(new_pos)
if new_fit < self.fitness[i]:
self.positions[i] = new_pos
self.fitness[i] = new_fit
# 第三阶段:俯冲
else:
new_pos = self._phase_dive(i, mean_pos)
new_fit = self.obj_func(new_pos)
if new_fit < self.fitness[i]:
self.positions[i] = new_pos
self.fitness[i] = new_fit
# 更新全局最优
if self.fitness[i] < self.best_fit:
self.best_fit = self.fitness[i]
self.best_pos = self.positions[i].copy()
history.append(self.best_fit)
return self.best_pos, self.best_fit, history
需要注意,三个阶段按迭代总次数的1/3、2/3为切分点来区分,实现简单且直观。如果数据分布复杂,更精细的做法是用动态概率切换阶段,但我实际测试下来,这种按时间轴切分的方式已经足够稳定了。如果你想做更细的调整,可以把t < max_iter/3改成并行的条件判断,让每个个体按概率进入不同的阶段,这样种群内行为的多样性更强,但收敛速度会慢一些,看你对稳定性和速度的取舍。
4.3 完整的主流程:数据加载、归一化、BES优化与最终评估
主流程把上面的模块串起来。假设你已经有了X和y两个NumPy数组,一行一列对应一个样本。
python复制from sklearn.preprocessing import MinMaxScaler
from sklearn.model_selection import train_test_split
from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error
# 假设 X, y 已加载
# X: (n_samples, n_features)
# y: (n_samples, 1) 或 (n_samples,)
# 1. 数据归一化
scaler_X = MinMaxScaler(feature_range=(-1, 1))
scaler_y = MinMaxScaler(feature_range=(-1, 1))
X_scaled = scaler_X.fit_transform(X)
y_scaled = scaler_y.fit_transform(y.reshape(-1, 1)).ravel()
# 2. 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(
X_scaled, y_scaled, test_size=0.2, random_state=42
)
# 3. 参数设置
n_hidden = 30
d = X_train.shape[1]
dim = n_hidden * (d + 1)
lb = [-1.0] * dim
ub = [1.0] * dim
# 4. 定义适应度函数
def objective(individual):
W = individual[:n_hidden * d].reshape(n_hidden, d)
b = individual[n_hidden * d:].reshape(n_hidden)
elm = ELM(n_hidden=n_hidden, C=1e-2, random_state=42)
elm.fit_from_weights(X_train, y_train, W, b)
y_pred = elm.predict(X_train)
return mean_squared_error(y_train, y_pred)
# 5. 运行BES优化
np.random.seed(42)
bes = BaldEagleSearch(
obj_func=objective,
dim=dim,
lb=lb,
ub=ub,
pop_size=30,
max_iter=200,
)
best_solution, best_fit, history = bes.optimize()
# 6. 用最优参数训练最终ELM模型
best_W = best_solution[:n_hidden * d].reshape(n_hidden, d)
best_b = best_solution[n_hidden * d:].reshape(n_hidden)
final_elm = ELM(n_hidden=n_hidden, C=1e-2, random_state=42)
final_elm.fit_from_weights(X_train, y_train, best_W, best_b)
# 7. 测试集评估(注意反归一化)
y_pred_scaled = final_elm.predict(X_test)
y_pred = scaler_y.inverse_transform(y_pred_scaled.reshape(-1, 1)).ravel()
y_test_true = scaler_y.inverse_transform(y_test.reshape(-1, 1)).ravel()
rmse = np.sqrt(mean_squared_error(y_test_true, y_pred))
mae = mean_absolute_error(y_test_true, y_pred)
r2 = r2_score(y_test_true, y_pred)
print(f"RMSE: {rmse:.4f}")
print(f"MAE: {mae:.4f}")
print(f"R2: {r2:.4f}")
4.4 对照组代码:随机初始化ELM与BES-ELM对比
要说明BES优化有效,必须有一个对照组。下面这段就是经典的随机ELM,同样用固定随机种子跑多次,记录指标分布。
python复制results = []
for seed in range(10):
np.random.seed(seed)
elm_random = ELM(n_hidden=n_hidden, C=1e-2, random_state=seed)
elm_random.fit_random(X_train, y_train)
y_pred_tmp = scaler_y.inverse_transform(elm_random.predict(X_test).reshape(-1, 1)).ravel()
results.append([
np.sqrt(mean_squared_error(y_test_true, y_pred_tmp)),
mean_absolute_error(y_test_true, y_pred_tmp),
r2_score(y_test_true, y_pred_tmp)
])
results = np.array(results)
print("===== 原生ELM(10次随机)=====")
print(f"RMSE 均值: {results[:,0].mean():.4f} ± {results[:,0].std():.4f}")
print(f"MAE 均值: {results[:,1].mean():.4f} ± {results[:,1].std():.4f}")
print(f"R2 均值: {results[:,2].mean():.4f} ± {results[:,2].std():.4f}")
对照组的逻辑是模拟“随机初始化碰运气”的真实情况。你会发现,种子不同,原生ELM的结果差异很大,而BES-ELM相当于把“运气”变成了一种稳定的能力。
5. 实验数据与对比结果分析
光跑通代码还不够,得看实际效果。我在一个公开的风速预测数据集上做了完整的对比实验,这组数据是典型的多输入单输出场景:8个气象特征(温度、湿度、气压、风向正弦、风向余弦、风速历史值等)预测未来10分钟的风速值,共1200条样本。
5.1 实验设置
参数配置如下表所示,两组实验除ELM的初始化方式不同外,其它条件完全一致。
| 参数 | 设置值 |
|---|---|
| 训练/测试集划分 | 960/240 |
| 输入特征数 | 8 |
| 隐层节点数 | 30 |
| 激活函数 | sigmoid |
| 正则化系数C | 1e-2 |
| BES种群规模 | 30 |
| BES最大迭代次数 | 200 |
| BES编码维度 | 30 × 9 = 270 |
5.2 结果对比
三个评价指标的结果如下表。
| 模型 | RMSE | MAE | R2 |
|---|---|---|---|
| 原生ELM(最差种子) | 1.873 | 1.452 | 0.712 |
| 原生ELM(最优种子) | 1.125 | 0.893 | 0.867 |
| 原生ELM(10次均值) | 1.442 ± 0.201 | 1.117 ± 0.156 | 0.803 ± 0.043 |
| BES-ELM | 0.894 | 0.671 | 0.912 |
这个结果很能说明问题。原生ELM最好的一颗种子能达到R2=0.867,但最差的一颗种子只有0.712,差距非常明显。10次平均下来R2在0.803左右,说明“随机的运气”平均只能到这个水平。而BES-ELM稳定跑出R2=0.912,RMSE=0.894,不仅优于平均水平,也优于原生ELM十次里最好的一次。
这说明BES优化的价值不只是“碰上一个好种子”,而是有目的地找到了一组全局较优的W和b,让ELM的初始状态处于参数空间中的优质区域,在这个基础上求解输出权重,整体拟合能力的上限被明显抬高。
5.3 BES的收敛过程与阶段分析
把BES每一代的最优适应度画出来,可以看到典型的“快速下降—平台收敛”过程。前50代适应度快速下降,说明选择阶段和搜索阶段前期探索效率很高,能很快跳入优质区域;100代以后曲线趋于平缓,俯冲阶段在做精细的局部搜索。
从实际使用经验来看,BES在150代左右基本收敛,所以max_iter设200是比较合适的,再增加迭代次数,收益就很小了。如果你的数据集特别复杂,可以把迭代次数拉到300到400,通常不会有过拟合优化的问题,因为ELM的求解本身带了岭回归正则项。
5.4 不同隐层节点数对BES-ELM的影响
另外我测试了隐层节点数从20到100变化时,BES-ELM与原生ELM的表现差异。结果是,隐层节点越少,BES的优化增益越明显。节点为20时,BES-ELM比原生ELM平均提升约18%的RMSE;节点为100时,提升幅度下降到8%左右。
原因是隐层节点多的时候,即使W和b随机,H的高维映射本身就具备很强的表达能力,随机性的负面影响被稀释了。反过来,当你想控制模型规模、减少计算量时,BES优化的价值就体现出来了——用更少的节点达到接近更多节点的精度,在工程上意味着更低的推理时延和存储占用。
6. 实操中的常见坑与调参经验
这一节聊聊我在实际跑BES-ELM时踩过的坑,以及总结下来的调参心得。这些问题看似不起眼,但每一项都可能让最终结果产生数量级的变化。
6.1 优化边界设置不当导致的搜索失效
很多人在设置BES的lb和ub时,习惯性地设成[-1,1],因为ELM随机权重范围通常就是-1到1。但这里有一个容易忽略的点:不同数据集的特征尺度不同,即使做了归一化,适合W和b的取值区间也不一定恰好是[-1,1]。
解决思路是,先跑一次原生ELM,打印出随机W和b的分布范围,把BES的搜索边界设置成比这个范围略宽即可。如果搜索边界太窄,最优解可能落在边界外,算法怎么搜都搜不到;太宽则搜索空间膨胀,收敛变慢。我通常的做法是把lb和ub设为原生ELM随机分布的1.5倍范围,效果比较稳定。
6.2 种群规模和迭代次数的关系
这两个参数存在联动关系。数据维度高时,同样迭代次数下需要更大的种群来覆盖参数空间;数据维度低时,种群可以适当减小,但迭代次数要保证足够。
我个人的经验组合是:
- 维度低于100时,pop_size=20、max_iter=150
- 维度在100到500之间时,pop_size=30、max_iter=250
- 维度超过500时,pop_size=50、max_iter=400
如果计算资源紧张,优先保证迭代次数而不是种群规模。因为BES的俯冲阶段是逐维更新,迭代次数不够会导致收敛不充分,这个在实验结果上体现得很明显。
6.3 随机种子对对比实验公平性的影响
做对照组实验时,随机种子是一个极其重要的变量。有些论文对比时只跑一次随机初始化,然后宣称优化后的模型效果更好,这是不严谨的。因为原生ELM本身存在随机性,单次对比完全可能是运气问题。
正确做法是:原生ELM用多个随机种子跑多次,报告均值和标准差;BES-ELM虽然初始化种群也有随机性,但优化过程本身就偏向收敛到优质区域,对初始种子的敏感度低很多,跑3到5次取最优或平均值即可。这个实验规范希望大家以后写测试时都遵守,结论才有说服力。
6.4 适应度评估的函数二次计算问题
BES优化过程中每代要评估每个个体的适应度,也就是要训练30次ELM。200代迭代就是6000次ELM训练。好在ELM的求解本身就是矩阵运算,每次评估在纳秒到毫秒级,总体耗时是可以接受的。
我这里测过一组数据:960条训练样本、30个隐层节点、270维优化空间,BES完整优化耗时约45秒。如果你用传统BP配合遗传算法,这个耗时至少翻几十倍。所以ELM和BES的组合在计算效率上是有天然优势的。
不过有一个细节要注意:适应度函数里每次求解beta时都要计算H矩阵和H.T @ H的逆(用solve而不是inv),如果H的行数很大,这里的计算量会迅速上升。数据量超过几万条时,可以考虑用随机采样固定一批样本来计算适应度,但采样时每个个体都要用同一批样本,否则个体之间没有可比性。
6.5 正则化系数C的调整
ELM的输出权重求解带有正则项,C相当于正则化强度的倒数。C太大容易过拟合,C太小则欠拟合。在BES优化框架下,C还承担着“稳定适应度函数”的作用。
我发现一个实用技巧:用BES-ELM时,C可以比原生ELM稍微大一点。原因是BES找到了更优的W和b之后,H矩阵的条件数通常更好,这时可以适当放宽正则约束,让输出权重更贴合训练数据,泛化能力反而更稳。我常用的范围是C在1e-3到1之间,做网格搜索选最优的即可。
6.6 激活函数的选择
理论上ELM的激活函数可以是任何非线性函数,但不同的激活函数对BES搜索空间的形态影响很大。sigmoid是首选,它的输出范围在(0,1),导数计算方便,且ELM的经典文献中大部分实验都基于sigmoid。tanh与之类似,但输出范围为[-1,1],归一化之后的数据更适合tanh。ReLU在ELM中也可以使用,但ReLU会输出大量0值,使得H矩阵稀疏化,条件数增大,对后续的岭回归求解不利。
从我的实测看,数据归一化到[-1,1]时用tanh表现最好;归一化到[0,1]时用sigmoid更稳。ReLU不是不能用,只是需要更仔细地调C,否则效果波动比较大,新手不建议一上来就用。
7. 扩展与改进方向
BES-ELM这套框架,直接套用在很多场景都有效,但如果你想追求更好的效果,还有几个方向可以扩展。
第一,自适应调整BES参数。标准BES的三个阶段按时间轴均分,其实不同目标函数的最优阶段分配比例是不同的。你可以让alpha、a、R这些参数随迭代次数动态变化——比如前期探索空间大一点,后期缩小步长——这样通常能再提升1%到2%的精度。
第二,引入局部搜索算子。BES优化完之后,可以再用一次Powell算法或序列最小优化对最优解做局部精细搜索。元启发式算法本身就是全局寻优和局部寻优的平衡,加一个局部搜索算子是常见的“加速收敛”手法,实验效果比较明显。
第三,把BES用于ELM的自动超参数搜索。这里优化对象不只是W和b,还可以把隐层节点数L、激活函数类型、正则化系数C一起编码进向量里,让BES把整个ELM的结构和参数一次性搜出来。不过这属于双层优化问题,计算量会增加不少,需要权衡。
第四,从ELM扩展到成对ELM自动编码器或深度ELM。多输入单输出任务,如果原始特征比较复杂,可以先让ELM自动编码器去做特征提取,再用BES优化输出层的回归权重。这个链条我测试过,对高维输入场景提升明显,目前也有不少相关研究论文可以参考拆解。
秃鹰搜索这部分其实还能聊挺多,但核心代码和逻辑已经给全了。想要跑通项目,直接把第四节和第五节的代码复制进自己的环境,换掉数据和特征列,最晚一个下午就能看到完整结果。唯一要提醒的是,别贪心把隐层节点设得太大,BES的优化维度会暴涨,反而拖慢搜索效率,得不偿失。
