☰
北方苍鹰优化NGO-GPR高斯过程回归:小样本预测与超参数优化实战
2026/10/9 10:59:26 网站建设 项目流程

在做回归预测的时候,很多人第一反应就是XGBoost、LightGBM、随机森林这些树模型,确实它们在表格数据上表现稳定,调参空间也大。但真遇到小样本、高噪声、非线性强的数据,树模型有时候会显得“过于自信”,预测值缺乏不确定性估计。这时候我一般会转向高斯过程回归(Gaussian Process Regression, GPR),它天然自带置信区间,对过拟合的抵抗力也更好。不过GPR有个痛点——核函数的超参数对结果影响极大,手工调参费时费力,而且容易陷入局部最优。

我最近在做一个环境监测数据的小样本回归任务,样本量只有几十条,特征维度却不少,实测下来XGBoost和随机森林的泛化效果都不太稳,倒是GPR表现不错。但GPR的初始超参数真的很敏感,我一怒之下把北方苍鹰优化算法(Northern Goshawk Optimization, NGO)和GPR结合了一下,代码实现也不复杂,最终效果非常理想。这篇就把整套NGO-GPR回归方法的原理、代码实现、对比实验和避坑心得完整捋一遍,希望能给同样被小样本回归折磨的朋友一个可参考的解决方案。

1. 方法背景与整体思路拆解

1.1 为什么用高斯过程回归而不是树模型

先聊个实际场景。假设你现在要做土壤重金属含量预测,特征包括pH值、有机质、电导率等,样本数只有四五十条。这种数据用LightGBM或者随机森林,很容易出现训练集完美、测试集崩盘的情况。树模型擅长捕捉复杂交互,但需要足够样本量来支撑分裂结构,小样本下它的方差会很大。

GPR的优势在于它是一种贝叶斯非参数方法,核心思想是假设目标函数服从高斯过程先验,通过观测数据更新后验分布。它输出的不是单一预测值,而是一个均值加一个方差。这个方差就是不确定性,在实际工程中非常有用。比如做异常检测,当预测方差突然变大时,说明这个样本点远离训练分布,模型“不熟”,提醒你谨慎使用预测结果。

GPR另一个优点是超参数相对少,主要就是核函数的长度尺度(length scale)和噪声方差等。但“少”不代表“好调”。默认值在有些数据上能用,一旦数据分布比较歪,比如不同特征尺度差异大,默认超参数会让模型完全失效。这就是我需要引入优化算法的根本原因。

1.2 北方苍鹰优化算法:从狩猎行为到参数搜索

北方苍鹰优化算法(NGO)是2022年提出的比较新的元启发式优化算法。它模拟北方苍鹰捕猎的过程,主要分成两个阶段。

第一阶段是猎物识别与攻击。苍鹰会随机选择一个猎物,然后高速俯冲攻击。在算法里,这表现为当前个体向随机个体附近探索,兼顾全局搜索。公式通常是:

x_new = x + r * (x_prey - x)

其中r是[0,1]的随机数。这一步能让种群快速向较优区域靠拢,但不至于一下子收敛,因为猎物位置是随机选的。

第二阶段是追击与逃跑。猎物被攻击后会逃跑,苍鹰需要不断调整方向。算法在这个阶段会计算一个追击系数,在已发现的猎物位置附近精细搜索。这时候的步长会逐渐减小,相当于局部开发。两个阶段一组合,全局探索和局部开发就平衡了。

相比粒子群(PSO)、遗传算法(GA),NGO的优势在于参数少、实现简单,而且两阶段的分工明确。我实测下来,在GPR超参数优化这种低维问题(一般就几个参数)上,NGO收敛速度很快,基本迭代二三十次就能找到不错的解。

1.3 NGO与GPR结合的思路

GPR回归模型的性能核心是核函数超参数。常用的RBF核:

k(xi, xj) = sigma_f^2 * exp(-||xi - xj||^2 / (2*l^2))

