☰
蝴蝶优化算法与LSSVR结合:实现高效回归预测超参数自动搜索
2026/10/3 18:06:47 网站建设 项目流程

1. 为什么把蝴蝶优化算法和LSSVR放在一起

1.1 LSSVR:被低估的快速回归模型

做数据建模的人大概率都熟悉支持向量机(SVM),但说到 LSSVR——最小二乘支持向量回归——很多新手会一脸茫然。它和标准 SVR 的区别其实非常小:标准 SVR 用不等式约束,把问题变成一个二次规划;LSSVR 直接把约束改成等式,损失函数用平方误差,于是求解过程从二次规划降为线性方程组。这个改动带来一个非常实际的好处:训练速度快,而且不需要额外的二次规划求解器。对小样本、高维、非线性回归任务,LSSVR 的精度不一定比 SVR 差,但训练成本低一个量级。

不过 LSSVR 有一个让人头疼的毛病:对超参数非常敏感。最核心的是正则化参数 γ(控制模型复杂度和误差之间的平衡)和核函数宽度 σ(RBF 核的带宽)。这两个参数选得好不好,直接决定模型是欠拟合、过拟合还是恰到好处。手动调参在低维度场景下还能忍,一旦需要同时优化核参数、特征子集甚至多个核权重,人工试参基本就是折磨。我的做法是直接上元启发式优化算法,让算法替我去连续参数空间里找那组最优解。

1.2 BOA:一种带“气味导航”的群体智能算法

蝴蝶优化算法(Butterfly Optimization Algorithm, BOA)是近十来年出现的一种群体智能算法,灵感来自蝴蝶觅食时的嗅觉感知行为。蝴蝶能感知空气中气味浓度,气味越浓,它越容易被吸引过去,同时它自身也会释放气味,形成一种群体协作式的搜索。把这个行为抽象成算法以后,每一只蝴蝶就是优化问题的一个候选解,它会在“向全局最优飞行”和“在附近随机探索”两种模式之间切换。

相较于粒子群算法(PSO)和遗传算法(GA),BOA 的实现并不复杂,需要调节的参数也相对少:种群大小、最大迭代次数、气味感知模态指数、切换概率,再加上一个气味浓度常数。这不是说 BOA 在所有问题上都一定优于 PSO 或者 GA,但在连续参数优化任务里,它的探索和开发平衡做得不错,尤其是用气味浓度来动态控制移动步长,这个设计在不少局部极值较多的目标函数上表现得很稳定。我在做回归模型超参数搜索时试过粒子群、灰狼算法和 BOA,BOA 的收敛速度不一定最快,但它不容易在早期陷进一个局部小坑里出不来。

1.3 BOA-LSSVR 的组合逻辑

把 BOA 和 LSSVR 放在一起,本质上是一个很朴素的思路:LSSVR 负责做预测,BOA 负责找 LSSVR 的最优超参数。建模链路也不复杂,先对原始数据做清洗和归一化,然后划分训练集和测试集;在训练集上,BOA 每次生成一组候选参数,LSSVR 用这组参数做交叉验证,返回平均误差作为适应度;BOA 根据适应度不断更新蝴蝶位置,迭代结束后把最优参数给到 LSSVR,再在测试集上评估最终效果。

有人会问,为什么不用网格搜索?网格搜索在参数空间低维的时候确实可靠,但网格分辨率一旦加密,计算量指数上升;随机搜索虽然便宜,但完全没有利用历史评估信息,不会在效果好的区域加密搜索。而 BOA 这类群智能算法天然是连续优化器,可以在对数尺度、跨越几个数量级的参数范围内高效搜索,还能根据历史适应度动态调整搜索方向。后续我会把完整实现细节和代码拆开讲,包括那些文档里很少写的坑。

2. 拆开看:核心公式与参数为什么这么定

2.1 LSSVR 的数学形式与核函数

LSSVR 的优化目标可以写成:

[ \min_{w,b,e} J(w,e) = \frac{1}{2} w^T w + \frac{\gamma}{2} \sum_{i=1}^{n} e_i^2 ]

约束条件不再是 SVR 那种 ( \epsilon ) 不敏感带,而是直接的等式:

