1. 疾病诊断小数据场景,为什么GRNN比大模型更“顺手”
我在实际做医学相关数据项目的时候,经常被问到一个问题:为什么不用深度学习?大模型不是效果更好吗?道理没错,但前提是有足够多的数据。现实里的疾病诊断数据压根不是那个画风——某家医院的病案库能用的大概几百到几千份,剔除缺失严重的记录之后能进模型的可能只剩几百行。几百行样本去训一个动辄几十万参数的深度网络,结果就是过拟合到让你怀疑人生。
我不是说深度模型没用。我的意思是,在小样本、特征维度几十个、且特征之间高度非线性的场景下,广义回归神经网络(GRNN)这种“古老”的模型反而有天然优势。第一次接触GRNN是在做一个体检指标预测糖尿病风险的项目里,样本只有不到800条记录,特征包括空腹血糖、糖化血红蛋白、BMI、血脂、年龄等十多项。当时我先后用逻辑回归、随机森林、XGBoost都试过,效果中规中矩。后来有个同事提到GRNN,说它在小样本回归和分类上表现很稳,我才开始查它的原理,一查发现这东西确实被低估了。
GRNN的核心优势概括起来就几条:
- 训练过程没有梯度下降,没有反向传播,不迭代,理论上不存在“训练不收敛”的问题。
- 网络结构由训练样本直接决定,样本数量就是隐层神经元数量,算法会把所有训练样本原样“记住”。
- 预测时对样本做加权平均,输出天然平滑,不会像决策树那样输出跳跃性极强的硬边界。
- 唯一需要调的超参数是平滑因子,调参空间极小,对医学这种“不想花大量时间调模型”的场景很友好。
这套特性让它特别适合“样本量小、噪声大、需要快速得到可解释结果”的诊断分类任务。下面我把原理、实操、避坑全部展开讲,希望能帮到正在做类似任务的同学。
2. GRNN的决策逻辑拆解:概率密度估计是怎么变成分类结果的
GRNN全称General Regression Neural Network,中文叫广义回归神经网络,是美国学者Specht在1991年提出的。它本质上不是一个传统的“神经网络”,而是一种基于核密度估计的非参数回归模型。说人话就是:它把每个训练样本当成一个“高斯核”的中心点,预测一个新样本时,计算它跟所有训练样本的距离,距离越近的样本在结果中占的权重越大,最后做加权平均得出预测值。
2.1 从Parzen窗到回归期望
要理解GRNN,先要理解Parzen窗密度估计。假设你有一堆样本点,想估计它们在空间中的概率密度分布,一个朴素的办法是每个样本点附近放一个高斯分布,把所有高斯分布叠加起来再归一化。样本点密集的地方,密度估计值自然高;稀疏的地方则低。Parzen窗就是这个思想。
GRNN把Parzen窗用在了条件期望计算上。对于一个训练集,样本点记为x_i,对应的输出是y_i。预测新输入x时,模型输出就是:
[ \hat{y}(x) = \frac{\sum_{i=1}^{n} y_i \cdot \exp\left(-\frac{||x - x_i||^2}{2\sigma^2}\right)}{\sum_{i=1}^{n} \exp\left(-\frac{||x - x_i||^2}{2\sigma^2}\right)} ]
这个公式看着有点吓人,拆开看其实非常朴素。分母是所有高斯核权重的总和,分子是每个训练样本的输出值乘以它的权重之后求和。权重取决于新样本x到每个训练样本x_i的欧氏距离,距离越近exp项越大,权重越高。σ是平滑因子,控制高斯核的“宽度”。
我拿糖尿病风险预测举个例子。假设某条训练样本表示一个45岁、BMl 27、空腹血糖6.2的人,最终标签是“高风险”。新来一条记录,年龄46、BMI 27.5、空腹血糖6.5,跟那条训练样本非常接近,于是那条样本在预测结果中的权重就会很高。如果新样本跟所有训练样本都不太像,它的预测结果就会趋向于全体样本输出的均值——这是GRNN的“回归到先验均值”特性,也是一个需要特别注意的点。
2.2 分类任务的转化方式
严格来说GRNN是一个回归器,输出是连续值。用于分类任务时有两种常见做法。第一种最简单:把类别编码成数值,比如正常=0、风险=1,然后直接用GRNN回归,预测值超过0.5判为风险。这种做法只适用于二分类,且对类别不平衡比较敏感。
第二种做法更稳妥:把标签做one-hot编码,例如三个类别就是[1,0,0]、[0,1,0]、[0,0,1],GRNN对每个类别维度分别做加权平均,得到一个3维输出向量,再做softmax归一化,每个分量就代表样本属于该类别的置信度。我个人强烈建议用第二种,因为输出的连续性还能拿来当风险评分用,后面讲预测应用时再展开。
2.3 平滑因子σ到底在控制什么
σ是GRNN唯一的超参数,它的作用可以类比成“你有多信任相似样本”。σ设得很大,高斯核变得很宽,每个样本的权重差异变小,最终预测结果趋近于全部样本的均值,这样会抹平细节,造成过平滑;σ设得很小,只有距离极近的样本才贡献权重,预测结果完全被近邻样本决定,过度敏感,导致过拟合,某个类别只有一条样本,那新样本只要离它稍微近一点就被拉过去。
更有意思的是,σ的选择和数据尺度强相关。如果你不把特征标准化,某个特征量纲特别大,比如BMI是几十,空腹血糖是6左右,那么距离计算中BMI会主导欧氏距离,血糖的影响几乎被吃掉。所以做GRNN之前,标准化是必须的第一步。标准化的做法我一般用Z-score,让每个特征均值为0、方差为1。这样σ的搜索范围可以稳定在0.01到5之间,避免量纲干扰。
3. 一次完整的GRNN疾病分类实验:数据、代码、验证这么串起来
这一节我拿一个模拟的体检指标疾病风险分类项目来演示完整流程。数据形态是表格型,每行是一个人的体检记录,字段包括年龄、性别、BMI、收缩压、空腹血糖、糖化血红蛋白、总胆固醇、HDL、LDL、吸烟史、饮酒史、家族史,标签是是否确诊为某代谢类疾病的高风险人群。这里用模拟数据代称,方法可以直接迁移到任何类似场景。
3.1 数据预处理的优先级排序
很多同学一上来就做特征工程,实际上对医学表格数据来说,第一步是清洗和确认语义。我按优先级处理了这些事项:
- 缺失值处理。体检数据里缺失值很常见,比如空腹血糖没测、血脂少了一项。样本量小的时候直接删行太浪费,我用均值填充连续变量、众数填充分类变量,并且在建模后对比一下“填充前删行”和“填充后建模”的结果差异。通常填充后的效果更稳,但前提是缺失率低于20%。
- 类别特征编码。性别、吸烟史这类二值特征直接映射成0和1;家族史如果有多个层级,用哑变量编码实现one-hot。
- 数值特征标准化。如前所述,Z-score标准化,这一步千万注意要在训练集上fit,再用同样的scaler去transform测试集。很多人拿全量数据fit_transform,然后在交叉验证里看到好得离谱的分数,一上线就崩,就是数据泄漏的典型表现。
- 特征筛选。GRNN不像树模型有内建特征重要性,特征越多,样本在高维空间里越稀疏,距离计算越不可靠。我做了两轮筛选:第一轮看相关性矩阵,去掉相关系数绝对值大于0.9的冗余特征,避免共线性干扰距离;第二轮根据医生朋友的建议保留临床意义明确的指标,而不是纯靠统计指标来筛。
3.2 从零实现一个轻量GRNN分类器
GRNN的数学结构非常简单,完全不需要依赖专门库,用Python写一个核心类大约三十行代码就够了。我在项目里基于numpy手写了一个,过程如下:
import numpy as np from sklearn.preprocessing import StandardScaler from sklearn.model_selection import LeaveOneOut from sklearn.metrics import accuracy_score, roc_auc_score class GRNNClassifier: def __init__(self, sigma=1.0): self.sigma = sigma self.X_train = None self.y_train_onehot = None self.classes = None def fit(self, X, y): self.X_train = np.array(X, dtype=np.float64) self.classes = np.unique(y) y_onehot = np.zeros((len(y), len(self.classes))) for i, cls in enumerate(self.classes): y_onehot[y == cls, i] = 1 self.y_train_onehot = y_onehot def _kernel_weights(self, x): diff = self.X_train - x sq_dist = np.sum(diff ** 2, axis=1) return np.exp(-sq_dist / (2 * self.sigma ** 2)) def predict_proba(self, X): X = np.array(X, dtype=np.float64) probas = [] for x in X: w = self._kernel_weights(x) weighted_sum = np.sum(self.y_train_onehot * w[:, None], axis=0) proba = weighted_sum / (np.sum(w) + 1e-12) probas.append(proba) return np.array(probas) def predict(self, X): probas = self.predict_proba(X) return np.array([self.classes[np.argmax(p)] for p in probas])这段代码的fit只做了两件事:记住训练样本和把标签转成one-hot。没有迭代,没有损失函数,没有优化器。predict时逐条计算权重并做加权平均。解释一下那个1e-12:如果某条新样本离所有训练样本都非常远,exp项会全部下溢到接近0,加一个极小值防止除零。
有人可能担心逐条循环预测速度慢,确实,预测的计算复杂度是O(n),n是训练样本数。一千条样本几乎无感,五万条就开始有压力。解决办法之一是先用聚类给训练样本做减枝,把样本压到几千条再用GRNN,但前提是精度可接受。
3.3 留一法验证跟GRNN是绝配
小样本条件下,常用的K折交叉验证有个问题:每次训练只用了80%的样本,GRNN作为记忆型模型,对训练集规模极其敏感,少一条样本都可能导致决策边界明显变化。我把数据分成5折,结果发现不同折之间验证分数波动特别大。
改成留一法(Leave-One-Out)之后,每次只留一条样本做验证,其余全部进训练集,这跟GRNN的记忆型特性天然匹配。而且因为GRNN训练没有成本,留一法的n次预测代价完全可以接受。实验里我直接对σ进行网格搜索,σ从0.01到5按对数间隔取值,评价指标用AUC和F1,因为诊断场景里敏感度和特异度不能只看一个。
3.4 实验结果怎么看:别只盯准确率
先说明,下面表格里的数字是模拟项目中的典型结果,代表性在于趋势,不同数据集上会有波动。我在同样的预处理流程下对比了逻辑回归、随机森林、XGBoost和一个两层MLP,结果如下:
| 模型 | 准确率 | 敏感度 | 特异度 | AUC |
|---|---|---|---|---|
| 逻辑回归 | 0.81 | 0.74 | 0.86 | 0.83 |
| 随机森林 | 0.83 | 0.78 | 0.87 | 0.86 |
| XGBoost | 0.84 | 0.77 | 0.90 | 0.87 |
| 两层MLP | 0.79 | 0.70 | 0.85 | 0.84 |
| GRNN | 0.87 | 0.83 | 0.91 | 0.90 |
GRNN在小样本上的优势很直观,尤其是敏感度一项,意味着“真正有病的人被找出来”的比例更高,在筛查场景里这比准确率更重要。MLP排最后不奇怪:几百条样本对它来说太少,即使加了Dropout和早停也掩饰不了过拟合倾向。
需要注意的是,当样本量上升到上万级别、特征维度上百之后,GRNN的优势会逐渐消失,预测速度也会变慢,那时XGBoost或者更现代的模型库会更有优势。小样本阶段用GRNN,本质上是借助它的“非参数、强记忆”特性,把样本的全部信息保留在预测过程中,不做信息压缩。
4. 最容易翻车的三个地方:σ取值、样本不平衡、特征泄漏
模型本身结构简单,翻车的点不在网络结构上,而在使用细节。这三个坑我每个都踩过,也看到周围同学反复踩,单独拉出来说一下。
4.1 σ网格搜索的代价曲线和“宽平台”现象
先说σ。很多人直接把σ取1,觉得“反正就一个参数,差不多得了”。这个想法很容易把模型推向过平滑或者过拟合。实操里我用的是对数网格:np.logspace(-2, 0.7, 20),也就是从0.01到约5之间取20个候选值。留一法跑完看AUC的变化曲线。
这里有个值得注意的现象:σ在某个区间内AUC基本不动,形成一个“宽平台”。比如从0.3到1.0之间AUC可能只差0.005,你选哪个都行。但σ低于0.1之后,AUC会断崖式下跌,因为核宽度太窄,只有极端相似的样本才发挥作用,模型几乎退化成最近邻。σ大于2之后,AUC缓慢下滑,因为所有样本权重趋同,输出逼近整体均值。
所以我的建议是:σ不要只挑AUC最大的那个点,最好在平台区间内取偏大一点的保守值,因为偏大能提高泛化稳定性,对训练集噪声不那么敏感。我习惯在最优σ附近乘上1.5再测一次,如果精度下降不超过1个百分点,就取这个保守值。这个做法救过我的一次上线事故:最优σ在测试集上AUC很高,但部署到新一批数据上跌了4个点,换成保守值之后跌幅控制在1个点以内。
4.2 类别不平衡下GRNN的“均值回归”陷阱
医学诊断数据天然不平衡,正类样本往往只有总样本的20%甚至更低。GRNN对每个类别做加权平均之后,多数类样本多,权重总和自然更大,结果就是预测概率被系统性拉向多数类。举个极端例子,两类样本比例是9比1,即使新样本离少数类很近,少数类的权重之和也可能比不过远处一大片多数类样本的累积权重。
这个跟随机森林的“样本量大的类更占便宜”还不一样,GRNN的均值回归更隐蔽,因为输出是概率值不是投票,你看着概率0.7觉得挺高,其实已经是多数类偏置下的结果。
处理方法我试过三种,效果最好的是加权预测时的类别倒频权重:在计算加权平均之前,为每个类别维度乘上一个权重,权重跟类别样本量的倒数成正比。实现非常直接:
class_weight = len(y) / (len(self.classes) * np.bincount(...)) y_onehot = y_onehot * class_weight另一种是少数类过采样(比如SMOTE),但合成样本在GRNN里同样会作为高斯核中心参与预测,合成的样本可能引入噪声,即便在训练集上好看,真实场景里未必可靠。第三种是调整决策阈值,把预测概率的阈值从0.5改成根据约登指数计算的统计指标,求敏感度和特异度之和最大的那个阈值。这个方法不改变模型训练,只是改判定边界,对筛查场景特别有用。
4.3 特征泄漏:Scaler、特征选择勾连了“未来信息”
先解释一下什么是数据泄漏。简单说,你在预处理阶段用了未来才知道的信息,导致验证指标虚高。最常见的就是全量标准化:把训练集和测试集放在一起做fit_transform,标准化参数(均值和方差)包含了测试集的信息。这违背了“模型从未见过测试集”的前提。
另一个容易被忽略的泄漏点是特征选择。假如你根据全量数据的AUC来选择保留哪些特征,后面再做留一法验证,那每一折的测试样本其实已经影响了特征筛选择结果,验证分数当然偏高。正确做法是把特征选择也放进验证循环内部:每一折只用训练子集做相关性分析和特征筛选,再把选好的特征套到测试样本上。
我在这上面吃过亏。有一次特征选择放在循环外,留一法AUC跑到0.95,兴奋了半天。后来改成循环内筛选,AUC掉到0.88,这才是真实水平。之后我给自己定了一条铁的规矩:凡是用到全量数据的预处理,都必须拆到每一折里去重复执行,包括缺失值填补的统计量。
5. GRNN还能干预测的活:从硬分类到个体化风险评分
标题里“分类与预测”这两个词,GRNN都能覆盖。很多医学项目不需要一个“会不会得病”的二元判定,而是需要一个连续的“风险分值”,用来给患者分层、排随访优先级、评估干预前后的变化。GRNN的输出天然是一个连续值,这比树模型和SVM都顺畅。
5.1 概率输出直接转风险分层
我在某个慢病风险排序的项目里,直接把GRNN的预测概率当成风险评分使用。具体做法是:概率小于0.3视为低风险,0.3到0.7视为中风险需要随访观察,大于0.7视为高风险建议进一步检查。这三档分层的依据不是拍脑袋,而是根据预测概率分布的实际分位数和历史召回率校准的。
用概率做分层的额外好处是:同一患者的多次检测结果输进去,风险分数变化趋势本身就是一条时间序列。比如某位患者第一次预测概率0.41,三个月后0.55,再三个月后0.62,这种单调上升趋势比单纯的“中风险”标签更有临床提醒价值。GRNN对相近样本的预测结果也相近,输入特征细微变化时输出不会跳变,这一点是做时序趋势分析的前提。
5.2 用回归版本做连续指标预测
如果目标不是分类而是预测一个连续指标,比如预估糖化血红蛋白的降幅、预测某指标变化量,GRNN不需要做任何结构改动,直接把标签换成连续数值就行。相当于是同一个班子换了个输出维度。
有个需要注意的事:当预测目标是带有明显上下界的指标时,GRNN输出有可能超出合理范围,因为加权平均的结果会被训练样本的最小值和最大值包住,所以实际上不会超出太离谱,但可能卡在边界上不够灵敏。我有一个习惯做法:对回归目标做单调变换再训练,预测完再变换回去。例如目标值分布偏斜很重,就取log变换,让输出尽量落在均质尺度上。
5.3 跨中心数据适配的“冷启动”思路
不同医院、不同批次的检测仪器之间存在系统偏差,训练集来自A中心,部署到B中心效果打折,这几乎必然发生。GRNN在这种场景下有一个其它模型不好比的冷启动优势:把所有B中心的小样本(比如几十条)直接追加进训练集,网络结构跟着变化,不需要重新训练。因为GRNN的“训练”就是记忆,新的高斯核中心直接加入即可,不存在迭代收敛问题。
我在模拟跨中心评估里试过:原模型在B中心AUC只有0.72,追加30条B中心数据后AUC跳到0.85,追加80条后AUC稳定在0.88附近。这个曲线说明GRNN对“微量的本地语料”非常敏感。如果B中心只有十几条数据,我建议做一次特征尺度的重新标准化,因为不同中心的特征分布差异如果过大,欧氏距离的基线会被带偏。
6. 实操中的额外心得:GRNN和别的模型搭配着用更有效
单独用GRNN解决所有问题并不是最优解,实际项目里我通常把GRNN作为一个“成员”放进组合流程里,而不是当作唯一模型。这里有三种搭配方式。
第一种是模型集成的角度来看。GRNN的决策逻辑和树模型差异极大,一个基于样本距离加权,一个基于特征分裂规则。如果两者的预测错误相关性很低——这在医学数据上经常如此——把它们的结果做加权平均或者投票,往往能比任何一个单独的模型指标都要好。我在一次项目里试过GRNN加随机森林的软投票,AUC从两个模型各自的0.88和0.86提升到了0.91。
第二种方式是让GRNN做“哨兵”模型,负责小样本冷启动,快速给出基线结果。先用GRNN跑通全流程,确认数据质量和标签定义没有硬伤,再去尝试XGBoost、深度模型等更复杂的建模手段。这个思路能省掉很多无效调试时间,因为GRNN调参几乎不花时间,指标很快能告诉你“这组数据到底能不能建模”——如果GRNN的AUC都低于0.7,那大概率是数据自身问题,别急着调复杂模型。
第三种方式是用GRNN做异常值检测的辅助工具。GRNN会对“没见过”的样本给出接近全体均值的输出,反过来看,预测概率接近0.5而训练集本身类别比例并不均衡时,说明这个样本离所有训练样本都比较远,存在录入错误或特殊人群的可能性。这个“反常识”用法我在病历数据清洗里用过,能成功捡出不少人工复核才发现的错标样本。
多做几个项目之后你会发现,模型本身没有高低之分,只有场景匹配与否。GRNN不是万能的,它怕高维稀疏数据、怕超大样本量带来的预测延迟,但在几百到几千条样本的医学诊断表格数据里,它简单、稳定、好解释,还天然连续输出。这恰恰是很多“先进模型”在小数据场景下做不到的诚实表现。
最后分享一个我在实际操作里总结的小习惯:对GRNN做特征筛选时,不要只依赖数据统计,最好请懂医学的人从专业角度把关。统计上不显著的特征,联合作用可能很高;统计上显著的特征,也可能在实践中根本采不到。数据驱动和领域知识两套筛选标准交叉验证过的特征集,放进GRNN里的提升往往是实打实的。