☰
KNN填充缺失值实战:原理、调参与避坑指南
2026/10/16 7:09:47 网站建设 项目流程

1. 为什么缺失值处理值得单独拎出来讲

做过数据清洗的人都有一个共识:缺失值处理是整个机器学习流水线里最脏、最烦、但最不能跳过的一环。你拿到的原始数据,不管是传感器采集的、用户填写的、还是从多个系统拼接过来的,几乎不可能干干净净。行业里有个粗略的统计口径,真实业务数据集中含有缺失值的特征列通常占到六成以上,差别只是缺失比例高低而已。

常见的处理套路无非几种:直接删行、均值/中位数填充、前向/后向填充、用模型预测填充。前几种方法胜在快,但代价是要么丢信息,要么引入偏差。比如你有一列“月收入”,缺失的那部分人往往恰好是高收入或低收入群体,你用全体均值去填,等于人为把分布往中间挤压,后续模型学到的关系就被污染了。

KNN 填充(K-Nearest Neighbors Imputation)的思路不一样:它不假设全局分布,而是为每一个缺失样本单独找它最相似的 K 个邻居,用邻居的已知值来推断这个缺失值。这背后的直觉非常朴素——和你条件相近的人,他的取值对你更有参考价值。一个 25 岁、工作 3 年、月消费 4000 的用户,他的收入缺失了,你拿全体均值填,不如拿和他年龄、工龄、消费都接近的那几个人的收入来填靠谱。

这篇文章我会把 KNN 填充从原理到落地完整走一遍:它到底怎么算距离、K 怎么选、权重怎么设、和常见的KNNImputer怎么配合使用、有哪些坑我实际踩过。适合已经会用 pandas 做基础清洗、想进一步提升填充质量的人,也适合刚接触缺失值处理、想搞明白“为什么不用均值”的新手。全文基于我自己的实操经验,代码可以直接抄去改。

2. KNN 填充的核心原理与方案选型

2.1 从“找邻居”说起:KNN 到底在算什么

KNN 本身是个分类/回归算法,填充只是它的一个变体用法。核心动作只有一个:算距离。对于每一个含有缺失值的样本,算法会拿它和其他所有“在该特征上不缺失”的样本逐一比较,算出距离,挑出最近的 K 个,然后把这 K 个样本在该特征上的值聚合起来(均值或加权均值),填进去。

这里有个关键细节很多人第一次会懵:算距离的时候,缺失的那个特征本身是不参与计算的。也就是说,如果样本 A 的“收入”缺失,那么 A 和样本 B 之间的距离,是用除“收入”以外的其他特征算出来的。否则你没法算——缺失值没法参与欧氏距离。

用公式表达,对于两个样本 x 和 y,在特征子集 S(S 是不含缺失特征的那些列)上的欧氏距离是:

d(x, y) = sqrt( sum_{j in S} (x_j - y_j)^2 )

举个具体例子。假设有三个特征:年龄、工龄、月消费,现在要填“月收入”。

样本年龄工龄月消费月收入
A(待填)2534000?
B26342008000
C45201500030000
D24238007500

算 A 和 B 的距离(只用年龄、工龄、月消费):sqrt((25-26)² + (3-3)² + (4000-4200)²)。注意这里月消费的量纲是千级,年龄是十级,直接算的话月消费会完全主导距离,年龄工龄几乎不起作用。这就是为什么标准化是 KNN 填充的前置必做步骤,后面会专门讲。

2.2 为什么选 KNN 而不是均值填充

我把几种常见填充方式拉个表对比,你就明白 KNN 的定位了。

填充方式原理优点缺点适用场景
删除行直接丢掉含缺失的样本简单、无偏(如果完全随机缺失)样本量骤减,可能丢关键信息缺失比例极低(<1%)
均值/中位数用全局统计量填极快压缩方差,破坏特征间关系快速原型、缺失极少
前向/后向填充用相邻记录填适合时间序列只对有序数据有意义时序数据
模型预测(回归)用其他特征训练模型预测精度高慢,需额外建模特征关系强、数据量大
KNN 填充用相似样本的已知值填保留局部结构,无需训练计算量大,对量纲敏感中小规模、特征间有关联

KNN 填充最大的价值在于它保留了样本之间的局部相似性结构。均值填充是把所有人拉平,KNN 填充是“物以类聚”。当你后续要做聚类、分类,或者特征之间存在明显相关性时,这个差别会直接体现在模型效果上。