[ y_i = w^T \phi(x_i) + b + e_i ]

这里的 ( e_i ) 是拟合误差,( \gamma ) 是正则化参数。用拉格朗日乘子法推导以后,最终会得到一个线性方程组:

[ \begin{bmatrix} 0 & 1^T \ 1 & \Omega + \gamma^{-1} I \end{bmatrix} \begin{bmatrix} b \ \alpha \end{bmatrix}

\begin{bmatrix} 0 \ y \end{bmatrix} ]

其中 ( \Omega_{ij} = K(x_i, x_j) )。求出来的 ( \alpha ) 和 ( b ) 就是模型参数,预测时直接用核函数内积:

[ \hat{y}(x) = \sum_{i=1}^{n} \alpha_i K(x, x_i) + b ]

这个公式看起来复杂,但代码实现其实很短,不需要调用任何专门的二次规划库,直接用numpy.linalg.solve解一个 ( (n+1)\times(n+1) ) 的线性方程组就行。我们实践中常选 RBF 核:

[ K(x_i, x_j) = \exp\left(-\frac{|x_i - x_j|^2}{2\sigma^2}\right) ]

为什么不选线性核?因为大多数实际回归问题里,特征和目标的关系并不是线性的。RBF 核可以把数据映射到高维空间,同时又只靠一个宽度参数 ( \sigma ) 控制局部范围,和 BOA 的连续搜索配合得很好。需要特别强调的是,这里我刻意用 ( \sigma ) 表示核宽度,避免和正则化参数 ( \gamma ) 混淆。很多开源代码里gamma一会儿是正则化系数,一会儿是核函数的缩放参数,太容易踩坑。

γ和σ的作用可以这么理解:γ是“我有多信任数据”,越大越放任模型贴合训练集,小则更强调模型平滑;σ是“每个样本影响范围有多大”,越小越容易学出尖锐边界,越大则预测曲线越平坦。它俩不是独立起作用的,往往需要联合调整。手工调参时,我们往往顾此失彼,而 BOA 正好可以同时搜索两个参数。

2.2 BOA 的关键参数和两个搜索策略

BOA 的原始版本里,每只蝴蝶会计算一个“气味浓度” ( f ):

[ f_i = c \cdot I_i^{a} ]

其中 ( I ) 是刺激强度,在优化问题里我通常把它映射成适应度;( c ) 是气味感知常数,( a ) 是模态指数,一般取值 ( c=0.01, a=0.1 )。这里的逻辑很直观:越好的解,刺激强度越大,气味浓度也越大,蝴蝶移动的步长就越大;反之,差的解气味淡,移动步长小,更倾向于局部小范围调整。

在每次迭代中,每一只蝴蝶按概率 ( p ) 选择全局搜索,否则选局部搜索。典型的全局搜索公式是:

[ x_i^{t+1} = x_i^{t} + \left(r^2 \cdot g_{\text{best}} - x_i^{t}\right) \cdot f_i ]

局部搜索公式是:

[ x_i^{t+1} = x_i^{t} + \left(r^2 \cdot x_j^{t} - x_k^{t}\right) \cdot f_i ]

其中 ( r ) 是 0 到 1 之间的随机数,( j ) 和 ( k ) 是不同于 ( i ) 的另外两只蝴蝶。切换概率 ( p ) 在原论文中通常取 0.8,也就是说大部分情况下蝴蝶会朝全局最优方向飞,只有小部分时间在局部蝴蝶之间做随机探索。这个比例听起来偏向“开发”,但实测中效果不错;如果你发现容易陷入局部最优,可以适当调低 ( p ),给局部随机搜索更多机会。

需要注意,气味浓度公式里的 ( I ) 怎么取非常影响搜索行为。我的做法是把目标函数 MSE 变换为:

[ I = \frac{1}{1 + \text{MSE}} ]

这样 MSE 越小,刺激强度越大,且始终大于 0。如果你直接把原始 MSE 塞进公式,步长容易忽大忽小,迭代后期很难稳定收敛。这个小细节是很多复现 BOA 的代码里没写清楚的。

