☰
Pandas数据清洗与预处理:从脏数据到可分析数据
2026/10/1 3:13:28 网站建设 项目流程

干数据分析这几年,我越来越确定一件事:真正决定一个项目顺利与否的,往往不是后面那个看起来很高端的模型,而是最开始那几步——用 Pandas 清洗数据、做预处理的过程。很多朋友拿着 Excel 里导出的“初始数据”直接开始跑分析,结果要么报错,要么统计出来的数字自己都不敢信。这还真不是 Python 的问题,而是数据在进入分析流程之前,没有经过一次系统性的预处理。

这篇文章会完整走一遍 Pandas 清洗数据和预处理的流程。从读取文件时怎么提前规避脏数据,到诊断数据结构、处理缺失值、转换数据类型、清理重复值和异常值,再到文本字段的定向清洗,最后用一个模拟项目把这些步骤串成一条可复用的流水线。适合刚接触 Pandas 的人建立整体思路,也适合有一定基础、但总觉得清洗步骤“差点意思”的朋友查漏补缺。

1. 原始数据带回来的那些“脏东西”:清洗前先想清楚这四件事

很多人对数据清洗有个误解,觉得它就是“删掉空行、去掉重复值”这么简单。真上了项目你会发现,脏数据的形态远比想象中丰富。

1.1 真实的脏数据长什么样

我接过一个电商订单导出的 CSV,字段有订单编号、用户ID、下单时间、商品名称、金额、城市、渠道。打开一看,问题全来了:

  • 金额列里混着中文逗号和货币符号,比如¥1,200,还有1.200这种欧洲写法,直接 to_numeric 会报错;
  • 下单时间有三种格式:2024/1/5、2024-01-05 14:32:11、20240105,排序和分组时各排各的;
  • 城市字段里“北京市”“北京”“BJ”都有,分组统计时被当成三个城市;
  • 缺失值不在同一列,而是散落在十几个字段里,没有规律可循;
  • 部分订单出现了完全相同的两行,但是它们的索引不同。

这些还不是最离谱的。我还见过商品备注字段里带着换行符和制表符,导入数据库后把表结构都搞乱了。所以说,数据清洗不是一个“锦上添花”的步骤,而是保证后续分析可信度的地基。

1.2 动手清洗之前,先想清楚的四个问题

每次拿到数据,我建议你别急着一顿操作,先回答四个问题,答案清楚了,清洗路径自然就出来了。

第一,数据口径是什么。比如“销售额”到底是含税还是不含税?“登录次数”是去重后的用户数还是总点击数?如果口径不统一,你后面做再多统计都是白费功夫。这个往往需要和业务方反复确认,Pandas 层面能做的是先把字段名和含义整理成对照表。

第二,清洗的边界在哪里。不是所有字段都需要洗。有的字段对当前分析目标毫无用处,比如研究销售趋势时,用户备注字段根本不影响结论,那就别浪费时间在它上面。清洗的优先级应该从分析目标倒推,先洗核心字段,有时间再处理锦上添花的部分。

第三,清洗顺序怎么定。我的习惯是:先转换类型,再处理缺失,然后去重和异常,最后做文本清洗。为什么?类型转换会揭示出一批“伪缺失”(比如空字符串、空格、'nan'这种字符串形态的缺失值),先把它们暴露出来,fillna 的时候才不会被骗。重复值删除也应该尽量放在类型统一之后,因为只有字段标准化了,重复判断才准确。

第四,原始备份不能省。不管你的清洗脚本写得多自信,都要保留一份原始数据的副本。我见过不止一次,清洗后发现规则写错了,或者老板说“这个字段的口径变了”,只能从头再来。处理办法很简单,读取之后立刻:

df_backup = df.copy()

这一行看着不起眼,但能让你在后面的任何一次“后悔”中全身而退。

1.3 复杂在读取阶段就处理掉一部分

很多人不知道,pd.read_csv和pd.read_excel本身就带了一堆“预清洗”参数,用好了能省掉后面的大量工作。

