跳到正文

Pandas数据清洗:缺失值、重复值与 melt/pivot 重塑

清洗是分析里最花时间、也最容易被糊弄过去的一步。危险的不是报错,是没报错:缺失值当成 0 参与了求均值,重复行让样本量翻倍,一列数字因为混进一个字符串整体变成文本类型,最后算出来的全是无效结果。判断标准只有一条——清洗的每一步都写进脚本,包括删掉了多少行、填了多少个值。

import numpy as np
import pandas as pd
raw = pd.DataFrame({
"sample_id": [1, 2, 3, 4, 5, 6, 6],
"group": ["A", "A", "B", "B", "C", "C", "C"],
"dose": ["2.5", "2.5", "5.0", "oops", "5.0", "10.0", "10.0"],
"day1": [12.0, np.nan, 15.5, 14.0, 9.5, 45.0, 45.0],
"day2": [13.0, 14.5, np.nan, 15.0, 10.0, 11.5, 11.5],
"day3": [12.5, 15.0, 16.0, np.nan, 10.5, 12.0, 12.0],
})
print(raw)
print(raw.dtypes)
sample_id group dose day1 day2 day3
0 1 A 2.5 12.0 13.0 12.5
1 2 A 2.5 NaN 14.5 15.0
2 3 B 5.0 15.5 NaN 16.0
3 4 B oops 14.0 15.0 NaN
4 5 C 5.0 9.5 10.0 10.5
5 6 C 10.0 45.0 11.5 12.0
6 6 C 10.0 45.0 11.5 12.0
sample_id int64
group str
dose str
day1 float64
day2 float64
day3 float64
dtype: object

七个样本、三类典型问题:day1day3 有缺失(NaN),第 5、6 行完全重复,dose 里混了一个 oops 导致整列是字符串类型。还有第 6 行的 day1 = 45.0 明显偏离其他组——异常值不在这里处理,后面单说。

str 是 pandas 3.0 起的字符串 dtype,更早的版本显示 object,功能一致。)

print(raw.isna().sum())
sample_id 0
group 0
dose 0
day1 1
day2 1
day3 1
dtype: int64

isna() 逐格判断是否缺失,.sum() 按列汇总。清洗前先跑这一行,比凭印象判断缺失情况可靠得多——你以为只有一列有问题,实际可能有一半的列都缺。缺失的位置分布也要看:如果 day1 的缺失全部集中在某个组,那缺失就不是随机的,删掉会系统性偏移结论。

notna() 是反过来的判断,组合 all(axis=1) 能找出信息完整的行:

print(raw.notna().all(axis=1).sum())
4

七个样本里只有四行一个缺失都没有。做多变量分析(比如三个时间点都要用)时,这是能用的样本量上限。

dropna 有两条路:按列判断用 subset,按行判断用 how

print(raw.dropna(subset=["day1", "day2"]))
print("剩下行数:", len(raw.dropna(subset=["day1", "day2"])))
sample_id group dose day1 day2 day3
0 1 A 2.5 12.0 13.0 12.5
3 4 B oops 14.0 15.0 NaN
4 5 C 5.0 9.5 10.0 10.5
5 6 C 10.0 45.0 11.5 12.0
6 6 C 10.0 45.0 11.5 12.0
剩下行数: 5

subset 只要求这几列不缺失,day3 缺不缺不影响——这是删行时最有用的参数。不写 subsetdropna() 要求所有列都不缺,一刀切下去往往剩不下几行。默认的 how="any" 是「有一个缺失就删」,改成 how="all" 才是「全缺才删」。

填缺失值要看数据结构。同一组内部的测量值通常比全局均值更接近,用组均值填比整体均值合理:

d = raw.drop_duplicates().copy()
d["day1"] = d["day1"].fillna(d.groupby("group")["day1"].transform("mean"))
print(d[["sample_id", "group", "day1"]])
print("填补后缺失:", d["day1"].isna().sum())
sample_id group day1
0 1 A 12.0
1 2 A 12.0
2 3 B 15.5
3 4 B 14.0
4 5 C 9.5
5 6 C 45.0
填补后缺失: 0

