第四章 数据清洗:用Pandas把脏数据收拾利索

说实话,干这行这么多年,我最怕的不是算法调不出参数,而是数据根本没法用。你想想看,从各种数据库、Excel、API里拉回来的药物数据,缺胳膊少腿是常态。今天咱们就聊聊怎么用Pandas把这堆烂摊子收拾干净。

4.1 数据清洗,到底是洗什么?

我习惯把数据清洗分成三个层面:缺失值、重复值、数据类型。你看下面这张图,基本就是我们的作战路线。

数据清洗核心路线 原始药物数据 缺失值处理 重复值处理 数据类型转换 填空/dropna 插值/填充 drop_duplicates subset+keep astype/convert_dtypes pd.to_datetime 清洗完成的药物数据集 持续迭代,随时返回

这张图就是咱们这章要干的事。别被吓到,一个一个来不难。

4.2 缺失值处理——药不能停,数据也不能缺

我记得第一次拿到医院处方数据时,有个字段叫“用法用量”,将近30%是空值。要是直接建模,结果肯定跑偏。Pandas里处理缺失值就那么几板斧。

4.2.1 先摸清家底:isnull 和 info

import pandas as pd

df = pd.read_csv('drug_prescriptions.csv')

# 看看每列缺多少
print(df.isnull().sum())

# 或者直接用info,一目了然
print(df.info())

我个人习惯先跑info()。它不仅显示非空数量,还把数据类型一起列出来,一举两得。

方法作用常用场景
isnull().sum()每列的缺失值总数快速概览
info()数据类型 + 非空计数综合检查
isna() / notna()返回布尔矩阵条件筛选

4.2.2 填空还是删行?

遇到缺失值,无非两条路:要么填,要么扔。扔数据挺心疼的,但有时候没办法。

# 方法一:直接扔掉缺失比例>50%的列
thresh = len(df) * 0.5
df_clean = df.dropna(axis=1, thresh=thresh)

# 方法二:用均值/中位数填充
df['dosage'].fillna(df['dosage'].median(), inplace=True)

# 方法三:用前一个值填充(时间序列常用)
df['time'] = df['time'].fillna(method='ffill')
避坑指南: 我曾经天真地整列用fillna(0)填充药物浓度,结果模型预测值全偏低。后来才意识到,对于药物血药浓度这类连续变量,中位数或分组中位数更合理。

4.2.3 高级玩法:插值

如果数据有顺序,比如按时间排列的用药记录,用interpolate()做线性插值效果不错。

# 线性插值,自动填补缺失的中间值
df['concentration'].interpolate(method='linear', inplace=True)
小技巧: 插值前建议先排序,否则插出来的结果可能是乱的。我一般会在interpolate()之前先sort_values()。

4.3 重复值处理——拒绝注水数据

重复数据在药物数据里特别常见。比如同一个患者同一天的处方,被录入系统两次。这种重复如果不清理,统计结果会严重失真。

4.3.1 发现重复

# 查看全部重复的行
duplicates = df[df.duplicated()]
print(f"重复行数: {len(duplicates)}")

# 只看某几列是否有重复
df.duplicated(subset=['patient_id', 'drug_code', 'prescription_date'])

你想想看,如果只按patient_id检查,那一个人多次用药记录都会被标记为重复,这不对。所以我建议subst参数要选对——要加上时间戳或用药ID。

4.3.2 删除重复

# 保留第一次出现的,去掉后面的
df.drop_duplicates(inplace=True)

# 保留最后一次出现的
df.drop_duplicates(keep='last', inplace=True)

# 完全不要重复行
df.drop_duplicates(keep=False, inplace=True)

我在项目中遇到过一种情况:同一个医嘱有两次录入,但其中一次有完整的检验结果,另一次没有。这时候keep='last'可能更好——通常后录入的那条信息更全。