2.3 参数范围与对数编码:容易被忽视的细节

拿到 LSSVR 之后,很多人的第一反应是把γ和σ直接放进 BOA 里当成两个连续变量搜索。但实际跑下来会发现,这个方案非常蠢。因为γ和σ的合理范围往往横跨多个数量级,比如γ可以取 0.001,也可以取 1000;如果 BOA 直接在原始尺度上撒点,几乎所有初始解都会集中在大数值区域,小数值区域几乎不会飞到。

所以我强烈建议用对数编码。个体的每一位不是直接的γ和σ,而是它们的常用对数:

  • 第一位:( \log_{10}(\gamma) )
  • 第二位:( \log_{10}(\sigma) )

搜索边界可以设置为 ([-3, 3]),对应的实际参数范围就是 ( 10^{-3} ) 到 ( 10^{3} )。在适应度函数里再做一个gamma = 10**params[0]; sigma = 10**params[1]的解码。这样做有两个好处:一是参数空间的尺度更均匀,BOA 的随机移动不会在量级上失衡;二是边界控制更自然,不会出现某个参数跑到负数这种非法值。

数据归一化也是一个前提动作。RBF 核依赖样本间的欧氏距离,如果特征量纲差异巨大,距离会被量纲大的特征主导,核宽度参数基本白调。我一般用StandardScaler对所有特征做标准化,对目标变量y也会做标准化处理,否则γ的取值边界会受y量纲影响。预测得到结果后再反归一化回去,这样才能保证 MSE 等指标和原始业务数据的单位一致。

3. 手把手实现 BOA-LSSVR

3.1 环境准备与 LSSVR 的极简实现

只要装好 Python、NumPy、scikit-learn 和 pandas 就足够。LSSVR 不需要额外安装专门的包,因为它的求解过程极其简单,直接手写一个类就行。下面是我常用的 LSSVR 实现,去掉注释以后大概四十行,比调用库函数更能看清楚细节:

import numpy as np class LSSVR: def __init__(self, gamma=1.0, sigma=1.0): self.gamma = gamma self.sigma = sigma self.alpha = None self.b = 0.0 self.X_train = None def _kernel(self, X1, X2): # RBF kernel matrix sq_norm = ( np.sum(X1**2, axis=1)[:, None] + np.sum(X2**2, axis=1)[None, :] - 2 * X1 @ X2.T ) return np.exp(-sq_norm / (2.0 * self.sigma**2)) def fit(self, X, y): n = X.shape[0] K = self._kernel(X, X) A = np.zeros((n + 1, n + 1)) A[0, 1:] = 1.0 A[1:, 0] = 1.0 A[1:, 1:] = K + np.eye(n) / self.gamma rhs = np.zeros(n + 1) rhs[1:] = y sol = np.linalg.solve(A, rhs) self.b = sol[0] self.alpha = sol[1:] self.X_train = X return self def predict(self, X_new): K = self._kernel(self.X_train, X_new) return K @ self.alpha + self.b

这段代码用np.sum(X1**2, axis=1)和交叉项展开欧氏距离,比双重循环快得多。构造矩阵时,对角线上加了1 / gamma,这一项实际上就是 LSSVR 里 ( \gamma^{-1}I ) 的位置,能起到缓解核矩阵奇异的作用。注意fit里A[1:, 0] = 1.0对应等式约束 ( \sum_i \alpha_i = 0 ),千万别漏。

如果你自己基于这个类去跑实验,最好先用一组手工参数跑通一个小数据集,确认预测结果合理,再接 BOA。不要一上来就整个大流程,否则出了问题很难定位。

3.2 适应度函数设计:交叉验证怎么加

BOA 需要一个标量适应度来判断蝴蝶好坏。我默认用交叉验证的均方误差作为适应度,因为直接用训练集误差容易选到过拟合参数,直接用测试集误差又等于偷看测试信息,最后评估不公平。通常取 5 折交叉验证,每折训练一次模型,得到五个验证集 MSE,取平均。代码如下:

from sklearn.model_selection import KFold from sklearn.metrics import mean_squared_error def evaluate_solution(params, X, y, n_splits=5, random_state=42): gamma = 10.0 ** params[0] sigma = 10.0 ** params[1] kf = KFold(n_splits=n_splits, shuffle=True, random_state=random_state) errors = [] for train_idx, val_idx in kf.split(X): model = LSSVR(gamma=gamma, sigma=sigma) model.fit(X[train_idx], y[train_idx]) pred = model.predict(X[val_idx]) errors.append(mean_squared_error(y[val_idx], pred)) return np.mean(errors)

交叉验证的折数需要平衡效率和稳定性。数据集小就多折一点,比如 5 折或 10 折;数据上千条以上,10 折成本会明显增加。BOA 每次迭代要评估种群内所有个体,一般迭代 100 次、种群 20 个,就是 2000 次模型训练,再乘以 5 折就是一万次 LSSVR 求解。LSSVR 因为只需解一个线性方程组,所以还能扛得住;如果你换标准 SVR,同样的流程可能慢到怀疑人生。

因此,实际应用中我经常把 n_splits 设成 3,或者采用分层抽样、只保留一部分验证集。要记住,我们找的是参数的大致最优区域,不是精确到小数点后四位的“最优”,所以没必要为了交叉验证的无偏性付出过多计算代价。

3.3 BOA 主循环与调用示例

BOA 部分我封装成一个通用函数,这样以后换数据集、换优化目标,只需要替换evaluate_func。下面是一个最精简但可用的版本:

def boa_optimize(evaluate_func, dim=2, pop_size=20, max_iter=100, p=0.8, c=0.01, a=0.1, bounds=(-3.0, 3.0), seed=42): rng = np.random.default_rng(seed) # 初始化种群 pop = rng.uniform(bounds[0], bounds[1], size=(pop_size, dim)) fitness = np.array([evaluate_func(ind) for ind in pop]) best_idx = np.argmin(fitness) g_best = pop[best_idx].copy() best_fitness = fitness[best_idx] for _ in range(max_iter): # 气味浓度 intensity = 1.0 / (1.0 + fitness) fragrance = c * np.power(intensity, a) for i in range(pop_size): r = rng.random() if r < p: # 全局搜索 r2 = rng.random() new_sol = pop[i] + (r2 * r2 * g_best - pop[i]) * fragrance[i] else: # 局部搜索 candidates = [idx for idx in range(pop_size) if idx != i] j, k = rng.choice(candidates, size=2, replace=False) r2 = rng.random() new_sol = pop[i] + (r2 * r2 * pop[j] - pop[k]) * fragrance[i] new_sol = np.clip(new_sol, bounds[0], bounds[1]) new_fit = evaluate_func(new_sol) if new_fit < fitness[i]: pop[i] = new_sol fitness[i] = new_fit if new_fit < best_fitness: best_fitness = new_fit g_best = new_sol.copy() return g_best, best_fitness

这里有几个细节容易踩坑。第一,fragrance是用当前群体的适应度统一算出来的,迭代里如果个体适应度更新了,它不会立刻重新计算气味浓度,这是为了省算力;在原始 BOA 里这个方法也能收敛,实际效果已经很稳。第二,全局搜索公式里我用r2 * r2 * g_best,原论文写法是 ( r^2 g^* ),平方操作会让移动方向更偏向当前解,相当于一个随机扰动。第三,新解必须做边界裁剪,否则log10解码出来的参数可能跑到不可解释的范围。

调用时保持清晰:

best_params, best_mse = boa_optimize( evaluate_func=lambda p: evaluate_solution(p, X_scaled, y_scaled), dim=2, pop_size=20, max_iter=100, seed=7 ) gamma_best = 10 ** best_params[0] sigma_best = 10 ** best_params[1] print(f"best gamma: {gamma_best:.6f}, best sigma: {sigma_best:.6f}, MSE: {best_mse:.6f}")

3.4 结果评估和可视化

优化结束后,我会用最优参数再训练一个完整的 LSSVR 模型,在独立的测试集上评估,不能直接把交叉验证的 MSE 当成最终指标。测试集评估要做的事情包括:

  • 计算 RMSE、MAE、R²,和优化过程中的交叉验证 MSE 对比,判断有没有明显过拟合评估过程。
  • 绘制预测值和真实值的散点图,理想情况下都落在对角线附近。
  • 如果业务允许,绘制误差分布直方图,看看是否存在系统性偏差。

