资讯动态

Pandas缺失值填充实战:fillna、插值与分组前向填充陷阱

发布时间:2026/10/27 3:05:59 来源:尧图企业网站定制
有一次接手一份销售明细表三万多行两个关键字段各有几百个空。当时第一反应是dropna()一行删干净结果跑完发现月度汇总对不上——被删掉的很多是同一张订单的不同商品行删掉一行整张单的金额就少一块。那次之后我才真正把 Pandas 缺失值处理当成一个需要做决策的环节而不是一句fillna(0)就收工。这一节聊的就是填充这条路线什么时候能补、补什么值、补完之后数据的结构和类型会不会偷偷变形。它适合已经会写isna()和dropna()、但在真实脏数据上总是补出问题的朋友也适合刚看完前几节、想看看工程里到底怎么落地的人。填充是缺失值处理里自由度最大、也最容易翻车的一环因为它不像删除那样有明确的边界补什么值完全取决于你对业务的理解。1. 从删掉到补回去这一节要解决的是形状保持问题1.1 前三节留下的尾巴数据不能随便丢删除法之所以简单是因为它把决策权交给了行数。只要dropna的参数一调剩下多少行是确定的你不用思考每一行的含义。但真实业务里行往往带着身份。一条订单记录、一条用户行为日志、一条设备采样删掉它等于抹掉一个事实。特别是当你后面要做同比、环比、留存、漏斗这类强依赖行数完整性的分析时缺失行会被算成没发生而不是没记录这两种含义在业务上差得很远。我见过一种很典型的错误某次做日活统计先dropna(subset[user_id])再nunique()结果日活比后台报表低了百分之十几。原因不是数据错了而是user_id缺失的那批记录被当成无效行清掉了可它们在业务口径里属于未登录访客是要算进某些指标的。这就是删除法的隐性代价——它不报错只是悄悄改变了分母。所以进入填充环节之前先问自己一个问题这一列的缺失代表没有这个值还是这个值没被记录前者可以考虑填一个语义占位符后者更适合删除或单独标记。这个问题没有标准答案但你必须回答因为后面每一个fillna的取值都由它决定。1.2 填充的三条路线与它们的代价工程里常见的填充思路大致分三类代价各不相同我把它整理成一张表方便你对着场景选路线典型写法适用场景主要代价常量填充fillna(0)、fillna(未知)缺失本身有业务含义或后续要单独统计会稀释均值可能掩盖异常统计量填充fillna(mean/median/mode)数值型特征、缺失比例不高低估方差破坏列间相关性前后值填充ffill()、bfill()、interpolate()时间序列、有顺序的流水数据依赖顺序假设顺序错了结果全错常量填充最省事但最容易被滥用。fillna(0)放在金额列上会让没记录金额和金额为零混成一类后面做分布统计时均值直接往下掉。fillna(未知)放在类别列上相对安全因为字符串本身就是一个明确的类别标签后续做value_counts()也不会丢信息。我的习惯是数值列优先考虑中位数或业务基准值类别列优先考虑未知这类显式标签只有确实存在零这个业务事实的场景才填 0。统计量填充的核心风险是方差收缩。用中位数填一百个空这一百个点的取值全部压到同一个数上列的离散程度被人为压平。如果你后面要用这列做回归或者做异常检测这个影响是实实在在的。前后值填充则完全依赖顺序假设一旦数据没有按正确的时间或编号排好序ffill()会把不相邻的记录串到一起这种错误在结果上很难看出来因为补出来的值本身是合理的。1.3 先做一次缺失画像再决定补不补动手之前我一般会先跑几行代码把缺失的分布摸清楚这一步花不了一分钟但能省掉后面大量返工import pandas as pd import numpy as np df pd.read_csv(orders.csv) # 每列缺失数量与比例 profile pd.DataFrame({ missing: df.isna().sum(), ratio: df.isna().mean().round(4), dtype: df.dtypes.astype(str), }) print(profile.sort_values(ratio, ascendingFalse)) # 每行缺失了几个字段 df[_miss_cnt] df.isna().sum(axis1) print(df[_miss_cnt].value_counts().sort_index())df.isna().mean()这个写法比sum()/len(df)更稳因为它在空表上不会除零报错。看结果的时候我关注三件事第一有没有某一列缺失比例超过一半这种列填充的意义已经不大更该考虑整列丢弃或者单独建一个是否有值的标记列第二缺失是不是集中在少数几行上如果_miss_cnt的分布里有一批行缺了七八个字段那基本可以判定是采集环节断掉了这批行应该单独隔离而不是粗暴填充第三缺失比例在dtype上有没有规律比如所有object列都缺那多半是导出时字符编码或空字符串的问题。提示_miss_cnt这种工作列记得在用完之后删掉别让它跟着数据流进下游否则某天你会发现模型里多了一个莫名其妙的特征。2. isna 判不出来就等于没缺失假缺失的识别与清洗前置2.1 None、NaN、NaT、pd.NA 四者的检测差异很多人以为缺失值只有一种其实 Pandas 里至少活跃着四种None、np.nan、pd.NaT、pd.NA。好消息是df.isna()和pd.isna()对这四种都能识别所以你日常用isna()做检测基本不会漏。坏消息是它们的比较行为完全不同这会在条件筛选中给你埋雷。np.nan的经典特性是它不等于自己np.nan np.nan返回False。这在写筛选条件时非常坑df[df[col] np.nan]永远返回空表。同理df[col] ! np.nan也不是非缺失的意思它会返回全True。正确写法只有df[df[col].isna()]和df[df[col].notna()]两种别用等号。pd.NA是 Pandas 1.0 之后引入的标量缺失值配合Int64、boolean、string这些可空类型使用。它的比较行为更理性——pd.NA pd.NA返回的是pd.NA而不是True或False所以拿它做布尔判断会直接抛异常val pd.NA if val: # TypeError: boolean value of NA is ambiguous pass这个报错我第一次遇到时找了半天因为代码逻辑看起来完全正常。根因是pd.NA参与布尔上下文时无法确定真假。规避办法很简单判断缺失统一用pd.isna(val)不要写if val。NaT是时间类型的缺失值行为和np.nan类似pd.NaT pd.NaT同样是False。2.2 字符串形式的缺失和占位符真正让我吃亏的是假缺失。从业务系统导出的 CSV 里缺失经常长这样空字符串、NULL、null、N/A、-、无、nan。这些在 Pandas 眼里都是合法的字符串isna()一个都不会报。你跑df.isna().sum()看到零缺失心里踏实了结果value_counts()一出来NULL排第三。这类问题必须在入口处解决不要留到填充阶段。我的做法是在read_csv阶段就把常见占位符注册成缺失df pd.read_csv( orders.csv, na_values[, NULL, null, N/A, NA, -, 无, nan, NaN], keep_default_naTrue, )keep_default_naTrue是默认值它会把NaN、null、None这些常见标记一起识别掉一般不用动。但如果你业务里真的存在字符串NA比如某个地区代码就是 NA那就得把它从na_values里拿掉或者用keep_default_naFalse然后手动列全。这个取舍必须做因为一旦把有效值误判成缺失后面补回去的值就全是错的。已经在内存里的 DataFrame 也有补救办法df df.replace({: np.nan, NULL: np.nan, -: np.nan})replace会遍历匹配列多的时候速度不快但胜在直观。如果只针对某几列写成df[[col_a, col_b]].replace(...)再赋值回去会更快注意别漏掉赋值这一步replace默认不原地修改。2.3 用 dtype 反推缺失来源还有一个不太起眼的排查角度看 dtype。如果一列本该是整型结果dtypes显示float64那几乎可以确定里面混了NaN因为整型数组装不下缺失值Pandas 会自动把它升成浮点。反过来一列object类型却全是数字字符串说明导入时没做类型推断这时候用pd.to_numeric(errorscoerce)转一遍转换失败的会变成NaN这本身就是一次缺失识别。df[amount] pd.to_numeric(df[amount], errorscoerce) # 转换后 isna 数量变化的部分就是原来的脏值errorscoerce是关键参数默认的raise遇到脏值直接抛异常ignore则原样返回不做转换只有coerce会把无法解析的转成缺失。这个写法我在清洗金额、数量、评分这类字段时用得最多比正则匹配省事得多。3. fillna 的四种传参姿势以及 inplace 到底该不该用3.1 标量、字典、Series、method 的选择逻辑fillna的参数类型决定了它的作用范围选错了会出现以为填了其实没填或者填错了列的情况。传标量最直接df.fillna(0)会把全表所有缺失都换成 0。这个写法在混合类型的表上非常危险因为字符串列也会被塞进数字 0整列 dtype 变成object后面做字符串操作时全是坑。除非你确认表里只有数值列否则不要这么写。传字典是精准控制的主力写法键是列名值是该列的填充值df df.fillna({ amount: 0, category: 未知, score: df[score].median(), })字典里没出现的列保持原样这一点很关键——它意味着你可以只处理该处理的列剩下的留给后面的逻辑。我一般会先用一个字典把每列的填充策略写清楚相当于把决策显式记录下来方便回看。传 Series 适合每列一个不同的值且有计算逻辑的场景最常见的是用均值填充num_cols df.select_dtypes(includenumber).columns df[num_cols] df[num_cols].fillna(df[num_cols].mean())注意这里必须显式选数值列。直接写df.fillna(df.mean())在 Pandas 2.x 上会因为mean()遇到非数值列而报错老版本则会静默丢掉非数值列两个版本行为不一致是个典型的版本迁移坑。method参数ffill、bfill在新版本里被标记为弃用Pandas 2.1 之后推荐直接用df.ffill()和df.bfill()。如果你维护的代码要跨版本跑建议统一改成新写法fillna(method...)虽然还能用但警告会一直刷屏。3.2 inplaceTrue 与链式赋值的那点事inplaceTrue看起来省事实际上是 Pandas 里最容易出问题的一类写法。它的问题不在于功能而在于它不返回新对象所以不能参与链式调用# 这样写df 根本没变 df.fillna(0, inplaceTrue) # 这样写会报错因为 fillna 返回 None df df.fillna(0, inplaceTrue)第二种写法是新手高频错误赋值之后df变成None后面所有操作全部崩掉而且报错信息往往出现在很远的地方排查起来很费劲。更隐蔽的是视图问题。如果你手上的df是从一个大表切出来的inplaceTrue可能触发SettingWithCopyWarning甚至在某些情况下改的是副本而不是原表你以为改了其实没改sub big_df[big_df[status] paid] sub.fillna(0, inplaceTrue) # 可能警告也可能不生效稳妥的做法是永远不用inplace改成显式赋值并且在切片时加.copy()sub big_df[big_df[status] paid].copy() sub sub.fillna(0)多写一行赋值能省掉后面半小时的排查时间这笔账很划算。我现在的习惯是项目里全局禁用inplaceTrue代码审查时看到就打回去改。3.3 limit 和 axis前向填充的真正粒度ffill和bfill都支持limit参数用来限制连续填充的最大长度。这个参数在传感器数据、日志流水场景里特别有用因为长时间断档往往意味着设备离线这时候继续沿用最后一个值就是在编数据。# 最多连续补 3 个超过的保持缺失 df[sensor] df[sensor].ffill(limit3)axis参数决定填充方向。默认axis0是按行往下填也就是沿着索引方向axis1是按列往右填用同一行左边的值补右边的空。后者在宽表场景里有奇效比如每月一列的表想用上月值补本月空值就可以用axis1配合ffill。但这个操作对列顺序极其敏感列一旦乱序补出来的值毫无意义用之前务必先排序并确认列序。还有一个limit_area参数inside表示只填充被有效值夹在中间的缺失outside表示只填充两端的缺失。这个在时间序列里很实用因为首尾的缺失往往不能用插值补中间断档才是真正需要插的地方。4. interpolate 不是万能胶插值方法的适用边界4.1 线性插值、时间插值、索引插值的差别interpolate()的默认方法是linear它在缺失点的前后各找一个有效值按等距做线性过渡。这个等距指的是索引位置等距不是时间等距。如果你的数据是按天采集但中间跳过了周末索引不连续linear就会把周五到下周一当成一步补出来的值偏保守。methodtime解决的就是这个问题它按索引的实际时间间隔加权。前提是索引必须是DatetimeIndex否则直接报错df df.set_index(date).sort_index() df[value] df[value].interpolate(methodtime)methodindex和values的区别在于前者用索引的数值做插值依据后者用实际数据的值做依据。日常用得最多的是time和默认的linear其余方法按需。这里有个容易被忽略的前提interpolate默认只填中间的缺失。如果缺失出现在序列开头或结尾默认参数下它不会动你会看到isna().sum()还有残留。要处理首尾就加limit_directionboth但加之前想清楚——首尾缺失没有参照值补出来的其实是外推可信度要打折。4.2 多项式与样条order 调大不等于更准methodpolynomial配合order参数可以做多项式拟合插值methodspline做样条插值。这两种方法看起来很高级实际使用限制很多。多项式要求有效点数量至少是order 1order3就意味着缺口两侧加起来至少要有 4 个有效值否则报错。更麻烦的是高次多项式会出现龙格现象也就是在区间边缘剧烈震荡补出来的值可能远超原始数据的取值范围。我试过在一条平稳的温度曲线上用order5插值结果补出来的峰值比实际最高温还高出好几度这种结果拿去做告警阈值就是灾难。样条方法依赖scipy环境里没装会直接报ImportError这在容器化部署时是个常见的漏装项。而且样条同样对噪声敏感数据本身抖动大的时候插值曲线会跟着抖。我的判断标准很简单数据本身是平滑的物理量温度、压力、连续位移可以试试样条数据是业务指标订单量、访问量老老实实用线性或前向填充。业务指标往往有突变和周期性任何平滑假设都不成立。4.3 limit_direction 与 limit_area 的配合这两个参数经常一起用配合不好会出现补了一半的情况。limit_direction控制方向forward、backward、bothlimit_area控制区域inside、outside、None。默认组合是forwardNone效果是只填中间的缺失首尾不动。一个实际的组合示例我想补中间断档但最多补 5 个且不动两端filled df[value].interpolate( methodlinear, limit5, limit_directionforward, limit_areainside, )如果我想把两端的缺失也用最近值补上就改成limit_directionboth并且limit_areaNone。这时候首尾会分别用bfill和ffill的效果填满但要注意这是外推不是插值。4.4 非数值列与边界缺失的处理interpolate只对数值列有效对object列调用会报TypeError。所以批量插值前一定要先筛列num_cols df.select_dtypes(includenumber).columns df[num_cols] df[num_cols].interpolate(methodlinear, limit_directionboth)另外一个边界情况是整列全空或者只有一个有效值。全空列插值后还是全空这没什么可说的只有一个有效值的列线性插值会把这个值铺满整列看起来补全了实际上是伪数据。这种情况我宁可保留缺失也不要制造一整列常数因为它会严重误导后续的统计。5. 分组内的前向填充groupby transform 的对齐陷阱5.1 为什么全表 ffill 会把上一个用户的记录带过来这是我在真实项目里踩过最狠的一个坑。有张用户行为表按用户分组、按时间排序中间有些字段缺失我图省事直接全表ffill()结果第一个用户的最后一个值被带到了第二个用户的第一行。数据量大的时候这种污染是隐蔽的因为补出来的值看起来完全正常只有做用户级别的聚合时才会发现某些用户的初始状态莫名其妙。根因很直接ffill()只看行的物理顺序不知道分组这回事。它眼里只有上下行没有用户边界。所以只要涉及多实体数据前向填充几乎一定要配合分组。5.2 transform、apply、直接 groupby.ffill 三种写法对比三种写法我都用过效果有差异稳定性也不同。第一种是直接调用df[val] df.groupby(user_id)[val].ffill()这是最简洁的写法Pandas 内部会对每个分组做填充再拼回原索引对齐是正确的。我在 1.5 之后的版本里用这个没出过问题。第二种是transform配合 lambdadf[val] df.groupby(user_id)[val].transform(lambda s: s.ffill())功能上和第一种等价但多了一层 Python 函数调用大数据量下会慢一些。它的优势是可以在 lambda 里塞更复杂的逻辑比如先判断再填。第三种是applydf[val] df.groupby(user_id)[val].apply(lambda s: s.ffill())这个写法要小心。apply在某些 Pandas 版本和某些分组结构下返回的索引层级不一样直接赋值可能触发索引对齐问题结果是整列变成NaN或者错位。如果你非要用apply先reset_index(level0, dropTrue)把多余的分组层级去掉再赋值。我现在的默认选择是第一种简单、快、对齐可靠。只有在填充逻辑带条件判断时才退到transform。5.3 分组键本身缺失时会发生什么还有一个更隐蔽的坑如果分组键自己就有缺失值groupby默认会把这些行整个丢掉dropnaFalse才会保留。这意味着你按user_id分组填充user_id为空的那批行根本不在任何分组里填充结果对它们是NaN而且原表里这些行的其他字段也不会被处理。# 默认行为user_id 为 NaN 的行被排除在分组之外 df[val] df.groupby(user_id, dropnaFalse)[val].ffill()加上dropnaFalse之后缺失的分组键会单独成一组填充逻辑才能覆盖到它们。这个参数在 Pandas 1.1 之后才有如果你的环境版本较老就得先用一个占位符把user_id的空值替换掉再分组。5.4 多列同时分组填充的写法实际业务里经常需要按多个键分组比如按用户 会话填充分组内的字段keys [user_id, session_id] cols [device, channel] df[cols] df.groupby(keys, dropnaFalse)[cols].ffill()多列同时ffill时Pandas 会对每个分组内的每一列独立做前向填充互不干扰。但要注意如果某一行device有值而channel为空填充后的channel会取同一分组内最近一次有值的记录而不是取同一行的device。这个语义是对的但你得清楚自己在做什么。6. 填充之后的 dtype 变化与下游连锁反应6.1 整型列被 NaN 拖成 float这是 Pandas 里最经典的隐形变化。一个整数列只要出现一个NaN整列 dtype 就从int64变成float64因为 NumPy 的整型数组装不下缺失值。这个变化不会报错但会在下游引发一连串问题ID 列变成浮点后显示成1.0跟字符串拼接时多出.0做主键关联时1和1.0匹配不上导出 CSV 时整列多了小数点。s pd.Series([1, 2, None]) print(s.dtype) # float64规避方式有两种。一是填充之后再转回整型df[id] df[id].fillna(-1).astype(int64)前提是填充值本身是整数这里用 -1 做缺失标记。二是从一开始就用可空整型Int64df[id] df[id].astype(Int64)Int64注意大写 I是 Pandas 的可空整型能同时装整数和pd.NA不会被迫升成浮点。代价是它和其他库的兼容性不如原生int64比如某些旧版的机器学习库拿到Int64列会报错需要显式.astype(int64)或.to_numpy(dtypefloat64, na_valuenp.nan)转换。6.2 用填充值当缺失标记时的一致性用-1或0这类哨兵值填充之后最大的风险是后续忘了这回事把它当成真实值参与计算。防止的办法有两个一是在填充的同时建一个标记列记录哪些位置是补的二是在管道里约定哨兵值只在展示层出现计算前必须还原成 NaN。df[amount_missing] df[amount].isna() df[amount] df[amount].fillna(-1).astype(int64)标记列的好处是信息不丢代价是表变宽、需要维护。我一般只在关键指标列上加标记比如金额、等级、评分这几个直接影响结论的字段普通描述性字段就不折腾了。6.3 填充结果的回归校验填充完一定要做一次校验看看分布有没有被改得离谱。最省事的做法是比对填充前后的描述统计before df_before[amount].describe() after df_after[amount].describe() print(pd.concat([before, after], axis1, keys[before, after]))重点看mean和std的偏移。用中位数填充时mean通常会往中位数方向靠std会明显下降。如果偏移超过 10%说明缺失比例已经不低填充本身在改变数据的面貌这时候应该在报告里显式说明处理方式而不是悄悄补完了事。另外一个校验角度是分位数。填充常量会让某个分位点突然堆积大量相同值quantile([0.25, 0.5, 0.75])的结果里如果连续两个分位点相等基本就是这个原因。7. 时间序列里的缺失重采样、asfreq 与业务口径7.1 resample 之后为什么凭空多出 NaNresample是按时间频率重新组织数据如果原始数据在某些时间点上没有记录重采样后的结果里这些位置就是NaN。这不是数据出错而是频率对齐的必然结果。比如原始数据只有工作日的记录按自然日重采样后周末两天就会空出来。daily df.set_index(date).resample(D).agg({amount: sum})这里有个语义问题必须先想清楚周末没有记录到底应该补 0 还是保留NaN如果是每天销售额这种口径周末没交易就是 0补 0 是对的如果是每天平均温度这种口径没记录就是没测补 0 会把均值拉低应该保留缺失或做插值。选错了整条曲线的形状就变了。我一般的判断标准是看指标的可加性。可加指标金额、次数、条数在时间维度上聚合时空档期的合理值通常是 0不可加指标比率、均值、温度、价格空档期应该保持缺失用插值或前值更合理。7.2 asfreq 和 reindex 的补位逻辑asfreq是把索引转换成指定频率不涉及聚合纯粹是补位。它的作用和reindex类似但更简洁ts df.set_index(date)[amount] full ts.asfreq(D)asfreq之后可以用fill_value直接指定补位值full ts.asfreq(D, fill_value0)这个写法比先asfreq再fillna(0)少一步语义也更清楚。但要注意fill_value只对新插入的位置生效原本就存在的NaN不会被它填充这一点很容易误解。reindex的优势是可以指定完整的时间范围包括原始数据里根本没有的区间full_range pd.date_range(start2024-01-01, end2024-12-31, freqD) full ts.reindex(full_range)这个用法在做年度报表时很常见因为原始数据可能只覆盖了部分月份而报表需要完整的 365 天。7.3 补位之后再做差分和同比的注意事项时间序列补完位很多人下一步就是做差分或者同比。这里有个细节差分前必须确保索引是连续的否则diff()会把不相邻的两个点当成相邻算出来的差值没有意义。asfreq或reindex之后索引就连续了但如果中间有重复索引结果还是会错最好加一句ts ts[~ts.index.duplicated(keeplast)]去重。同比计算也是同理pct_change(periods365)依赖索引的等距性索引不连续时这个 365 就不是一年。稳妥的做法是先把数据对齐到日频并补齐再做位移计算。补位的值会影响同比结果用 0 补和用前值补同比数字能差出一大截这个口径必须在做分析之前跟需求方确认清楚不能自己拍脑袋。7.4 重置索引前的最后一次检查补位和填充做完重置索引之前我会跑一遍完整的检查清单assert df.index.is_monotonic_increasing, 索引未排序 assert not df.index.has_duplicates, 索引有重复 print(df.isna().sum()[df.isna().sum() 0])第一句确保有序第二句确保无重复第三句列出仍有缺失的列。第三句的结果不一定是错的——有些列本来就该保留缺失但你必须逐列确认过而不是默认应该都补完了。这个习惯帮我抓到过好几次漏填的情况特别是那些在字典里写错了列名导致没匹配上的填充规则。最后分享一个我在实际项目里的体会缺失值处理最难的不是写代码而是给每一个空位找到一个能说服自己的理由。fillna(0)只需要一秒但解释为什么这里填 0 而不是中位数可能要花十分钟。这十分钟花得值因为它在后面每一次数据被质疑的时候都能替你回答那个问题。我现在的习惯是在清洗脚本里给每个填充决策写一行注释写清楚依据和影响范围半年后回头看这些注释比脚本本身更有价值。

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

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

免费获取报价 →
↑