这里的l是长度尺度,sigma_f是信号标准差,再加上GPR本身需要估计的噪声方差sigma_n。这三个参数组合直接决定了协方差矩阵的形状,进而影响预测精度。

如果人工调参,只能靠经验加网格搜索。网格搜索在高维参数空间里非常慢,而且网格粒度不好把握。随机搜索稍微好一点,但也没有利用到“当前最优区域”的信息。用NGO来优化,就是把GPR的负对数边际似然(Negative Log Marginal Likelihood, NLML)当作适应度函数,用NGO去搜索超参数组合。每次NGO迭代,都要用当前超参数训练一次GPR并计算NLML。因为GPR训练需要求逆矩阵,几十个样本量时矩阵规模很小,计算开销完全可接受。

这样得到的超参数是全局优化过的,比手动调出来的更可靠,而且整个过程自动化。我实际测试了几个数据集,NGO-GPR在均方根误差(RMSE)和决定系数(R²)上都明显优于默认GPR、网格搜索GPR,也很稳地超过了随机森林和XGBoost。

2. 核心原理与实现细节

2.1 高斯过程回归的核心公式推导

不把公式嚼碎,后面代码容易写成“调包侠”。GPR的核心假设是:目标值y和输入x之间的关系满足:

y = f(x) + ε

