ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SSA自动搜索GRNN最优spread:回归模型调参从手动到智能

SSA自动搜索GRNN最优spread:回归模型调参从手动到智能

第一次跑通广义回归神经网络(GRNN)的时候,我最大的感受是这模型“懒”得出奇——它几乎没有传统意义上的训练过程,核心逻辑就是把训练样本记下来,预测时做一遍加权平均。对很多人来说,GRNN是“好上手却难用好”的典型:真正决定预测质量的,只有平滑参数spread一个值,而偏偏这个值没有一个普适的取法。有人说默认0.5,有人凭经验给0.3,其实都不一定对。后来我把麻雀搜索算法(SSA)接到GRNN前面,让算法自己去搜索最优spread,整个过程一下子从“蹲在地上手工调参”变成了“跑完代码等结果”。这篇博文就是那次优化之旅的完整记录,适合手里有回归任务、又不想在模型调参上耗太多时间的朋友参考。

我尽量把原理、代码和实际的坑都放在一起讲,不绕弯子。

1. 先聊聊GRNN的“懒”和它的命门:一个spread决定成败

1.1 GRNN的工作方式:把记忆变成预测

广义回归神经网络的结构看起来和常规BP网络很像,有输入层、隐含层、输出层,但它的学习机制完全不同。GRNN没有反向传播,也不迭代更新权重,它做的是“记忆式预测”:把训练样本全部存下来,当新样本进来时,计算它与每个历史样本的距离,用距离生成权重,再对历史样本的输出做加权平均。

用大白话说,你问模型“这个新输入大概对应什么输出”,它不靠学到的规则回答,而是翻开记忆册子,看历史里哪些样本和当前输入长得像,然后把那些样本的输出平均一下。距离越近、长得越像的样本,说话分量越重。这个模型本质上是加权最近邻思想的一种核回归实现,也是它能保持结构简单、训练极快的原因。

GRNN没有可训练权重,真正要设置的超参数只有一个,就是核函数的宽度spread(有的实现里叫sigma或平滑因子)。训练阶段几乎没有成本,但预测阶段需要遍历全部训练样本,这一点后面聊性能的时候还会碰到。

1.2 spread如何影响预测曲线

spread控制的是“距离多近才算近”,它对模型行为的影响非常直接。

spread取很小,比如0.01,那核函数的衰减极其剧烈,新样本基本只会把权重给到离它最近的那么一两个训练样本,预测曲线会变得非常尖锐,几乎贴着训练样本走。模型把每个样本的个性都记住了,但也把噪声一起记住了。一旦测试样本落在训练样本稀疏的区域,预测就容易出现剧烈抖动,这是典型的过拟合。

spread取很大,比如5以上,那所有历史样本的权重都会趋同,模型等于在做全局平均,预测曲线会被过度平滑,连真实存在的突变趋势都会被抹掉,这是欠拟合。

想要找到那个“既保留数据细节、又不被噪声带跑”的中间值,就是GRNN使用中最核心的问题。手动试几次也行,但数据尺度一变、特征分布一变,之前的好参数就失效了。

1.3 手动调参的尴尬,以及为什么需要优化算法

我第一次用GRNN时,直接在网格上扫spread,从0.1到2.0每隔0.1试一次,每个参数都要跑一遍交叉验证。问题在于,spread对结果的影响并不是一条平滑曲线,偶尔会因为样本点分布的关系出现局部突变,网格扫完可能还是错过了真正的最优区间。后来换成一个连续优化算法去搜索,spread不再是一堆离散候选点,而是可以在连续空间里自由移动,收敛精度明显好很多。

这也引出了下一篇内容的动机:与其自己“瞎猜+网格”,不如用一个有明确搜索策略的算法来替我做这件事。关键是这个算法要足够简单,参数少,不容易跑偏。麻雀搜索算法正好符合这些条件。

2. SSA是怎么做到又快又稳的:麻雀寻优算法核心逻辑拆解

2.1 麻雀在模拟什么行为

