☰
Pandas缺失值处理避坑:空值类型、插值、分组填充与聚合影响
2026/10/27 6:26:01 网站建设 项目流程

平时跑清洗脚本,最怕的不是报表跑不出来,而是它跑出来了、数字看着还挺顺眼,结果某一列悄悄少了一半的样本,你还没察觉。缺失值处理这件事,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 数据结构、数据库 NULL
np.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(method='linear'),它把断连前后两个点当成相邻点,直接拉一条直线过去,等于把几小时的波动抹平成了一个平滑过渡。换成method='time',它会按时间戳的实际间隔加权,结果才合理。method='index'也是类似思路,只不过它按索引的数值大小加权,适合索引本身就有物理意义(比如以秒为单位的整数索引)的情况。

除了这几个,pad/ffill是拿前一个有效值填,bfill/backfill是拿后一个有效值填,nearest取最近的有效值。polynomial需要配order参数做多项式拟合,spline走样条插值,这两个我一般只在数据本身足够平滑、且缺失段很短的时候才动,段一长就容易在边缘产生夸张的振荡,看着比原来的空洞还假。

3.2 时间序列插值必须配的那几个参数

光有method还不够,interpolate有四个参数必须一起考虑:limit、limit_direction、limit_area、inplace。前三个决定了“填多远、往哪个方向填、只填哪一段”,第四个已经被弃用,别再用。

limit限制的是连续填充的最大数量。这个参数的价值在于防止长断档被无脑补齐。比如一个用户的行为序列,中间断了 200 天,你用ffill一路拖过去,那 200 天里每天都带着 200 天前的旧值,看起来数据完整,实际上全是噪声。设成limit=3,超过 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], index=idx) # 按实际时间间隔加权,1月2日到1月5日是3天,斜率按天摊开 print(s.interpolate(method='time')) # 只补被有效值夹住的内部空洞,尾部那一段保持为空 print(s.interpolate(method='time', limit_area='inside')) # 限制最多连续补 1 个位置,其余保留 print(s.interpolate(method='time', limit=1))

上面这段代码我建议自己跑一遍,把三个输出并排放着看,差异非常直观。method='time'下 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默认dropna=True,如果分组键本身含缺失,这些行在分组时就被扔掉了,transform回来赋值的时候对应位置是 NaN,等于填充根本没生效。要处理这种情况,得显式写groupby('city', dropna=False),让空分组也参与运算。我碰到过一次,清洗完发现还有 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']排序。这一行代码不能省。

跨组污染是另一个高频问题。数据里如果混合了多个实体(多个设备、多个用户),不分组直接ffill,A 设备的最后一条记录会被填到 B 设备的开头。这种错误在数据量大、实体多的时候几乎必然发生,而且因为它不报错,极难被发现。标准写法是先groupby再ffill:

df = df.sort_values(['device_id', 'ts']) df['v'] = df.groupby('device_id')['v'].ffill(limit=2)

groupby上的ffill会按组独立执行,组边界处不会串值。limit参数建议一定要加,不加等于允许无限长的拖尾。

5. 缺失值对计算结果的隐性影响

5.1 skipna 的默认值和它的连带后果

Pandas 的聚合函数默认skipna=True,也就是计算时自动跳过空值。这个默认值让大部分场景下结果“看起来正常”,但也埋了一堆雷。最典型的是全空列:一个全 NaN 的列做sum(),结果返回0,不是NaN。这个 0 会被下游当成真实的总和,如果你在做汇总报表,这一列就凭空产生了一个 0 值指标。

规避方式有两种。稳妥的是加min_count=1,它表示“至少要有 1 个非空值才计算”,不满足就返回 NaN:

# 全空列返回 NaN 而不是 0 total = df['amount'].sum(min_count=1) # 至少要 3 个有效样本才计算均值 avg = df['amount'].mean(min_count=3)

另一种是算完之后手动检查,把非空计数为 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_position='first'可以改到最前。rank有独立的参数na_option,取值keep(保持 NaN)、top(排最前)、bottom(排最后)。做分位数打标时,如果没设na_option,空值会带着 NaN 排序号进入后续的qcut,直接报错。

还有value_counts,Pandas 2.0 之后默认dropna=True,空值不计数。做数据质量核查时我习惯显式写value_counts(dropna=False),这样一眼能看到空值占比,比单独跑一次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三值逻辑缺失转boolean

category类型需要单独提一句:它的空值就是NaN,不是pd.NA,而且对 category 列做fillna时,填入的值必须在已有的分类里,否则会报ValueError。要么先把新值加进 categories,要么先astype(object)再填,后者更省心但会丢掉内存优势。

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

6.1 问题速查表

现象大概率原因排查与解决
fillna执行完还有空值分组键含空导致行被跳过groupby(..., dropna=False)
整型列变 float,导出带.0列里插入了 NaN转Int64或填值后astype
df[df.col == np.nan]结果为空NaN 不等于自身改用df.col.isna()
ffill结果时序错乱数据未按时间排序填充前sort_values
全空列求和得到 0sum默认 skipna 且无最小值限制加min_count=1
布尔筛选报错Nullable 列返回三值逻辑.fillna(False).astype(bool)
category列 fillna 报错填入值不在 categories 内先扩 categories 或转 object
插值后曲线出现长直线断档过长 + 插值无限制加limit控制填充距离
重采样后出现虚假零值状态型指标被 sum/填 0改last().ffill()
rank或qcut报错空值参与排序设na_option='keep'

6.2 我踩过的三个具体坑

第一个坑是读文件的阶段就把空值弄丢了。read_csv默认会把NA、N/A、null、NULL等一批字符串识别为缺失值,这个清单在pandas.io.parsers里有定义。但我遇到过一份数据,缺省标记写的是-和unknown,默认清单里没有,于是这两类全被当成了正常的字符串值,isna()一个都抓不到。后来统一在读取时就指定:pd.read_csv(path, na_values=['-', 'unknown'], keep_default_na=True)。如果是纯粹的数值列,na_filter=True打开能让解析更快,但列里全是字符串时关掉它反而更省时间。

第二个坑是链式赋值。df['col'].fillna(0, inplace=True)这种写法在某些版本里会触发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(ascending=False) # 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, dropna=False)[c].transform( lambda s: s.fillna(s.median()) ) df[c] = df.groupby(key_cols, dropna=False)[c].ffill(limit=2) # 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的变化告诉你填充把分布带偏了多少。这个对比比写一堆断言更直观,也方便直接贴进数据质量报告里。缺失值处理这件事,做到最后其实不是技术问题,是“你能不能对自己填进去的数负责”的问题——每个被填上的格子,都应该能回答清楚它为什么是这个值。

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

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

立即咨询