Pandas数据清洗:缺失值、重复值与 melt/pivot 重塑
清洗是分析里最花时间、也最容易被糊弄过去的一步。危险的不是报错,是没报错:缺失值当成 0 参与了求均值,重复行让样本量翻倍,一列数字因为混进一个字符串整体变成文本类型,最后算出来的全是无效结果。判断标准只有一条——清洗的每一步都写进脚本,包括删掉了多少行、填了多少个值。
一份「脏」数据长什么样
Section titled “一份「脏」数据长什么样”import numpy as npimport 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 day30 1 A 2.5 12.0 13.0 12.51 2 A 2.5 NaN 14.5 15.02 3 B 5.0 15.5 NaN 16.03 4 B oops 14.0 15.0 NaN4 5 C 5.0 9.5 10.0 10.55 6 C 10.0 45.0 11.5 12.06 6 C 10.0 45.0 11.5 12.0sample_id int64group strdose strday1 float64day2 float64day3 float64dtype: object七个样本、三类典型问题:day1 到 day3 有缺失(NaN),第 5、6 行完全重复,dose 里混了一个 oops 导致整列是字符串类型。还有第 6 行的 day1 = 45.0 明显偏离其他组——异常值不在这里处理,后面单说。
(str 是 pandas 3.0 起的字符串 dtype,更早的版本显示 object,功能一致。)
缺失值:先数清楚在哪
Section titled “缺失值:先数清楚在哪”print(raw.isna().sum())sample_id 0group 0dose 0day1 1day2 1day3 1dtype: int64isna() 逐格判断是否缺失,.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 day30 1 A 2.5 12.0 13.0 12.53 4 B oops 14.0 15.0 NaN4 5 C 5.0 9.5 10.0 10.55 6 C 10.0 45.0 11.5 12.06 6 C 10.0 45.0 11.5 12.0剩下行数: 5subset 只要求这几列不缺失,day3 缺不缺不影响——这是删行时最有用的参数。不写 subset 的 dropna() 要求所有列都不缺,一刀切下去往往剩不下几行。默认的 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 day10 1 A 12.01 2 A 12.02 3 B 15.53 4 B 14.04 5 C 9.55 6 C 45.0填补后缺失: 0A 组只有 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()) day0 NaN1 3.02 3.03 3.04 7.0 day0 3.01 3.02 7.03 7.04 7.0ffill() 向后复制最近的有效值,开头的 NaN 没有前值可填,仍然缺失;bfill() 方向相反。这两种填法隐含「状态在两次测量之间不变」的假设,用在浓度、体重这类连续累积的指标上说得过去,用在每天独立采样的指标上就没有依据。
print("重复行数:", raw.duplicated().sum())print("去重后:", len(raw.drop_duplicates()))重复行数: 1去重后: 6duplicated() 把第二次及以后出现的行标为 True,drop_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 的第一条: 6dose 列现在还是字符串,求不了均值。to_numeric 把能转的转成数字,转不了的按 errors 参数处理:
d["dose"] = pd.to_numeric(d["dose"], errors="coerce")
print(d[["sample_id", "dose"]])print("转换失败的个数:", d["dose"].isna().sum()) sample_id dose0 1 2.51 2 2.52 3 5.03 4 NaN4 5 5.05 6 10.0转换失败的个数: 1errors="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 int64group categorydose float64day1 float64day2 float64day3 float64dtype: 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 × IQR 到 Q3 + 1.5 × IQR 的算异常。
q1, q3 = d["day1"].quantile([0.25, 0.75])iqr = q3 - q1lo, 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 day15 6 C 45.045.0 被识别出来了,但识别出来不等于要删——异常值可能是记录错误(小数点错位、单位不一致),也可能是真实的生物学现象,后者删掉就是在伪造数据。稳妥的做法是先回去看原始记录:确认是录入错误就修正或置为缺失;确认是真实观测,就改用中位数这类对极端值不敏感的描述,或者在方法部分写明处理方式。筛出异常行只是标记,动它之前先想清楚理由。
宽表变长表:melt
Section titled “宽表变长表:melt”day1、day2、day3 这种把时间点铺成列的结构叫宽表(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 expression0 1 A day1 12.01 2 A day1 12.02 3 B day1 15.53 4 B day1 14.04 5 C day1 9.55 6 C day1 45.06 1 A day2 13.07 2 A day2 14.58 3 B day2 NaN9 4 B day2 15.010 5 C day2 10.011 6 C day2 11.512 1 A day3 12.513 2 A day3 15.014 3 B day3 16.015 4 B day3 NaN16 5 C day3 10.517 6 C day3 12.0(18, 4)六行乘三个时间点,变成 18 行。id_vars 是保持不动的标识列,value_vars 是要合并的列,var_name 和 value_name 是新生成的两列的名字。转完之后数据量变大是正常的,长表就是更啰嗦——换来的是可以按 day 分组、按 day 上色、按 day 做交互项。
转成长表之后按时间点汇总只要一行:
print(long.dropna(subset=["expression"]).groupby("day")["expression"].mean())dayday1 18.0day2 12.8day3 13.2Name: expression, dtype: float64注意 day1 的均值 18.0 明显高于另外两天(12.8、13.2),源头就是那个 45.0——把它拿掉,day1 的均值回到 12.6,三天就齐平了;而 day1 的中位数是 13.0,几乎不受这个极端值影响。均值和中位数差得远,通常就是有异常值在拉——这也是前面强调异常值不能随手删的原因:它同时是「数据有问题」的信号。
长表变宽表:pivot 与 pivot_table
Section titled “长表变宽表:pivot 与 pivot_table”转回去用 pivot,但它对数据有要求:
long.pivot(index="group", columns="day", values="expression")ValueError: Index contains duplicate entries, cannot reshape报错的原因是 group 和 day 的组合不唯一——A 组在 day1 有两个样本,pandas 不知道该取哪一个填进单元格。pivot 只做形状变换,不做聚合,所以要求每个(行, 列)组合唯一。
pivot_table 允许重复,因为它会聚合:
print(long.pivot_table(index="day", columns="group", values="expression", aggfunc="mean"))group A B Cdayday1 12.00 14.75 27.25day2 13.75 15.00 10.75day3 13.75 16.00 11.25aggfunc 决定重复值怎么合一,不传默认是 "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。