上周处理一份门店销售明细表拿到手第一眼挺干净列名规范、数字工整。结果用pandas读进来一查日期列缺了17个值城市列有3种写法单价列里混着空值和字符串金额列还有个负得离谱的数字。业务方甩了一句“你看着办”我就知道这活没法直接干。数据清洗从来不是delete和fillna咔咔两下的事尤其是数据填充填错了比不填更危险因为你制造的“看起来正常”的数据会直接喂给下游报表和模型。这篇文章把我在实际项目中用到的6种数据填充方式完整梳理一遍每种都会给出pandas代码、适用场景和坑点适合刚接触pandas数据处理的分析师也适合想系统化清洗流程的数据工程师。1. 数据清洗先搞清楚缺失值从哪来再谈怎么填1.1 缺失值的基本类型与判断很多人拿到数据第一反应是“填空”但我建议先停下来问一句这些空值是怎么来的统计学上把缺失机制分成三类理解这件事能帮你避免很多无效填充。第一类是完全随机缺失。比如门店的POS机在某个时段集体宕机那段时间的订单就是整段空白和任何其他变量都没关系。第二类是随机缺失缺失概率受其他观测变量影响。比如女性客户更不愿意填“年龄”年龄列的缺失就和性别相关但性别本身是观测到的。第三类是非随机缺失缺失本身就和未观测到的值有关。比如高收入人群故意不填收入你看到的收入缺失恰恰说明收入可能很高。判断方法不复杂先看缺失值所在列和其他列之间有没有明显关联再问业务方生成这张表的系统逻辑。如果纯属系统故障导致的整块缺失可能直接剔除就完了如果是业务规则造成的“未填写”填充策略就得格外小心。我见过一个反例有人用均值填充“收入”列结果每月经营分析里客单价直接被拉高了8%因为高收入样本的缺失被强行填成中位数整个分布被压扁了——这就是没搞清缺失机制硬填的结果。1.2 填充前必须查的三件事正式动手之前我会固定检查三样东西缺失比例、缺失模式、业务含义。先说缺失比例如果某列缺失超过50%填充出来的数据基本是自欺欺人不如直接删列最多跟业务方确认一下这个字段是否还要保留。缺失模式一般用isnull矩阵扫一眼看看缺失是集中在某几行还是随机分布在整张表里。集中缺失通常是采集链路断了随机缺失更像是“用户没填”。业务含义是最后一道坎这一列在数据字典里是怎么定义的“客户备注”为空很正常“订单金额”为空就是事故两种情况的处理逻辑完全不同。代码层面我用下面的方式快速摸底import pandas as pd df pd.read_excel(sales.xlsx) # 缺失值总量和占比 missing_summary df.isnull().sum() missing_ratio df.isnull().mean().round(4) print(pd.DataFrame({缺失数量: missing_summary, 缺失占比: missing_ratio})) # 按行看缺失分布 row_missing df.isnull().sum(axis1) print(row_missing.value_counts().sort_index())这一套跑完你对数据的信任度基本就有数了。我习惯把结果存一份missing_report.csv后面所有填充操作的依据都从这份报告里来而不是拍脑袋。2. 六种填充方式的适用场景与代码实现2.1 常数填充最基础但要小心误导常数填充就是直接把缺失位置替换成一个固定值pandas里一行fillna(value)搞定。它适合两类场景一类是类别型字段空值可以明确解释为“未知”比如性别列填“未知”、客户等级列填“未分级”另一类是业务上确实有明确含义的0比如促销活动前不存在的折扣字段填0这个0代表“不打折”。容易踩坑的是滥用0。金额、销量、库存这类连续变量缺了不代表就是0。把“没记录”和“实际为0”混为一谈最直接的后果就是拉低均值搞得下游做同比环比的时候永远对不上数。我见过一个案例某仓库盘点表把缺失库存全填成0结果第二天的补货建议直接多出40%因为系统以为这批货卖爆了。所以用常数填充之前一定要跟业务方确认“空值是否等价于0或未知”。# 类别列填“未知” df[city] df[city].fillna(未知) # 业务确认过的数值列填0 df[discount] df[discount].fillna(0)2.2 前向/后向填充时序数据的默认选择前向填充ffill的意思是用上一个非空值填充当前缺失后向填充bfill反过来用下一个非空值填充。它最典型的应用场景是时间序列——股票价格、传感器读数、web访问日志里偶尔丢一拍用前值补上是最自然的。选ffill还是bfill取决于数据特性。传感器数据连续采集单点缺失用ffill就够因为物理世界不会瞬间跳变但如果缺失发生在一段记录的开头没有前值可用就得靠bfill补一下。这里有个硬前提行顺序必须真实可靠。如果索引没排序ffill会乱找邻居填出来的数据完全是错的。# 按时间排序后再填充 df df.sort_values(timestamp).reset_index(dropTrue) df[sensor_value] df[sensor_value].ffill() # 开头缺失用后向填充补救 df[sensor_value] df[sensor_value].bfill()我还遇到过更复杂的场景每个门店单独按时间采样如果直接对整个DataFrame做ffill会把上一家门店的最后一个值填到下一家门店的缺失位置。这种情况必须先groupby在组内做向前填充代码是df[value] df.groupby(store_id)[value].ffill()千万别贪省事。2.3 统计量填充均值、中位数、众数怎么选统计量填充是最常见的“无脑”做法但选哪个统计量有讲究。连续变量一般先看分布近似正态分布用均值填充既能保持均值不变计算也直观存在明显右偏或极端值用中位数填充因为中位数不受长尾极端值影响。收入、成交金额这类变量基本都右偏我90%的情况会用中位数而不是均值。分类变量则用众数也就是出现次数最多的类别。不过众数有一个隐患如果某个缺失行的真实值本来属于小众但重要的分类填成众数就把稀有信息覆盖了。比如客户渠道里“企业客户”只占5%缺失值全填“个人客户”模型基本学不到企业客户的特征。所以分类变量填充前先看类别分布如果某个类别的业务价值很高就算频率低也不能简单填众数。# 均值填充 df[amount].fillna(df[amount].mean(), inplaceTrue) # 中位数填充 df[amount].fillna(df[amount].median(), inplaceTrue) # 众数填充注意mode()返回Series取第一个值 mode_value df[channel].mode()[0] df[channel].fillna(mode_value, inplaceTrue)关于inplaceTrue多说一句现在pandas很多版本都在提示直接用赋值写法我更推荐df[amount] df[amount].fillna(...)因为inplace在某些链式操作里容易失效还不报错调试的时候非常难发现。2.4 插值法让缺失值更平滑地过渡插值法的思路是利用已知点的规律估算缺失点线性插值相当于在前后两个已知值之间画一条直线取中间位置。适合温度、销量、价格这类连续、变化平滑的变量pandas里直接调interpolate()# 线性插值 df[temperature] df[temperature].interpolate(methodlinear) # 时间感知插值按实际时间间隔计算 df[temperature] df[temperature].interpolate(methodtime) # 多项式插值order2或3别太高否则龙格现象严重 df[temperature] df[temperature].interpolate(methodpolynomial, order2)插值法听起来高级但有一个经常被忽略的前提它假设变量在缺失区间内的变化是平滑、可预测的。如果序列本身剧烈波动或者缺失跨度很长插值出来的中间值几乎就是编故事。比如某电商平台“双11”前后的销量曲线0点前后销售量差了几十倍用线性插值把这一小时补成平均值简报里看是平滑了实际运营决策会完全跑偏。还有个常见翻车点对离散变量做插值。像“购买人数”“库存件数”这类整数变量线性插值可能出现76.3人、210.8件这种荒谬结果必须再取整或者干脆换成分组填充。我用插值法前都会画一眼缺失区间的曲线走势如果发现变化是跳变型的就直接放弃插值。2.5 分组填充同组内统计更接近真实分布分组填充是统计量填充的进阶版核心思想是先按某个关键字段分组在组内分别计算均值/中位数/众数并填充。为什么这比全局统计量好说一下我踩过的坑。某次处理全国门店订单数据“客单价”列有3%缺失全局中位数是72元我直接填进去了。后来组长看了一眼让我分组看一线城市门店客单价中位数是118元县城门店只有45元。用全局72元填充等于把一线门店的订单特征均匀抹平后面做城市对比分析时反而多出了很多“人为噪声”。分组填充分两步走先用groupby按组计算统计量再用transform映射回每一行。注意transform返回的是和原DataFrame等长的Series这是和apply最大的区别。# 按城市分组用组内中位数填充单价 df[unit_price] df.groupby(city)[unit_price].transform( lambda s: s.fillna(s.median()) )更稳妥的写法是先定义映射字典再用fillna传字典避免transform里嵌套fillna出现视图拷贝警告city_median df.groupby(city)[unit_price].median() df[unit_price] df[unit_price].fillna( df[city].map(city_median) )分组填充的优势在于它自动考虑了类别之间的差异但前提是分组字段本身质量要高且和待填充列业务相关。如果分组字段也有大量缺失或者分组过多导致组内样本量太少组内统计量会比全局统计量还不可靠。组内样本少于5个的时候我一般会退回到全局统计量。2.6 模型预测填充把缺失值当预测目标模型填充的本质是把缺失列当作目标变量用其他非缺失列构建模型来预测空缺值。常见算法有KNN、随机森林、链式方程多重插补。pandas本身没有这个功能要借助sklearn.impute模块。KNNImputer的原理好理解找到和当前样本最相似的K个邻居把邻居在该特征上的加权平均值填给缺失值。适用于特征间相关性较强、样本量充足的情况。IterativeImputer更高级一点它把每一列轮流作为目标用其他列建模反复迭代多次属于MICE思想的scikit-learn实现。from sklearn.impute import KNNImputer, IterativeImputer import numpy as np # KNN填充处理前最好做标准化不然量纲差异会影响“相似度” knn_imputer KNNImputer(n_neighbors5, weightsdistance) df_filled knn_imputer.fit_transform(df[[unit_price, quantity, amount]]) df_filled pd.DataFrame(df_filled, columns[unit_price, quantity, amount]) # MICE风格迭代填充 iter_imputer IterativeImputer(max_iter10, random_state42) df_filled iter_imputer.fit_transform(df[[unit_price, quantity, amount]])模型填充听起来“最智能”但实际项目里使用要非常谨慎。第一它隐式利用了列间相关性如果缺失列的缺失机制是非随机缺失模型依然会系统性偏移。第二它容易引入数据泄漏和过拟合尤其当缺失比例不高、模型复杂度偏高时填充值可能“好看”但真实分布被扭曲。第三跨数据集使用时模型是拟合在训练集上的上线前必须确认特征分布没变。我一般只在缺失比例大、单列统计量填充会明显劣化下游模型时才用它。2.7 六种填充方式横向对比方式适用场景优点缺点pandas实现常数填充类别型空值、业务上等价于0的数值型简单快速含义明确对连续变量易引入偏差fillna(value)前向/后向填充时间序列、流水记录、合并单元格展开保留前后趋势操作简单依赖顺序错位会传播ffill()/bfill()均值填充近似正态分布的无偏连续变量保持均值计算简单压缩方差受极端值影响fillna(mean)中位数填充右偏分布、含极端值的数据稳健不受长尾影响可能破坏分布形状fillna(median)众数填充分类变量保留最频繁类别掩盖稀有类别特征fillna(mode()[0])插值法平滑连续型变量、时间序列利用相邻信息趋势自然假设平滑离散值会失真interpolate()分组填充存在明显类别差异的数据保留组间差异更贴近业务分组质量差时反而不稳groupby().transform()模型填充特征相关性强、数据量大利用多变量信息填补精度高有泄漏风险计算成本高KNNImputer等3. 实操用pandas走一遍完整填充流程3.1 构造测试数据与缺失侦察理论说了半天还是用一份完整例子把流程串起来。假设有一份门店销售订单表包含字段日期、城市、商品、销量、单价、金额。人为制造缺失日期有一段掉点、部分城市为空、单价和金额存在缺失。import pandas as pd import numpy as np df pd.DataFrame({ date: pd.date_range(2025-01-01, periods20, freqD), city: [北京, 上海, 北京, 杭州, 广州, 上海, 北京, 杭州, 广州, 上海, 北京, 杭州, 广州, 上海, 北京, 杭州, 广州, 上海, 北京, 杭州], product: [可乐, 薯片, 可乐, 啤酒, 可乐, 薯片, 啤酒, 可乐, 薯片, 啤酒, 可乐, 薯片, 可乐, 啤酒, 薯片, 可乐, 啤酒, 薯片, 可乐, 啤酒], quantity: [10, 5, 12, 8, np.nan, 6, 14, 9, np.nan, 7, 11, 6, 13, 8, 9, 12, 7, 10, 15, 8], unit_price: [3.5, 5.0, 3.5, 6.0, 3.5, 5.0, 6.0, 3.5, 5.0, np.nan, 3.5, 5.0, 3.5, 6.0, 5.0, 3.5, 6.0, 5.0, np.nan, 6.0], }) # 人为制造日期缺失 df.loc[7, date] pd.NaT df.loc[8, date] pd.NaT # 人为制造城市缺失 df.loc[3, city] np.nan df.loc[12, city] np.nan # 金额列由数量*单价推导暂时留空 df[amount] df[quantity] * df[unit_price]先做缺失侦察不要直接动手填print(df.isnull().sum()) print(df.isnull().mean().round(3))输出结果里日期缺2个、城市缺2个、销量缺2个、单价缺2个、金额由于前两者缺失也会多出几个空值。这时候先不要急着把amount直接算出来因为它的缺失根源在quantity和unit_price必须先处理上游字段。3.2 组合策略先分组再选择填充方式我的填充顺序是从依赖链条的上游往下游走。这张表里date是时间维度先做前向填充city是分组维度用众数填充会引入局部分布偏差我用业务规则填充quantity是销量受城市和商品双重影响用分组中位数unit_price同理按城市和商品分组填充。# 1. 日期前向填充时序数据默认选择 df[date] df[date].ffill() # 2. 城市缺失用业务经验填充 # 这里模拟一个规则销量为8的那行是杭州门店的记录但这里不能编造 # 实际项目里应该查业务系统演示环境我用众数 city_mode df[city].mode()[0] df[city] df[city].fillna(city_mode) # 3. 销量不用全局中位数按城市分组填充 city_qty_median df.groupby(city)[quantity].transform(median) df[quantity] df[quantity].fillna(city_qty_median) # 4. 单价按城市和商品分组填充更细粒度 price_key df.groupby([city, product])[unit_price].transform(median) df[unit_price] df[unit_price].fillna(price_key) # 5. 最后再计算金额此时上游字段已完整 df[amount] df[quantity] * df[unit_price]如果分组后某些组仍然缺失比如“杭州薯片”组在数据里恰好全是空值中位数也是NaN那就要再退一级用全量中位数兜底。生产环境里我会写一个多级回退函数def smart_fillna(series, group_cols, df): # 第一层细粒度分组 s df.groupby(group_cols)[series.name].transform(median) # 第二层若仍有缺失退回全局中位数 s s.fillna(df[series.name].median()) return s.fillna(0) # 第三层兜底写代码还不够我每次做完以上操作都会把填充前后的描述性统计打印出来做对比。正常情况是均值、分位数不要出现断崖式跳跃如果中位数变了超过5%说明填充策略对分布影响太大需要回头检查。3.3 处理Excel合并单元格导致的大量空值热搜词里有一条“填充数据合并单元格”这是Excel数据清洗里非常经典的问题。pd.read_excel读出来的合并单元格只有左上角第一个单元格有值其他全是NaN。比如一张区域销售汇总表区域门店销售额华东上海店100(合并)杭州店80华北北京店90直接读进pandas后第二行的“区域”是NaN。这种空值既不能填众数也不能插值正确做法是前向填充df[区域] df[区域].ffill()但这里有个细节合并单元格对应的空区域可能横跨多行ffill会把上一个区域的值带到下一块区域的第一行之前如果表里还存在其他维度的交叉就会填错。我在项目里用过一个更稳妥的方案先用openpyxl读取合并单元格范围把合并信息转换成显式的每个单元格值然后再进pandas。这样能从源头避免ffill误传。from openpyxl import load_workbook wb load_workbook(merged_report.xlsx, data_onlyTrue) ws wb.active # 展开所有合并单元格 for merged_range in list(ws.merged_cells.ranges): top_left ws.cell(merged_range.min_row, merged_range.min_col) for row in range(merged_range.min_row, merged_range.max_row 1): for col in range(merged_range.min_col, merged_range.max_col 1): ws.cell(rowrow, columncol).value top_left.value wb.save(merged_report_expanded.xlsx)这种方法处理复杂表头、多层合并时都有效唯一的代价是文件读写变慢但数据完整性好得多。如果只是单层合并直接ffill完全够用。4. 填充中的常见坑与排查记录4.1 inplaceTrue的坑fillna(..., inplaceTrue)是我见过最隐蔽的问题来源之一。它不返回新对象而是修改原DataFrame在筛选出的子集上使用时会失效# 错误示范这个填充不会生效 sub_df df[df[city] 北京] sub_df[unit_price].fillna(0, inplaceTrue) # 原df里的unit_price依然是空值而且不会报错更麻烦的是pandas新版本已经对inplaceTrue发出弃用警告官方建议一律用赋值写法。我现在已经养成习惯每条填充指令都写成df[col] df[col].fillna(...)这样即使以后抽出来做函数封装也不会踩子集坑。4.2 填充导致分布被破坏用均值或中位数填充后缺失位置全部变成同一个值直方图上会冒出一根明显高耸的尖峰。这会让下游模型误以为存在一个“超级常见的均值档位”尤其对树模型影响很大它会尝试学习这个峰值的业务含义实际上是人为造的。排查方法很简单填充后马上做描述性统计对比重点看方差和分位数。我遇到过方差缩水30%的情况后来改用分组填充加轻微噪声扰动才让分布恢复到接近原始状态。加噪声不是说乱造数而是从模型训练角度考虑给填充值增加合理波动具体噪声幅度可以根据组内标准差估算这一步在规范项目里要写进数据字典。4.3 索引与时间顺序错乱ffill和插值都严重依赖行顺序。有一次我收到一份日频销售数据索引是原始的流水号不是日期直接用ffill结果一片混乱。排查半天才发现问题不在填充而在没有先按日期排序。# 正确流程先排好序再做时序填充 df df.sort_values([store_id, date]).reset_index(dropTrue) df[sales] df.groupby(store_id)[sales].ffill()另一个容易忽视的点是排序时不能只排日期如果数据是多门店的必须先按门店分组再排日期否则跨门店的填充会张冠李戴。4.4 分类特征不要用均值填充模型训练里如果把分类变量编码成数值后直接填均值相当于凭空创造出一个介于类别之间的“幽灵类别”。比如城市编码北京1上海2杭州3缺失值填了2模型会认为这个样本属于“上海”但它可能根本不来自上海。分类变量要么填众数要么单独建一个“未知”类别用-1或999做编码让模型自己学习这一类别的规律。4.5 磁场扇区填充和数据填充不是一回事最后回应一个搜索里常出现的词“空硬盘写入数据填充磁道和扇区的顺序”。这种查询会出现在数据填充关键词下面其实和pandas清洗完全没有关系。硬盘本身的磁道扇区填充属于物理存储层的写盘策略涉及低格、坏道处理、覆盖写顺序处理的是磁盘介质上比特放置的组织方式数据清洗里的填充处理的是逻辑表里的缺失值。两者只是都叫“填充”目标、工具、层完全不同。真要研究硬盘层面的写盘顺序要看文件系统、固件算法那一路知识和这里讨论的fillna不是一回事。看到这类词时留意一下上下文别把概念带混。5. 填充策略的选择优先级与个人经验5.1 填充选型速查思路我自己在项目里不会每次翻算法手册而是按照一套固定的决策路径来选先问业务这个空值有没有明确业务含义有就填对应语义的常数。再看字段类型时间连续型优先ffill/bfill/插值类别型优先众数或“未知”类连续数值看分布。有分组字段吗有就先用groupby transform细化统计量效果通常好于全局统计量。下游是模型训练且数据量大、特征相关性强才考虑KNNImputer或IterativeImputer。每次填充后都要做填充前后分布对比并忠实记录填充方法、参数、业务依据。这个顺序不是标准答案但它在多数业务场景里能让你少走弯路。核心原则永远是先用简单方法至少跑通一版结果再逐步升级复杂度不要一上来就模型填充。5.2 我踩过几次坑之后的体会写到最后我想说一句实在话数据填充不是数学越复杂越好而是越可解释越好。在我处理过的几十张表里真正需要上模型的不足一成绝大多数问题一个ffill加一个分组中位数就能解决。你填出来的每一个值未来都会被业务方拿去跟真实世界核对能解释清楚“为什么这里填了72.5”比“用了先进的MICE算法”重要得多。所以每次填充我都会额外保存一份fill_log.csv记录哪一列、哪些行、用了什么规则、依据是什么这份日志在复盘数据质量时价值巨大。还有一个习惯推荐给大家凡是填充过的数据都在最终报告里单独标注出来。比如增加一列unit_price_filled_flag1代表填充0代表原始值。这样下游分析时如果发现某个异常聚合结果可以马上定位是不是填充数据引起的。这个小细节帮我避免过很多次背锅也算是我做数据清洗这些年最想分享的一句话经验。