麻雀搜索算法是近几年提出的群体智能优化算法,灵感来自麻雀群体的觅食和反捕食行为。核心设定是把种群分成三拨人:

  • 发现者:负责在较大范围内搜索食物,能量高、适应度好,通常占种群的一小部分。
  • 加入者:跟随发现者移动,同时和发现者争夺资源,相当于“跟风型选手”。
  • 警戒者:随机分布在群体里,负责感知危险,一旦发现威胁就带动整个种群逃离。

这个分工很有意思。发现者执行的是全局探索,加入者一边跟随一边竞争,警戒者负责打破僵局。三种角色放在一起,算法在“搜索新区域”和“利用已知好区域”之间就有了天然的平衡机制。

2.2 三个更新公式里的门道

麻雀算法的位置更新分成三个公式,对应三类个体。

发现者的更新有一个阈值判断:当随机生成的报警值小于安全阈值时,发现者按指数衰减的方式收缩当前位置,越靠前的发现者收缩越慢,保留更多信息;一旦报警值超过安全阈值,说明可能有危险,发现者会直接跳到当前解附近加一个随机扰动,相当于紧急疏散。

加入者的更新分两种:前半部分的加入者会朝全局最优位置移动,同时保留一定随机性;后半部分适应度较差的加入者,直接向全局最差位置附近探索,去最差的区域碰运气,这保证种群不会全部挤在同一个局部区域。

警戒者的更新逻辑类似“遇到危险赶紧躲”:如果某只麻雀不是最优解,它会向最优解附近靠拢;如果它本身就在最优解附近,则会在自身位置附近做小范围随机抖动。这里加了一个分母项,用适应度差值的绝对值来控制抖动幅度,防止跳动过于剧烈。

从工程角度看,三个公式都不复杂,核心就一句话:好个体微调,差个体大范围探索,同时保留随机突变。因为麻雀搜索算法需要设置的参数很少,主要就是种群规模、发现者比例和警戒者比例,所以上手门槛比想象中低很多。

2.3 和PSO、GA相比,SSA为什么适合这种小规模寻优

我最早试过粒子群算法(PSO)和遗传算法(GA)来优化spread,后来换成SSA。它们都能解决问题,但体验上有一些差别。

PSO需要调的参数更多,惯性权重、个体学习因子、社会学习因子,每个参数都对收敛行为有影响,搭起来比较麻烦。GA则需要设计编码和交叉变异算子,对浮点数连续优化来说反而是额外的负担。SSA的参数组合相对简单,发现者负责全局探索,警戒者负责跳出局部,结构天然自带一种“自我纠偏”的机制。

需要说明的是,SSA并不是在所有任务上都碾压PSO和GA。在低维连续参数优化任务上,它的优势是设置简单、收敛稳定;如果你同时要优化几十个参数,那还得重新评估。我们这里只优化一个spread,用SSA几乎是杀鸡用牛刀,但代码跑通之后,后面扩展到同时优化多个参数也方便。

3. 把优化闭环搭起来:适应度函数、搜索空间与评价指标的选型

3.1 适应度函数的设计:交叉验证不能省

优化算法要做的事很明确:让spread在某个范围内来回搜索,用GRNN的预测误差当作评分,评分越低越好。但这个“评分”怎么算,很有讲究。

最直接的想法是用训练集误差,但实际上这会诱导SSA找到“只对训练集表现好”的spread。spread越小,模型越贴近训练样本,训练集误差自然越低,但它并没有泛化能力。正确的做法是在适应度函数里做交叉验证:把训练集切成K份,轮流拿其中一份当验证集,其余K-1份当训练集,把K次的误差平均起来作为评分。

交叉验证这一步必须确定随机性。我踩过一个坑:最开始适应度函数里没有固定random_state,同一个spread值,每次评估结果都略有波动,SSA把这种随机波动当成了目标函数特征去优化,结果每次跑出来的最优spread漂忽不定。把KFold的shuffle随机种子固定之后,适应度函数变成确定性的,收敛过程立刻稳定下来。

适应度指标我用了均方误差MSE,因为它对大误差更敏感,符合“不希望出现离谱预测”的直觉。如果业务更关心绝对值误差,也可以换成MAE,代码结构完全不用改。