但 KNN 也有它明确的短板,我必须提前说清楚:计算复杂度是 O(n²) 级别。每个缺失样本都要和所有非缺失样本算一遍距离。如果你的数据有几十万行、缺失又分散在很多列,纯 KNN 填充会慢到让你怀疑人生。所以它更适合中小规模数据集(几万行以内),或者缺失集中在少数几列的情况。

2.3 距离度量怎么选:欧氏、曼哈顿还是别的

默认用欧氏距离,但不是所有场景都合适。

  • 欧氏距离:最常用,适合连续特征、量纲统一后的场景。
  • 曼哈顿距离:对异常值更鲁棒,因为它是绝对值求和,不会像平方那样放大离群点的影响。
  • 余弦距离:适合高维稀疏数据(比如文本向量),关注方向而非大小。
  • 马氏距离:考虑了特征间的协方差,理论上更合理,但计算成本高,且需要估计协方差矩阵。

我的经验是:绝大多数表格数据用欧氏距离就够了,前提是做好标准化。如果你的数据里异常值特别多,换成曼哈顿距离往往能稳一些。至于余弦距离,除非你在处理文本或推荐系统的 embedding,否则不用考虑。

2.4 K 值的选择逻辑

K 是邻居数量,直接决定填充结果的平滑程度。

  • K 太小(比如 1):完全依赖最近的那一个样本,对噪声极其敏感。如果那个邻居恰好是个异常点,你的填充值就废了。
  • K 太大:邻居范围扩得太大,等于变相在做全局平均,失去了“局部相似”的意义,退化成均值填充。
  • 经验区间:通常取 3 到 10 之间。数据量大、噪声多,往大了取(7~10);数据量小、特征区分度高,往小了取(3~5)。

我一般会先用 K=5 跑一版,然后看填充后特征的分布和原始非缺失部分的分布是否接近。如果填充值明显往中间聚,说明 K 偏大;如果填充值波动特别剧烈、方差比原始还大,说明 K 偏小。这个调参没有银弹,得结合业务判断。

2.5 加权还是不加权

KNNImputer默认是均匀权重,也就是 K 个邻居一视同仁取平均。但直觉上,距离更近的邻居应该更有话语权。所以有了weights='distance'这个选项,权重是距离的倒数:

w_i = 1 / d_i

距离越近,权重越大。这个设置在邻居质量参差不齐时特别有用。但要注意一个边界情况:如果某个邻居距离为 0(完全相同的样本),权重会变成无穷大。sklearn 的实现里对距离为 0 的情况做了特殊处理(直接采用该邻居的值),但你自己手写实现时一定要防这个除零。

我的建议:数据干净、邻居区分度明显时用distance;数据噪声大、距离差异不明显时用uniform,避免个别近邻过度主导。

3. 实操前的数据准备与关键细节

3.1 环境与依赖

用 sklearn 的KNNImputer是最省事的路子,从 0.22 版本开始就有。依赖很简单:

pip install scikit-learn pandas numpy

如果你要可视化填充前后的分布对比,再加个 matplotlib:

pip install matplotlib seaborn

版本上我建议 sklearn 用 1.0 以上,早期版本KNNImputer在某些边界情况(比如整列全缺失)的行为不太一致。用之前先确认一下:

import sklearn print(sklearn.__version__)

3.2 缺失值的识别:别被“假缺失”骗了

在动手填之前,第一步是搞清楚缺失到底长什么样。真实数据里缺失值有好几种伪装:

  • NaN/None:标准缺失,pandas 能直接识别。
  • 空字符串"":读进来是 object 类型,isnull()认不出来。
  • 占位符:-1、999、"未知"、"N/A"、"null"这些,本质是缺失但被编码成了有效值。
  • 空格" ":比空字符串更阴险,肉眼几乎看不出。

我踩过最坑的一次是某列用-999表示缺失,我直接拿去算均值,结果整列均值被拉到负数,排查了半天才发现。所以读数据后第一件事是看每列的取值分布和 dtype:

import pandas as pd import numpy as np df = pd.read_csv("your_data.csv") # 看整体缺失情况 print(df.isnull().sum()) # 看 object 列的取值,揪出占位符 for col in df.select_dtypes(include="object").columns: print(col, df[col].unique()[:20]) # 看数值列有没有异常的极值(可能是占位符) print(df.describe())

确认占位符后,统一替换成np.nan:

df = df.replace([-999, -1, "未知", "N/A", "null", ""], np.nan)

注意:替换占位符这一步一定要在填充之前做,而且要结合业务确认。比如-1在某些场景下是合法的(比如温度偏移量),不能无脑替换。

3.3 标准化:KNN 填充的生死线