A 组只有 12.0 一个观测,所以 2 号样本填成了 12.0;B 组用 15.5 和 14.0 的均值。transform("mean") 返回的 Series 和原表等长,能直接喂给 fillna,原理见 Pandas分组聚合

填之前问一句:缺失是「没测到」还是「测出来是 0」。 仪器没读到数就填 0,会让整组的均值往 0 偏。均值填充会人为压低方差(填进去的值都等于均值,组内变异变小),后续做 t 检验、方差分析时标准误会偏小、p 值偏小。样本量够的情况下,删掉缺失行比填更干净;样本本来就少、又必须保留所有个体时,用多重插补(multiple imputation),不要用均值填充糊过去。

时间序列有另一套办法,用前一个有效值往后填(forward fill):

e = pd.DataFrame({"day": [np.nan, 3.0, np.nan, np.nan, 7.0]})
print(e.ffill())
print(e.bfill())
day
0 NaN
1 3.0
2 3.0
3 3.0
4 7.0
day
0 3.0
1 3.0
2 7.0
3 7.0
4 7.0

ffill() 向后复制最近的有效值,开头的 NaN 没有前值可填,仍然缺失;bfill() 方向相反。这两种填法隐含「状态在两次测量之间不变」的假设,用在浓度、体重这类连续累积的指标上说得过去,用在每天独立采样的指标上就没有依据。

print("重复行数:", raw.duplicated().sum())
print("去重后:", len(raw.drop_duplicates()))
重复行数: 1
去重后: 6

duplicated() 把第二次及以后出现的行标为 Truedrop_duplicates() 保留第一次出现的那行。数据来源是拼接多个 CSV、或者从数据库导出时连接写错,重复行几乎必然出现——同一份数据算两遍,均值被拉向重复样本的方向,而且是静默的。

两张表合并前也要查一次重复:连接的键列有重复,合并结果会按笛卡尔积膨胀,这个坑在 Pandas数据合并 里详细说过。

只想按部分列判重就传 subset

print("按 sample_id 判重:", raw.duplicated(subset=["sample_id"]).sum())
print("只留每个 id 的第一条:", len(raw.drop_duplicates(subset=["sample_id"])))
按 sample_id 判重: 1
只留每个 id 的第一条: 6

dose 列现在还是字符串,求不了均值。to_numeric 把能转的转成数字,转不了的按 errors 参数处理:

d["dose"] = pd.to_numeric(d["dose"], errors="coerce")
print(d[["sample_id", "dose"]])
print("转换失败的个数:", d["dose"].isna().sum())
sample_id dose
0 1 2.5
1 2 2.5
2 3 5.0
3 4 NaN
4 5 5.0
5 6 10.0
转换失败的个数: 1

errors="coerce" 把无法解析的值变成 NaN,默认的 errors="raise" 则直接抛错。别把 coerce 当成万能清洗:转成 NaN 之后问题就从「类型不对」变成「缺失值」,如果后面又被均值填掉,那个 oops 就彻底消失了。转换后先数一下生成了多少个 NaN,再决定是删、是填、还是回去查原始记录。

分类变量转成 category 类型能省内存,也让取值顺序可控:

d["group"] = d["group"].astype("category")
print(d.dtypes)
print(d["group"].cat.categories.tolist())
sample_id int64
group category
dose float64
day1 float64
day2 float64
day3 float64
dtype: object
['A', 'B', 'C']

类别少(几十个以内)的字符串列值得转,几万行的表能省下一半以上内存;取值几乎每行都不同的列(比如备注、编号)不要转,category 会为每个取值建一个类别,反而更占空间。category 的取值有顺序,cat.categories 按字母或数值排序,需要自定义顺序(比如「低、中、高」)就用 pd.Categorical(..., categories=[...], ordered=True)——顺带一提,这正好对应 R 里因子(factor)的概念,R数据类型详解 里把因子讲成了默认类型,Python 里则要显式转。

异常值:先识别,再决定动不动它

Section titled “异常值:先识别,再决定动不动它”

判断异常值最常用的是四分位距(IQR,interquartile range):把数据按大小排序,取第 25 和第 75 百分位,两者之差就是 IQR,超出 Q1 - 1.5 × IQRQ3 + 1.5 × IQR 的算异常。