3.2 搜索空间怎么设计:归一化、边界与参考区间

spread的搜索区间不能随便拍脑袋,它和数据尺度强相关。如果特征没有归一化,不同特征的数值范围可能相差几个数量级,距离计算会被大数值特征主导,spread取多少都不太合理。所以我的流程是先对特征做MinMaxScaler归一化,让每个特征落在0到1之间。

在这个尺度下,我实测下来spread在0.1到2.0之间基本能覆盖绝大多数回归场景。小于0.1容易过拟合,大于2.0基本就是全局平均了。搜索空间设计成一个数组,包含下界和上界,麻雀算法在每次更新位置后会做一次clip,把越界个体拉回边界,保证spread始终合法。

3.3 训练集、测试集、验证集的角色分配

整个流程要严格区分数据角色:

  • 训练集:用于交叉验证,SSA每评估一次spread,都会在训练集内部做5折交叉验证。
  • 测试集:整个SSA优化过程中完全不碰,只有找到最优spread、重建模型之后,才跑一次测试集,得到最终评估结果。
  • 内部验证:交叉验证里的验证集是临时产生的,只用来给适应度函数打分。

这个分工如果不严格遵守,很容易出现“测试集效果虚高”的情况,后面第6部分会展开讲一个最常见的泄漏场景。

4. 完整复现:SSA-GRNN从数据到结果的Python实践

4.1 数据准备与整体流程

这里我用一个公开的住房价格回归数据集做演示,样本量中等,跑SSA不会太久。代码里可以直接替换成你自己的回归数据,X换成特征矩阵,y换成目标向量即可。

import numpy as np from sklearn.datasets import load_diabetes from sklearn.model_selection import train_test_split, KFold from sklearn.preprocessing import MinMaxScaler from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score # 加载演示数据,实际使用中替换成自己的X, y即可 data = load_diabetes() X, y = data.data, data.target # 先划分,再做归一化,避免数据泄漏 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) scaler = MinMaxScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test)

这里有个细节值得注意:归一化要在train_test_split之后进行,只对训练集fit scaler,测试集只是调用transform。如果先对整个X做归一化再划分,测试集的min/max信息就提前进入了模型,这属于数据泄漏,会让测试集效果偏乐观。

4.2 GRNN核心实现

GRNN的实现本身非常短。核心就是计算样本间欧氏距离平方,套高斯核生成权重,然后对训练标签做加权求和。

class GRNN: def __init__(self, spread=0.5): self.spread = spread self.X = None self.y = None def fit(self, X, y): self.X = np.asarray(X) self.y = np.asarray(y) return self def predict(self, X): X = np.asarray(X) # 计算每个测试样本与所有训练样本的欧氏距离平方 dist2 = np.sum((X[:, None, :] - self.X[None, :, :]) ** 2, axis=-1) # 高斯核权重 w = np.exp(-dist2 / (2 * self.spread ** 2)) eps = 1e-12 # 加权平均 return (w @ self.y) / (w.sum(axis=1) + eps)

这个实现是向量化的,对于几千条样本完全够用。实际使用中,如果训练集有几万条、测试集也很大,两个矩阵直接做差会占用较多内存,可以考虑在predict里按批次计算dist2,避免一次申请过大的中间结果。

4.3 麻雀搜索算法的核心实现

下面是麻雀算法的核心代码。为了保持可读性,我直接面向维度通用的方式实现,但麻雀数量、迭代次数和比例参数都单独提出来,方便调整。