还可以把 BOA 的每一代最优适应度记录下来,画一条收敛曲线。正常情况下曲线会先快速下降,然后趋于平缓;如果曲线像锯齿一样上下跳动,可能说明气味浓度计算或参数范围设置有问题。另一个值得做的是画出不同γ, σ组合的适应度热力图,这在二维参数空间里非常直观。这个热力图能告诉你不止“最优参数在哪”,还能看出 BOA 是否真的飞到了全局最优区域附近。

我在实际项目中会用matplotlib把群体迭代过程叠加画到热力图上,观察蝴蝶位置是否逐渐聚拢到最优区域。如果最终种群还分散在好几个不同区域,说明算法没有完全收敛,可能需要增加迭代次数或调低切换概率。但注意,可视化只适合 2 维参数问题,参数维度高的时候就不要勉强了,容易误导。

4. 实战中的常见问题与排查实录

4.1 优化结果不稳定,每次跑都不一样

这是元启发式算法的通病。BOA 的初始化具有随机性,每次运行结果自然会有波动。如果同一份数据、不同随机种子跑出来的 MSE 差了几个百分点,该如何处理?我的经验是先把“随机性来源”拆开看:初始种群、内部随机数、交叉验证数据划分。代码里统一固定了random_state的交叉验证和种子固定的 BOA,但如果你在标准化的步骤里也用了随机性很强的操作,那结果依然不稳定。

解决办法是重复实验。最少跑 10 次独立 BOA,每次都固定不同的随机种子,记录每一个最优参数和测试集指标,最后报告平均值和标准差。如果平均结果仍然稳定,就能说明 BOA-LSSVR 在该数据集上是可靠的。如果 10 次结果方差极大,那大概率不是随机性问题,而是目标函数本身参数敏感度太高,特别是σ的微小变化就会让核矩阵剧烈变化,这时候需要收紧搜索范围或改用更平滑的核函数。

实操中我还习惯保存每次运行的历史轨迹到 CSV 文件,包括迭代次数、当前最优适应度、种群均值,这样即使结果不理想,也可以事后分析是哪一代开始分化的。

4.2 核矩阵接近奇异或收敛异常

LSSVR 需要解一个线性方程组,而 RBF 核矩阵有一个特点:当σ过小时,所有样本两两之间的距离都会变得很大,核函数值趋近于 0,矩阵对角线加上1/γ后仍然可能接近奇异;当γ非常大时,对角线项很小,核矩阵本身接近奇异,np.linalg.solve会给出数值上极不稳定的解,甚至直接报LinAlgError。

遇到这个情况,我一般做两个处理:一是给对角线增加一个小的 jitter,比如K + np.eye(n) / gamma + 1e-8 * np.eye(n)。这个 jitter 源于机器学习里常见的“岭修正”,对最终模型影响很小,但能避免矩阵求逆爆掉。二是直接在 BOA 里限制参数范围,不要把σ的下限设得过低。我常用的范围是10^{-2}到10^{2},而不是10^{-3}到10^{3},这可以大幅减少矩阵奇异的发生。

如果出现收敛异常,比如说 BOA 的最好适应度先是下降,后来又回升,多半是“精英解”没有保存好。上面的代码用了best_fitness和g_best的全局保存,但要注意更新条件:只有new_fit < fitness[i]才接受新解。千万不要无条件接受每个新解,否则好解会被破坏,曲线就乱了。

4.3 优化了参数却还是过拟合

BOA 找到的参数本质上是在交叉验证目标下最优的参数,并不天然保证泛化。一个很常见的误判是:交叉验证 MSE 很低,但测试集 MSE 很高。这通常说明交叉验证过程有泄漏,或者数据本身存在时间序结构,用普通的 KFold 随机划分会把未来数据混进训练集。时序预测问题里,应该改用时间序列交叉验证,例如,前一个时间块训练、后一个时间块验证,按顺序滚动。

