资讯动态

Pandas缺失值处理避坑:空值类型、插值、分组填充与聚合影响

发布时间:2026/10/27 6:26:02 来源:尧图企业网站定制
平时跑清洗脚本最怕的不是报表跑不出来而是它跑出来了、数字看着还挺顺眼结果某一列悄悄少了一半的样本你还没察觉。缺失值处理这件事Pandas 给我上过好几次课fillna(0)一行代码下去均值和相关系数全变样ffill顺着时间往下拖拖过了三个月的断档整型列里塞了个控制符一夜之间变成 float导出的时候全带着.0。这篇是 Pandas 缺失值处理的收尾章节前面几篇把isna、dropna、fillna这些基础动作过了一遍这一篇不再重复那些入门用法专讲在真实项目里最容易翻车的那批场景四种“空”的差别、插值的参数怎么选、分组和重采样下的空洞怎么补、缺失值对聚合结果的隐性影响以及一套我自己反复在用的处理模板。适合已经能写df.fillna()、但经常被结果“看起来对、实际错”折磨的人。1. 这一篇到底想解决什么问题1.1 为什么单靠 fillna 填不满这个坑我见过太多清洗脚本缺失值处理就那么一行df.fillna(0)或者df.fillna(df.mean())。它当然能跑DataFrame 里也确实看不到 NaN 了但问题在于“没有 NaN”和“数据干净”根本是两件事。数值列填 0等于你主动告诉后续的聚合逻辑“这里发生过一次数值为 0 的真实事件”均值被拉低、方差被抬高、相关系数被稀释这些都还算温和的要是这列是金额或者库存量sum()出来的总数直接偏离一个数量级业务方拿着这个数字去做决策责任没法推给 Pandas。再往深一层看fillna只会回答“空的位置放什么数”它没法回答另外一堆更要命的问题这个位置是“采集设备没回传”还是“业务上本来就不该有值”同一台机器连续 30 天空缺是该按前后插值补齐还是该判定为停机不补按行填充和按组填充结果能差出十几万条。这就是我把这一篇单独拎出来的原因fillna是工具选哪种填充策略、填充之前要做哪些判断才是真正决定清洗质量的部分。1.2 四条主线和选型逻辑这篇我按四条线往下铺开每条线都对应一类我在实际项目里必须做的判断。第一条线是认清“空”的种类。Pandas 里至少存在None、np.nan、NaT、pd.NA四种表达缺失的方式它们的比较行为、所属 dtype、在聚合函数里的表现各不相同。分不清这个你连df.isna()到底抓到了什么都没把握。第二条线是插值interpolate的method参数有将近二十个可选项linear和time在不等距时间轴上的结果能差出一大截参数选错了等于自己制造失真。第三条线是分组和时间维度下的填充真实数据几乎都带分组键和时间戳这时候ffill会跨国界地往前拖groupby会把分组键为空的整行默默扔掉。第四条线是缺失值对计算结果的隐性污染skipna的默认值、全空列求和返回 0、NaN ! NaN带来的去重异常都属于“不报错但结果错”的类型。至于为什么不按“函数名”来组织内容理由很简单fillna、interpolate、dropna加起来也就那么几个 API查文档十分钟的事真正消耗时间的是判断——这段空缺该不该补、用什么补、补完怎么验证。所以这篇的组织方式是“场景主导函数服务场景”每个小节都从一个真实的判断点切入再落到具体参数上。2. 先搞清楚缺失值到底是哪几种空2.1 None、np.nan、NaT、pd.NA 的来龙去脉这四个东西混在一起是新手期最容易懵的地方。None是 Python 自带的空值类型就是NoneType它出现在 object 类型的列里一般来自 Python 字典、列表或者数据库读出来的 NULL。np.nan是 NumPy 的浮点缺失标记本质是一个 IEEE 754 规定的特殊浮点值float(nan)和np.nan是同一个东西它只能待在浮点列里——这也解释了一个现象整型列插进去一个 NaN整列会自动变成 float64因为整数类型没有地方放这个特殊值。NaT是Not a Time的缩写专门给 datetime64 类型的列用它是时间维度上的空位。而pd.NA是 Pandas 从 1.0 开始推的标量缺失值配套的是一套叫 Nullable dtypes 的东西比如Int64注意大写 I、boolean、string。这套类型的意义在于它终于允许一个整数列里存在“空”这个概念而且不牺牲整数精度。之前做订单量统计astype(int)一执行就报错就是因为列里藏着 NaN换成Int64之后问题才算根治。注意Int64和int64是两个完全不同的东西前者是 Pandas 的扩展类型后者是 NumPy 的原生类型。写代码时大小写弄反逻辑会静默走偏。2.2 四种空值的比较行为对比真正让人踩坑的不是它们的名字而是它们的比较行为。我把常遇到的几个差异整理成一张表测的时候对着跑一遍就记住了。空值所属类型x xx np.nan是否被isna()捕获典型来源NoneobjectTrueFalse是Python 数据结构、数据库 NULLnp.nanfloat64FalseFalse是NumPy 运算、缺失数值NaTdatetime64FalseFalse是时间解析失败、缺失时间戳pd.NANullable 扩展类型返回pd.NA返回pd.NA是Int64/string/boolean列重点看第三列和第四列。np.nan np.nan的结果是False这不是 bug是 IEEE 754 的规定pd.NA更特殊任何和它做比较的运算都返回pd.NA本身而不是True或False。这意味着你不能用df[df[col] np.nan]去筛选空值——这条语句永远返回空结果我早期写脚本时在这上面浪费过整整一个下午。正确写法只有df[df[col].isna()]这一条路。pd.NA的三值逻辑还有个连带效应df[col] xxx在 Nullable 列上得到的是booleandtype 的 Series里面混着True、False和pd.NA直接当布尔索引用会报错必须加.fillna(False)或者.astype(bool)才能用。这个细节在处理用户标签列时特别容易撞上。2.3 什么时候该主动切到 Nullable 类型不是所有场景都值得切我自己的判断标准有三条第一这列的业务含义上确实存在“空”且“空”有意义比如用户评分没评分和评 0 分是两回事第二这列需要保持整数或者布尔语义不能被 float 污染比如订单件数、是否首单第三这列要做大量的相等比较和布尔筛选Nullable 的三值逻辑虽然多一步处理但能避免 object 列带来的性能塌方。切换方式不复杂df[col] df[col].astype(Int64)前提是列里现有的空值能被识别。string类型同理astype(string)之后None和np.nan会统一成pd.NA。但我得提醒一句切换之后很多下游操作的行为会变比如sum()的返回值可能变成Int64标量而不是 Python int写进某些数据库驱动时可能不认需要.item()或者int()转一下。这一步没有银弹只能按下游的接受能力来定。3. 插值把“猜”这件事做得有理有据3.1 method 参数到底该选哪个插值本质上是在“猜”缺失位置应该是什么值既然是猜就得有一套明确的规则method就是这个规则。默认的linear是按位置做线性插值也就是不管你索引是 1、2、3 还是 1、100、1000它都当作等间距处理。这个特性在等距采样数据上完全没问题但只要你的时间轴有断档linear就会给出错误的斜率。我在处理传感器数据时就栽过一次。原始数据按分钟采集但设备断连了几小时重新上线后时间戳是跳跃的中间没有行。这时候用interpolate(methodlinear)它把断连前后两个点当成相邻点直接拉一条直线过去等于把几小时的波动抹平成了一个平滑过渡。换成methodtime它会按时间戳的实际间隔加权结果才合理。methodindex也是类似思路只不过它按索引的数值大小加权适合索引本身就有物理意义比如以秒为单位的整数索引的情况。除了这几个pad/ffill是拿前一个有效值填bfill/backfill是拿后一个有效值填nearest取最近的有效值。polynomial需要配order参数做多项式拟合spline走样条插值这两个我一般只在数据本身足够平滑、且缺失段很短的时候才动段一长就容易在边缘产生夸张的振荡看着比原来的空洞还假。3.2 时间序列插值必须配的那几个参数光有method还不够interpolate有四个参数必须一起考虑limit、limit_direction、limit_area、inplace。前三个决定了“填多远、往哪个方向填、只填哪一段”第四个已经被弃用别再用。limit限制的是连续填充的最大数量。这个参数的价值在于防止长断档被无脑补齐。比如一个用户的行为序列中间断了 200 天你用ffill一路拖过去那 200 天里每天都带着 200 天前的旧值看起来数据完整实际上全是噪声。设成limit3超过 3 天的空洞就保留原样你还能在后续分析里把这些行单独拎出来标记。limit_direction有三个取值forward默认从有效值往后填、backward从后往前填、both两个方向都填。新手常见的问题是想两头都补结果只写了limit没写方向默认只往前填序列头部的空缺纹丝不动。limit_area只有两个值inside和outside配合其他参数使用。inside表示只填被有效值包围的内部空洞头尾的空缺不动outside正好相反。这个参数在做时间序列对齐时非常好用——中间的空洞确实该补但序列开头和结尾的空缺往往意味着数据还没开始采集或已经结束补上就是造假。import pandas as pd import numpy as np idx pd.to_datetime([ 2024-01-01, 2024-01-02, 2024-01-05, 2024-01-06, 2024-01-20 ]) s pd.Series([10.0, np.nan, np.nan, 22.0, np.nan], indexidx) # 按实际时间间隔加权1月2日到1月5日是3天斜率按天摊开 print(s.interpolate(methodtime)) # 只补被有效值夹住的内部空洞尾部那一段保持为空 print(s.interpolate(methodtime, limit_areainside)) # 限制最多连续补 1 个位置其余保留 print(s.interpolate(methodtime, limit1))上面这段代码我建议自己跑一遍把三个输出并排放着看差异非常直观。methodtime下 1 月 2 日那格的填充值是按 1 月 1 日到 1 月 6 日的实际天数比例算出来的不是简单的两点平均。这个细节在日报转周报、周报转月报的对齐流程里影响很大差一天的比例累计到几十个指标上就是明显偏差。3.3 插值之后必须做的校验动作插值是所有缺失值处理手段里最“自信”的一种它总会给你一个数从不报错所以你更需要自己验。我固定会做三件事第一算出插值前后的分位数分布看 Q1 到 Q3 有没有异常的压缩插值过量会让分布向中间收拢方差变小第二把插出来的位置单独标记一列比如is_imputed后续做敏感指标时能按标记剔除第三对关键列画一条前后对比的折线肉眼扫一遍有没有出现长直线段长直线段基本就是插值过度或者ffill拖太远的信号。实操心得插值数量占该列非空值的比例超过 20% 时我会在报告里显式标注这件事。不是不能插而是任何基于这列做的结论都得打个折扣把这个前提写清楚比事后被追问要省事得多。4. 分组与时间维度下的缺失值怎么补4.1 groupby 加 transform 做组内填充全局均值填充是我最不推荐的做法原因很直接把不同量纲、不同业务含义的组混在一起算均值填进去的数在组内可能完全不合理。比如三个城市的门店销量一线城市日均 800三线城市日均 60全局均值可能是 300填到三线城市那列就是五倍的虚高。正确做法是按组算统计量再回填groupby配合transform是标准解法df[sales] df.groupby(city)[sales].transform( lambda s: s.fillna(s.median()) )transform的特点是返回和原 DataFrame 同长度、同索引的 Series所以能直接赋值回原列这是它比apply更适合这类场景的原因。apply返回的是聚合结果行数变了还得自己 merge 回去多一步操作就多一次出错机会。用中位数而不是均值是因为销量这类数据通常右偏均值容易被极端值带跑中位数更稳。这里有个隐藏的坑groupby默认dropnaTrue如果分组键本身含缺失这些行在分组时就被扔掉了transform回来赋值的时候对应位置是 NaN等于填充根本没生效。要处理这种情况得显式写groupby(city, dropnaFalse)让空分组也参与运算。我碰到过一次清洗完发现还有 3% 的空值排查半天才想起来是分组键为空导致整行被跳过。4.2 resample 之后出现的空洞怎么处理时间重采样是空洞的高发区。原始数据是按事件记录的有事件才有行resample(D)之后没事件的日期就变成空行。这些空行有些是“真的没有发生”比如某天零订单应该填 0有些是“数据没采到”比如接口挂了这种填 0 就是错误。这两种情况在代码层面长得一模一样只能靠业务判断。我自己会用一条规则来区分如果这列的语义是计数型订单数、点击数、告警数重采样后填 0 是合理的因为“没有记录”约等于“数量为零”如果语义是状态型或测量型温度、余额、在线人数就应该用ffill保持上一个状态而不是填 0填 0 会让曲线出现一堆虚假的谷底。# 计数型重采样后补 0 daily_cnt df.resample(D)[order_cnt].sum().fillna(0) # 状态型重采样后按前值保持 daily_bal df.resample(D)[balance].last().ffill()注意第二行用了.last()而不是.sum()状态类指标重采样应该取区间内的最后一个有效值sum在状态数据上毫无意义。这个选择关系到后面所有计算的正确性比填充方法本身更关键。4.3 ffill 跨组污染与时间倒序的问题ffill有个特别隐蔽的失效场景数据没有按时间排序。ffill是严格按行顺序往前找有效值的如果你的数据是乱序的它会把时间上更晚的值填到更早的位置等于未来信息泄漏到过去。我在一次回溯分析里踩过这个坑当时数据是从多个文件 concat 起来的每个文件内部有序但文件之间顺序颠倒ffill之后的结果看起来完整无缺做趋势分析时发现某个指标在时间上出现了诡异的前后矛盾查了两天才定位到排序问题。固定动作任何ffill/bfill之前先df.sort_values(ts)分组内填充则先按[group, ts]排序。这一行代码不能省。跨组污染是另一个高频问题。数据里如果混合了多个实体多个设备、多个用户不分组直接ffillA 设备的最后一条记录会被填到 B 设备的开头。这种错误在数据量大、实体多的时候几乎必然发生而且因为它不报错极难被发现。标准写法是先groupby再ffilldf df.sort_values([device_id, ts]) df[v] df.groupby(device_id)[v].ffill(limit2)groupby上的ffill会按组独立执行组边界处不会串值。limit参数建议一定要加不加等于允许无限长的拖尾。5. 缺失值对计算结果的隐性影响5.1 skipna 的默认值和它的连带后果Pandas 的聚合函数默认skipnaTrue也就是计算时自动跳过空值。这个默认值让大部分场景下结果“看起来正常”但也埋了一堆雷。最典型的是全空列一个全 NaN 的列做sum()结果返回0不是NaN。这个 0 会被下游当成真实的总和如果你在做汇总报表这一列就凭空产生了一个 0 值指标。规避方式有两种。稳妥的是加min_count1它表示“至少要有 1 个非空值才计算”不满足就返回 NaN# 全空列返回 NaN 而不是 0 total df[amount].sum(min_count1) # 至少要 3 个有效样本才计算均值 avg df[amount].mean(min_count3)另一种是算完之后手动检查把非空计数为 0 的列置回空。我倾向于用min_count因为它把校验写在了计算表达式里不依赖后续补救。mean、median、std这些函数在样本量很小时同样危险。一个组里只有 1 个有效值std()返回 NaN 是合理的但如果你用fillna(0)把标准差填成 0就等于宣称“这个组毫无波动”这是完全不同的事实。用min_count控制最小样本量比事后填值安全得多。5.2 比较、排序、去重中的 NaN 行为NaN ! NaN这条规则会渗透到很多你以为无关的操作里。drop_duplicates()是个例外它内部用了特殊的哈希逻辑会把多个 NaN 视为相等所以含空值的行去重结果是符合直觉的。但isin([np.nan])返回的是全 False想筛出空值只能用isna()。query()里的col np.nan同样失效得写col ! col对 float 列成立或者col.isna()。排序方面sort_values默认把空值排在最后na_positionfirst可以改到最前。rank有独立的参数na_option取值keep保持 NaN、top排最前、bottom排最后。做分位数打标时如果没设na_option空值会带着 NaN 排序号进入后续的qcut直接报错。还有value_countsPandas 2.0 之后默认dropnaTrue空值不计数。做数据质量核查时我习惯显式写value_counts(dropnaFalse)这样一眼能看到空值占比比单独跑一次isna().sum()更省事。5.3 dtype 漂移与内存开销整型列混进 NaN 会变 float64这是最常见的 dtype 漂移。后果不只是显示多出.0还有精度问题超过 2^53 的整数在 float64 里无法精确表示订单号、时间戳这类大整数一旦变成 float末几位可能就对不上了。这也是我前面推荐Int64的原因之一。另一个容易被忽略的是 object 列的内存开销。object 类型的列存的是一堆指针每个值都是一个独立的 Python 对象内存占用比数值类型高一个数量级而且聚合、比较都慢。含缺失的字符串列切到string类型既能统一空值表示又能省内存。场景原始 dtype问题处理方式整数列插 NaNint64自动变 float64大整数精度丢失转Int64字符串列含 Noneobject内存高、空值不统一转string低基数字符串object重复存储、比较慢转category布尔列含 NaNobject三值逻辑缺失转booleancategory类型需要单独提一句它的空值就是NaN不是pd.NA而且对 category 列做fillna时填入的值必须在已有的分类里否则会报ValueError。要么先把新值加进 categories要么先astype(object)再填后者更省心但会丢掉内存优势。6. 常见问题与排查技巧实录6.1 问题速查表现象大概率原因排查与解决fillna执行完还有空值分组键含空导致行被跳过groupby(..., dropnaFalse)整型列变 float导出带.0列里插入了 NaN转Int64或填值后astypedf[df.col np.nan]结果为空NaN 不等于自身改用df.col.isna()ffill结果时序错乱数据未按时间排序填充前sort_values全空列求和得到 0sum默认 skipna 且无最小值限制加min_count1布尔筛选报错Nullable 列返回三值逻辑.fillna(False).astype(bool)category列 fillna 报错填入值不在 categories 内先扩 categories 或转 object插值后曲线出现长直线断档过长 插值无限制加limit控制填充距离重采样后出现虚假零值状态型指标被 sum/填 0改last().ffill()rank或qcut报错空值参与排序设na_optionkeep6.2 我踩过的三个具体坑第一个坑是读文件的阶段就把空值弄丢了。read_csv默认会把NA、N/A、null、NULL等一批字符串识别为缺失值这个清单在pandas.io.parsers里有定义。但我遇到过一份数据缺省标记写的是-和unknown默认清单里没有于是这两类全被当成了正常的字符串值isna()一个都抓不到。后来统一在读取时就指定pd.read_csv(path, na_values[-, unknown], keep_default_naTrue)。如果是纯粹的数值列na_filterTrue打开能让解析更快但列里全是字符串时关掉它反而更省时间。第二个坑是链式赋值。df[col].fillna(0, inplaceTrue)这种写法在某些版本里会触发SettingWithCopyWarning更麻烦的是它可能只改了临时对象原表纹丝不动。我现在一律不用inplace全部写成df[col] df[col].fillna(0)语义清楚也方便在中间插入日志。第三个坑是跨表合并之后的空值语义。两张表 left join 之后右表没匹配上的列全是空值。这时候的空值含义是“没有匹配记录”和“有记录但字段为空”完全是两回事。直接fillna(0)会把两种语义混成一锅。我的做法是加一列_matched标记用右表的主键是否为空来判断后续所有的填充逻辑都基于这个标记分流。6.3 我现在固定用的一套处理模板最后把这套模板放出来它不见得是最优的但足够稳可以直接抄。import pandas as pd import numpy as np def clean_missing(df, key_cols, time_col, num_cols, cat_cols, cnt_cols): df df.copy() # 1. 质量快照留痕 report df.isna().mean().sort_values(ascendingFalse) # 2. 时间与分组排序避免 ffill 错位 df df.sort_values(key_cols [time_col]) # 3. 类别列新增“未知”分类而不是填空字符串 for c in cat_cols: df[c] df[c].astype(string).fillna(__UNKNOWN__) # 4. 计数列重采样口径下补 0 是合理的 for c in cnt_cols: df[c] df[c].fillna(0) # 5. 测量列组内中位数填充短断档再补前值 for c in num_cols: df[c] df.groupby(key_cols, dropnaFalse)[c].transform( lambda s: s.fillna(s.median()) ) df[c] df.groupby(key_cols, dropnaFalse)[c].ffill(limit2) # 6. 落地标记方便后续剔除 df[_imputed] report.index[report 0].tolist() and False return df, report模板里有几个刻意的设计。类别列填空字符串是我明确不做的因为空字符串在后续value_counts里会和正常值混在一起而__UNKNOWN__这种显式标记能让人一眼看出这是填充产物需要的时候能整体过滤掉。计数列和数值列分开处理是因为它们的填充逻辑在业务上就是两套混在一起写迟早出问题。最后那个_imputed标记我留了个半成品实际用法是逐列记录填充比例因为一个全局布尔管不住粒度——有的列填了 1%有的列填了 40%风险差着量级。补充一个我最近才用顺手的技巧填充之前先用df[num_cols].describe()和填充之后的做对比把两次结果concat起来看差值。描述性统计里count的变化告诉你填了多少mean和std的变化告诉你填充把分布带偏了多少。这个对比比写一堆断言更直观也方便直接贴进数据质量报告里。缺失值处理这件事做到最后其实不是技术问题是“你能不能对自己填进去的数负责”的问题——每个被填上的格子都应该能回答清楚它为什么是这个值。

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

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

免费获取报价 →
↑