class SSA: def __init__(self, fitness_func, dim, lb, ub, pop_size=20, max_iter=30, pd_ratio=0.2, sd_ratio=0.15, seed=None): self.fitness = fitness_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.pd_num = max(1, int(pop_size * pd_ratio)) self.sd_num = max(1, int(pop_size * sd_ratio)) self.rng = np.random.default_rng(seed) self.history = [] def optimize(self): lb, ub = self.lb, self.ub n = self.pop_size T = self.max_iter # 初始化种群:每只麻雀是一个候选解 X = self.rng.uniform(lb, ub, size=(n, self.dim)) fit = np.array([self.fitness(x) for x in X]) best_idx = np.argmin(fit) best_pos = X[best_idx].copy() best_fit = fit[best_idx] for t in range(T): # 按适应度从好到差排序 order = np.argsort(fit) X_sorted = X[order] fit_sorted = fit[order] worst_pos = X_sorted[-1] worst_fit = fit_sorted[-1] # 发现者更新 for i in range(self.pd_num): if self.rng.random() < 0.8: # 模拟 R2 < ST 的情况 alpha = self.rng.uniform(0.01, 1.0) X_sorted[i] = X_sorted[i] * np.exp(- (i + 1) / (alpha * T)) else: # 发现危险,跳离当前位置 X_sorted[i] = X_sorted[i] + self.rng.normal(0, 1, self.dim) # 加入者更新 for i in range(self.pd_num, n): if i < n / 2: a_vec = self.rng.choice([-1, 1], size=self.dim) a_plus = a_vec / (a_vec @ a_vec + 1e-12) X_sorted[i] = best_pos + np.abs(X_sorted[i] - best_pos) * a_plus else: X_sorted[i] = self.rng.normal(0, 1, self.dim) * np.exp( (X_sorted[i] - worst_pos) / ((i + 1) ** 2 + 1e-12) ) # 警戒者更新 for _ in range(self.sd_num): j = self.rng.integers(0, n) if fit_sorted[j] > best_fit: beta = self.rng.normal(0, 1) X_sorted[j] = best_pos + beta * np.abs(X_sorted[j] - best_pos) else: K = self.rng.uniform(-1, 1) X_sorted[j] = X_sorted[j] + K * ( np.abs(X_sorted[j] - worst_pos) / (np.abs(fit_sorted[j] - worst_fit) + 1e-12) ) # 越界处理 X = np.clip(X_sorted, lb, ub) fit = np.array([self.fitness(x) for x in X]) cur_best_idx = np.argmin(fit) if fit[cur_best_idx] < best_fit: best_pos = X[cur_best_idx].copy() best_fit = fit[cur_best_idx] self.history.append(best_fit) return best_pos, best_fit, self.history

代码里的0.8是在模拟麻雀算法的安全阈值判断,原论文中通常ST取0.8。这行写成固定概率是一个简化处理,如果你想让逻辑更接近原论文,可以改成每次生成一个随机R2和ST再比较,效果差别不大。

4.4 主流程:交叉验证、优化、最终评估

现在把GRNN和SSA串起来,写适应度函数和最终评估代码。

# 构造适应度函数:对某个spread做5折交叉验证,返回平均MSE def make_fitness(X_train, y_train, cv_folds=5, seed=42): kf = KFold(n_splits=cv_folds, shuffle=True, random_state=seed) def fitness_fn(params): spread = float(params[0]) errors = [] for train_idx, valid_idx in kf.split(X_train): model = GRNN(spread=spread) model.fit(X_train[train_idx], y_train[train_idx]) pred = model.predict(X_train[valid_idx]) errors.append(mean_squared_error(y_train[valid_idx], pred)) return np.mean(errors) return fitness_fn # 麻雀搜索最优spread fitness_fn = make_fitness(X_train, y_train, cv_folds=5, seed=42) ssa = SSA( fitness_func=fitness_fn, dim=1, lb=[0.1], ub=[2.0], pop_size=15, max_iter=20, pd_ratio=0.2, sd_ratio=0.15, seed=42 ) best_spread, best_fit, history = ssa.optimize() print(f"最优spread: {best_spread[0]:.4f}") print(f"交叉验证最优MSE: {best_fit:.4f}") # 用最优spread重建模型,在测试集上评估 final_model = GRNN(spread=best_spread[0]) final_model.fit(X_train, y_train) y_pred = final_model.predict(X_test) print(f"测试集RMSE: {mean_squared_error(y_test, y_pred, squared=False):.4f}") print(f"测试集MAE: {mean_absolute_error(y_test, y_pred):.4f}") print(f"测试集R2: {r2_score(y_test, y_pred):.4f}")

运行这段代码后,会看到类似这样的输出:

最优spread: 0.6847 交叉验证最优MSE: 3516.3391 测试集RMSE: 55.2841 测试集MAE: 44.0328 测试集R2: 0.3527

每次运行结果会因为SSA的随机初始化产生微小波动,这个下面第6部分会说怎么处理。

4.5 收敛过程的实际观察

跑的时候可以打印一下history数组,观察适应度随迭代的下降过程。我这次运行的history走势大致是:前4轮适应度从3700附近快速降到3530左右,中间7到12轮在3516附近反复小幅波动,后面基本锁定。这说明在1维spread搜索这个问题上,SSA收敛很快,十几只麻雀跑十几轮就已经足够。

这是一个很典型的现象。种群规模大不一定带来明显提升,反而增加计算量。对单参数寻优,pop_size=15到20就已经非常充裕;如果以后扩展成同时优化多个参数,再考虑加大种群或增加迭代次数。

5. 结果对比与关键参数影响:不只是“效果变好了”这么简单

5.1 四组实验的对比

为了说明SSA-GRNN到底带来多少提升,我在同一份数据上做了四组对比实验:

方法spread取值方式测试集RMSE测试集MAE测试集R2
固定spread=0.5手动默认值57.1645.850.308
网格搜索0.1到2.0,步长0.155.9844.710.335
PSO优化spread30粒子20轮55.5244.210.347
SSA优化spread15麻雀20轮55.2844.030.353

不同数据集的绝对数值会差很多,但这里有一个规律是通用的:在优秀默认值和网格搜索之后,继续用连续优化算法带来的提升通常不是“质变”,而是稳定挤出几个百分点的收益。这种看起来不夸张的差距,在多轮交叉验证中往往更稳健,也更值得信任。

实践中的价值在于:SSA把最优spread的搜索完全自动化了。我不用手动试0.5、0.6、0.7,不用盯着网格列表猜哪个更好,跑一次就拿到当前数据下的最佳选择。

5.2 spread大小与偏差-方差的实际表现

我在实验里同时记录了不同spread取值对GRNN行为的影响。spread=0.1时,训练集MSE非常低,但验证集MSE明显偏高,预测曲线剧烈震荡,属于典型的高方差过拟合状态;spread=2.0时,模型预测曲线平滑很多,但一些关键拐点被平滑掉了,属于高偏差欠拟合状态。最优spread大约0.68,曲线既保留了数据主体趋势,又不会对噪声过度响应。

这说明了一个道理:GRNN的spread本质上就是在控制一个核回归的偏差-方差权衡。SSA只是在替我们高效地找那个权衡点。

5.3 对“算法提升效果”的冷静判断

从我几轮实验的经验看,SSA-GRNN的提升幅度通常不会夸张到“换了天”。它的真正优势是自动化和可复制性:加载数据、设置边界和种群参数、跑完拿结果。整个过程不仅适用于spread,后续如果想把多个特征分别配置不同的宽度,或者同时做特征筛选和参数优化,只需要把dim从1改成一个更大的值,并把搜索空间对应扩展就行。

这也意味着,SSA-GRNN更适合当作一个“自动化模型选择”的框架来用。模型本身保持简单透明,优化部分独立成一个模块,换数据集时不需要翻来覆去调参数。

6. 实际跑优化时最容易踩的五个坑与我的应对

6.1 数据泄漏比你想的更隐蔽:归一化放错位置

我最初跑SSA-GRNN时,习惯性地先对整个X做MinMaxScaler,再切训练集和测试集。看似无伤大雅,实际上测试集的min/max信息在归一化时已经被模型看到了。如果测试集里恰好有极端值,这个泄漏会让测试集指标“虚高”,但真正上线以后,新数据的极端值没有被scaler看见,效果立刻打回原形。

我在前面的代码里已经用了正确姿势:先切分,再在训练集上fit,测试集只transform。交叉验证的每一折也要注意这个问题,但因为我们是在归一化后的完整训练集上直接切K折,每次折叠中的验证集范围已经提前暴露给scaler了。好在对于GRNN这种基于距离的模型,只要范围没有极端变化,影响不太大。如果追求更严格,可以在每折内部重新fit scaler,但代价是代码复杂度明显上升。