另一个原因是目标变量或特征标准化时用了全样本的统计量。正确的做法是先划分训练集和测试集,再在训练集上拟合StandardScaler,然后变换测试集。如果用全样本计算均值和方差,等于把测试集的信息偷给了模型,测试误差一定会被低估。

当然,如果模型在测试集上还是过拟合,可以适当增大正则化项,也就是把 BOA 搜索边界里的γ上限调低,或者把σ下限调高。这相当于限定模型的表达空间,牺牲一点训练集精度换取更平滑的预测。

4.4 对比实验怎么设计才有说服力

如果这篇文章是你的项目记录,而不是正式论文,对比实验可以放松;但如果你想把 BOA-LSSVR 作为方案汇报给团队,那对比实验必须严谨。至少要把这几个对照做齐:网格搜索或随机搜索、粒子群优化的 LSSVR、灰狼优化的 LSSVR,以及不调参的默认 LSSVR。所有方法的评价口径要完全一致:同一份交叉验证策略、同一个测试集、同一个随机种子列表、同样的训练终止条件。

控制变量方面,我给不同优化算法分配相同的“函数评估次数”,而不是相同的迭代次数。因为有的算法单次迭代会评估多个个体,有的则只评估一个,简单的迭代次数对齐并不公平。基于评估次数对齐以后,对比结果才有意义。最好是每个算法跑 20 次,统计均值加减标准差,然后做简单的配对 t 检验,或者至少用交并比看箱线图是否重叠。我自己实践下来的体感是,BOA 不一定每次都能拿第一,但它在多数情况下能落在前两名,而且代码改动量比 GA 小很多。

5. 一些心得和扩展方向

5.1 我个人更推荐先做一件事再开始调参

如果你拿到一个新数据集,不要急着把 BOA-LSSVR 整个流程跑起来。我吃过亏后的固定套路是:先用默认参数训练一版 LSSVR,看看测试集 RMSE 的量级;再手动随机试 30 组参数,画一下参数和误差的关系图;如果随机参数已经能显著改善结果,才值得上 BOA。这么做的好处是能提前发现数据预处理是否有问题。有一次我在一个工业数据集上跑了几个小时 BOA,结果发现原始数据有一列存在缺失值填充错误,模型再优也白搭。先跑基线、再调参,永远是最高性价比的路线。

另外,优化出的参数不要直接深信不疑。特别是γ或σ落在搜索边界上,这是一条重要的提示:边界可能设置得不对,或者真实最优解在边界之外。这时候应该把边界向外扩一个数量级再跑一次,观察最优值是否离开边界。边界上的最优解基本等于“没搜到”。

5.2 BOA-LSSVR 的扩展:多目标、混合特征选择

BOA-LSSVR 不是只能做两个参数的优化。常见的扩展方向包括:把特征选择也编码进蝴蝶个体,每位 0 或 1 代表特征是否被选中,目标函数里把特征数量作为惩罚项,变成多目标优化;或者用多种核函数组合成混合核,用 BOA 同时优化混合权重和各核参数,这样在复杂非线性数据上能进一步提升表达力。

特征选择这个方向在回归项目里很值得试试。很多业务数据特征冗余严重,LSSVR 的 RBF 核在高维空间里并不天然免疫噪声特征,而特征选择可以显著减少核距离的扰动。不过二进制编码下 BOA 的搜索方式需要改变,不能用简单的加减法更新位置。一个折衷办法是保留连续编码,给每个特征设一个权重,然后用权重阈值决定特征是否参与建模。这样 BOA 主代码几乎不用改,只是评价函数里多了一个“按权重过滤特征”的步骤。

我自己最近在做的一个扩展是把 BOA 替换成并行版本,多台机器同时跑多组种群,定期交换最优个体。LSSVR 训练本身很容易并行,调参任务更是天然独立,所以并行加速效果立竿见影,算是 BOA-LSSVR 工程化落地的一个小方向。总之,模型本身不复杂,难的是把优化过程和数据问题处理干净。每次踩坑以后回头看一眼代码,往往不是 BOA 不行,而是归一化、边界、随机种子这些细节没有处理好。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询