import pandas as pd import numpy as np df = pd.read_csv( 'order_data.csv', encoding='utf-8-sig', dtype={'user_id': str, 'order_id': str}, # ID类字段按字符串读,防丢失前导0 parse_dates=['下单时间'], # 读进来就转成datetime usecols=['订单编号', '用户ID', '下单时间', '金额', '城市'], # 只留需要用的列 thousands=',', # 自动处理千分位逗号 )

三个容易被忽略的点:

  • encoding='utf-8-sig':如果你用 Excel 打开过导出文件再另存,文件头可能带 BOM,用这个参数能避免第一列列名出现\ufeff乱码;
  • dtype 指定字符串:用户ID、订单号这类字段如果按数字读,18位以上的长ID会被转成科学计数法,或者丢失尾数;
  • parse_dates:读取阶段就把日期列解析成 datetime64 类型,后面排序、分组、画图都顺手。

如果处理的是文本文件,比如日志或者行为数据,pd.read_csv()同样适用,只需要在sep参数里换成对应的分隔符,比如\t制表符或者正则表达式\s+匹配连续空白。E文不好没关系,记住sep和encoding这两个参数,就能应付绝大多数文本文件读取场景。

2. 数据体检怎么做:结构性诊断与缺失值处理实操

数据读进来以后,先别急着洗,先“体检”。体检的目的是弄清楚数据到底长什么样、哪里有毛病、严重到什么程度。

2.1 用 info、dtypes 和 describe 给数据来一次全面体检

我习惯拿到数据后先打一套组合拳:df.info()、df.dtypes、df.describe(),再加一个df.head()。这一步不是走形式,是真的能看出很多门道。

print(df.info())

info()会输出每个列的名字、非空值数量、数据类型和总内存占用。这里有一个关键判断:如果一个列显示为object类型,但它实际内容是数值,那你这列就必须进行类型转换。Pandas 的object类型是个“大筐”,什么都能装,但一旦装进“大筐”,性能会下降,很多数值运算也不支持。

如果数据本身不大,但列数多,我会再手动创建一个 DataFrame 骨架来看结构,这种方式在搭建特征工程时尤其好使:

feature_template = pd.DataFrame({ 'feature_name': [], 'dtype': [], 'missing_rate': [], 'n_unique': [] })

先把要用的特征列名、预期类型写进去,再和实际数据做比对。这个“先搭骨架、后填数据”的习惯,能让你在字段多的时候不至于抓瞎。

接着看描述统计:

print(df.describe(include='all'))

describe()默认只统计数值列,加上include='all'以后,对象类型列也能看到计数、唯一值数量、出现频次最高的取值。这一步的价值在于:你能在几秒钟内发现“金额列的最小值为什么是负数”“城市列唯一值为什么有 87 个但业务上只有 30 多个城市”这类明显异常。

体检阶段我还会顺带看一眼内存占用,df.memory_usage(deep=True)。如果跑的是大数据量文本,光一个字段就吃了好几个G,那你在后面做 groupby 之前就得先考虑降采样或者只保留必要列。

2.2 缺失值到底要不要删:先算清楚缺失率和业务容忍度

Pandas 里检查缺失值有两套等价的方法:isnull()和isna()。我习惯用isna(),因为拼写顺一点,功能完全一样。

missing_count = df.isna().sum() missing_rate = df.isna().mean().round(4) missing_info = pd.DataFrame({'缺失数量': missing_count, '缺失率': missing_rate}) print(missing_info[missing_info['缺失率'] > 0])

这样一筛,哪个字段缺了多少、缺的比例多高,一目了然。根据我自己的经验,缺失率的处理大致可以分成三档:

  • 缺失率低于 5%:多数情况下可以直接丢行,或者用中位数/众数填补,影响不大;
  • 缺失率在 5%-30%:需要看缺失是否与某些业务状态有关。比如“支付时间”缺失,很可能是订单未支付,那这个缺失本身就是有效信息,不能随便填;
  • 缺失率超过 30%:如果字段不是分析核心,建议删除整列。强行填补超过三分之一的缺失,填出来的值基本是编的,对模型和统计都是污染。

理论上有 MCAR、MAR、MNAR 这些统计概念,但实际上做项目时没那么讲究。核心原则就一句话:缺失本身就是信息,先搞清楚缺失的原因,再决定怎么处理。

2.3 dropna 与 fillna 的用法和取舍

缺失值处理有两大流派:删和填。

先说说删。dropna()最基础的用法是删掉带缺失的整行,但这样操作后遗症很大。如果一张表有 20 列,单行只要有一列缺失就会被删,最后能留下的行数可能只剩一半。这里有一个非常实用但经常被忽略的参数:thresh。

df_clean = df.dropna(thresh=3)

这个参数的含义是:保留那些在整行中非空值数量不少于 3 的行。换句话说,一行如果有超过 2 个字段是空的,就整行删掉,否则保留。这比dropna()的“零容忍”策略温和得多,适合字段多、缺失分散的场景。

再说填。fillna()的填法有很多种,不是简单填 0 就完事。我按列类型整理了常用的填补策略:

列类型推荐策略理由
连续数值列中位数填充均值容易被极端值拉偏,中位数更稳健
分类型列众数填充保留分布特征
时间序列列ffill / bfill用前值或后值填补,符合时间趋势
业务有上下限的列上下限值或业务默认值比如“库存”缺失按0填,符合业务逻辑
明确表示“无”的列填'unknown'保留缺失状态,不让数值污染统计

光说不练假把式,来两个典型实操场景。

数值列如果存在较多的极端值,我不用均值而是用分组中位数填充:

df['金额'] = df.groupby('渠道')['金额'].transform( lambda x: x.fillna(x.median()) )

为什么分组?因为不同渠道的消费水平可能差异很大,统一填一个中位数会掩盖渠道差异。分组之后的填充更贴近真实分布。

时间序列字段的填充则要谨慎,我去填补订单的“送达时间”缺失时,会用ffill先看看情况:

df['送达时间'] = df['送达时间'].ffill()

但要注意,如果数据不是严格按时间排序的,ffill会产生严重误导,必须确认索引或之前已经 sort_values 过,才可以用时间序列填充。

还有一类“伪缺失”,其实是空字符串或者一堆空格。isna()检测不出来,但实际内容和缺失没区别。这种情况我会先统一转成真正的缺失:

df['备注'] = df['备注'].str.strip() df['备注'] = df['备注'].replace('', np.nan)

先把空格清掉,再批量转换,比逐个字段手工处理高效多了。

3. 数据类型转换:字符串、数值、时间之间来回折腾的坑与技巧

类型转换是整个预处理里最容易踩坑、也最体现“预处理”价值的一步。热搜里有人搜“pandas 数据类型转换”,说明这块确实是高频卡点。

3.1 为什么类型转换是数据预处理的核心

Pandas 里的数据类型直接决定了你能对列做什么操作。

  • 数值列和字符串列执行sum(),一个给数字,一个把字符串拼在一起;
  • 字符串列排序,是按字典序排的,'100'会排在'99'前面;
  • datetime 类型的列才能做时间差计算、按月重采样、绘制时间序列图。

换句话说,一个列的类型错了,后面所有操作都可能是错的。类型转换的目的,就是把“机器理解不了”的数据,变成“机器能正确处理”的数据。

3.2 数值转换:to_numeric 和 astype 的正确姿势

数值转换的最佳工具是pd.to_numeric(),不是astype()。虽然两个都能转数值,但to_numeric有一个核心杀招:errors='coerce'。

df['金额'] = pd.to_numeric(df['金额'], errors='coerce')

coerce的意思是把转不过去的值强制变成 NaN。比如一个字符串列表['1200', 'N/A', '1,500'],转换之后会得到[1200.0, NaN, 1500.0]。这一步的价值是:它不报错,而是把脏数据暴露为缺失值,让你后续可以统一处理。

相比之下,astype('float')一旦遇到不能转的值,会直接抛异常,整个脚本中断。要我说,astype只适合在数据已经确定干净的情况下做“确认性转换”。尤其是把字符串'123'转成int这种完全可预测的操作,才能放心用。

注意一个高危操作:如果列里有 NaN,你直接写df['列名'].astype('int'),大概率会得到一个ValueError。正确的绕行方式是先fillna再astype,或者用 Pandas 2.0 之后新增的Int64类型来保存带缺失的整型:

df['数量'] = df['数量'].astype('Int64')

这个Int64第一个字母是大写的 I,它能在保留缺失值的同时,仍然按整数类型存储。很多人不知道这个类型,在线上一搜全是astype('int')然后踩坑的帖子。这里重点标记一下。

实际项目里,最经典的就是“金额字段带货币符号、千分位、前后空格”的清洗链。我会这样一口气处理完:

# 1. 去掉货币符号和千分位逗号 df['金额'] = df['金额'].str.replace(r'[¥¥,,\s]', '', regex=True) # 2. 转数值,转不过去的变成NaN df['金额'] = pd.to_numeric(df['金额'], errors='coerce') # 3. 用中位数填补转换失败导致的缺失 df['金额'] = df['金额'].fillna(df['金额'].median())

第 1 步用了正则表达式,一次把中文符号、半角逗号、全角逗号、空格全删了。如果字符串里还有括号注释之类的干扰,就再扩展正则规则。这三步跑下来,金额列基本就干净了。

3.3 时间转换:to_datetime 和 parse_dates 的配合

时间字段的清洗,核心工具是pd.to_datetime()。

我在读取时如果已经指定了parse_dates,那这一步可以简化;但如果你在项目中途才想起来日期列还没转换,那就手动处理:

df['下单时间'] = pd.to_datetime( df['下单时间'], format='%Y-%m-%d %H:%M:%S', errors='coerce' )

这里有三个容易被忽略的点。

format 参数显式指定格式很重要。如果数据里有2024/01/05、2024.01.05、20240105三种写法混在一起,不指定format,to_datetime虽然多半也能猜出来,但速度慢几个数量级,而且遇到歧义比如01/02/2024这种月日/日月混合的,可能猜错。显式指定格式后,不匹配的值会变成 NaN,这样你能立刻发现哪些数据格式异常。

errors='coerce' 同样好使。时间字段里偶尔混入'NULL'、'unknown'之类的垃圾值,不报错、转成 NaN,后续统一填充或者删行,比脚本中断要可控得多。

年月日分列的情况也别慌。数据表里不一定有完整的日期列,可能只有年、月、日三列,比如日志表就是这种结构。可以拼回去:

df['日期'] = pd.to_datetime( df['年'].astype(str) + '-' + df['月'].astype(str) + '-' + df['日'].astype(str), errors='coerce' )

如果表里只给了时间戳(Unix 时间戳或者毫秒时间戳),那就更简单了,pd.to_datetime(df['ts'], unit='s')就行。唯一的坑是单位不统一,有的是秒、有的是毫秒,少写一个unit参数,日期会差出几十倍。

3.4 自定义拆分转换:单列拆多列

有一种数据类型转换不是“字符串转数字”,而是“一列拆成多列”。实际业务里的典型代表是“规格”字段,比如'500ML/瓶'、'1000件/箱'。这种字段不拆,你就没法做单位维度的统计分析。

df[['规格数值', '规格单位']] = df['规格'].str.extract( r'(\d+(?:\.\d+)?)\s*([A-Za-z/]+)' )

str.extract()配合正则表达式,可以直接把匹配到的分组变成新列。上面的规则把500ML/瓶拆成500和ML/瓶。我建议拆完之后把数值列再做一次pd.to_numeric,因为 extract 出来的永远是字符串。

字符串的批量操作也是同样道理,str.split配合expand=True也能实现类似效果:

df[['一级类目', '二级类目']] = df['类目'].str.split('/', expand=True)

4. 重复值、异常值和文本脏数据的定向清理

结构和类型问题处理完之后,就到了“定向清理”环节。这一步针对的是具体的脏数据形态:重复值、异常值、文本乱码。

4.1 重复值处理:哪些重复该删,哪些不该删

先上基础操作:

df = df.drop_duplicates()

这一行会删掉“整行完全一致”的重复记录。真实场景里,整行重复通常来自数据源重复导出或者网络重试,删掉基本没风险。

但大多数重复不是整行重复,而是业务主键重复。比如同一用户在同一秒点击了两次“提交订单”,可能是手抖,也可能是合法操作。这时候你不能单纯按所有列去重,而是应该指定业务主键:

df = df.drop_duplicates(subset=['订单编号', '用户ID'], keep='first')

subset指定判断重复的列,keep='first'保留第一条。重点来了:到底保留哪条,要看你分析目标是什么。如果分析订单金额,保留第一条还是最后一条对总额没影响;如果分析用户修改记录的轨迹,那你可能一条都不能删,反而要保留所有记录。

还有一个常见误区:不去重直接分组统计,行数看着没问题,其实全部被翻倍。我习惯在清洗流程里加一个断言式检查:

assert df['订单编号'].nunique() == len(df), "存在重复订单编号"

nunique()和len()不相等,说明主键有重复,代码直接中断报警。这样比事后发现统计数字离谱要舒服得多。

4.2 异常值判定:统计方法给线索,业务规则做决策

异常值比缺失值和重复值都棘手。缺失和重复至少“长得明显”,异常值却混在正常数据里,一不小心就把均值、方差拉到离谱的水平。

我的处理逻辑分两层:先统计找线索,再业务定去留。

第一层,统计线索。最快的方式是看describe()的 min 和 max。比如金额列的 min 是 -500,这大概率有问题,正常的交易金额不应该为负。如果数据列很多,你还可以画个箱线图辅助判断,Pandas 的df.plot(kind='box')配合 matplotlib 一两行就能出图,比干看数字直观得多。

定量判断常用两个方法:

  • 3σ 原则:数据近似正态分布时,超过均值±3倍标准差的值算异常。但真实业务数据很少是标准正态,这个方法的误杀率不低;
  • IQR 四分位距法:以 Q1 和 Q3 的 1.5 倍为边界,超出边界的值视为离群点。这个方法不依赖正态假设,更稳健,我对绝大多数业务字段都优先用它。
Q1 = df['金额'].quantile(0.25) Q3 = df['金额'].quantile(0.75) IQR = Q3 - Q1 lower = Q1 - 1.5 * IQR upper = Q3 + 1.5 * IQR df_outlier = df[(df['金额'] < lower) | (df['金额'] > upper)] print(f"疑似异常值 {len(df_outlier)} 条")

第二层,业务决策。统计算出来的“异常”只是一个信号,不能直接当成“脏数据”删掉。比如一个卖高端家电的店铺,客户单笔订单 10 万元可能就是正常消费;而一个奶茶店的单笔订单 10 万元,基本可以断定是测试单或者刷单。所以我的处理框架是:

  • 先看异常值数量占总体比例,如果低于 0.1%,多半是录入错误,可以删;
  • 如果比例偏高,可能是业务本身长尾分布(比如收益、销量这类数据),不要删,尝试用ewm这类指数加权方法做平滑,或者直接进行对数变换;
  • 如果异常值恰好落在业务规则的禁区(如金额必须大于 0),那不需要统计方法,直接用业务规则过滤。

ewm函数在这个场景很实用,它的核心参数是span和adjust。比如对店铺的每日销售额做平滑,减少偶发大单对趋势判断的干扰:

df['销售额_平滑'] = df['销售额'].ewm(span=7, adjust=True).mean()

span=7的意思是近似按 7 天窗口做指数加权,越近的日期权重越大。adjust参数一般保持默认就行,实际项目里我很少去动它。注意ewm是为了“平滑抖动”,不是为了“删掉异常”,没有特殊需求别用它来做异常值过滤。

4.3 文本脏数据:strip、大小写统一、替换和正则

文本字段是最让新人头疼的部分,因为它的“脏”没有固定模式。但只要掌握四板斧,大部分问题都能处理。

第一板斧:去空格和特殊符号。

df['城市'] = df['城市'].str.strip() df['商品名称'] = df['商品名称'].str.replace(r'[\t\n\r]', '', regex=True)

str.strip()只能去掉首尾的空格,中间的空格要用str.replace()配合正则才能处理干净。制表符和换行符这种隐形脏字符,也靠这一步解决。

第二板斧:统一大小写。

分类字段经常出现大小写混用,比如GZIP、gzip、Gzip在语义上完全一样。统一大小写之后再分组,统计结果就整齐了:

df['文件格式'] = df['文件格式'].str.lower()

第三板斧:批量替换做同义词归一。

城市字段写“北京市”“北京”“BJ”的问题,我一般用replace配合字典做归一化:

city_map = { '北京市': '北京', 'BJ': '北京', 'Beijing': '北京', '上海市': '上海', 'SH': '上海', 'Shanghai': '上海', } df['城市_标准化'] = df['城市'].replace(city_map)

这个字典可以维护在外部配置文件里,新增映射随时追加,不用改主代码。

第四板斧:正则提取和匹配。

文本里藏信息是常态。商品备注里可能写着“备注:急件”,评论里可能带着电话号码,这些都可以用str.extract和str.contains提取。热搜里有“pandas 正则表达式”这个词,说明大家确实用得多,这里给一个典型例子:

df['是否急件'] = df['备注'].str.contains('急件', na=False).astype(int) df['联系电话'] = df['评论'].str.extract(r'(1[3-9]\d{9})')

str.contains返回布尔值,拼上.astype(int)就能生成 0/1 标签列,非常方便做特征工程。str.extract则把匹配的唯一分组抽出来作为新列,提取联系电话和身份证号这类结构化信息时尤其好用。

文本清洗的顺序也很重要。我一般把步骤定为:先strip清空格,再replace做规则替换(包括去掉换行符之类),然后lower统一大小写,最后str.extract提取信息。顺序乱了,可能你先提取了字段,结果后面替换时把提取结果里的内容也改了,导致信息错乱。

5. 组合操作让数据真正可用:筛选、排序、分组与合并

清洗完成不等于数据能用。数据要真正被分析和模型吃进去,还得经过筛选、排序、分组、合并这些组合操作。这一步最能体现 Pandas 的“数据处理”功力。

5.1 按条件筛选:布尔索引、isin 和 query

筛选数据最自然的方式是布尔索引。原理简单粗暴:df['金额'] > 100产生一列布尔值,把True对应的行留下,False的丢掉。

df_filtered = df[(df['金额'] > 100) & (df['渠道'] == 'APP')]

多个条件叠加时,注意要用&和|,不能用 Python 的and和or,这两个会让 Pandas 报“模糊真值”的错误。新手在这里没有少踩坑。

如果要筛的字段值是一个列表,用isin更清爽:

target_cities = ['北京', '上海', '广州'] df_filtered = df[df['城市'].isin(target_cities)]

条件表达式写得多且复杂,比如大于、小于、包含、不等于组合在一起时,我改用query,可读性高很多:

df_filtered = df.query( '金额 > 100 & 城市 in @target_cities & 是否急件 == 1' )

query里的@变量名可以引用外部 Python 变量,这个技巧很多人不知道,但非常实用。筛选之后经常出现索引不连续的情况,后续如果用groupby或者按位置取数会出问题,顺手重置一下索引:

df_filtered = df_filtered.reset_index(drop=True)

drop=True是必要的,否则原来的索引用会被当成新的一列加进来,白白多出一个字段。

5.2 groupby 聚合:预处理阶段就看数据长什么样

groupby是 Pandas 数据处理里使用频率最高的操作之一,本质就是“按某个字段分组,然后对每个组做统计”。在预处理阶段,我用它来做分组填补缺失,在探索阶段用来看各维度数据分布。

grouped = df.groupby('渠道').agg( 订单数=('订单编号', 'count'), 总金额=('金额', 'sum'), 平均金额=('金额', 'mean'), 最大金额=('金额', 'max'), )

agg可以同时聚合多个字段、多个统计指标,上面这个写法可读性最强:左边是结果列名,右边是(来源列, 聚合函数)的元组。

如果你做时间序列分析,groupby还能结合时间维度做重采样,比如按月统计:

monthly = df.set_index('下单时间').groupby('渠道')['金额'].resample('M').sum()

这条链式操作是先按渠道分组,再对每个渠道按月份汇总,输出是一个 MultiIndex 的 Series,展开后用reset_index()恢复成普通表格。

分组之后还有一类操作叫transform,它能把分组计算结果广播回原数据的每一行。前面提到的分组填充就是标准案例:

df['金额'] = df.groupby('渠道')['金额'].transform(lambda x: x.fillna(x.median()))

transform与agg最大的区别在于:agg把多个组的结果压缩成统计表,行数变少;transform保持行数不变,把统计结果贴回到每一行。涉及“求每个用户自己的平均消费,然后和全表平均消费做对比”这类需求时,transform是核心工具。

5.3 merge 和 concat:多表合并之前的必要准备

多表合并是数据清洗最容易踩大坑的地方,尤其当你处理的是订单表+用户表+渠道表这种结构化数据。

pd.merge()的核心参数就三个:left、right、on(或者left_on/right_on)。先上一个中规中矩的例子:

df_order = pd.merge( df_order, df_user, how='left', on='user_id' )

how='left'的意思是保留左侧表的所有行,右侧匹配不到的字段填 NaN。这是业务分析里最常用的合并方式,因为订单一般不会因为缺了用户信息就丢失。

合并最容易踩的坑不在函数用法,而在合并键的脏数据。举个例子:订单表里的用户ID是'A001',用户表里的用户ID是'A01',两边看着是一个人,但字符串不相等,merge 之后要么匹配不上,要么重复匹配产生大量笛卡尔积。所以我在多表合并之前一定会做键的预处理,三件事必做:

df_order['user_id'] = df_order['user_id'].str.strip().str.upper() df_user['user_id'] = df_user['user_id'].str.strip().str.upper()

去空格、统一大小写,有时候还要统一类型。object类型和category类型合并时也可能出问题,最好先把键统一转成字符串再合并。

为了在合并前发现键的问题,我会用isna()统计合并结果的缺失情况:

merged = pd.merge(df_order, df_user, on='user_id', how='left') print(merged['user_name'].isna().sum())

如果左侧有订单但右侧匹配不到用户名,说明两边键数据不齐,需要回头看是不是 ID 格式不一致。

concat的使用场景不一样,它主要用来纵向堆叠结构相似的表。纵向堆叠时最大的坑是两边列名不一致,一列叫金额、一列叫总金额,堆叠以后变成两列,各剩一半空白。所以concat之前先确认列名完全一致,或者用ignore_index=True去掉原来的索引干扰。如果左右列名确实不一样但含义相同,先用rename统一,再 concat。

6. 一条完整的数据预处理流水线:从 CSV 到可分析数据集

知识点散着讲容易飘,最后用一个模拟项目把前面的操作串成一条完整流水线。这个例子的场景是:拿到一份电商订单明细 CSV,字段包括订单编号、用户ID、下单时间、商品名称、类目、城市、金额、渠道、备注,目标是清洗出一个可以直接做统计分析和建模的数据集。

先制造一批带脏数据的模拟数据,这一步也顺便演示了 Pandas 数据结构的基本创建方式:

import pandas as pd import numpy as np df = pd.DataFrame({ '订单编号': ['A001', 'A002', 'A003', 'A004', 'A005', 'A006'], '用户ID': ['U001', 'u001', 'U002', 'U003', None, 'U004'], '下单时间': ['2024-01-05 10:21:00', '2024/1/5 11:30', '20240105', '2024-01-06 09:15', '2024-01-07 08:00', '2024-01-08 14:45'], '金额': ['¥1,200', '800', '1.500', '250', 'N/A', '3000'], '城市': [' 北京市', '北京', 'BJ', '上海', '广州', 'shanghai'], '渠道': ['APP', 'APP', '网页', '小程序', 'APP', '网页'], '备注': ['急件', '', '客户指定顺丰', '急件\n请尽快', np.nan, '无'], '类目': ['手机/数码', '手机/数码', '家用/电器', '图书/教育', '服装/男装', '家用/电器'], })

然后按顺序执行清洗流程。

第 1 步,备份原始数据。保证后面任何时候反悔都能一键恢复。

df_raw = df.copy()

第 2 步,统一用户 ID 并补缺失标记。用户ID是合并和去重的重点键,先 strip、转大写,再检查重复。这里的'u001'和'U001'可视化层面很像,实际是两个字符串,必须先归一。

df['用户ID'] = df['用户ID'].astype(str).str.strip().str.upper() df['用户ID'] = df['用户ID'].replace({'NONE': np.nan, '': np.nan, 'NAN': np.nan})

.astype(str)这一步很关键,因为列里有 NaN,直接.str会出错。注意:如果列里混着 None,astype(str)会把None变成字符串'None',所以后面那次replace就必须跟上,把'NONE'、'NAN'、空字符串这类伪缺失重新变回 NaN。

第 3 步,金额清洗链。先正则清掉货币符号和空格,再to_numeric转数值,无法转换的变成 NaN,最后统一用中位数填补。这个顺序不能乱,先清洗再转换再填补。

df['金额'] = df['金额'].astype(str).str.replace(r'[¥¥,,.\s]', '', regex=True) df['金额'] = pd.to_numeric(df['金额'], errors='coerce') df['金额'] = df['金额'].fillna(df['金额'].median())

这里我把1.500里的.也替换掉了,替成1500,避免欧洲数字格式的干扰。具体怎么写正则要看你数据里点号是小数点还是千分位,不能照搬。

第 4 步,时间格式统一。三套时间写法通过to_datetime统一成 datetime64 类型。这一步不需要先拆分再拼接,用errors='coerce'让无法解析的变成 NaN 即可。

df['下单时间'] = pd.to_datetime( df['下单时间'], errors='coerce', format='mixed' )

format='mixed'是 Pandas 2.0 之后才有的参数,允许日期字段混合多种格式统一解析。如果你的 Pandas 版本较老,去掉这个参数,让 Pandas 自行推断,或者干脆先处理成同一种格式再转换。

第 5 步,城市字段归一化。去掉首尾空格,然后批量替换同义词。

df['城市'] = df['城市'].str.strip() df['城市'] = df['城市'].replace({ '北京市': '北京', 'BJ': '北京', 'shanghai': '上海' })

第 6 步,缺失值处理。在替换伪缺失之后再看一遍缺失情况,逐个字段选择策略。这里用户ID的缺失直接删掉对应行,备注的缺失(包含空字符串)填充为'无备注'。

df = df.dropna(subset=['用户ID']) df['备注'] = df['备注'].str.strip().replace('', np.nan).fillna('无备注')

先strip再replace再fillna,一套下来备注字段的空值状态最干净。dropna(subset=['用户ID'])只针对用户ID这一列做删行,不会误伤其他字段。

第 7 步,去除重复和异常。订单编号加用户ID组合键去重,金额字段按 IQR 法过滤极端值,同时结合业务规则保证金额大于 0。

df = df.drop_duplicates(subset=['订单编号', '用户ID'], keep='first') Q1 = df['金额'].quantile(0.25) Q3 = df['金额'].quantile(0.75) IQR = Q3 - Q1 df = df[(df['金额'] >= Q1 - 1.5 * IQR) & (df['金额'] <= Q3 + 1.5 * IQR)] df = df[df['金额'] > 0]

这里金额低于 0 的行,业务上可以直接判为无效订单,不需要再做统计层面的验证。两种情况结合,过滤规则才完整。

第 8 步,文本提取。从备注里提取“是否急件”标签,从类目字段里拆分一级和二级类目。

df['是否急件'] = df['备注'].str.contains('急件', na=False).astype(int) df[['一级类目', '二级类目']] = df['类目'].str.split('/', n=1, expand=True)

实际操作里,这一步的str.split经常遇到分割后两列数量对不齐的情况,用n=1可以只分割第一个/,避免二级类目里再带斜杠导致结果变三列。

第 9 步,汇总验证。清洗完不是直接收工,先做一次验证性统计。比如按城市分组的销售汇总,看结果是否和业务认知一致。

result = df.groupby('城市').agg( 订单数=('订单编号', 'count'), 总金额=('金额', 'sum'), 平均金额=('金额', 'mean'), ).reset_index() print(result)

一步到位之后的结果,应该是一个字段类型正确、无重复无缺失、异常值已过滤、文本字段已归一化的干净表格。最后输出的时候,按你的需要选择to_excel还是to_csv。有个小细节:如果是给 Excel 用户看的文件,encoding='utf-8-sig'比默认 UTF-8 更友好,避免 Excel 打开出现中文乱码;如果是给下游程序读取的,用普通 UTF-8 就行。

result.to_excel('clean_order_data.xlsx', index=False) result.to_csv('clean_order_data.csv', encoding='utf-8-sig', index=False)

这一步跑一遍,清晰看到数据从“原始脏数据”到“可分析数据”的完整变化。我也建议把流水线封装成函数,每个步骤一个def,后续新数据进来,只要调同一个函数就能复用整套清洗逻辑,而不是每次都复制粘贴改代码。

数据清洗和预处理这类工作,看似琐碎,实际上是在为后面所有分析动作打地基。我自己在跑完一整套流程之后,最大的心得是:不要迷信某个“万能函数”,每一步都要回到业务逻辑上去判断,缺失怎么填、异常怎么删、重复怎么去,答案都在业务场景里。把 Pandas 的这些基础操作练熟了,遇到任何一张乱糟糟的表,你都会有一种“心中有数”的底气。

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

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

立即咨询