核心原则: 去重前,一定要和业务确认“重复”的定义。是全部字段相同才算重复,还是关键字段相同就算?这差别很大。

4.4 数据类型转换——让Pandas理解你的数据

药物数据里最头疼的就是类型不对。明明是数值型的“剂量”,读进来成了object;明明是日期,成了字符串。机器不认识,你分析的逻辑就跑偏。

4.4.1 用astype转换常见类型

# 把剂量列转为浮点数
df['dosage'] = df['dosage'].astype(float)

# 把患者ID转为字符串
df['patient_id'] = df['patient_id'].astype(str)

但有个坑——如果列里混了非数值字符(比如“50mg”),astype(float)会直接报错。我一般先用pd.to_numeric加errors='coerce'。

# 安全转换,非法值变成NaN
df['dosage'] = pd.to_numeric(df['dosage'], errors='coerce')

4.4.2 日期时间处理——最容易翻车的环节

说实话,日期这块我翻车过好多次。各种格式:2023-01-15、01/15/2023、15-Jan-2023……还好Pandas有万能工具。

# 自动识别常见日期格式
df['prescription_date'] = pd.to_datetime(df['prescription_date'])

# 处理混合格式,遇到解析失败转为NaT
df['prescription_date'] = pd.to_datetime(df['prescription_date'], errors='coerce')
注意: to_datetime有个参数dayfirst,默认False,如果数据是日/月/年要设成True。我曾经因为忘了这个参数,把3月4号读成了4月3号,整个月报都错了。

4.4.3 类别数据优化

药物分类、剂型这类重复值多的列,用category类型能省内存、提速。

df['drug_category'] = df['drug_category'].astype('category')
print(df['drug_category'].cat.categories)  # 查看所有类别
转换函数适用场景注意
astype(float)纯数字列先清理非数字字符
pd.to_numeric含非法值的数字列errors='coerce'
pd.to_datetime日期/时间列指定format或dayfirst
astype('category')低基数类别列确保值有限且稳定

4.5 组合拳:一个真实的清洗流水线

最后给你看一个我在课程项目中真正用过的清洗流程,把今天讲的串起来。

def clean_drug_data(df):
    # 1. 移除全空的行和列
    df.dropna(how='all', inplace=True)
    df.dropna(axis=1, how='all', inplace=True)
    
    # 2. 日期统一
    df['rx_date'] = pd.to_datetime(df['rx_date'], errors='coerce')
    
    # 3. 剂量转数值,非法变NaN
    df['dosage'] = pd.to_numeric(df['dosage'], errors='coerce')
    
    # 4. 用分组中位数填充缺失的剂量
    df['dosage'] = df.groupby('drug_code')['dosage'].transform(
        lambda x: x.fillna(x.median())
    )
    
    # 5. 去重:同一天同患者同药品只保留一条
    df.drop_duplicates(subset=['patient_id', 'drug_code', 'rx_date'], keep='last', inplace=True)
    
    # 6. 去掉日期还是None的记录
    df.dropna(subset=['rx_date'], inplace=True)
    
    return df.reset_index(drop=True)

这段代码你完全可以拿去用。但记住,每步之后最好print(df.shape)看看数据量,我习惯每改一下就来个计数,心里有底。

我的习惯: 每次清洗时都把原始数据备份一个df_raw,万一洗坏了还能恢复。别问我怎么知道的。

4.6 本章小结要点

  • 缺失值:先看分布,再选策略——填空、插值还是删行,取决于业务场景。
  • 重复值:明确重复的定义,用subset精确控制;keep参数决定保留哪一条。
  • 类型转换:数值用pd.to_numeric,日期用pd.to_datetime,类别用astype('category')。
  • 清洗不是一次性的——每步检查,随时回退。

嗯,数据清洗看着琐碎,但如果你在这一章下够功夫,后面的建模会顺很多。我自己就是吃了不少亏才养成了这些习惯,希望你少走弯路。