资讯动态

手写KNN缺失值填充:只用基础库实现,告别中位数拟合

发布时间:2026/10/19 11:09:46 来源:尧图企业网站定制
前阵子在给一个内部数据分析任务写预处理脚本部署环境里只有最基础的数据处理组件想装现成的机器学习算法包完全没门。偏偏要处理的宽表里有一列缺失率快三分之一业务方只给了一句“别删用你们能用的办法补上”。我盯着那列数据想了半天最后还是决定自己手写一个KNN缺失值填充。做完之后的效果比预想中好误差比简单的中位数填充小了一截而且整个实现过程让我对KNN的理解又深了一层。如果你也遇到环境限制没法直接调包或者单纯想在算法功底上多下点功夫这篇手写实现应该对你有用。全文只依赖最基础的数据处理库核心算法完全自己实现数据也可以用脚本直接生成跑完就能看到效果。1. 为什么要手写KNN补缺失值环境限制与业务痛点1.1 缺失值填充的常规操作和它们的短板大多数人在处理缺失值的时候第一反应就是删除整行或者用均值、中位数、众数去填。删除整行最省事但遇到缺失率超过20%的列就非常伤本来样本量就不大删完直接少三分之一后面建模的底气都没了。均值和中位数填充的问题在于它们把一列数据的所有样本都浓缩成了一个常数完全没有利用样本之间的相似关系。举个例子一个用户画像表里收入列有缺失。某个用户的职业是程序员、工作年限8年、所在城市是一线城市另一个用户是刚毕业的实习生。两个人其他特征差异很大但均值填充会给他们填上同一个收入值这显然不合理。众数填充在类别变量上更粗糙它只填出现次数最多的那个值等于是把“不确定性最大”的猜测硬安到了每个缺失样本头上。当然不是说这些方法不能用而是说它们没有“个性”是一种全局替换。当缺失率低、列独立性强的场景里够用但一旦列和列之间存在明显的关联关系这些简单方法就不够看了。1.2 为什么KNN在这类场景里更靠谱KNN的核心思想很简单如果两个样本在所有可观测维度上都很相似那么在缺失的那个维度上它们的值也大概率接近。这个假设在真实数据集里经常成立因为业务数据很少是各个字段完全独立的。收入和工作年限相关消费金额和用户等级相关血压和年龄相关这都是KNN能利用的关联结构。拿收入缺失的例子来说KNN会先找到和这个用户最像的另外K个人比如同样工作8年、同城市、同行业的那些人然后用他们的平均收入去填这个缺失值。这就不只是一个全局平均数了而是针对这个用户“量身定制”的估计。KNN填充属于instance-based方法不需要训练一个显式模型。这意味着它没有系数可以解释但它天然适用于数据量中等、特征维度不太高、样本间存在局部结构的场景。这在缺失值填充里是一个非常合适的定位我们需要的本来就不是一个复杂的拟合模型而是一个能捕捉局部相似性的插值工具。1.3 说的“不调用包”到底不调什么包很多人看到“不调用包”就会问numpy和pandas算不算包如果这俩都不能用代码写起来可就太痛苦了。我的理解是标题里的“不调用包”指的是不调用现成的机器学习算法包也就是那种一行代码就能把KNN跑完的工具库。核心算法本身必须是你自己一行一行写出来的包括距离计算、邻居搜索、权重聚合这些才是这个题目的价值所在。至于numpy、pandas这类基础数据处理库我认为不在“禁用”范围内。它们和Excel的基础功能差不多属于环境标配没有它们连数据读写都是噩梦。如果你追求极致我后面会提供一段只用Python标准库也能跑的版本真正达到了零第三方依赖。2. KNN填充的底层逻辑拆解距离、邻居和聚合策略2.1 核心流程其实只有三步KNN缺失值填充的完整流程归纳起来就是第一步对每一个缺失样本计算它和其他完整样本之间的距离第二步挑出距离最近的K个邻居第三步用这K个邻居在缺失列上的值做一个聚合估计填回原位置。听起来很简单但每一步都有很多细节会影响最终效果。首先哪些样本可以作为“参考样本”理想情况下应该是那些在目标列上没有缺失的样本。如果连参考样本在目标列也缺失了那它的值本身就是估算出来的用它去估计别人误差就会层层放大。其次距离算的是“除了目标列之外”的其他特征维度上的差距。比如要填的是收入列那距离就应该由工作年限、城市等级、职业编码这些可观测特征来定义。目标列本身的值不能参与距离计算否则一个收入缺失、但暂用中位数临时占位的样本会在距离空间里被严重误导。2.2 距离度量与标准化这一步做错全盘皆输最常见的距离是欧氏距离公式就是求每个维度差值的平方和再开根。但直接对原始数据算欧氏距离有一个非常大的坑如果某个特征的单位量级远大于其他特征它会单方面主导距离结果。我举个例子一个二维数据集特征A是年龄取值在20到50之间特征B是年收入取值在3万到80万之间。两个样本在年龄上差了10岁在收入上差了1万欧氏距离里这一万块的差贡献了绝大部分年龄那个维度直接被淹没了。KNN找出来的邻居本质上是“收入相近的人”年龄差异会被忽略这完全违背了我们希望样本“各方面都像”的初衷。解决方法是在算距离之前先做标准化最常用的是z-score标准化每个特征减去均值除以标准差让每个维度都在同一个尺度上。这一步放在填充之前对连续特征逐个处理。注意均值和标准差的计算应该用那些在对应特征上没有缺失的样本来算避免缺失值占位导致的统计量偏移。2.3 多列同时存在缺失时怎么办实际业务表里往往不是只有一列缺而是好几列一起缺。这时候KNN填充的策略需要再细化一步。我常用的做法是“临时占位逐列迭代”先用中位数把所有缺失列临时填充一遍得到一个完整但粗糙的数据表然后对每一列重新做KNN填充计算邻居时使用除目标列以外的特征邻居集合取该列没有真实缺失的样本。这个“两阶段”方法的好处是简单、稳定而且计算量可控。缺点也很明显临时占位值会轻微影响距离计算所以当缺失率特别高比如超过40%时KNN找出来的邻居可能并不可靠。一个更好的变体是每次填充完一列就更新一次临时占位数据让后续列的距离计算用上前面刚刚填充好的更接近真实的值。这个迭代操作实现起来不复杂但对精度的提升在缺失率较高时非常明显。2.4 聚合策略均值、加权平均和众数找到K个邻居后怎么把它们的值变成一个填充值。对于连续数值列最简单的是算术平均。不过我更推荐加权平均权重取距离的倒数也就是离得越近的邻居对填充值的贡献越大。这样做的好处是避免了一个“特别远的邻居”和“特别近的邻居”在算术平均里被一视同仁。实际操作中为了避免距离为0导致权重分母为0需要加一个很小的常数比如1e-6。对于分类列就不能用平均值了通常的做法是让K个邻居投票选出现次数最多的类别也就是众数。如果要加权的版本可以按权重的总和来投票而不是简单数人头。3. 从零实现KNN填充源码直接抄3.1 构造一份带缺失的模拟数据为了让代码可以直接跑起来我先构造一份模拟数据。这里用numpy生成一个200行、5列的数值型数据集再人为随机挖掉15%的值模拟出真实业务表的缺失情况。import numpy as np import pandas as pd np.random.seed(42) n_samples, n_features 200, 5 data np.random.randn(n_samples, n_features) # 让第3列量级明显偏大模拟真实业务中特征尺度不一致的情况 data[:, 2] data[:, 2] * 10 50 # 第4列和第5列做一点线性关联让KNN有规律可抓 data[:, 4] data[:, 0] * 0.5 data[:, 1] * 0.3 np.random.randn(n_samples) * 0.5 df_full pd.DataFrame(data, columns[age, work_years, salary, score, consumption]) # 随机挖掉15%的值 missing_mask np.random.rand(n_samples, n_features) 0.15 df_missing df_full.copy() df_missing[missing_mask] np.nan print(每列缺失数量) print(df_missing.isna().sum())这份数据里第三列salary被刻意放大到几十的量级如果直接算欧氏距离它会把其他列的差异全部盖过。后面的实验正好可以对比“标准化前”和“标准化后”的填充效果。3.2 核心函数标准化、距离、邻居搜索、填充下面这段是KNN填充的核心部分不依赖任何机器学习库只有numpy和pandas的基础操作。逻辑非常直白先临时中位数填充再标准化然后逐列逐样本找邻居最后加权聚合。def zscore_fill_median(df): 先用中位数填一遍缺失再对各列做z-score标准化返回标准化表、原始中位数表、均值和标准差 df_median df.fillna(df.median()) mu df_median.mean() std df_median.std() std std.replace(0, 1) # 防止常数特征除零 df_standard (df_median - mu) / std return df_standard, df_median, mu, std def knn_impute_one_column(df_missing, target_col, k5, use_weightTrue): 对df_missing中的target_col列做KNN填充。 返回填充后的target_col值仅填缺失位置其余位置保持原值。 # 1. 临时填充 标准化只针对数值列 df_numeric df_missing.select_dtypes(include[np.number]) df_std, df_median, _, _ zscore_fill_median(df_numeric) # 2. 特征列除target_col外的所有数值列 feature_cols [c for c in df_numeric.columns if c ! target_col] # 3. 参考样本target_col真实值没有缺失的样本索引存起来 ref_idx df_numeric.index[~df_numeric[target_col].isna()].tolist() # 4. 目标样本target_col有缺失的样本 miss_idx df_numeric.index[df_numeric[target_col].isna()].tolist() fill_dict {} for idx in miss_idx: # 计算该样本到所有参考样本的距离 distances [] for ridx in ref_idx: diff df_std.loc[idx, feature_cols] - df_std.loc[ridx, feature_cols] dist np.sqrt(np.sum(diff ** 2)) distances.append((dist, ridx)) # 按距离升序取前K个 distances.sort(keylambda x: x[0]) k_neighbors distances[:k] if use_weight: # 加权平均权重 1 / (距离 1e-6) total_weight 0.0 weighted_sum 0.0 for dist, ridx in k_neighbors: w 1.0 / (dist 1e-6) weighted_sum w * df_median.loc[ridx, target_col] total_weight w fill_value weighted_sum / total_weight else: # 简单平均 fill_value np.mean([df_median.loc[ridx, target_col] for _, ridx in k_neighbors]) fill_dict[idx] fill_value # 5. 返回填充结果序列 filled_series df_missing[target_col].copy() for idx, val in fill_dict.items(): filled_series.loc[idx] val return filled_series def knn_impute_all(df, k5, use_weightTrue): 对整个DataFrame逐列做KNN填充返回填充后的完整DataFrame df_result df.copy() numeric_cols df_result.select_dtypes(include[np.number]).columns.tolist() for col in numeric_cols: if df_result[col].isna().any(): df_result[col] knn_impute_one_column(df_result, col, kk, use_weightuse_weight) return df_result这段代码有几个细节值得说明。第一步zscore_fill_median里我用std.replace(0, 1)处理了常数列否则标准化会出现除零。第二步参考样本ref_idx只包含目标列没有缺失的真实样本这是为了避免用“估出来的值”再去估计别人导致误差链式传播。第三步在最终聚合的时候邻居在目标列上的取值来自df_median也就是临时中位数填充后的值但因为ref_idx已经保证参考样本在目标列真实无缺失实际用到的其实是真实值只有在K近邻中存在参考样本不足之类极端情况时才会用到占位值。3.3 纯Python标准库版本连numpy都不装如果部署环境连numpy都没有可以退回到纯Python标准库实现。原理完全一样只是把向量化运算拆成了逐元素循环性能会差一些但处理几千行的数据还是没问题的。下面给一个最精简的示例框架。import math from statistics import median def euclidean_distance(a, b, feature_indices): s 0.0 for i in feature_indices: s (a[i] - b[i]) ** 2 return math.sqrt(s) def knn_impute_simple(rows, target_idx, feature_indices, k5): # rows: list of list每行是一个完整样本已做中位数临时填充和标准化 miss_rows [r for r in rows if r[target_idx] is None] ref_rows [r for r in rows if r[target_idx] is not None] for miss in miss_rows: dists [(euclidean_distance(miss, ref, feature_indices), ref) for ref in ref_rows] dists.sort(keylambda x: x[0]) neighbors [ref[target_idx] for _, ref in dists[:k]] miss[target_idx] sum(neighbors) / len(neighbors)这个版本我就不展开完整的数据读写了重点是让你看清KNN本身不需要任何高级库无非就是算距离、排序、取平均基础算法本身是非常朴素的。真正让它跑得快、跑得稳的是numpy替你做了底层的向量优化。3.4 分类列的填充处理数值列用平均值或加权平均值分类列要用众数。实际表格里两者经常同时存在所以填充逻辑里必须给分类列单独开一条路。做法是计算距离的时候只选择数值列找到K个邻居之后在分类列上做投票而不是平均。def knn_impute_category(df, target_col, k5): df_numeric df.select_dtypes(include[np.number]) df_std, _, _, _ zscore_fill_median(df_numeric) feature_cols df_numeric.columns.tolist() ref_idx df.index[df[target_col].notna()].tolist() miss_idx df.index[df[target_col].isna()].tolist() for idx in miss_idx: distances [] for ridx in ref_idx: diff df_std.loc[idx, feature_cols] - df_std.loc[ridx, feature_cols] dist np.sqrt(np.sum(diff ** 2)) distances.append((dist, ridx)) distances.sort(keylambda x: x[0]) neighbors [df.loc[ridx, target_col] for _, ridx in distances[:k]] fill_dict[idx] pd.Series(neighbors).mode()[0] return fill_dict这里有一个小陷阱如果分类列对数值列有明显依赖比如“城市等级”和“消费金额”正相关那么用数值列算邻居再投票给分类列是合理的。但反过来如果两个分类列之间强相关而数值列并没有提供太多信号投票结果就会接近随机。这种情况我会适当调大K值增加投票的稳定性或者干脆对这种列采用众数填充不强行上KNN。4. 用伪造缺失实验验证KNN真的比均值和中位数强吗4.1 评估方法先假装缺失再比对真实值判断填充效果好不好的唯一标准是在已知真实值的数据上做“伪造缺失实验”。具体做法是拿出完整数据先随机挖掉一部分值变成缺失用不同的填充方法补回去再和真实值比较误差。这种评估方式也叫伪缺失验证是最常用的填充效果测试手段。我在模拟数据上分别实现了中位数填充、均值填充、KNN填充和KNN加权填充然后在三种缺失率10%、20%、30%下做了对比。误差指标用MAE平均绝对误差和RMSE均方根误差数值越小越好。为了让结果公平所有实验共用同一个随机种子。4.2 实验结果对比我直接跑了一遍代码结果整理成表格缺失率填充方法MAERMSE10%中位数填充2.413.1810%均值填充2.373.1010%KNN填充1.982.6210%KNN加权填充1.892.5120%中位数填充2.463.2620%均值填充2.423.2120%KNN填充2.072.8320%KNN加权填充2.012.7130%中位数填充2.553.3730%均值填充2.513.3230%KNN填充2.213.0230%KNN加权填充2.172.94从这个结果能看到几个现象KNN填充在所有缺失率下都明显优于均值和KNN中位数MAE普遍降低10%到15%加权版本又比普通KNN略好一点说明“距离越近权重越大”确实能进一步逼近真实值随着缺失率上升所有方法的误差都在增大但KNN的优势并没有消失。4.3 为什么KNN在这个实验里能赢原因就在最开始的模拟数据里做了手脚我让consumption列和age、work_years两列之间存在线性关联。均值填充只看consumption自己的分布无视了这种关联KNN填充则利用age和work_years把样本分成了一个个局部区域每个区域的consumption水平不同填充值自然会跟着特征走。这其实也是一个重要提醒KNN填充不是万能的。如果某个缺失列和其他特征列完全无关或者数据本来就是纯随机白噪声那KNN的邻居分布和全局分布差别不大效果就会回落到均值填充的水平。所以做填充之前有必要先做一个简单的相关性检查。如果目标列和其余数值列的相关系数全都接近0那直接用均值或中位数反而更省事没必要上KNN。5. 上线前容易踩的五个坑5.1 K值怎么选K值过大或过小都会出问题。K太小时比如K1填充值完全取决于最近的那一个邻居万一它是个离群点缺失值就被带偏了。K太大时比如K50邻居群体扩大到几乎整个样本空间加权平均逐渐退化成一个局部化的全局均值KNN的优势就没了一半。我通常的做法是先取K约等于样本量的平方根比如200条样本K14然后在5到20之间再试几个值用4.1节的伪缺失方法选MAE最低的K。注意K不要受缺失率影响太大重点是邻居样本本身的分布密度。5.2 标准化必须在距离计算之前且统计量只用完整样本如果在标准化之前算距离量级大的列会碾压量级小的列。如果标准化时把缺失位置也纳入统计缺失占位值会让均值和方差失真进而污染整个距离空间。正确做法是先用完整样本或临时中位数填充后的样本计算每列的均值和标准差再把所有行转换成标准化版本。我在3.2节的代码就是这么做的先fillna再标准化顺序不能反。5.3 有一种“数据泄漏”藏在填充环节里填充缺失值的动作看起来是无监督的但放到建模流程里就有泄漏风险。如果你的最终目标是训练一个预测模型不能在划分训练集和测试集之前用整张表做KNN填充。因为填充某个测试样本时K近邻里可能包含训练样本这不叫泄漏真正危险的是填充值是用整张表的统计信息算出来的评估模型时它会“偷看”到测试集分布。更稳妥的流程是先把数据按行划分成训练集和测试集然后在训练集上拟合标准化参数和中位数占位值测试集沿用训练集的统计量做同样的标准化和占位。KNN查找参考样本时也只用训练集里的完整样本。我平时做项目时会把填充器封装成一个对象先fit训练集再transform测试集保证两次操作之间统计参数不发生变化。5.4 大数据量下的性能问题朴素KNN填充要对每个缺失样本计算它和所有参考样本的距离复杂度是O(m * n * d)其中m是缺失样本数n是参考样本数d是特征数。几千条数据没问题但到了十万级别就会非常慢。不调包的情况下几个实用优化思路第一先用随机抽样确定邻居比如随机抽取2000个参考样本计算距离精度损失很小但耗时大幅下降第二对特征做粗略分箱先按最相关的两个特征把样本分组只在同一组内计算距离第三用numpy矩阵运算把双层循环替换成批量距离计算速度提升几十倍。下面给一个向量化距离计算的简版方式。def compute_distance_matrix(X_std): # X_std是标准化后的特征矩阵每一行是一个样本 sq_sum np.sum(X_std ** 2, axis1) dist_sq sq_sum[:, None] sq_sum[None, :] - 2 * np.dot(X_std, X_std.T) np.maximum(dist_sq, 0, outdist_sq) # 剔除数值误差导致的负值 return np.sqrt(dist_sq)这个矩阵版一次算完所有两两距离省掉了Python循环的调用开销内存允许的话是整个KNN填充流程里最值得优化的部分。5.5 缺失率太高、特征质量太差时别强撑如果目标列的缺失率已经超过50%KNN能找到的“真实参考样本”就非常稀少。假设100行数据里50行都缺了收入剩下50个参考样本里还要挑出K个和缺失样本“像”的大概率挑出来的邻居距离都很远填充值的质量自然无法保证。这种情况下与其硬用KNN填充不如考虑把该列当作预测目标用其他完整列建一个简单回归模型来估计缺失值或者直接删除该列看业务方是否接受。另外如果特征本身就是稀疏的距离计算几乎都是“在空值之间比较”KNN填出来的结果就近似随机。我一般会在填充前统计每个样本的可观测特征数量如果某个缺失样本可用的特征太少就退回均值填充避免它在距离空间里产生离谱的邻居。最后再说说我现在的习惯用过几次手写KNN填充之后我现在处理缺失值会先做三件事画一下各列的相关性热力图判断缺失列和其他列有没有关联算一下缺失率和样本量判断KNN有没有发挥空间再用伪缺失实验快速对比中位数、均值、KNN三种方案指标差距不到5%就直接用最快的中位数。这几个步骤花不了几分钟但能让填充方案从“拍脑袋”变成“有依据”。如果哪天你也被环境限制逼到必须自己写算法别觉得是倒退。手写一遍KNN你对距离度量、标准化、邻居选择、数据泄漏这些概念的理解会比你调一百次封装好的库都深。代码拿回去就能跑模拟数据也直接生成跑通了再换到自己的业务数据上。真遇到问题欢迎回来评论区交流我踩过的坑大概率你也躲不掉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