q1, q3 = d["day1"].quantile([0.25, 0.75])
iqr = q3 - q1
lo, hi = q1 - 1.5 * iqr, q1 + 1.5 * iqr
print(round(q1, 2), round(q3, 2), round(iqr, 2), round(lo, 2), round(hi, 2))
print(d[d["day1"] > hi][["sample_id", "group", "day1"]])
12.0 15.12 3.12 7.31 16.69
sample_id group day1
5 6 C 45.0

45.0 被识别出来了,但识别出来不等于要删——异常值可能是记录错误(小数点错位、单位不一致),也可能是真实的生物学现象,后者删掉就是在伪造数据。稳妥的做法是先回去看原始记录:确认是录入错误就修正或置为缺失;确认是真实观测,就改用中位数这类对极端值不敏感的描述,或者在方法部分写明处理方式。筛出异常行只是标记,动它之前先想清楚理由。

day1day2day3 这种把时间点铺成列的结构叫宽表(wide format),画分组箱线图、跑重复测量方差分析都要先转成长表(long format):

long = d.melt(
id_vars=["sample_id", "group"],
value_vars=["day1", "day2", "day3"],
var_name="day",
value_name="expression",
)
print(long)
print(long.shape)
sample_id group day expression
0 1 A day1 12.0
1 2 A day1 12.0
2 3 B day1 15.5
3 4 B day1 14.0
4 5 C day1 9.5
5 6 C day1 45.0
6 1 A day2 13.0
7 2 A day2 14.5
8 3 B day2 NaN
9 4 B day2 15.0
10 5 C day2 10.0
11 6 C day2 11.5
12 1 A day3 12.5
13 2 A day3 15.0
14 3 B day3 16.0
15 4 B day3 NaN
16 5 C day3 10.5
17 6 C day3 12.0
(18, 4)

六行乘三个时间点,变成 18 行。id_vars 是保持不动的标识列,value_vars 是要合并的列,var_namevalue_name 是新生成的两列的名字。转完之后数据量变大是正常的,长表就是更啰嗦——换来的是可以按 day 分组、按 day 上色、按 day 做交互项。

转成长表之后按时间点汇总只要一行:

print(long.dropna(subset=["expression"]).groupby("day")["expression"].mean())
day
day1 18.0
day2 12.8
day3 13.2
Name: expression, dtype: float64

注意 day1 的均值 18.0 明显高于另外两天(12.8、13.2),源头就是那个 45.0——把它拿掉,day1 的均值回到 12.6,三天就齐平了;而 day1 的中位数是 13.0,几乎不受这个极端值影响。均值和中位数差得远,通常就是有异常值在拉——这也是前面强调异常值不能随手删的原因:它同时是「数据有问题」的信号。

转回去用 pivot,但它对数据有要求:

long.pivot(index="group", columns="day", values="expression")
ValueError: Index contains duplicate entries, cannot reshape

报错的原因是 groupday 的组合不唯一——A 组在 day1 有两个样本,pandas 不知道该取哪一个填进单元格。pivot 只做形状变换,不做聚合,所以要求每个(行, 列)组合唯一。

pivot_table 允许重复,因为它会聚合:

print(long.pivot_table(index="day", columns="group", values="expression", aggfunc="mean"))
group A B C
day
day1 12.00 14.75 27.25
day2 13.75 15.00 10.75
day3 13.75 16.00 11.25

aggfunc 决定重复值怎么合一,不传默认是 "mean"。分不清该用哪个时的判断很简单:只想改形状用 pivot,需要合并重复值用 pivot_table。另外要注意 pivot_table 默认会把行、列排序(sort=True),原始顺序有含义时(比如剂量梯度「低/中/高」)会被打乱,需要显式 reindex 拉回来,或者把那一列设成有序的 Categorical,让排序按你定义的顺序走。

R 用户对这两步不会陌生:melt 对应 tidyr 的 pivot_longer()pivot / pivot_table 对应 pivot_wider(),连「重复组合会报错」的行为都一样。对照 tidyr数据重塑 看一遍,两边的概念就能对上。清洗完的表存成 CSV 或者进数据库的写法,回到 Pandas数据读取与写入to_csv 那节,记得加 index=False