前面提过,KNN 靠距离找邻居,而距离对量纲极度敏感。年龄范围 18~60,月消费范围 1000~50000,如果不标准化,月消费会完全主导距离计算,年龄工龄形同虚设。

标准化的标准做法是 Z-score:

z = (x - mean) / std

或者用 sklearn 的StandardScaler:

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() # 注意:只在非缺失部分 fit,避免缺失值影响统计量 # 但 KNNImputer 内部其实会处理,这里演示手动流程

这里有个顺序问题必须讲清楚:标准化和填充谁先谁后?

  • 如果你用KNNImputer,它内部算距离时用的是原始值,所以你应该先标准化再填充,否则距离计算还是被量纲污染。
  • 但标准化本身需要均值和标准差,而缺失值会影响这两个统计量。所以正确顺序是:先在非缺失部分 fit scaler,transform 整个数据(缺失值会变成 NaN 传播),再送进 KNNImputer。

实际操作中更省事的做法是用Pipeline把两步串起来,但要注意KNNImputer不接受含 NaN 的输入做 fit——它本身就是来处理 NaN 的,所以标准化那步要能容忍 NaN。StandardScaler在 transform 时遇到 NaN 会保留 NaN,这点是符合预期的。

from sklearn.preprocessing import StandardScaler from sklearn.impute import KNNImputer from sklearn.pipeline import Pipeline pipeline = Pipeline([ ("scaler", StandardScaler()), ("imputer", KNNImputer(n_neighbors=5, weights="uniform")) ])

提示:如果你的特征里有类别型变量,标准化之前必须先做编码(One-Hot 或 Ordinal)。KNN 的距离计算只认数值,字符串它算不了。

3.4 类别特征怎么办

KNN 填充原生只处理数值。类别特征有两条路:

  1. 先编码成数值再填:One-Hot 后每个类别变成一个 0/1 列,可以参与距离计算。但 One-Hot 会让维度暴涨,距离被稀释,效果未必好。
  2. 数值列用 KNN 填,类别列单独处理:类别列用众数填充,或者用基于相似度的投票填充。

我一般选第二条。因为类别特征的“距离”定义本身就模糊,硬塞进欧氏距离里反而添乱。如果类别列缺失不多,众数填充足够;如果缺失多且重要,可以考虑单独训练一个分类模型来预测。

4. 完整实操流程与代码实现

4.1 造一份带缺失的数据来练手

为了让你能直接复现,我先造一份模拟数据。场景设定:某平台的用户画像数据,包含年龄、工龄、月消费、月收入四个特征,其中月收入有缺失。

import numpy as np import pandas as pd from sklearn.impute import KNNImputer from sklearn.preprocessing import StandardScaler np.random.seed(42) n = 200 # 造有相关性的数据:收入大致由年龄、工龄、消费决定 age = np.random.randint(22, 55, n) tenure = np.clip(age - 22 + np.random.randint(-2, 3, n), 0, None) consumption = 2000 + tenure * 300 + np.random.normal(0, 500, n) income = 5000 + tenure * 800 + consumption * 0.5 + np.random.normal(0, 1000, n) df = pd.DataFrame({ "age": age, "tenure": tenure, "consumption": consumption.round(0), "income": income.round(0) }) # 人为制造 20% 的收入缺失 mask = np.random.rand(n) < 0.2 df.loc[mask, "income"] = np.nan print(df.isnull().sum()) print(df.head())

这份数据里,收入和年龄、工龄、消费都有正相关,正好适合 KNN 发挥——相似的人收入也相似。

4.2 标准化 + KNN 填充的完整代码

# 1. 分离出要填充的目标列和其他特征 feature_cols = ["age", "tenure", "consumption"] target_col = "income" # 2. 标准化(在非缺失部分 fit) scaler = StandardScaler() df_scaled = df.copy() df_scaled[feature_cols] = scaler.fit_transform(df[feature_cols]) # 3. KNN 填充 imputer = KNNImputer(n_neighbors=5, weights="distance") df_filled = pd.DataFrame( imputer.fit_transform(df_scaled), columns=df_scaled.columns ) # 4. 反标准化还原到原始量纲 df_filled[feature_cols] = scaler.inverse_transform(df_filled[feature_cols]) print(df_filled.isnull().sum()) print(df_filled.head())

这里有个细节:KNNImputer会对所有列做填充,包括那些本来没缺失的列。没缺失的列它原样返回,不影响。但如果你只想填某一列,可以把其他列作为“辅助特征”传进去,目标列也传进去,它自然会填。

4.3 填充效果怎么验证

填完了不能就这么算了,得验证。最直接的办法是对比填充前后的分布:

import matplotlib.pyplot as plt fig, axes = plt.subplots(1, 2, figsize=(12, 4)) # 原始非缺失部分 axes[0].hist(df["income"].dropna(), bins=30, alpha=0.7, label="原始非缺失") axes[0].set_title("原始收入分布") axes[0].legend() # 填充后 axes[1].hist(df_filled["income"], bins=30, alpha=0.7, color="orange", label="填充后") axes[1].set_title("KNN填充后收入分布") axes[1].legend() plt.tight_layout() plt.show()

理想情况下,填充后的分布应该和原始非缺失部分的分布形状接近,不能出现明显的尖峰(说明填充值都挤在一个值附近)或者异常的长尾。

更严格的验证方法是留出法:从非缺失数据里再人为挖掉一部分,用 KNN 填,然后和真实值比 MAE/RMSE。

from sklearn.metrics import mean_absolute_error, mean_squared_error # 从非缺失里再挖 10% 做验证 valid_mask = df["income"].notna() & (np.random.rand(n) < 0.1) df_valid = df.copy() df_valid.loc[valid_mask, "income"] = np.nan # 同样流程填充 df_valid_scaled = df_valid.copy() df_valid_scaled[feature_cols] = scaler.fit_transform(df_valid[feature_cols]) imputer_v = KNNImputer(n_neighbors=5, weights="distance") df_valid_filled = pd.DataFrame( imputer_v.fit_transform(df_valid_scaled), columns=df_valid_scaled.columns ) # 对比真实值 true_vals = df.loc[valid_mask, "income"] pred_vals = df_valid_filled.loc[valid_mask, "income"] print("MAE:", mean_absolute_error(true_vals, pred_vals)) print("RMSE:", np.sqrt(mean_squared_error(true_vals, pred_vals)))

这个 MAE 就是你的填充误差。可以拿它和“均值填充的 MAE”对比,如果 KNN 明显更低,说明相似性结构确实存在,KNN 值得用。

4.4 K 值和权重的调参实验

我建议你跑一个小网格,把 K 从 3 到 10、权重 uniform 和 distance 都试一遍,看验证 MAE:

results = [] for k in [3, 5, 7, 10]: for w in ["uniform", "distance"]: imp = KNNImputer(n_neighbors=k, weights=w) filled = pd.DataFrame( imp.fit_transform(df_valid_scaled), columns=df_valid_scaled.columns ) mae = mean_absolute_error( df.loc[valid_mask, "income"], filled.loc[valid_mask, "income"] ) results.append({"k": k, "weights": w, "MAE": round(mae, 2)}) print(pd.DataFrame(results).sort_values("MAE"))

跑完你会看到,不同 K 和权重的 MAE 差异可能不大,但趋势是有意义的:K 太小时 MAE 往往偏高(噪声敏感),K 太大时也会回升(过度平滑)。找到那个谷底就是你的最优参数。

5. 常见问题与排查技巧实录

5.1 填充后数据分布“塌陷”了

现象:填充后目标列的方差明显变小,直方图中间冒出一个尖峰。

原因:K 太大,或者数据本身相似样本少,导致大量缺失样本都找到了同一批邻居,填出来的值高度雷同。

排查:先看缺失比例。如果缺失超过 30%,KNN 的邻居池子本身就小,效果必然打折。再看 K 值,试着调小。还可以检查是不是标准化没做,导致距离计算失效、所有样本距离都差不多。

解决:调小 K;如果缺失比例太高,考虑换模型预测填充,或者干脆把这一列作为“是否缺失”的指示特征,让模型自己学。

5.2 报错 “Input contains NaN” 或 “cannot convert”

现象:KNNImputer报错说输入含 NaN,或者类型转换失败。

原因:多半是数据里有非数值列(object 类型)混进去了。KNNImputer只吃数值。

排查:

print(df.dtypes) print(df.select_dtypes(include="object").columns)

解决:把 object 列先编码或剔除。如果某列是“数字但存成了字符串”(比如"1,234"),先清洗成数值:

df["col"] = pd.to_numeric(df["col"].str.replace(",", ""), errors="coerce")

5.3 填充速度慢到无法接受

现象:几万行数据跑 KNN 填充要几分钟甚至更久。

原因:KNN 是 O(n²) 复杂度,行数一多就爆炸。而且KNNImputer是逐列逐缺失样本算的,没有并行优化。

解决:

  • 只对真正需要填的列做 KNN,其他列用简单方法。
  • 对数据进行采样,用子集训练出填充规则再应用到全量(近似)。
  • 换用IterativeImputer(基于回归的迭代填充),在大数据上往往更快且效果不差。
  • 如果一定要 KNN,考虑用n_neighbors调小、或者用 KD-Tree/Ball-Tree 加速(sklearn 的KNeighborsRegressor支持algorithm='kd_tree',但KNNImputer不直接暴露这个参数,需要自己封装)。