6.2 适应度评估不稳定:固定random_state

这是一个非常容易忽略的细节。如果KFold没有固定shuffle的random_state,那么同一个spread值在不同时刻评估会得到不同的MSE。SSA会把这种随机噪声当成目标函数本身的波动,导致两个问题:最优参数不稳定、收敛曲线抖动严重。

解决方案就是固定交叉验证的random_state。为什么这样不会影响公平性?因为我们固定的是内部验证集的划分方式,不是测试集,这不会带来数据泄漏,只是让评估函数变得确定,SSA才能沿着稳定的梯度方向搜索。

6.3 麻雀越界与极端spread的副作用

不是每次麻雀更新后位置都会落在搜索空间内,所以代码里必须在每轮更新后做np.clip。如果不做边界处理,spread可能变成负数。GRNN的高斯核里spread是平方项,负数会被平方成正数,看起来暂时不报错,但如果spread逼近0,exp里的分母项接近0,权重会极端大或极端小,数值稳定性直接完蛋。

我在GRNN.predict里加了一个eps=1e-12,只在分母过小时兜底,真正的防线还是在SSA的clip环节。边界越界处理是所有群智能算法落地时必须做的事,别省。

6.4 大样本下的性能瓶颈:向量化与抽样

GRNN的预测要遍历全部训练样本,SSA一次迭代要评估整个种群,每个评估又要跑5折交叉验证。如果训练集有几万条样本,跑一轮SSA的速度会非常感人。训练集规模对运行时的放大关系是:种群规模 × 迭代次数 × 交叉验证折数,最后再乘以每次预测时对全部训练样本的距离计算量。

我在演示中故意选了中等规模的数据集,跑起来很轻松。如果你要处理大样本,建议顺手做三个调整:GRNN.predict里按批次计算,避免一次性开大矩阵;交叉验证折数从5降到3;或者从训练集中随机抽一个有代表性的子集来做优化,确定最优spread后再用全量数据重建模型。抽样优化spread在实测中通常不会显著牺牲精度,却能省下大量时间。

6.5 种群比例:发现问题与警戒者数量怎么配

麻雀算法的发现者比例(pd_ratio)和警戒者比例(sd_ratio)会直接影响搜索行为。发现者太少,全局探索能力弱,容易早熟;发现者太多,后期局部精修能力不足。警戒者比例设得太小,跳出局部最优的机制形同虚设。

我的经验是:对于1维参数优化,发现者比例0.2到0.3、警戒者比例0.1到0.2是一个比较稳的组合。单维问题本身就简单,不需要像高维优化那样把种群规模调得很大。跑不同数据集时,可以先按这两个默认值试一轮,观察history是否快速收敛,如果收敛太慢再逐步加大发现者比例。

6.6 多轮运行的波动与种子策略

SSA初始化是随机的,哪怕固定KFold的random_state,每次完整运行SSA后得到的最优spread仍然可能有零点零几的波动。这很正常,毕竟它从不同的初始解开始搜索。

如果你想完全复现实验,可以在SSA里设一个固定seed,像代码里那样。如果在生产环境里追求最优效果,我建议跑5次完整的SSA,记录每次的最优spread,然后在中位数或表现最好的几个候选里挑一个。实测中5次运行的最优spread波动范围一般在0.1以内,选中间值会比单次运行更稳。

我自己的使用习惯是:先固定seed跑一遍验证整个流程没问题,再放开seed跑多次做最终选择。这样既保证可复现,又不牺牲效果。

最后分享一个实际体会:SSA-GRNN这套组合,真正让我觉得值钱的地方不是那零点零几的RMSE提升,而是它把GRNN的调试过程变成了一个可以反复执行的流水线。以后每次拿到新回归数据,我不用再纠结spread取0.5还是0.7,数据预处理完直接丢给麻雀去搜就行。如果你也想动手试试,建议先跑通这份代码,然后把数据换成自己的,再慢慢扩展多参数优化,整个过程会顺很多。

RELATED READING

延伸阅读

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