1. 为什么我放着BP神经网络不用,反而回头用GRNN
先交代一下背景。之前接手一个工业场景的建模任务:输入侧有十几个传感器通道,输出侧只有一个关键质量指标,数据量不算大,总共几千条,但特征之间明显存在非线性关系,而且部分通道还有噪声。团队里有人提议直接上BP神经网络或者更深的MLP,我试了一轮之后果断换成了广义回归神经网络GRNN,最后无论是拟合精度还是训练效率,都明显比BP更省心。
这里先给不熟悉的读者一个定位:GRNN全称General Regression Neural Network,广义回归神经网络,是Specht在1991年提出的,属于径向基函数网络的一个变种。它的核心思想非常朴素——用非参数估计的方式直接逼近回归函数,而不是像BP那样通过反向传播一点一点调权重。换句话说,GRNN没有传统意义上的“训练”过程,不需要迭代求解权重矩阵,它的学习过程本质上是把训练样本“记忆”进网络结构里,然后对新输入做加权平均。
我常说GRNN更适合“小数据、多特征、单输出”的场景,这句话不是拍脑袋。很多人一听到神经网络就觉得必须海量数据、GPU、几百层,但GRNN是完全不同的物种。它在小样本下表现稳定,不会因为参数过多而严重过拟合,训练速度又极快,而且对非线性关系的拟合能力一点也不弱。这篇文章就围绕“用GRNN实现多特征输入、单因变量输出的拟合预测”这条主线,把原理、实现、调参、踩坑完整讲一遍。
适合看这篇文章的人大概有三类:一是刚入门机器学习,想找一个比线性回归强、又比深度学习简单的建模方案;二是手头有工业或实验数据,特征多但样本量不足,正在纠结用什么模型;三是已经在用BP或随机森林,但觉得训练麻烦或者泛化不稳定,想对比一下GRNN到底值不值得换。无论你是哪一类,这篇文章都能给你一个可以直接落地的参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GRNN的工作机制:为什么它能做到“零训练”还能拟合非线性
2.1 从非参数回归说起
要理解GRNN,先绕不开它的理论根基—— Nadaraya-Watson 核回归。这个统计方法的名字听起来唬人,其实思想特别简单:你想预测一个新样本的输出值,就看这个新样本和历史上所有样本有多“像”,越像的样本,它的真实输出值对新样本预测结果的影响权重就越大。
用数学语言描述就是:
y_hat(x) = sum( y_i * K(x, x_i) ) / sum( K(x, x_i) )
其中x是新的输入特征向量,x_i是第i个训练样本的特征向量,y_i是第i个训练样本的输出值,K是核函数,用来度量x与x_i之间的相似度或距离。
这个公式就是整个GRNN的顶层设计。网络做的所有事情,本质上就是在算这个加权平均。所以你会发现GRNN的输出永远落在训练样本输出值的“加权范围”内,它不会像多项式回归那样在外推时疯狂发散,也不会像BP那样可能因为权重初始化问题而跳出离谱的预测值。
核函数K通常取高斯函数:
K(x, x_i) = exp( - ||x - x_i||^2 / (2 * sigma^2) )
这里的sigma就是光滑因子,是GRNN里唯一需要调的超参数。公式里||x - x_i||表示欧氏距离。所以GRNN的判断逻辑就变成了:距离越近,相似度越高,权重越大;距离越远,权重呈指数级衰减。
2.2 四层网络结构里到底发生了什么
GRNN的网络结构一般被描述为四层:输入层、模式层、求和层、输出层。很多人第一次看这个结构图会问:这不就是一个带径向基函数的网络吗?和RBF神经网络有什么区别?区别确实有,但GRNN把RBF那套“需要解线性方程组确定输出权重”的步骤省掉了,直接在求和层做加权和与加权和的比例,结构上更简单,理论上的逼近能力也不弱。
输入层做的事很直白:接收一个多维特征向量,原封不动传给模式层,不做任何权重变换。这跟BP的第一层完全不同,BP的输入层后面还要接权重矩阵和激活函数,GRNN的输入层就是个管道。
模式层的每个神经元对应一个训练样本。它的激活函数就是高斯核函数,计算的是当前输入与对应训练样本之间的相似度,输出一个0到1之间的值。sigma越大,高斯曲线越平坦,远处的样本也能获得一定权重;sigma越小,曲线越尖锐,只有非常近的样本才有话语权。
求和层有两个神经元,这是GRNN结构上最有辨识度的地方。第一个神经元做分子计算:把模式层的输出乘以对应训练样本的真实输出值,再全部累加。第二个神经元做分母计算:把模式层的输出直接累加。两者相除,得到的就是加权平均结果。
输出层就一个神经元,直接把相除的结果作为最终预测值输出。整个流程是一次前向传播,没有任何迭代。所以GRNN的训练时间基本花在“把训练样本存下来”和“确定一个合适的sigma”上,跟BP动辄几千轮迭代相比,几乎可以忽略不计。
2.3 为什么说GRNN是“懒惰学习器”
有一类机器学习方法被称为懒惰学习器,典型代表是K近邻,GRNN也属于这一类。所谓懒惰,不是说它训练偷懒,而是它在训练阶段几乎不做什么实质性的模型拟合,真正的工作都推迟到预测阶段才发生。
这种特性的直接后果有两个。第一个是训练极快,刚才已经说过了。第二个是预测阶段要对每个新样本计算它与所有训练样本的距离,所以当训练样本数量很大时,预测速度会变慢,内存占用也会快速上升。这一点在工业实时预测场景中需要特别留意,后面我会专门讲怎么处理。
用生活化的类比来解释就是:BP像一个学生,考前疯狂刷题、总结规律,考场上直接套规律解题;GRNN像一个带着全部课本进考场的学生,碰到题目就把课本翻一遍,找到相似例题照葫芦画瓢,平时不用复习,但翻课本需要时间。你说哪个更好?没有绝对答案,取决于你手头是什么数据、考场的限时严不严。
搞清楚了GRNN的底层逻辑,你大概已经能猜到这个模型的优缺点边界了。下面我把它放进实际项目中,看看多特征输入、单因变量输出的场景到底怎么建模。
3. 多特征输入单输出的建模流程:从数据预处理到GRNN落地
3.1 数据预处理决定了GRNN一半的成败
GRNN对输入特征非常敏感,因为它的核心计算是欧氏距离。这就引出一个关键问题:如果不同特征的量纲差异很大,比如一个特征范围是0到1,另一个特征范围是几百到几千,那么距离计算时量纲大的特征会完全主导相似度,量纲小的特征等于被无视了。
对GRNN来说,特征归一化不是“建议做”,而是“必须做”。我常用的方式是最大最小值归一化,把所有特征统一映射到[0, 1]区间:
x_scaled = (x - x_min) / (x_max - x_min)
还有一种选择是z-score标准化:
x_scaled = (x - mean) / std
对于GRNN,我更推荐最大最小值归一化。原因在于高斯核函数对距离的尺度很敏感,如果数据分布不够均匀,z-score标准化后某些特征仍然可能出现较大方差,对距离的贡献比例不均衡。而最大最小值归一化可以严格统一特征的取值范围,让每个特征在距离计算中处于相似的尺度。
有人可能会问:输出变量y需要归一化吗?我的答案是建议归一化,尤其是在不同批次数据、特征物理意义差异大的情况下。虽然GRNN的加权平均机制对输出值的大小不敏感,但归一化输出后sigma的调参范围会更统一,便于调试。预测得到的结果再做逆变换还原回来即可。
另一个预处理重点是异常值处理。GRNN的预测本质上是对训练样本输出值的加权平均,如果某个训练样本的输出值明显异常,它就像一个锚点一样,在相似输入出现时把预测值拉向异常方向。虽然高斯核的指数衰减会削弱远距离样本的影响,但异常样本往往与正常样本在特征空间里并不是离得很远,所以这种影响很难完全避免。
我建议在训练前先对输出变量做一个简单的箱线图或者标准差检测,把明显偏离正常区间的样本剔掉或者做平滑处理。这一点对于工业数据尤其重要,因为传感器偶尔的跳变、通信丢包、记录错误都会产生离群点。
3.2 用MATLAB实现GRNN的最小可行例子
MATLAB的Newgrnn函数是最简洁的实现方式,原型是:
net = newgrnn(P, T, spread)
其中P是输入特征矩阵,每一列是一个样本,T是输出向量,spread就是需要手工指定的光滑因子sigma。训练完成后直接调用net(x)即可得到预测值。
假设我们有1000条样本,每条样本包含6个特征,输出是单个连续变量。数据已经提前归一化到trainData和trainOutput中,那么建模代码就三行:
matlab复制net = newgrnn(trainData', trainOutput', 0.5);
pred = net(testData');
对,你没看错,就是这么简单。不需要设置学习率、不需要选择激活函数、不需要考虑权重初始化,newgrnn函数内部把所有逻辑都封装好了。甚至对于有基础的人,这个简单程度可能会让你产生“是不是太儿戏了”的怀疑,但实践下来,它的拟合效果在很多场景下真的不输精心调参的BP。
Python实现也很直接。新手朋友可能更常用sklearn,不过sklearn内置的GRNN并不常见,更多时候是直接用neurolab库或者自己用numpy实现。neurolab的GRNN类用法如下:
python复制import neurolab as nl
net = nl.net.newgrnn(train_data.T, train_output.reshape(1, -1), sigma=0.5)
pred = net.sim(test_data.T).ravel()
如果你不想引入额外依赖,用numpy手写一个GRNN预测函数也就二十几行代码的事情。核心就是两层循环:第一层遍历训练样本算高斯核权重,第二层做加权求和,最后相除。但是两层循环在样本量大时效率很低,建议用矩阵运算一次性算出所有距离。下面是我常用的实现:
python复制import numpy as np
def grnn_predict(train_x, train_y, test_x, sigma):
# train_x: (n_samples, n_features)
# test_x: (m_samples, n_features)
# 计算测试样本与所有训练样本的欧氏距离平方
# 利用 (a-b)^2 = a^2 + b^2 - 2ab 避免显式循环
x2_train = np.sum(train_x ** 2, axis=1).reshape(-1, 1)
x2_test = np.sum(test_x ** 2, axis=1).reshape(1, -1)
dist2 = x2_train + x2_test - 2 * np.dot(train_x, test_x.T)
dist2 = np.maximum(dist2, 0) # 消除数值误差
weights = np.exp(-dist2 / (2 * sigma * sigma))
# 加权平均
pred = np.dot(weights.T, train_y) / weights.sum(axis=0)
return pred
熟悉numpy广播机制的话,这段代码很容易看懂。它把两层循环优化成了矩阵运算,对几千个训练样本的预测可以在毫秒级完成。不过要注意dist2计算过程中的数值稳定性,用np.maximum把可能的负值截断为0。
3.3 到底怎么确定sigma:交叉验证是唯一靠谱的路
如果你只用newgrnn(P, T, spread)建了一次模型就收工,然后发现预测效果不好,那十有八九是spread没选好。sigma选的太小,高斯核只对离得很近的样本有响应,预测结果会剧烈波动,几乎就是最近邻回归的疯狂版本;sigma选的太大,所有样本的权重趋近相等,模型退化成简单的全局平均,完全丢失局部结构信息。
找sigma最好的办法是交叉验证。把训练集划分为K折,轮流用K-1折建模、1折验证,统计不同sigma下的验证误差,选误差最小的那个sigma。注意不是简单地在训练集上追求误差最小,而是要在验证集上评估泛化能力。
下面是我常用的网格搜索代码框架,基于numpy手写GRNN预测函数:
python复制from sklearn.model_selection import KFold
import numpy as np
def evaluate_sigma(train_x, train_y, sigma, n_folds=5):
kf = KFold(n_splits=n_folds, shuffle=True, random_state=42)
errors = []
for train_idx, val_idx in kf.split(train_x):
tr_x, tr_y = train_x[train_idx], train_y[train_idx]
va_x, va_y = train_x[val_idx], train_y[val_idx]
pred = grnn_predict(tr_x, tr_y, va_x, sigma)
errors.append(np.mean((pred - va_y) ** 2))
return np.mean(errors)
sigma_candidates = np.logspace(-2, 1, 50)
best_sigma = min(sigma_candidates, key=lambda s: evaluate_sigma(train_x, train_y, s))
print("best sigma:", best_sigma)
sigma的搜索范围怎么定?我的经验是先在log空间里从0.01到10之间粗略搜一遍,确定大致区间后再细化。归一化后的特征都落在[0, 1]区间,所以样本间的距离通常也不会太大,sigma在0.1到1之间往往是比较合理的起步区间。如果数据特征不是很多,比如只有两三个,sigma可以适当小一些;特征维度高的时候,距离计算会被维度稀释,sigma要适当增大。
另外建议配合可视化来选sigma:把不同sigma对应的训练误差和验证误差曲线画在一张图上。训练误差通常会随着sigma减小而降低,验证误差则呈现U型曲线,U型底部对应的sigma就是比较好的选择。如果训练误差和验证误差的差距很大,说明出现过拟合,sigma应该加大;如果两者都很高,说明欠拟合,sigma应该减小。
4. 实战案例:12个传感器特征预测一个关键质量指标
4.1 问题描述与数据情况
假设我们在做一个工业过程建模:某条生产线上有12个传感器通道,包括温度、压力、流量、振动、转速等,目标是预测最终产品的某个关键质量指标,比如硬度、浓度或者强度。样本量大概有3000条,是从历史数据库中抽取的,特征之间存在相关性和非线性交互。
这类问题在工业界非常常见。传统的做法是用多元线性回归或者逐步回归,但面对非线性关系时效果很差。也有人直接上BP,但BP有几个痛点:一是对超参数敏感,层数、神经元数、学习率都要调;二是小样本下容易过拟合,又不敢用太复杂的网络结构;三是训练时间相对长,不利于快速迭代。
GRNN在这个场景下的优势非常明显。它不需要训练迭代,只要确定了sigma,放进数据就能建模,几分钟内可以得到一个可以接受的预测模型。下面我完整描述操作流程和中间的关键判断。
首先做数据清洗。12个传感器通道里,有一个通道出现了明显的传感器漂移,在某个时段数据异常偏高。这种漂移如果没有处理,会在GRNN的模式层中形成一批与正常样本相距较远的“锚点”,在预测时对特定输入区域产生误导。我用滑动窗口方法检测了这个通道的局部均值,把超过局部均值三倍标准差的点标记出来,并用前后正常值的均值做了替换。
然后做归一化。12个通道的量纲差异很大,温度大约是200到500,压力是0.1到0.6,流量是几十到几百,振动是0到10。直接用原始数据建模,距离计算时压力和振动通道几乎没有任何话语权。我把全部特征归一化到[0, 1],输出变量也归一化,最后预测结果再逆变换回原始量纲。
数据划分上,我用了80%训练、20%测试的划分方式,并且用5折交叉验证在训练集内部选择sigma。注意工业时序数据不能直接随机打乱,需要按照时间顺序划分,否则会引入未来数据泄露,导致评估结果虚高。这是一个很容易被忽略的坑。
4.2 不同sigma下的表现差异
交叉验证搜索sigma时,我记录了sigma从0.05到2.0之间的验证误差变化情况。为了方便对比,我用均方根误差RMSE作为评估指标。
| sigma | 训练RMSE | 验证RMSE | 现象描述 |
|---|---|---|---|
| 0.05 | 1.28 | 12.64 | 严重过拟合,预测曲线剧烈震荡 |
| 0.2 | 2.31 | 5.42 | 仍有明显过拟合,局部波动大 |
| 0.5 | 3.87 | 3.95 | 最佳区间,泛化良好 |
| 0.8 | 4.55 | 4.63 | 略微平滑,误差略有上升 |
| 1.5 | 6.42 | 6.51 | 预测过于平滑,细节丢失 |
| 2.0 | 8.16 | 8.23 | 接近全局平均,完全失去局部拟合能力 |
从表格可以看得很清楚:sigma = 0.05时训练误差极低,但验证误差飙升,典型的过拟合;sigma = 2.0时训练误差和验证误差都很大,模型几乎等于在预测全局均值。sigma = 0.5时训练误差和验证误差非常接近,说明模型既没有过拟合,也没有欠拟合。
这个结果印证了前面说的U型验证误差曲线。实际操作中不需要追求训练误差最小,应该盯着验证误差。有些新手看到sigma小的时候训练误差漂亮,就误以为模型很好,结果一上测试集就原形毕露。
4.3 与BP、随机森林的对比结果
为了确认GRNN在这个场景下的优势,我做了一组对比实验。BP网络配置为单隐藏层20个神经元,学习率0.01,最大迭代2000轮;随机森林用200棵树,最大深度10;GRNN用交叉验证选出的sigma = 0.55。
| 模型 | 测试RMSE | 训练时间 | 调参难度 |
|---|---|---|---|
| 多元线性回归 | 6.87 | <1s | 低 |
| BP神经网络 | 4.32 | 约15s | 高(层数/神经元/学习率/激活函数都要试) |
| 随机森林 | 4.08 | 约5s | 中(树数/深度/特征采样) |
| GRNN | 3.91 | <1s | 低(只需调sigma) |
测试集上的RMSE对比,GRNN轻微胜出BP和随机森林,同时训练时间几乎可以忽略,调参工作量也最少。如果只看RMSE,GRNN的领先幅度并不夸张,但在工业场景中,训练效率和调参成本也是重要的考量因素。
更关键的差距体现在泛化稳定性上。同一个数据集,我用了不同的随机种子重新划分训练集和测试集,重复十次实验。GRNN的测试RMSE标准差是0.21,BP是0.48,随机森林是0.33。GRNN在数据划分变动时表现更稳定,这对生产环境中的模型维护非常重要。
当然这并不意味着GRNN在所有场景都碾压其他模型。如果样本量达到几十万甚至上百万,GRNN的记忆式结构会导致预测速度严重下降,这时随机森林或者深度网络可能更合适。如果特征维度极高,比如几千维,GRNN也会面临距离度量失效的问题,通常需要先做特征筛选或降维。
5. GRNN在应用中的边界和几个容易踩的坑
5.1 样本量太大时怎么救
GRNN最致命的短板就是预测阶段的计算量。训练样本一万条还好,如果到了十万条、百万条,每个测试样本都要和所有训练样本计算高斯核距离,预测速度会慢到无法接受。在实时预测场景里,这可能是致命的。
我总结了几种缓解方案,按推荐程度排序:
第一种是样本裁剪。用聚类算法对训练样本做代表性采样,比如K-means聚类后,每类中选中心附近的几个样本作为代表,把训练集压缩到原来的一半甚至十分之一。这样做会损失一定的精度,但可以显著提升预测速度。实际操作中,我通常会先测试不同压缩比例下的RMSE变化,找到一个精度和速度的平衡点。
第二种是KD树加速最近邻搜索。高斯核的值会随着距离增大迅速衰减,距离很远的样本对最终结果的影响微乎其微,可以近似忽略。所以我们可以只计算每个测试样本最近的K个训练样本,而不是全部样本。用KD树或者Ball Tree做近邻搜索,能把复杂度从O(N)降到O(logN)级别。sklearn的KDTree可以直接用,实现起来很容易:
python复制from sklearn.neighbors import KDTree
tree = KDTree(train_x, leaf_size=40)
dist, idx = tree.query(test_x, k=50)
pred = np.zeros(test_x.shape[0])
for i in range(test_x.shape[0]):
weights = np.exp(-dist[i] ** 2 / (2 * sigma * sigma))
pred[i] = np.sum(weights * train_y[idx[i]]) / np.sum(weights)
第三种是使用更复杂的近似方法,比如局部敏感哈希或者子采样集成,但工程复杂度较高,一般只有生产环境特别紧急时才会考虑。对大多数项目来说,前两种方案已经够用了。
5.2 多特征输入时的维度灾难
特征维度升高时,欧氏距离的区分度会迅速下降。这就是所谓的维度灾难。假设特征维度是100,样本间距离的分布会变得非常集中,高斯核的权重差异会缩小,GRNN预测结果会逐渐退化到全局平均,失去局部拟合能力。
面对高维特征,我的建议是先用特征选择或降维。工业数据里很多传感器通道之间高度相关,比如温度和压力往往正相关,可以用主成分分析PCA或者基于相关性的特征筛选,把有效维度降到10以下。GRNN对特征维度的容忍度大概在20维以内,超过这个范围就要谨慎了。
另外要注意,PCA的降维结果会改变特征空间的几何结构,降维后sigma的选择可能需要重新搜索。不要拿着原始特征下的sigma直接用降维后数据建模,误差会比较大。
5.3 GRNN不能做外推预测
这一点很多人容易忽略。GRNN的输出是训练样本输出值的加权平均,因此它的预测范围永远在训练样本输出值的最小值和最大值之间。如果新样本的特征组合在训练集中完全没有出现过,比如工况发生突变,特征超出了历史范围,GRNN的预测结果就会非常不可靠。
更准确地说,GRNN在特征空间边界的预测会趋向于边缘样本的输出均值,并不会像线性回归那样可以沿着趋势线继续外推。这既是优点也是缺点:优点是它不会给出离谱的极端预测,缺点是它本质上不具备外推能力。
所以,当工艺条件发生明显变化、传感器量程调整、原材料批次更换导致特征分布偏移时,一定要及时更新训练集并重新训练模型。我在实际项目中会做一个简单的分布监测:每隔一段时间计算新数据与训练集特征均值的马氏距离,一旦超出阈值就触发模型更新流程。
5.4 sigma的搜索范围没有统一标准
不同数据集的sigma最优值差异很大。特征经过归一化后,我的经验范围是0.01到10,但具体在哪一段,跟样本量、特征维度、数据噪声水平都有关系。样本量越大,sigma通常可以选得小一些,因为样本密集时局部信息更丰富;噪声越大,sigma则需要大一些,用更平滑的预测来抑制噪声。
还有一个小技巧:如果输出变量的范围很大,比如从几十到几千,建议先把输出变量也归一化,否则sigma搜索过程中不同输出尺度下的误差变化曲线会很不均匀,交叉验证结果参考价值下降。
6. 在MATLAB和Python里实现GRNN的代码级细节
6.1 MATLAB工具箱的完整代码示例
MATLAB的newgrnn使用非常方便,但我建议不要直接用默认参数,而是自己做一次sigma搜索。下面是一个完整示例:
matlab复制% 加载数据
data = xlsread('sensor_data.xlsx');
features = data(:, 1:12);
target = data(:, 13);
% 归一化
[features_norm, ps_input] = mapminmax(features', 0, 1);
[target_norm, ps_output] = mapminmax(target', 0, 1);
features_norm = features_norm';
target_norm = target_norm';
% 划分数据
train_ratio = 0.8;
n = size(features_norm, 1);
train_num = round(n * train_ratio);
train_x = features_norm(1:train_num, :);
train_y = target_norm(1:train_num);
test_x = features_norm(train_num+1:end, :);
test_y = target_norm(train_num+1:end);
% 交叉验证选sigma
sigmas = 0.01:0.02:1;
cv_errors = zeros(size(sigmas));
k = 5;
indices = crossvalind('Kfold', train_num, k);
for i = 1:length(sigmas)
errs = zeros(k, 1);
for j = 1:k
val_idx = (indices == j);
tr_idx = ~val_idx;
net = newgrnn(train_x(tr_idx, :)', train_y(tr_idx)', sigmas(i));
pred = net(train_x(val_idx, :)');
errs(j) = sqrt(mean((pred - train_y(val_idx)').^2));
end
cv_errors(i) = mean(errs);
end
[~, best_idx] = min(cv_errors);
best_sigma = sigmas(best_idx);
fprintf('Best sigma: %.4f\n', best_sigma);
% 用最优sigma训练最终模型
net = newgrnn(train_x', train_y', best_sigma);
% 预测并反归一化
pred_norm = net(test_x');
pred = mapminmax('reverse', pred_norm, ps_output);
test_y_orig = mapminmax('reverse', test_y', ps_output);
rmse = sqrt(mean((pred - test_y_orig).^2));
fprintf('Test RMSE: %.4f\n', rmse);
这段代码里有两个细节值得注意。第一个是mapminmax函数默认是按行处理的,所以转置操作很多,别搞混行列方向,否则报错会让你怀疑人生。第二个是newgrnn要求输入矩阵的每一列是一个样本,和sklearn的约定不同,这里一定要看清楚。
6.2 纯numpy版本的完整实现
我之前已经给过grnn_predict函数,这里扩展成一个完整的训练预测流程,包括数据标准化、交叉验证选sigma、最终评估:
python复制import numpy as np
from sklearn.preprocessing import MinMaxScaler
from sklearn.model_selection import KFold
class GRNNRegressor:
def __init__(self, sigma=0.5):
self.sigma = sigma
self.train_x = None
self.train_y = None
def fit(self, x, y):
self.train_x = np.asarray(x, dtype=np.float64)
self.train_y = np.asarray(y, dtype=np.float64).reshape(-1)
def predict(self, x):
x = np.asarray(x, dtype=np.float64)
x2_train = np.sum(self.train_x ** 2, axis=1).reshape(-1, 1)
x2_test = np.sum(x ** 2, axis=1).reshape(1, -1)
dist2 = x2_train + x2_test - 2 * np.dot(self.train_x, x.T)
dist2 = np.maximum(dist2, 0)
weights = np.exp(-dist2 / (2 * self.sigma * self.sigma))
pred = np.dot(weights.T, self.train_y) / weights.sum(axis=0)
return pred
# 数据准备
data = np.loadtxt('sensor_data.csv', delimiter=',')
features = data[:, :12]
target = data[:, 12]
scaler_x = MinMaxScaler()
scaler_y = MinMaxScaler()
features_scale = scaler_x.fit_transform(features)
target_scale = scaler_y.fit_transform(target.reshape(-1, 1)).ravel()
# 时序划分,不打乱
n = len(features)
train_n = int(n * 0.8)
train_x, train_y = features_scale[:train_n], target_scale[:train_n]
test_x, test_y = features_scale[train_n:], target_scale[train_n:]
# 交叉验证选sigma
kf = KFold(n_splits=5, shuffle=True, random_state=42)
sigma_candidates = np.linspace(0.01, 1.0, 50)
best_sigma = None
best_score = float('inf')
for sigma in sigma_candidates:
scores = []
for tr_idx, va_idx in kf.split(train_x):
model = GRNNRegressor(sigma=sigma)
model.fit(train_x[tr_idx], train_y[tr_idx])
pred = model.predict(train_x[va_idx])
scores.append(np.mean((pred - train_y[va_idx]) ** 2))
mean_score = np.mean(scores)
if mean_score < best_score:
best_score = mean_score
best_sigma = sigma
print(f"Best sigma: {best_sigma:.4f}")
model = GRNNRegressor(sigma=best_sigma)
model.fit(train_x, train_y)
pred_norm = model.predict(test_x)
pred = scaler_y.inverse_transform(pred_norm.reshape(-1, 1)).ravel()
test_y_orig = scaler_y.inverse_transform(test_y.reshape(-1, 1)).ravel()
rmse = np.sqrt(np.mean((pred - test_y_orig) ** 2))
print(f"Test RMSE: {rmse:.4f}")
这个类很简单,但已经足够应对大多数项目。如果你需要输出预测区间,GRNN的求和层其实还保留了额外的信息:两个求和神经元的比值是预测值,而单个求和神经元的值等于所有核权重的总和,反映了预测点的样本密度。样本密度低的地方,预测的不确定性自然更大。这一点可以用于简单的不确定性评估。
6.3 尝试用深度学习框架复刻GRNN
偶尔有读者问我能不能用PyTorch或TensorFlow实现GRNN。答案是可以,但说实话意义不大。GRNN的训练过程没有梯度回传,直接用深度学习框架反而增加复杂度。不过如果你想在一个已有的深度学习项目里嵌入GRNN模块,可以用自定义层实现。
在PyTorch里,GRNN的前向传播本质就是一次带核权重的注意力机制。如果你接触过Transformer,会发现GRNN的加权平均和注意力加权在形式上非常相似,只是Transformer的QKV映射是学出来的,而GRNN的权重直接由核函数得到。
用PyTorch实现的核心代码如下:
python复制import torch
import torch.nn as nn
class GRNNLayer(nn.Module):
def __init__(self, sigma):
super().__init__()
self.sigma = sigma
def forward(self, x, memory_x, memory_y):
# x: (batch, feature_dim)
# memory_x: (memory_size, feature_dim)
# memory_y: (memory_size, 1)
dist2 = torch.sum((x.unsqueeze(1) - memory_x.unsqueeze(0)) ** 2, dim=-1)
weights = torch.exp(-dist2 / (2 * self.sigma ** 2))
pred = torch.sum(weights.unsqueeze(-1) * memory_y.unsqueeze(0), dim=1)
pred = pred / weights.sum(dim=1, keepdim=True)
return pred
如果你要跑大批量数据,这个GPU加速版本会比numpy更快。但单机CPU场景下,numpy版本已经完全够用,没必要引入深度学习框架的额外依赖。
7. 提升GRNN拟合效果的进阶思路
7.1 特征工程:不要只依赖原始传感器通道
很多人在建模时直接把原始特征丢给模型,GRNN虽然能处理非线性,但它并不能自动构造出更有效的特征组合。距离计算对不同特征的权重是等价的,如果某些特征对输出的影响更大,我们可以通过加权距离来体现这一点。
加权距离的思路很简单:给每个特征乘以一个权重系数,再用加权后的特征计算欧氏距离。权重可以通过特征与输出之间的互信息或者相关系数来初始化,再用交叉验证微调。这样做的本质是在GRNN里引入了特征选择的思想,可以让重要特征在相似度计算中获得更大的话语权。
另外,在某些工业场景中,原始传感器数据可能存在滞后效应,当前时刻的输出不仅取决于当前时刻的输入,还取决于过去一段时间的输入。这时可以把时间窗口内的统计特征加进去,比如过去5分钟的平均值、最大值、变化率等。这些衍生特征往往能显著提升GRNN的预测精度,因为它们直接编码了动态过程信息。
7.2 多模型集成:GRNN和其他模型互补
GRNN擅长局部拟合,随机森林擅长处理特征交互,线性回归在趋势外推上有优势。把这三种模型集成起来,通常能取得比单一模型更好的效果。
我用的一个简单集成方法是加权平均:在验证集上分别计算三个模型的RMSE,然后根据RMSE的反比确定权重,对预测结果做加权融合。更高级的做法是训练一个元模型,以三个模型的预测值为输入,真实值为输出,用线性回归或者另一个GRNN作为元模型。
实测下来,GRNN加随机森林的集成在工业过程建模上效果尤其好。GRNN对局部细节敏感,随机森林对整体趋势稳定,两者互补性很强。集成后的RMSE通常比最优单模型降低5%到10%,代价是维护多个模型的复杂度。
7.3 输出量纲的细节处理
我之前强调过输出归一化,这里再补充一个容易被忽略的细节:如果你对输出做了log变换再训练GRNN,预测结果在反变换后可能存在偏差。因为GRNN预测的是log空间中的加权平均值,指数变换后得到的是几何意义上的均值,而不是原始空间的均值。
如果输出变量的分布严重右偏,比如浓度数据经常出现长尾分布,我建议先在原始空间做归一化,而不是先log变换。一旦做了非线性变换,GRNN的加权平均性质就不再对应原始空间的期望值,可能会引入系统性偏差。
如果必须做log变换,可以考虑在反变换后乘以一个经验修正系数,用验证集校准。这个修正常数可以用验证集上几何均值预测与真实均值的比值来估计。
8. 从项目实战中提炼的几点心得
第一,GRNN不是一个“看起来高大上”的模型,但它在小样本、多特征、单输出的拟合预测场景里的性价比,确实被很多人低估了。如果你手头的数据量不大、特征维度适中、又希望快速得到一个可靠的基线模型,GRNN应该排在前三的候选名单里。
第二,sigma的选择怎么强调都不过分。我见过太多人用newgrnn的默认spread就直接上项目,结果效果不好就下结论说GRNN不行。实际上GRNN的模型能力完全寄托在sigma这个参数上,其他模型需要调的参数那么多,GRNN只需要调一个,这真的是非常友好的调参体验。
第三,数据预处理在GRNN里的重要性被严重低估。特征归一化、异常值处理、时序划分这些步骤做到位,GRNN的效果会有质的提升。反过来,很多GRNN效果不佳的案例,根源都在数据准备阶段。
第四,GRNN不是万能的,它不适合大规模样本、高维特征和强外推需求。在这些场景下强行使用GRNN,你会撞上预测速度慢、维度灾难、外推失效这三堵墙。但如果你能提前识别这些边界条件,GRNN可以成为你工具箱里一个极其顺手且稳定的工具。
我自己现在的工作流是:拿到回归类项目,先用GRNN做快速基线,如果GRNN的表现已经足够好,就不再折腾复杂模型;如果GRNN的表现接近但差一点达标,再考虑上随机森林或者集成方法。这套流程帮我省下了大量调参时间,也规避了很多不必要的复杂度。建议你也可以试试这个思路。
