做医疗数据预测的时候,我遇到最多、也最容易被忽视的一个问题,不是算法选得不够高级,而是数据里的缺失值根本没处理好。有一回我接手一份用于疾病风险预测的临床数据,样本量不算小,但一查缺失率,血糖、胰岛素、血压这几个关键字段的缺失比例都在三成上下。当时组里的第一反应是做均值填充,理由是简单、不引入额外方差。结果模型跑出来,交叉验证的AUC勉强到0.7,特征重要性排第一的居然是缺失比例最高的那个指标——不是因为它真的强,而是因为填充方式把分布打乱了,模型学会了"这个字段等于均值"这种假规律。
后来把填充方案换成Scikit-learn的KNNImputer,同样的模型配置、同样的评估流程,AUC直接往上走了一截,而且更关键的是,多次重复实验的结果稳定了很多。这篇文章就把我在这类项目里的完整思路、代码实现和踩过的坑整理出来,给正在处理医疗数据、做预测建模的朋友一个可直接参考的路线。
1. 医疗数据里的缺失值,为什么不能小看
1.1 临床数据缺失的三个来源和两种类型
医疗数据缺失往往不是随机的,这是我做这类项目最深的体会。主要来源大致能分成三类:
- 检测设备或流程限制导致的数据未产生,比如某些昂贵的检查不是所有患者都做,这就会让某个特征成片缺失。
- 记录环节的疏漏,像电子病历录入不完整,某些字段在部分患者上就是空的。
- 患者自身状态的客观限制,比如急诊入院的患者来不及做全部检查,后续也未必补全。
从统计学角度看,缺失机制可以分成MCAR(完全随机缺失)、MAR(随机缺失,缺失概率与其他观测变量相关)和MNAR(非随机缺失,缺失概率与缺失值本身相关)。临床数据里真正MCAR的情况很少,更多是MAR甚至MNAR。举个例子,老年患者做某项侵入性检查的比例偏低,这个缺失就和年龄相关,属于MAR;如果某个检验结果本身就是异常低,低到设备测不出来就没有记录,那这就是MNAR。
处理这两类缺失,如果直接用"删除含缺失的样本"或者"填一个全局均值",后果是截然不同的。删除会让样本偏向缺失较少的群体,产生选择偏倚;填充均值会压缩这个字段的方差,削弱它和其他变量的真实相关性。
1.2 一个反面案例:均值填充把预测模型的结论带偏了
用之前提到的疾病风险预测数据来说,原始数据里有glucose和insulin两个字段,生理逻辑上二者是强相关的——胰岛素调控血糖,而血糖异常又会反馈到胰岛素分泌。可是填了均值之后,这两个字段的相关性被明显拉低了。
我做了个简单对比:把同一份数据分别用"均值填充"和"保留缺失标识 + KNN插补"处理,然后用同样的随机森林去训练,观察特征重要性排序。结果均值填充那一版把glucose的重要性顶到了第一位,而KNN插补那版里glucose和insulin的排序更贴近临床上已知的判断。原因不复杂:均值填充把这个维度上最有区分度的个体差异抹掉了,模型只能靠"这个样本是不是被填充过"来区别样本,但它看到的特征是失真的。
这就是为什么我一再强调,填充方案不是数据预处理里可以随便选的小环节,它会直接影响预测模型学到的规律是否真实。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. KNN插补的原理和它在医疗场景里的独特优势
2.1 "物以类聚":KNN插补到底在做什么
KNN插补的思路可以用一句话概括:找一个样本最相近的K个样本,用它们在目标字段上的值来估计这个样本的缺失值。这里"相近"的定义依赖距离度量,最常用的就是欧氏距离的变种nan_euclidean,也就是在计算距离时跳过缺失的维度。
比如某个样本在glucose上是空的,但它的年龄、BMI、血压这些字段和其他几个样本非常接近,那么算法就认为这些样本在glucose上的取值,就是这个缺失样本最合理的参考。
距离计算公式:
code复制d(x, y) = sqrt( (可同时观测的维度数量 / 总维度数量) × Σ(可同时观测维度上的平方差) )
这个公式里有一个权重系数(可同时观测维度数 / 总维度数),它的作用是:如果两个样本能共同观测的维度很少,那么它们距离的置信度就低,距离会被放大。
2.2 为什么说KNN插补比均值填充更适合临床数据
均值填充、中位数填充本质上是"用一个统计量替代一个值",它完全没有考虑这个样本在其他指标上的特征。但临床数据有一个很明显的特点:各指标之间往往存在生理上的协同关系。比如BMI和血压、血糖和胰岛素、年龄和某些生化指标之间,都有内在联系。
KNN插补保留了这个协同关系。它等于是在说:既然这个样本和那K个样本在很多维度上都非常相似,那么它在缺失维度上的值也应该参考这些相似样本。这比全局填一个均值要合理得多。
MICE(链式方程多重插补)这类方法也很强大,但它的计算成本明显更高,而且需要为每个含缺失的变量单独设定一个模型,调参量大、收敛判断也比较复杂。KNN插补是"一个算法解决所有字段",实现成本低,效果在中小规模数据集上通常很接近MICE,这是我在实际项目里选择它的核心原因。
2.3 KNN和KMeans别搞混
做插补的时候,经常被问到:KNN和KMeans有什么关系?两者名字里都有K,但职责完全不同。KNN是监督学习里的分类/回归/插补方法,它靠样本之间的相似度来给新样本"投票";KMeans是无监督聚类,它把样本划分成K个簇,迭代更新簇中心,目的是发现数据内在的分组结构。放到插补任务里,我们用的是KNN的思路,不是KMeans。
3. 基于Scikit-learn的KNN插补完整实现
3.1 环境和数据准备
我用的是scikit-learn的sklearn.impute.KNNImputer,建议scikit-learn版本在1.0以上,老版本的API和参数行为会有差异。数据方面,这里用一个结构类似公开糖尿病风险数据的示例来说明,包含了age、bmi、blood_pressure、glucose、insulin、diabetes_pedigree等字段,目标变量是是否有糖尿病。
bash复制pip install scikit-learn pandas numpy matplotlib seaborn
读取数据后,第一步永远不是急着插补,而是先搞清楚缺失的分布情况:
python复制import pandas as pd
import numpy as np
df = pd.read_csv('medical_data.csv')
missing_ratio = df.isnull().mean().sort_values(ascending=False)
print(missing_ratio[missing_ratio > 0])
这一步输出的结果直接决定后续策略。如果一个字段缺失超过50%,你就要认真考虑:这个字段是保留还是丢弃?如果缺失集中在某些样本上,这些样本本身可能就来自一个特殊亚群,删除它们反而会损失信息。
3.2 KNNImputer的基本用法
最基础的调用方式非常简单:
python复制from sklearn.impute import KNNImputer
imputer = KNNImputer(n_neighbors=5, weights='distance', metric='nan_euclidean')
df_imputed = pd.DataFrame(
imputer.fit_transform(df[feature_columns]),
columns=feature_columns
)
n_neighbors=5是经验默认值,但不是所有场景的最优值,这个后面单独讲参数调优。weights='distance'意味着越近的样本对填充值的贡献越大,这一点在样本分布不均的医疗数据里尤其重要,因为远处的相似样本很可能来自不同亚群。
metric='nan_euclidean'是KNNImputer目前实际可用的距离度量,它专门处理NaN值,在计算距离时自动跳过缺失维度并做缩放处理。有些教程会写可以传'euclidean'或其他距离,但在当前版本的实现里,真正稳定支持的就是nan_euclidean,自定义距离函数的写法比较绕,不推荐在项目里为了"花活"去折腾。
3.3 插补前必须做的数据缩放
这是我踩过最深的坑之一。KNNImputer的原理依赖距离,距离计算对量纲极其敏感。医疗数据里有的字段是年龄(30到80),有的是血小板计数(100到300),有的是某种指标(0.1到0.9)。如果不做缩放,那些数值范围大的字段会在距离计算里占据绝对主导权,数值范围小的字段形同虚设。
清晰的做法是先标准化再插补:
python复制from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
df_scaled = pd.DataFrame(
scaler.fit_transform(df[feature_columns]),
columns=feature_columns
)
imputer = KNNImputer(n_neighbors=5, weights='distance')
df_scaled_imputed = pd.DataFrame(
imputer.fit_transform(df_scaled),
columns=feature_columns
)
# 如果需要恢复原始尺度
df_imputed = pd.DataFrame(
scaler.inverse_transform(df_scaled_imputed),
columns=feature_columns
)
这里请注意:fit_transform里的fit是在整个数据集上拟合的。如果你后面要做模型评估,这一步放在交叉验证里要格外小心,不能提前在全量数据上缩放和插补,否则会有信息泄漏。关于这个,后面专门讲。
3.4 插补效果怎么验证:分布对比和相关性恢复
插补完不是直接扔进模型就完了,你得先验证插补出来的值"像不像真的"。我通常看两点:
第一,看插补前后字段的分布变化。用直方图或核密度图对比原始非缺失值和插补值的分布,如果插补值的分布和原始值的分布明显脱节,说明模型或参数有问题。
python复制import matplotlib.pyplot as plt
import seaborn as sns
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
sns.histplot(df['glucose'].dropna(), kde=True, ax=axes[0], color='steelblue')
axes[0].set_title('Original non-missing glucose')
sns.histplot(df_imputed['glucose'], kde=True, ax=axes[1], color='darkorange')
axes[1].set_title('After KNN imputation')
plt.show()
第二,看字段之间的相关性是否被保留。医学上已知glucose和insulin有相关性,插补之后这个相关系数不应该显著偏离原始数据中非缺失部分的相关系数。如果原来相关系数是0.6,插补后变成0.2,那说明插补值没有学到变量间的协同结构。
这里说的"原始非缺失部分的相关系数"本身也有偏差,因为你只能基于同时观测到的样本来算,但它至少是个可以对比的参考值。
4. n_neighbors、weights、metric:三个决定插补质量的参数
4.1 n_neighbors选多少:经验值和搜索策略
n_neighbors太小,插补值容易受个别异常样本干扰;太大,又会把远处不太相似的样本拉进来,插补结果趋于平滑,失去局部特异性。医疗数据里样本量少、噪声大的情况很常见,所以这个参数值得认真调。
我的经验是:数据集在几千条级别时,从3到10之间做网格搜索;如果样本量只有几百,n_neighbors超过10基本就开始明显劣化了。
网格搜索可以结合下游建模效果来调,用Pipeline把插补、缩放、分类器串起来,然后用交叉验证选最优组合:
python复制from sklearn.pipeline import Pipeline
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV, StratifiedKFold
pipeline = Pipeline([
('scaler', StandardScaler()),
('imputer', KNNImputer()),
('clf', RandomForestClassifier(random_state=42))
])
param_grid = {
'imputer__n_neighbors': [3, 5, 7, 10],
'imputer__weights': ['uniform', 'distance'],
'clf__n_estimators': [100, 200]
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
grid = GridSearchCV(pipeline, param_grid, cv=cv, scoring='roc_auc', n_jobs=-1)
grid.fit(X, y)
print(grid.best_params_)
print(grid.best_score_)
注意这里的X是原始含缺失的数据框,y是目标变量。整个Pipeline保证了缩放、插补都在每一折的训练集上学习,测试集只做transform,这样评估出来的分数才可信。
4.2 weights参数:uniform和distance怎么选
weights='uniform'表示K个邻居权重相同,weights='distance'表示距离越近权重越大。
医疗数据里样本异质性普遍比较大,我倾向于默认用distance,因为它能让最相似的样本对填充值有更大话语权。如果数据整体分布比较均匀、噪声较小,uniform也能用,而且计算量略小,但差异往往微乎其微。真正值得关注的是:用了distance之后,如果某个缺失样本的K个近邻里有极端异常值,异常值即使距离略远也会因为距离权重而被拉高影响,所以最好在插补前做一次异常值筛查。
在代码里做权重验证的方式也很简单:插补完成后,按missing前后均值和标准差对比,如果插补后标准差被明显压缩,而均值没有明显变化,说明近邻样本太集中,可以试试减小K或者换uniform。
4.3 metric参数为什么基本只用nan_euclidean
metric='nan_euclidean'是KNNImputer对缺失数据支持最成熟的距离度量,它在距离计算时会自动忽略缺失维度,同时对维度数量做惩罚校正。自定义距离函数在技术上可行,但实际项目里容易遇到:
- 自定义函数需要自行处理NaN,很容易写出bug。
- 不同的距离函数对量纲和数据分布的假设不同,你需要在验证集上一一对比,成本很高。
- 医疗数据字段类型复杂,有连续值、分类值、偏态分布,单一的"更好的距离公式"很难一劳永逸。
所以我的建议是:老老实实用nan_euclidean。真正的优化重心应该放在特征工程和数据缩放上,而不是在距离度量上搞创新。
4.4 分类特征怎么处理
KNN插补对连续特征效果很好,但临床数据里经常有分类特征,比如性别、血型、疾病分期。直接用0/1编码扔进KNNImputer,会导致距离计算把这些分类值当成有序连续值处理,语义上会失真。
稳妥的做法是:连续字段用KNN插补,分类字段用"众数填充"或单独的"分类插补模型"来处理,最后再合并。如果你非要把分类特征包含在一个框架里,可以试试OrdinalEncoder给类别编码,但插补出来的结果可能是小数,需要再四舍五入到最近的整数类别。这个办法虽然能用,但精度会受损,我对它的评价是"应急可以,别当常规方案"。
5. 稳定性验证:插补方案不能只跑一次就下结论
5.1 为什么KNN插补结果会有波动
KNNImputer本身是确定性的——同样的输入、同样的参数、同样的K个近邻,结果是完全一样的。那"波动"从哪来?
一个来源是下游模型对特征微小变化的敏感性。如果你在插补前对数据做了随机扰动(比如某些验证场景需要模拟测量误差),或者你在换训练集/测试集划分,KNN选到的近邻会变化,插补值就会波动。另一个来源是n_neighbors和weights的不同组合,在数据分布有偏时会产生不一样的插补结果,进而影响模型评估。
所以做医疗数据预测,不能只看一次交叉验证的AUC,至少要做多次重复实验,观察均值和标准差。
5.2 一套可操作的稳定性评估流程
我的标准做法是:
用StratifiedKFold做外层5折重复验证,每一折内部再做一次插补。重复整个流程10次,每次用不同的随机种子,记录AUC、准确率等指标的分布。
python复制from sklearn.model_selection import RepeatedStratifiedKFold
from sklearn.metrics import roc_auc_score
import numpy as np
cv = RepeatedStratifiedKFold(n_splits=5, n_repeats=10, random_state=42)
auc_scores = []
for train_idx, test_idx in cv.split(X, y):
X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
imputer = KNNImputer(n_neighbors=grid.best_params_['imputer__n_neighbors'],
weights=grid.best_params_['imputer__weights'])
X_train_imp = imputer.fit_transform(X_train_scaled)
X_test_imp = imputer.transform(X_test_scaled)
clf = RandomForestClassifier(n_estimators=200, random_state=42)
clf.fit(X_train_imp, y_train)
y_pred_proba = clf.predict_proba(X_test_imp)[:, 1]
auc_scores.append(roc_auc_score(y_test, y_pred_proba))
print(f'AUC mean: {np.mean(auc_scores):.4f} ± {np.std(auc_scores):.4f}')
这里一个容易被忽略的细节是:imputer.transform(X_test_imp)并不是"完全没有信息泄漏"。因为transform在为新样本插补时,仍会利用新样本和其他样本之间的距离关系,严格意义上它还是看到了测试集内部的结构。要完全避免这种泄漏,需要复杂得多的嵌套验证设计。实际项目里,我更看重的是"模型上线后在真实新数据上的表现"而不是"理论上的无泄漏上限",所以这种常规Pipeline的做法是可以接受的,只要你心里清楚它的边界。
5.3 不同插补方法的效果对照
我同一份数据上做过四种处理方式的对比:删除缺失行、均值填充、KNN插补(K=5,distance权重)、MICE插补。下游模型都用同一个随机森林,5折交叉验证重复10次,结果如下:
| 处理方法 | AUC均值 | AUC标准差 |
|---|---|---|
| 删除缺失行 | 0.687 | 0.034 |
| 均值填充 | 0.713 | 0.028 |
| KNN插补 (K=5) | 0.752 | 0.019 |
| MICE插补 | 0.758 | 0.021 |
从这个表能看到两点:第一,KNN插补比均值填充效果更好,而且标准差更小,这就是题目里说的"稳";第二,KNN和MICE的差距其实不大,但KNN的实现和调参成本低得多,所以实际项目中我优先用KNN。
6. 实操中容易踩的坑:高维、特征泄漏和运行效率
6.1 高维数据下KNN插补的失效风险
KNN的方法在低维空间里很直观,但维度一旦上去,距离的区分度会急剧下降。比如特征有50个维度时,任意两个样本之间的欧氏距离都会趋向于一个比较接近的值,近邻的"近"就失去了意义。这就是常说的维数灾难。
医疗数据经常会碰到高维场景,比如基因表达谱、影像组学特征。这时候KNN插补的效果往往不如专门的矩阵补全方法或模型插补。我的经验是:特征维度超过30到50时,先做特征选择或者PCA降维,再在降维后的空间里做KNN插补,然后把插补结果映射回原始空间。
另一种思路是添加is_missing指示特征,把"这个字段原本是否缺失"作为额外的特征喂给下游模型。这个方法在临床场景里很有用,因为缺失本身可能就携带信息——比如某项检查只在重症患者身上做,那缺失就意味着患者可能没有做这项检查,这本身就能提示疾病严重程度。
6.2 先插补再划分数据会让模型虚高
这是新手最容易踩的坑,危害也最大。如果你先在全量数据集上做标准化和KNN插补,然后再划分训练集和测试集,测试集的信息已经在插补阶段被模型看到了。这意味着测试集不再"纯洁",后面的评估指标会虚高,而且虚高多少还不确定,你根本没法判断模型真实水平。
正确做法是:先把数据切成训练集和测试集,然后让插补器只在训练集上fit,再分别transform训练集和测试集。在交叉验证里也是一样,插补器要在每一折的训练部分上fit,而不要在折叠之前对整个数据集做插补。
前面的Pipeline代码展示的就是这个正确流程,这也是为什么我要专门强调Pipeline而不是单独插补的原因。
6.3 运行效率问题和替代方案
KNNImputer的时间复杂度是O(n²),因为它要计算每个样本和所有其他样本的距离。当数据量达到几十万行的时候,插补过程会变得非常慢,甚至比后面训练模型还慢。
这种情况有几种处理思路:
- 对于超大样本量数据集,先随机抽样一部分来计算近邻关系,再去插补剩余数据。生产中我用过这个方案,效果可以接受,前提是抽样有代表性。
- 用
n_neighbors较小时可以借助BallTree或KDTree来加速,但KNNImputer内部是否自动应用这些加速结构取决于实现和输入数据格式,所以大数据量下性能仍然需要实测。 - 如果数据量大到KNN不可行,退路就是
SimpleImputer或者IterativeImputer。IterativeImputer作为MICE算法的sklearn实现,效果比KNN好、对数据量更友好,但计算时间更长,需要为每个变量建立模型,调参也更复杂。我通常在数据量>10万行时才会认真考虑它。
6.4 缺失值保留还是删除,取决于业务目标
最后想聊一个容易忽略但很重要的问题:不是所有缺失值都该被填充。有些情况下,缺失本身就是业务信号。比如一个患者没有做某项检查,可能是因为症状轻微、不需要做,也可能是因为病情太重、来不及做。这两种情况对应的预测结果完全不同。
所以我在处理临床数据时会先建立一个"缺失模式"分析,把样本按缺失模式分组,再去看这些组的目标变量分布是否有显著差异。如果答案是"有",我会考虑在特征里加入缺失标志,让模型自己学这部分规律。
这一套流程走完,你得到的不仅仅是一个"用KNN填充了缺失值的预测模型",而是对整个数据生成过程有了更深的理解。KNN插补是工具,但工具放在什么样的流程里用,才是真正决定项目成败的关键。
我个人在实际项目里的体会是:不要追求插补方法"复不复杂",要看它能不能和你的业务逻辑自洽。KNN插补在医疗场景里的价值,恰恰就在于它用最简单的"物以类聚"逻辑,保留了临床指标之间天然的协同关系。先把这一步做扎实,后面的预测模型才能真正站在一个稳定的地基上。