5.4 类别列被 KNN 填出了“小数”

现象:本来应该是 0/1 的类别列,填充后出现了 0.3、0.7 这种值。

原因:KNN 填充本质是取邻居的均值,对类别列来说这个均值没有意义。

解决:类别列不要交给KNNImputer。要么单独用众数填,要么用 KNN 找到邻居后做多数投票。sklearn 没有现成的投票填充器,得自己写:

def knn_mode_impute(df, target_col, feature_cols, k=5): from sklearn.neighbors import NearestNeighbors known = df[df[target_col].notna()] unknown = df[df[target_col].isna()] nn = NearestNeighbors(n_neighbors=k).fit(known[feature_cols]) _, idx = nn.kneighbors(unknown[feature_cols]) modes = known[target_col].values[idx] # 对每个缺失样本的 k 个邻居做多数投票 filled = pd.Series([pd.Series(row).mode()[0] for row in modes], index=unknown.index) df.loc[unknown.index, target_col] = filled return df

5.5 常见问题速查表

问题可能原因快速排查解决方向
分布塌陷K 过大 / 缺失过多看填充值方差调小 K,或换方法
报 NaN 错误含 object 列df.dtypes编码或剔除
速度慢数据量大看行数采样 / 换 IterativeImputer
类别列出小数类别列被均值填看填充值单独投票填充
填充值全一样标准化没做看距离分布补标准化
距离为 0 报错重复样本查重复行去重或特殊处理

提示:每次填充完,务必用df_filled.isnull().sum()确认没有残留缺失。KNNImputer在整列全缺失时会填成该列的均值(如果全缺失则填 0),这个行为要心里有数。

6. 几个我踩过的坑和实战心得

6.1 别在填充后再做标准化

很多人图省事,先填充再统一标准化。这在 KNN 场景下是错的。因为 KNN 填充时算距离用的是原始量纲,量纲不统一,距离就失真,填出来的值本身就不可靠。正确顺序永远是:先标准化(在非缺失部分 fit),再 KNN 填充,最后按需反标准化。

6.2 缺失指示特征有时比填充本身更有价值

如果某列缺失比例很高(比如超过 40%),与其费劲填一个可能不准的值,不如加一列“是否缺失”的 0/1 指示特征。因为“缺失”这件事本身可能就携带信息——比如用户不愿意填收入,可能暗示收入偏低或偏高。让下游模型同时看到填充值和缺失标记,往往比单纯填充效果更好。

6.3 用交叉验证选 K,而不是拍脑袋

前面给的网格搜索是最简单的版本。更严谨的做法是把 KNN 填充嵌进整个建模 Pipeline,用交叉验证看最终模型指标,而不是只看填充的 MAE。因为填充误差小不代表下游任务效果好,有时候填充值稍微“糙”一点反而保留了更多原始信号。

from sklearn.model_selection import cross_val_score from sklearn.ensemble import RandomForestRegressor # 假设下游是回归任务 for k in [3, 5, 7]: pipe = Pipeline([ ("scaler", StandardScaler()), ("imputer", KNNImputer(n_neighbors=k)), ("model", RandomForestRegressor(n_estimators=50)) ]) scores = cross_val_score(pipe, X, y, cv=5, scoring="neg_mean_absolute_error") print(f"k={k}, MAE={-scores.mean():.2f}")

6.4 大数据集上的替代方案

如果你的数据超过十万行,纯 KNN 填充基本不可行。这时候我会考虑两个替代:

  • IterativeImputer:用其他特征回归预测缺失值,迭代多轮。速度比 KNN 快,效果在很多场景下接近甚至更好。
  • 分块 KNN:把数据按某个关键特征(比如地区、年龄段)分块,块内做 KNN。块内样本量小,速度快,而且“相似性”更聚焦。

6.5 填充不是终点,验证才是

我见过太多人填完就往下跑模型,从来不回头看填充质量。结果模型效果差,排查半天发现是填充引入的偏差。养成习惯:每次填充后,至少做三件事——看缺失是否清零、看分布是否合理、用留出法算一下填充误差。这三步花不了十分钟,但能帮你省下几小时的 debug。

最后分享一个我常用的小技巧:把填充前后的数据都存一份,跑下游模型时对比一下。如果填充后模型效果反而变差,说明填充方式有问题,或者这列数据本身就不该填——有时候“缺失”就是最好的特征。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询