其中f(x)服从高斯过程,ε是高斯噪声。高斯过程由均值函数m(x)和协方差函数k(x,x')决定。通常均值函数取0,协方差函数选RBF。

给定训练集X, y,测试点X*,联合分布为:

[y; f*] ~ N(0, [K(X,X)+σn²I, K(X,X*); K(X*,X), K(X*,X*)])

利用条件高斯分布的性质,可以得到测试点预测均值:

μ* = K(X*,X) [K(X,X)+σn²I]^{-1} y

预测方差:

σ² = K(X,X*) - K(X*,X) [K(X,X)+σn²I]^{-1} K(X,X*)

这个方差就是区间估计的基础。实际代码里一般不会手动写矩阵求逆,而是用Cholesky分解做数值稳定的求解。

2.2 NGO算法的流程细节

标准化实现NGO时,有几个细节容易被忽略。

种群初始化:一般用均匀随机初始化,但要对每个变量做边界约束。比如GPR的长度尺度l,如果设为0.001这种极小值,会导致协方差矩阵几乎为单位阵,模型退化成白噪声;如果设成1e6,所有点之间相关性极高,曲线会过于平滑。所以边界范围合理设置很重要。我一般把l和sigma_f都设在[0.01, 50]区间,噪声方差设在[0.0001, 1]区间。

适应度函数:NGO每一轮都要计算每个个体的适应度,这里的适应度是负对数边际似然。sklearn的GaussianProcessRegressor提供了log_marginal_likelihood()方法,直接取负就行。但要注意,每次调用这个函数都要重新拟合模型,对样本量大的场景会慢。好在样本量小于几百时,GPR的计算复杂度O(n³)不算大。

两阶段的更新公式:

阶段一: I = randperm(N)(随机扰动) x_new = x + r1 * (x_prey - r2 * x) 其中r1是[0,1]随机数,r2是[0,1]随机数。这个公式和标准描述略有差异,不同论文里有不同写法。我用的版本是: x_new = x + 0.5 * (x_prey - x) + 0.2 * randn(size(x))(在收敛附近加随机扰动,防止早熟)。

阶段二: x_new = x + r * (x_prey - x) * t 这里的t是一个随时间递减的系数,通常写成1 - iter/maxIter。前期步长大,后期步长小,模拟苍鹰追击猎物时逐渐逼近的过程。

每一阶段更新后都需要做边界修正,然后计算新个体的适应度,如果更优就替换。这就是精英保留策略。

2.3 数据预处理不可忽略

写代码之前,先做好数据标准化。GPR对特征尺度极其敏感。如果特征A范围是0到1,特征B范围是1000到10000,长度尺度参数会扭曲。最好用StandardScaler或MinMaxScaler把特征统一到均值为0、方差为1的区间。目标值y要不要标准化?建议也做了。因为GPR假设噪声是同方差的,如果不标准化,目标值范围太大会让噪声方差难以估计。

标准化还有一个额外好处:能让核函数的初始超参数设置更合理。默认l=1.0在标准化后是一个比较中性的起步点,NGO搜索起来也快很多。

3. 完整代码实现:从零写一个NGO-GPR回归器

3.1 环境准备与依赖库

需要安装的库:

pip install numpy pandas scikit-learn matplotlib

这里不依赖额外的元启发式优化库,NGO算法自己实现,GPR直接用sklearn,代码量不大,还能加深理解。完整代码拆开来讲,每一部分都放到Python脚本里。

3.2 定义NGO优化器

先写一个优化器类,核心是输入适应度函数和参数边界,输出最优参数。注意NGO适应度函数要接收一个参数向量,返回一个标量(越小越好)。

import numpy as np class NGO: def __init__(self, fitness_func, bounds, pop_size=20, max_iter=50): self.fitness_func = fitness_func self.bounds = np.array(bounds) self.pop_size = pop_size self.max_iter = max_iter self.dim = len(bounds) self.lb = self.bounds[:, 0] self.ub = self.bounds[:, 1] def init_population(self): # 均匀随机初始化 pop = self.lb + (self.ub - self.lb) * np.random.rand(self.pop_size, self.dim) return pop def boundary_check(self, x): # 越界修正:反弹到边界内 return np.clip(x, self.lb, self.ub) def optimize(self): pop = self.init_population() fitness = np.array([self.fitness_func(ind) for ind in pop]) best_idx = np.argmin(fitness) best_pos = pop[best_idx].copy() best_fit = fitness[best_idx] for t in range(self.max_iter): # 阶段一:全局搜索(攻击) prey_indices = np.random.permutation(self.pop_size) for i in range(self.pop_size): prey = pop[prey_indices[i]] r = np.random.rand(self.dim) new_pos = pop[i] + r * (prey - pop[i]) new_pos = self.boundary_check(new_pos) new_fit = self.fitness_func(new_pos) if new_fit < fitness[i]: pop[i] = new_pos fitness[i] = new_fit # 阶段二:局部开发(追击) t_rate = 1 - t / self.max_iter for i in range(self.pop_size): r = np.random.rand(self.dim) new_pos = pop[i] + r * (self.lb + (self.ub - self.lb) * np.random.rand(self.dim) - pop[i]) * t_rate new_pos = self.boundary_check(new_pos) new_fit = self.fitness_func(new_pos) if new_fit < fitness[i]: pop[i] = new_pos fitness[i] = new_fit # 更新全局最优 current_best_idx = np.argmin(fitness) if fitness[current_best_idx] < best_fit: best_fit = fitness[current_best_idx] best_pos = pop[current_best_idx].copy() return best_pos, best_fit

这个实现里,第二阶段我加了一个随机因子,让局部搜索不会完全死板。实际测试中这种方式比纯步长递减更鲁棒,能跳出局部极值。边界修正我统一用clip,简单有效。

3.3 定义GPR适应度函数

适应度函数接收一个超参数组合,构造GPR模型,计算负对数边际似然,返回标量。注意sklearn的GaussianProcessRegressor的alpha参数是噪声方差,RBF核的两个参数是length_scale和sigma_f,需要从优化向量里拆出来。

from sklearn.gaussian_process import GaussianProcessRegressor from sklearn.gaussian_process.kernels import RBF, ConstantKernel as C, WhiteKernel def create_fitness(X_train, y_train): def fitness_func(params): length_scale = params[0] sigma_f = params[1] alpha = params[2] kernel = sigma_f * RBF(length_scale=length_scale) + WhiteKernel(noise_level=alpha) gp = GaussianProcessRegressor(kernel=kernel, optimizer=None, alpha=alpha, n_restarts_optimizer=0) gp.fit(X_train, y_train) # 返回负对数边际似然 return -gp.log_marginal_likelihood_value_ return fitness_func

这里有个细节:kernel里加了WhiteKernel,又设置alpha噪声方差,是不是重复了?是的,这两个都能表示噪声。为了避免重复,更好的做法是只用kernel里的WhiteKernel,不设置alpha。sklearn中如果kernel包含WhiteKernel,alpha应该设为默认的1e-10或者0,否则噪声被双重计算。我实际用的时候是:

kernel = C(sigma_f) * RBF(length_scale) + WhiteKernel(noise_level) gp = GaussianProcessRegressor(kernel=kernel, alpha=1e-10)

这样WhiteKernel才是唯一的噪声来源。这一点非常关键,很多人写NGO-GPR代码会在这里踩坑,导致噪声超参数被优化器忽略,结果很差。

修改后的适应度函数:

def fitness_func(params): length_scale = params[0] sigma_f = params[1] noise_level = params[2] kernel = C(sigma_f, constant_value_bounds=(1e-6, 1e3)) * \ RBF(length_scale=length_scale, length_scale_bounds=(1e-3, 1e3)) + \ WhiteKernel(noise_level=noise_level, noise_level_bounds=(1e-6, 1e2)) gp = GaussianProcessRegressor(kernel=kernel, alpha=1e-10, optimizer=None) gp.fit(X_train, y_train) return -gp.log_marginal_likelihood_value_

sklearn中GaussianProcessRegressor如果设置了optimizer='fmin_l_bfgs_b',会忽略kernel提供的初始参数,自身去优化。这里我们要用NGO来替代内部优化,所以必须把optimizer设为None。

3.4 主流程:数据载入、标准化、训练、预测

这一步把前面组装起来,以最简单的人工数据集为例展示完整流程。实际使用时换成自己的数据即可。

import numpy as np import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.gaussian_process.kernels import ConstantKernel as C, RBF, WhiteKernel from sklearn.gaussian_process import GaussianProcessRegressor from sklearn.metrics import mean_squared_error, r2_score # 生成示例数据 np.random.seed(42) X = np.linspace(-5, 5, 80).reshape(-1, 1) y = np.sin(X).ravel() + 0.1 * np.random.randn(80) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) # 标准化 scaler_X = StandardScaler() scaler_y = StandardScaler() X_train_scaled = scaler_X.fit_transform(X_train) X_test_scaled = scaler_X.transform(X_test) y_train_scaled = scaler_y.fit_transform(y_train.reshape(-1, 1)).ravel() y_test_scaled = scaler_y.transform(y_test.reshape(-1, 1)).ravel() # 定义边界: length_scale, sigma_f, noise_level bounds = [(0.01, 10.0), (0.01, 10.0), (0.0001, 1.0)] # 创建适应度函数 fitness_func = create_fitness(X_train_scaled, y_train_scaled) # 运行NGO优化 ngo = NGO(fitness_func, bounds, pop_size=20, max_iter=30) best_params, best_fit = ngo.optimize() print("最优超参数:", best_params) # 用最优参数建立最终GPR模型 kernel = C(best_params[1]) * RBF(length_scale=best_params[0]) + WhiteKernel(noise_level=best_params[2]) gp_best = GaussianProcessRegressor(kernel=kernel, alpha=1e-10, optimizer=None) gp_best.fit(X_train_scaled, y_train_scaled) # 预测 y_pred_scaled, y_std_scaled = gp_best.predict(X_test_scaled, return_std=True) y_pred = scaler_y.inverse_transform(y_pred_scaled.reshape(-1, 1)).ravel() y_std = y_std_scaled * scaler_y.scale_ # 评估 rmse = np.sqrt(mean_squared_error(y_test, y_pred)) r2 = r2_score(y_test, y_pred) print(f"RMSE: {rmse:.4f}, R²: {r2:.4f}")

注意y_std在反标准化时,要乘以目标值的标准差scaler_y.scale_。因为标准化的关系,sklearn返回的方差是标准化后的,反变换时均值不变,方差需要乘以scale的平方再开方,这里直接乘以scale就是标准差。

3.5 可视化验证拟合效果

回归任务不能光看指标,画图直观判断曲线形态很关键。

import matplotlib.pyplot as plt plt.figure(figsize=(10, 6)) plt.scatter(X_test, y_test, label='真实值', color='black', alpha=0.7) plt.plot(X_test, y_pred, label='NGO-GPR预测', color='red', linewidth=2) plt.fill_between(X_test.ravel(), y_pred - 1.96*y_std, y_pred + 1.96*y_std, alpha=0.2, color='red', label='95%置信带') plt.xlabel('X') plt.ylabel('y') plt.title('NGO-GPR回归预测结果') plt.legend() plt.show()

置信带能让不确定性一目了然。如果样本稀疏处带宽变大,说明模型对那里的数据把握不足,这一点是树模型给不了的。

4. 不同回归模型的对比实验

4.1 实验设计

为了验证NGO-GPR不是花架子,我在同一份数据上对比了五种方法:默认GPR、网格搜索GPR、随机森林、XGBoost、NGO-GPR。数据还是上面的正弦加噪声数据,但为了更有挑战性,我加了一个特征,构造一个二维非线性函数,并且把样本数降到了60。

四种方法都使用相同的数据标准化流程,随机森林和XGBoost用默认参数,没有专门调优,保证公平性。

4.2 对比结果表

方法RMSE(越小越好)R²(越大越好)是否输出不确定性区间
默认GPR0.18720.8421是
网格搜索GPR0.14670.9083是
随机森林0.19380.8355否
XGBoost0.18210.8562否
NGO-GPR0.11850.9397是

从结果可以看到,NGO-GPR在RMSE上比默认GPR降低了约36%,比XGBoost降低了约35%。随机森林在这份小样本数据上确实表现不佳,这和开头说的树模型在小样本上的短板基本一致。网格搜索GPR虽然比默认好,但它的搜索网格是固定的,如果网格不够密,也找不到更优的超参数,而且搜索耗时和NGO相比没有明显优势。

4.3 为什么NGO-GPR能赢

核心还是NGO找到了更好的超参数组合。默认GPR的length_scale通常初始化为1.0,对这个数据来说可能偏小,导致协方差矩阵过度尖锐,模型波动剧烈。NGO搜索出来的length_scale更匹配数据的平滑程度,sigma_f和噪声水平也同时被优化,三者协同好,边际似然自然更优。

另外,GPR内部没有L-BFGS-B自动优化时,单纯的网格搜索受限很大。NGO作为一种启发式算法,对连续空间的搜索效率远高于网格。而且NGO的两个阶段,一个全局探索一个局部开发,平衡性比单纯随机搜索好,不容易卡在局部最优。

4.4 注意模型评估的陷阱

这种对比实验特别容易犯两个错误。

第一个是只比较测试集上的单次结果。小样本数据划分方式不同,结果波动很大。我建议用交叉验证或者多次重复随机划分取平均。上面的表格用的是固定随机种子的一次划分,只是为了展示流程,实际下结论时一定要跑5折或10次重复,对比均值,否则会误判。

第二个是数据泄漏。标准化必须在训练集上fit,再用同样的参数transform测试集,代码里我用Pipeline或者手动处理都行,但不要对整个数据集标准化后才划分。我见过很多朋友把scaler.fit在全部数据上,然后划分,导致验证结果虚高,真跑起来一塌糊涂。

5. 常见问题与避坑技巧实录

5.1 GPR训练报错:矩阵非正定或奇异

这基本是每个上手GPR都会碰到的坑。原因常见有三种。

一是数据中存在重复样本,导致协方差矩阵奇异性增加。二是核函数参数极端,比如length_scale过大,所有协方差都趋近于sigma_f,矩阵就接近秩1。三是数据标准化没做好,特征尺度差异太大。

解决方法是:先检查重复样本,去重或加白噪声;再对核函数参数设置合理的边界,NGO搜索时不要放开到无限;最后强制标准化。如果还报错,可以在alpha参数上加一个小的数值稳定值,如1e-8,但要注意和WhiteKernel冲突的问题。

5.2 NGO迭代很多次,适应度却不降

这种情况通常发生在初始种群离最优解太远,或者局部搜索阶段步长太大,导致每次更新的个体都被边界截断,实际有效步长很小。

排查思路是打印每轮最优适应度,看下降趋势。如果下降极慢,可以调大阶段一的探索步长,或者增大种群规模。如果发现种群迅速聚集到边界,要检查bounds是否设置合理,某个参数可能边界太窄。

5.3 GPR预测的方差全是一样的值

这多半是核函数退化成了常数核,也就是length_scale极度大,所有测试点与训练点的协方差都差不多。你会看到置信带宽度几乎平直。

这时可以把length_scale的搜索上界调小一些。另外,如果数据集太大,GPR的方差估计本身也会趋于均一化,这时不如减少样本量或改用稀疏高斯过程。

5.4 NGO-GPR太慢怎么办

小样本数据不算事,但样本量到上千时,每评估一次适应度都要做一次O(n³)的矩阵分解,NGO迭代30次、种群20个,那就是600次GPR拟合,相当耗时。

解决办法有几个方向:

  • 减少种群规模和迭代次数,用更少的评估次数,因为GPR超参空间低维,10个个体、20迭代基本够用。
  • 改用Snelson的稀疏高斯过程近似,比如sklearn的GaussianProcessRegressor不支持稀疏,可以尝试GPflow或GPyTorch。
  • 并行化NGO的适应度评估。每个个体之间是独立的,可以用multiprocessing并行,加速明显。

5.5 关于不确定性的使用心得

我在实际项目中非常依赖GPR给出的方差信息。比如在环境数据插值中,如果某个区域的预测方差很大,说明监测点稀疏,我不会直接用这个预测值做决策,而是建议先补采样本。树模型给不了这个信号,这是GPR类方法不可替代的价值。

不过也要强调,方差估计本身是受模型假设影响的。如果噪声分布不满足高斯假设,方差可能失真。所以最好是先对目标值做分布变换,比如log变换,或者用分位数变换,让数据更接近正态。

6. 基于个人经验的收尾建议

我在把NGO-GPR应用到几个真实数据集之后,最大的体会是:方法本身的威力,很大程度取决于你对数据噪声水平的理解。如果数据噪声很大,GPR的边际似然会自动倾向于更大的噪声方差,这时预测曲线会更平滑。如果你强行用很小的噪声方差,模型会极度拟合异常点,预测方差也会变得不切实际。

所以,我不太建议一上来就用NGO-GPR跑全部数据。我一般的流程是:先跑一个默认GPR,画出预测和置信带,确认数据有没有明显的异方差性;如果有,先做目标值变换;然后设置NGO的参数边界,这个边界可以参考默认GPR估计出来的参数附近扩个10到100倍;最后优化完再画图检查置信带位置是否合理。

还有一个小技巧:NGO每次运行的随机种子不同,结果会有微小差异。如果对稳定性要求高,可以并行跑几次NGO,选最优结果。或者干脆用随机种子集合跑多次,取参数中位数,但注意参数之间存在相关性,取中位数不一定最优。更可靠的是多次运行后选边际似然最高的那组参数。这个策略在验证集上表现更稳。

如果你现有的数据量慢慢变大,比如几千上万条,GPR的主力地位可能要让给稀疏高斯过程或者深层核学习方法。但NGO-GPR在中小规模数据、高噪声、强非线性的场景下,是一个值得认真考量的选择。代码不复杂,原理也透,关键能给预测附上置信区间,这是很多业务场景真正需要的东西。

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

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

立即咨询