072、Pandas 数据清洗:缺失值处理、类型转换、字符串操作、apply 家族
072、Pandas 数据清洗缺失值处理、类型转换、字符串操作、apply 家族上周五晚上十一点我正盯着一个客户的生产环境数据报表发呆。那个CSV文件有三十万行客户说“数据很干净就是跑个统计”。结果我pd.read_csv一加载发现“年龄”列里混着“25岁”、“三十”、“NaN”、“None”甚至还有一行写着“保密”。更离谱的是“收入”列里有些数字带逗号有些带“元”还有几个单元格直接是空字符串。那一刻我意识到所谓“干净数据”只是数据清洗前的幻觉。缺失值处理别让NaN悄悄搞崩你的模型缺失值处理是数据清洗的第一步也是最容易翻车的地方。很多人一上来就df.dropna()结果把有用的行也删了。我见过最惨的案例一个同事删掉了80%的数据就因为某列有少量缺失。先摸清缺失的底细importpandasaspdimportnumpyasnp# 模拟一份真实数据——别笑这就是生产环境常见的样子dfpd.DataFrame({姓名:[张三,李四,np.nan,王五,赵六],年龄:[25,np.nan,30,保密,28],收入:[12,000元,15000,np.nan,20000元,None],城市:[北京,上海,广州,np.nan,深圳]})# 这里踩过坑直接用isnull()只能看到NaNNone和空字符串会被忽略print(df.isnull().sum())# 只统计NaN不统计None和空字符串# 正确姿势把常见缺失值统一替换dfdf.replace([None,,保密,未知],np.nan)print(df.isnull().sum())# 现在统计准确了处理策略别一刀切缺失值处理没有银弹得看业务场景。我一般按这个优先级来# 1. 数值列用中位数填充比均值更抗异常值# 别这样写df[年龄].fillna(df[年龄].mean()) # 均值会被极端值带偏df[年龄]pd.to_numeric(df[年龄],errorscoerce)# 先把非数字转成NaNdf[年龄]df[年龄].fillna(df[年龄].median())# 中位数更稳健# 2. 分类列用众数填充或者单独标记为未知df[城市]df[城市].fillna(未知城市)# 保留缺失信息比随便填一个城市好# 3. 如果缺失比例超过50%考虑直接删除该列ifdf[收入].isnull().sum()/len(df)0.5:dfdf.drop(columns[收入])else:# 这里有个小技巧用前后值填充适合时间序列df[收入]df[收入].fillna(methodffill)# 前向填充真实场景的坑有一次我处理销售数据用均值填充了缺失的销售额结果模型训练出来偏差很大。后来发现缺失的销售额都是周末的数据而周末销售额本来就低。所以填充前一定要分析缺失模式。类型转换Pandas的隐式类型推断是个坑Pandas读取数据时会自动推断类型但经常翻车。比如“年龄”列里有字符串它就把整列变成object类型。更坑的是有些列看起来是数字但因为有逗号或单位也被当成字符串。# 模拟一个典型的生产数据dfpd.DataFrame({日期:[2024-01-01,2024/01/02,2024.01.03,2024-01-04],销售额:[12,000,15,000,20,000,18,000],折扣率:[0.1,0.15,0.2,0.25],客户ID:[C001,C002,C003,C004]})# 这里踩过坑直接astype会报错# df[销售额] df[销售额].astype(float) # 报错逗号没处理# 正确姿势先清洗再转换df[销售额]df[销售额].str.replace(,,).astype(float)df[折扣率]df[折扣率].astype(float)# 日期转换统一格式df[日期]pd.to_datetime(df[日期],formatmixed)# 自动识别多种格式# 别这样写df[日期] pd.to_datetime(df[日期]) # 遇到混合格式会报错# 客户ID保持为字符串但去掉前导零df[客户ID]df[客户ID].str.lstrip(C).astype(int)# 变成1,2,3,4类型转换的黄金法则永远先检查dtypes再转换。我习惯在数据加载后立即打印df.info()看看哪些列类型不对。字符串操作正则表达式是你的瑞士军刀字符串清洗是数据清洗中最耗时的部分。一个地址字段可能包含“北京市海淀区”、“北京海淀”、“海淀区北京”等各种写法。这时候正则表达式就是你的救星。# 模拟脏数据dfpd.DataFrame({地址:[北京市海淀区中关村大街1号,上海浦东新区陆家嘴,广州天河区体育西路,深圳南山区科技园],电话:[138-1234-5678,13912345678,010-12345678,02112345678],邮箱:[zhangsanqq.com,lisi163.com,wangwugmail.com,zhaoliu126.com]})# 1. 提取城市信息df[城市]df[地址].str.extract(r([\u4e00-\u9fa5]{2,3}(?:市|区)))# 匹配中文城市名# 这里有个坑extract只返回第一个匹配组如果没匹配到会返回NaN# 2. 清洗电话号码统一格式df[电话_清洗]df[电话].str.replace(r[^\d],,regexTrue)# 只保留数字# 别这样写df[电话].str.replace(-, ) # 只能处理一种分隔符# 3. 提取邮箱域名df[邮箱域名]df[邮箱].str.extract(r(.)$)# 提取后面的部分# 4. 字符串分割把地址拆分成省市区df[[省,市,区]]df[地址].str.extract(r(.{2,3}?省)?(.{2,3}?市)?(.{2,3}?[区县])?)# 这个正则有点复杂但很实用。注意问号表示非贪婪匹配字符串操作的性能优化如果数据量超过10万行str操作会变慢。这时候可以考虑用向量化操作或者numba加速。我一般会先做一次小样本测试确认正则没问题再跑全量。apply家族从入门到放弃再到精通apply家族是Pandas里最灵活但也最容易被滥用的工具。很多人一遇到复杂操作就apply结果代码又慢又难读。其实大部分场景都有更好的替代方案。# 模拟数据dfpd.DataFrame({姓名:[张三,李四,王五,赵六],年龄:[25,30,35,28],收入:[12000,15000,20000,18000],部门:[技术部,市场部,技术部,财务部]})# 1. applymap对整个DataFrame应用函数已废弃别用了# 别这样写df.applymap(lambda x: str(x).upper()) # 效率低且已废弃# 正确做法用向量化操作dfdf.map(lambdax:str(x).upper()ifisinstance(x,str)elsex)# 2. apply对行或列应用函数# 计算收入等级defincome_level(row):ifrow[收入]15000:return高收入elifrow[收入]10000:return中等收入else:return低收入# 这里踩过坑apply默认是按列遍历要指定axis1才是按行df[收入等级]df.apply(income_level,axis1)# 3. 更高效的替代方案用np.selectconditions[df[收入]15000,df[收入]10000]choices[高收入,中等收入]df[收入等级_v2]np.select(conditions,choices,default低收入)# 4. apply的高级用法分组后apply# 计算每个部门的收入中位数defdept_median(group):returngroup[收入].median()dept_statsdf.groupby(部门).apply(dept_median)# 但这里其实有更简单的写法df.groupby(部门)[收入].median()apply的性能陷阱apply本质上是Python循环数据量大时很慢。我测试过100万行数据apply比向量化操作慢50倍以上。所以能用向量化操作就别用apply能用np.select就别用自定义函数。实战经验总结写了这么多年数据清洗代码我总结了几条血泪教训永远先备份原始数据。我见过太多人直接修改原DataFrame结果清洗错了回不去。养成习惯df_clean df.copy()。分步验证不要一次性跑完所有清洗逻辑。每做一步清洗就打印df.head()看看效果。特别是正则表达式很容易匹配错。缺失值处理要记录日志。我习惯在清洗后打印一份报告原始行数、删除行数、填充列数、类型转换列数。这样出了问题能快速定位。类型转换前先检查异常值。比如年龄列如果出现负数或者超过150的值先处理异常值再转换类型。apply家族要慎用。能用向量化操作解决的问题绝对不要用apply。如果实在要用考虑用swifter库加速。字符串操作注意内存。str.replace和str.extract会生成新的Series如果数据量大内存会暴涨。可以考虑用chunksize分批处理。最后数据清洗不是一次性的工作。我一般会写一个清洗函数每次加载数据都调用它。这样既保证了代码复用也避免了重复踩坑。记住数据清洗花的时间会在模型训练和业务分析中加倍还回来。