去年我们组接了一个ICU患者数据建模的活儿,目标是根据入院头24小时的生理指标预测患者出院后的再入院风险。数据拉下来一看,大部分特征缺失率在15%到30%,其中几项护理评分甚至超过40%。一开始图省事,直接用均值把缺的口填了,交叉验证结果那叫一个飘:第一次跑AUC 0.78,第二次换随机种子直接掉到0.73,换掉几个脏样本又跳回0.76。怎么调模型都不对劲,后来才意识到问题根本不在模型,而在缺失值这一环。
后来我把缺失值处理换成了Scikit-learn的KNNImputer,思路很简单:用其他特征维度上与每个缺失样本最相似的K个样本,把它们的观测值拿来做加权平均,填充到缺失位置。就这么一个改动,模型AUC稳定在0.81上下,交叉验证的方差明显变小。这篇文章就把这套流程完整写下来:为什么医疗数据要插补而不是直接删,KNN插补的原理和三个关键参数,以及一套可以直接照抄的实操代码。
为了把问题说透,我用Scikit-learn自带的糖尿病数据集模拟一个带缺失的医疗场景,完整演示从数据清洗、标准化、KNN插补到分类预测的每一步,也把坑都标出来。无论你是做学术研究还是工业落地,这套方法都能直接用。
1. 为什么医疗数据绕不开“缺失值插补”
1.1 医疗数据的缺失不是随机的
医疗记录和电商、社交数据不太一样。患者入院后,各项检查、检验、护理评分是“按需开具”的,不是每项指标对每个患者都一定做。很多生理指标只有病情到一定程度才会额外检测,这直接导致缺失本身携带了病情信息。换句话说,ICU里某个患者如果没测某项指标,有时候不是“忘了记录”,而是医生判断当前场景下没必要测。
缺失机制一般分成三类:
- MCAR(完全随机缺失):缺失和任何观测/未观测变量都无关,比如设备临时故障导致部分记录丢失。
- MAR(随机缺失,但跟已观测变量有关):缺失倾向依赖于已观测到的特征,比如年龄更大的患者更容易漏填某项量表。
- MNAR(非随机缺失):缺失值和缺失值本身有关,比如某种检查结果太差,反而不被记录。
医疗数据里最多的情况是MAR,也可能是MCAR,但几乎不会是完全的MNAR。这一点很关键:MAR和MCAR都意味着我们可以用已有观测信息去估计缺失值,这也是KNN插补能成立的前提。如果数据是MNAR,光靠插补救不回来,那是另一个层面的问题,后文会专门讲。
1.2 直接删样本或均值填充,损失到底在哪
很多人处理缺失的第一步就是 dropna(),在缺失率不高、样本量很大的场景下确实可以;但医疗数据往往样本量稀缺,动辄几百条到几千条,删掉30%带缺失的记录,直接损失的是统计功效和模型对罕见类别患者的覆盖。更麻烦的是,如果缺失和病情严重程度有关,删掉这些样本等于把最难预测的高危病人全删了,模型在真实场景里就是“睁眼瞎”。
均值/中位数填充是另一个常见做法,成本低、代码短,但问题非常明显:填充值不会带来“样本特定”的信息。用列均值填出来的值,不管患者本身多特殊,都被拉到了人群平均水平。这样做首先会压低该特征的方差,导致线性模型和距离类模型估计不准确;其次会破坏特征之间的相关结构。举个简单例子:BMI和血压在真实数据里是相关的,均值填充后这种局部相关被稀释,后续模型学到的特征关系就是错的。
我在初版模型里用均值填充,交叉验证AUC波动特别大,后来查了很多特征重要性的变化,发现关键特征的交互作用完全乱掉了。换成KNN插补后,每个缺失样本的填充值不再是一个全校平均值,而是“跟这个患者最像的那批患者的实际测量值”,这样特征间的协同关系就被保留了下来。这也是KNN插补在预测任务里比均值填充稳的根本原因。
这里我放一个最简单的对比实验,用的是模拟数据,数值不算漂亮,但趋势很典型。
| 处理方式 | AUC(RF, 5折CV) | 5折标准差 |
|---|---|---|
| 均值填充 | 0.743 | 0.031 |
| 删除缺失样本 | 0.702 | 0.045 |
| KNN插补(k=5, weights='distance') | 0.812 | 0.016 |
KNN插补不仅拉高了平均分,还压低了方差。“稳”这个词说的就是这件事——做交叉验证、换训练集种子的时候,模型表现不会一会儿天堂一会儿地狱。当然,表格只是结果展示,原理要往下看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. KNN插补的核心原理:邻居平均怎么在医学场景里生效
2.1 核心思想是“相似样本给相似答案”
KNN最早是用于分类和回归的监督学习算法,KNN插补则借用了同一套直觉:如果两个病人的特征向量在特征空间中很接近,他们缺失的那个变量的观测值大概率也不远。
比如有两个患者,年龄都是60岁上下,BMI都在29左右,血糖、肌酐、白细胞数据也差不多,那么其中一个人缺失了“血压”这个字段,最合理的估计方法之一就是参考另一个人的血压。KNN插补就是把这种“参考相似病历”的逻辑量化:找到与目标样本在特征空间里最近的K个近邻,用它们的观测值去估计缺失值。
在Scikit-learn中,KNNImputer对带缺失的特征矩阵进行逐特征处理。它先对全局缺失值用列均值做初始化,再用一个支持NaN距离的nan_euclidean_distances计算样本间距离,迭代若干次不断修正填充值。不用自己去写近邻搜索,但理解这个机制有助于调参——尤其是为什么n_neighbors的选择会显著影响结果。
2.2 三个关键参数:K、距离度量、权重
首先是 K值怎么选。K太小,比如K=1,填充值完全受一个最近样本影响,噪声很大,尤其在样本量小、缺失率高的医疗数据里,很容易过拟合到一个偶然相似的离群患者;K太大,比如K=20,会把特征空间里本来相距很远的样本也拉进来,填充值像被平均了一样,逐渐退化成均值填充。我一般从n_neighbors=5起步,在样本量几千左右的数据上做10以内的网格搜索,用“掩码验证”评估不同K的填充误差,选误差最小的。
其次是 距离度量。KNNImputer默认使用带NaN处理的欧氏距离,在标准化后的连续特征上表现不错。这里必须强调:插补前一定要做标准化。如果不做,像血糖值(几十上百的尺度)会主导距离计算,而像心率(70到100的小尺度)几乎对距离没有贡献,这会导致近邻选择完全被大尺度特征绑架。对分类变量,欧氏距离并不好用,一个稳妥的做法是先把有顺序关系的有序分类变量做标签编码,把无序分类变量做独热编码再参与距离计算。但独热编码的维度膨胀会稀释连续特征的距离权重,所以如果分类变量很多,我会考虑在插补前先做数值化/目标编码,或者换用Gower距离的插补方案,不过Scikit-learn没有直接实现,需要自己写,后面问题排查里再说。
第三是 权重策略。weights可以选'uniform'或'distance'。默认是'uniform',只考虑K个邻居的算术平均;如果选'distance',邻居的贡献会按距离倒数加权,越近的样本权重越大。在医疗数据这种噪声大、个体差异显著的场景,我更倾向用weights='distance',它能让最相似的患者对缺失值有更大的话语权,实测对预测的提升更稳定。
还有一个容易被忽略的点:KNNImputer默认只用非缺失的特征计算距离。也就是说,如果一个样本缺失了三个特征,计算它与其他样本的距离时,只基于两个样本共同观测到的特征维度。这个设计的优势是能处理高缺失率数据,但代价是当很多特征都缺失时,距离计算只能基于少量重叠特征,近邻质量下降。所以实践中我会先移除缺失率超过一个阈值的列(比如60%以上),再进入插补。
2.3 KNN插补的完整代码实现
先看一个最基础的实现:
python复制import numpy as np
import pandas as pd
from sklearn.impute import KNNImputer
# 假设 data 是经过预处理的特征矩阵,X_missing 是带缺失的 DataFrame
imputer = KNNImputer(
n_neighbors=5,
weights='distance',
metric='nan_euclidean'
)
X_filled = imputer.fit_transform(X_missing)
X_filled = pd.DataFrame(X_filled, columns=X_missing.columns)
这一行代码就把插补做完了。但实际项目中我绝不会只跑这一行就完了,后面必须做两个验证:一是用掩码法评估插补误差,二是把不同插补方案放进同一个预测流程里比效果。这两个验证我在第三部分详细展开。
3. 完整实操:从带缺失的医疗数据到稳定预测
3.1 搭建实验:构造带缺失的医疗数据集
为了便于复现,我用Scikit-learn自带的糖尿病数据集(load_diabetes)来做演示。它原本是一个回归任务,包含442个样本、10个特征。我把它改成二分类任务:以血糖进展值是否高于中位数作为标签,模拟“高风险患者识别”的任务。然后人为制造缺失值,模拟医疗记录常见的缺失场景。
python复制from sklearn.datasets import load_diabetes
from sklearn.model_selection import train_test_split
import numpy as np
import pandas as pd
data = load_diabetes()
X = pd.DataFrame(data.data, columns=data.feature_names)
y = (data.target > np.median(data.target)).astype(int)
# 在约20%的位置随机置为NaN,模拟MCAR缺失
np.random.seed(42)
X_missing = X.copy()
mask = np.random.random(X.shape) < 0.2
X_missing[mask] = np.nan
这里我用的是MCAR场景。如果你用的是真实医疗数据,缺失模式大概率是MAR,但处理流程完全一样,只是掩码验证更接近真实情况。
3.2 训练测试拆分:先拆再补,绝不能先补再拆
这是整个流程里最容易翻车的环节。很多人先把整个数据集插补完,再去做训练测试拆分,这在工业界是大忌。原因很简单:插补过程会用到整个数据集的信息,包括测试集。假设你用全体数据的均值填充了某个缺失值,那测试集的信息已经悄悄混进了训练集,交叉验证的得分会虚高,上线后一推真实数据就崩了。
正确顺序是先 train_test_split,再在训练集上进行标准化、插补步骤,并且把标准化器和插补器都fit下来,后面transform测试集时直接复用。注意:测试集的缺失值也要插补,但插补器只能在训练集上fit,不能接触测试集。
python复制X_train_raw, X_test_raw, y_train, y_test = train_test_split(
X_missing, y, test_size=0.2, random_state=42, stratify=y
)
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train_raw)
X_test_scaled = scaler.transform(X_test_raw)
3.3 三种插补方案对比:均值、删除、KNN
我在训练集上分别做三种处理,然后把同样的处理逻辑作用到测试集上,再统一丢给同一个随机森林模型做五折交叉验证。
python复制from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
from sklearn.impute import SimpleImputer, KNNImputer
# 方案1:均值填充
mean_imp = SimpleImputer(strategy='mean')
X_train_mean = mean_imp.fit_transform(X_train_scaled)
X_test_mean = mean_imp.transform(X_test_scaled)
# 方案2:KNN插补
knn_imp = KNNImputer(n_neighbors=5, weights='distance')
X_train_knn = knn_imp.fit_transform(X_train_scaled)
X_test_knn = knn_imp.transform(X_test_scaled)
# 方案3:删除缺失行,删完样本量会下降
keep_idx = ~np.isnan(X_train_scaled).any(axis=1)
train_drop = X_train_scaled[keep_idx]
y_drop = y_train[keep_idx]
然后是统一的模型评估函数:
python复制def evaluate(X, y, name):
rf = RandomForestClassifier(n_estimators=200, random_state=42)
scores = cross_val_score(rf, X, y, cv=5, scoring='roc_auc')
print(f"{name}: AUC={scores.mean():.4f} ± {scores.std():.4f}")
return scores
evaluate(X_train_mean, y_train, "Mean Imputation")
evaluate(X_train_knn, y_train, "KNN Imputation")
evaluate(train_drop, y_drop, "Drop Missing")
在我的模拟数据上,结果大致是:
- Mean Imputation: AUC=0.7894 ± 0.0331
- KNN Imputation: AUC=0.8127 ± 0.0156
- Drop Missing: AUC=0.7752 ± 0.0421
KNN插补的效果不一定总是比均值填充高出几个点,尤其在缺失率不高、特征线性关系不强的数据上,两者可能差别不大。但注意那个标准差:KNN的AUC方差明显更小。在真实项目里,模型稳定性往往比几个百分点的提升更重要,因为业务方难以接受同一天上线、下午模型分数却比上午摇摆不定的情况。
3.4 掩码验证:怎么单独评估插补质量
除了用下游模型AUC来间接评估插补,还有一种更直接的验证方式:把原本有取值的位置故意遮掉,让插补器填回来,然后计算真实值与填充值之间的误差。
python复制from sklearn.metrics import mean_squared_error
def mask_evaluate(imputer, X_full):
X_masked = X_full.copy()
mask = np.random.random(X_full.shape) < 0.3
X_masked[mask] = np.nan
X_filled = imputer.fit_transform(X_masked)
# 只比较被遮掉的位置
err = mean_squared_error(
X_full[mask], X_filled[mask]
)
return err
注意这里的X_full是标准化之后、无缺失的训练集。通过比较不同K值、不同权重策略的RMSE,我就能在建模前确定最合适的插补参数。虽然这种掩码验证算出来的“插补误差”和最终预测指标并不完全等价,但它能帮我快速筛选出明显不合理的配置,省去大量盲目的网格搜索。
4. 常见问题与排查技巧实录
4.1 插补完效果不升反降,先查这四件事
我踩过的坑排个序,第一个是 没做标准化就插补。第二个是 测试集信息泄漏,就是前面说的先补后拆。第三个是 K取值不合理。第四个是 分类变量直接参与了欧氏距离计算,尤其是用数值编码代表无序类别,比如“民族”“科室”这种编码,距离值完全没意义。
如果发现KNN插补后AUC反而差了几个百分点,按这个顺序逐步排查。多数情况下是前两点的问题,代码改对了,效果就回来了。
4.2 数据量大、特征多,KNN插补卡到怀疑人生
KNNImputer的计算复杂度是 O(n² × d) 级别,当样本量到5万、10万以上,或者特征维度几百上千时,速度确实会很难看。遇到这种情况,我一般分三步优化:
- 第一步,优先删除缺失率超过50%或方差接近0的特征列,减少维度。
- 第二步,如果样本数还是太大,用
NearestNeighbors配合algorithm='ball_tree'或'kd_tree',虽然KNNImputer的metric参数不能直接传kd_tree,但可以自己写一个“用ball_tree构造邻居再填充”的流程。 - 第三步,实在不行,采用分层抽样:按标签分层抽出一部分样本来训练插补器,再用这个插补器对整个数据集做transform。
实际项目里,我更常用第二步的变体:单独用KNNImputer在训练集上fit,然后对全量数据transform。fit_transform和transform内部虽然都做迭代,但是fit完成后直接复用,能省下重复搜索邻居的时间。
4.3 缺失率太高,KNN插补镇不住
当某些特征的缺失率超过60%时,KNN能依赖的已知维度太少,近邻质量急剧下降。我习惯的做法是先做一层“列筛选”,把缺失率过高的列直接拿掉,而不是强行插补。毕竟在预测任务里,一个90%缺失的字段基本上就是噪声,保留它只会让距离计算更不可控。
如果出于业务需求必须保留某个高缺失字段,那就要考虑单独为该字段建立一个“是否缺失”的二值特征,或者用更复杂的多重插补(MICE)方案。KNN插补不是万能的,它在MAR、缺失率适中的场景最合适。
4.4 分类变量到底怎么进KNN
这是所有KNN插补教程里最模糊的部分。分类变量分两种:
- 有序分类(如疾病严重程度1~5级):用标签编码是合理的,等级关系本身有距离含义。
- 无序分类(如血型、科室):标签编码会引入假的顺序,必须用独热编码。
但独热编码会让高基数类别特征膨胀,比如“科室”有20个取值,会一口气变成20列。这些列的距离权重跟连续特征一样参与计算,如果科目数多,近邻选择会被类别属性主导。我的经验是:插补阶段只对连续变量和有序分类变量做KNN,无序分类变量单独用众数填充,或者用单独训练的模型预测缺失值。等插补完成后,再把无序分类变量加密回来(独热编码/目标编码),喂给下游模型。
下面是一个更稳妥的混合插补流程示意:
python复制# 1. 连续变量 + 有序分类变量 -> KNN插补
cont_cols = ['age', 'bmi', 'bp', 's1', 's2', 's3', 's4', 's5', 's6']
knn_cols = X_train_scaled[cont_cols]
knn_imp2 = KNNImputer(n_neighbors=5, weights='distance')
X_train_cont_filled = knn_imp2.fit_transform(knn_cols)
# 2. 无序分类变量 -> 单独众数填充
from sklearn.impute import SimpleImputer
mode_imp = SimpleImputer(strategy='most_frequent')
X_train_cat_filled = mode_imp.fit_transform(cat_cols)
# 3. 拼接后进入下游模型
5. 从KNN插补往后走:什么时候该换MICE或深度学习插补
5.1 多重插补MICE和它的适用范围
KNN插补不是终点。它的优点是直观、好解释、开箱即用;局限是它假设“相似样本的缺失值可以用邻居估计”,但遇到非线性的复杂相关结构时,可能需要更灵活的方法。
多重插补(MICE)会为每个带缺失的变量训练一个条件模型,用其他变量去预测缺失值,反复迭代。Scikit-learn里没有现成的MICE,但IterativeImputer提供了类似机制(注意它默认是实验性质)。从效果上看,当变量间存在强交互、且缺失模式复杂时,MICE往往比KNN更准,但速度更慢,参数更多,也更难调。
5.2 从KNN插补出发的完整策略建议
深度学习方向的插补(比如GAIN、基于自编码器的补全)近年越来越流行,在图像、高维稀疏场景里表现很强,但对医疗表格数据来说,经常是杀鸡用牛刀:数据量小、训练不稳定、可解释性差。如果想商用落地,我建议先跑KNN,再跑MICE,两个结果一起进下游模型比对,选AUC更高、且方差更小的方案。不要一上来就上深度模型。
时间序列医疗数据(比如连续监测的生理指标)要另当别论:这种数据更适合用时间窗口、前后值插补,或者直接交给TCN/LSTM这类时序网络处理。KNN插补在时序数据上并不是最优解,如果没有按时间戳做邻居限制,很容易把未来信息泄漏进历史填充值。
做项目这两年,我最大的体会是:模型调参再怎么精心设计,都抵不过数据入口这一步的粗心。缺失值处理是数据管线里最容易低估的环节,但它直接决定了下游模型的鲁棒性。KNN插补在医疗数据上给我最深的印象不是“能提几个点的AUC”,而是它让交叉验证的方差明显收敛,模型上线之后的表现不再像开盲盒。
最后再分享一个小技巧:线上部署时,一定要把标准化器和KNNImputer用joblib或pickle一起保存下来,新样本进来先走同一个标准化、同一个插补器,再进模型。很多团队只保存了模型文件,结果新数据在格式、缺失模式上稍有偏差,模型预测就跑了偏。如果这篇内容对你手头的数据有了启发,可以先用掩码验证快速评估一下KNN插补值不值得换,再决定要不要深入调参。
