ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Pandas数据清洗实战:从标准化到去重的完整流程

2026/10/3 3:04:35 拓冰建站 浏览量
Pandas数据清洗实战:从标准化到去重的完整流程 电脑上打开一张从业务系统里导出的Excel第一眼觉得数据真全跑个统计才发现哪哪都对不上日期有的带斜杠有的是一串数字城市字段北京北京市beijing混着出现同一行记录换个格式又出现一次。这是我做数据分析这几年遇到的常态。用Pandas做数据清洗不是补两个缺失值、删两个空行那么简单最绕不开的就是标准化和去重这两件事。这篇文章我把自己的实操方法完整写出来从类型转换、文本处理到重复值判断和性能优化适合每天跟脏表格打交道的分析师也适合刚接触Pandas、想系统掌握数据清洗的初学者。1. 为什么洗数据时标准化和去重要一起做1.1 数据清洗到底在洗什么很多初学者会把数据清洗理解成删空值删重复好像数据裁掉一部分就干净了。实际接触真实项目会发现脏数据的形态远比这复杂。我习惯把数据清洗拆成四类缺失值处理、格式标准化、重复值剔除、异常值修正。四类工作不是独立执行而是互相纠缠。先说缺失值。你可能觉得dropna()删掉就完了但真正的问题是缺失值为什么缺失是用户在表单里没填还是系统导出时字段错位判断不清楚盲目删除会直接把样本量砍掉三分之一。再说异常值看到一个年龄200岁的记录到底是要删除还是要修正成正常值很多情况下这类异常来自单位不统一——比如月薪字段有人填年薪有人填日薪。此时你发现异常值的根子其实是标准化问题。所以更准确地说清洗的核心逻辑是先把所有字段统一到一个可比较的状态然后再去判断哪些数据该保留、哪些该合并。格式不统一的时候你连重复都识别不出来。1.2 标准化和去重为什么必须放在同一个流程里这里有一个我踩过坑之后才彻底想明白的点去重的前置条件是标准化已经完成。给你一个非常典型的例子。客户表里有两条记录一条城市写北京另一条写北京市电话号码一条是138-1234-5678另一条是13812345678。如果你不做标准化直接用drop_duplicates()去重这两行根本不会被认为重复因为字符串不等。于是重复数据继续留在表里后续发送营销短信时同一个人收到两遍。反过来如果先做标准化城市都转成北京号码去掉所有非数字字符日期都统一成YYYY-MM-DD做完之后再去重重复率才真正浮出水面。所以标准化的优先级高于去重这不是个人偏好是逻辑上的先后关系。顺序反了去重就是走过场。2. 数据类型标准化先把列类型整对再谈后面2.1 为什么类型错是脏数据的根源Pandas读入CSV或Excel后会自动推断每列的类型但这个推断经常不准。最典型的场景是一列数值型数据里混了一个不详两个汉字整列直接变成object字符串类型。你以为你拿到的是数字排序时发现100排在20前面因为字符串按字典序排。你说数据脏不脏脏的不是那一个不详而是整列的类型都被它带偏了。还有一个高频问题日期列。Excel里导出日期有的单元格是2024/1/5有的是20240105还有的是2024-01-05 13:22:00。如果放任不管这一列的dtype是objectmin()和max()算出来完全是错的。Pandas里的DataFrame本质上是Series的集合每一列是一个Series。清洗操作大部分时候就是对一个Series做变换理解这一点后面学API会顺手很多。类型标准化的目标就是把每一列都转成它本该有的类型数值列转数值日期列转datetime64分类列转category或统一字符串。2.2 to_numeric、to_datetime、astype怎么组合用类型转换我常用的三件套是pd.to_numeric、pd.to_datetime和astype。三者分工不同我实际工作中是混着用的。先看数值列。某列月薪数据长这样15000、12000、不详。直接astype(float)会直接报错因为不详没法转。正确做法是用pd.to_numeric配合errorscoercedf[salary_num] pd.to_numeric(df[salary], errorscoerce)errorscoerce的意思是转不了的统一变成NaN。这一步之后不详变成了缺失值你再决定是填充、删除还是单独标记都变得可控。比直接报错强一万倍。再看日期列。pd.to_datetime默认会尝试自动解析但自动解析有两个问题慢且容易猜错。比如01/02/2024到底是1月2日还是2月1日不同国家习惯不一样。我的建议是尽量带上format参数又快又准df[apply_date_clean] pd.to_datetime( df[apply_date], format%Y/%m/%d, errorscoerce )如果数据里混着多种日期格式一个format解决不了。我的做法是先把能匹配的批量转掉剩余部分单独提取后拼接或者用正则先从字符串里抽年、月、日再组装成统一格式。不要指望一个函数吃遍所有脏格式。astype适合那些目标类型非常明确的场景。比如一个状态列只有0和1确认无缺失后直接df[is_active] df[is_active].astype(int)。注意astype是强制转换遇到缺失值时转换整数会出问题先处理好NaN再转。2.3 转换中的NaN与保留原始列的权衡转换过程中有一个很容易被忽略的细节errorscoerce会引入新的缺失值。如果你把原列覆盖掉后续排查数据时你根本不知道这一行的原始值是什么。所以我强烈建议清洗时不要覆盖原列而是新建col_clean列。比如原来是df[salary]清洗后保留df[salary_raw]新增df[salary_num]。后面发现某些行结果不对还能回头对比原始值。这个习惯在多人协作时尤其重要你不想每次被人追问这个字段你原来到底是什么。类型标准化做完记得检查三件事df.dtypes确认每列类型符合预期df.isna().sum()确认强制转换产生了多少缺失取值分布比如df[apply_date_clean].dt.year.value_counts()看日期列是否解析正确3. 文本与取值标准化空格、别名、单位都要收拾干净3.1 空格、换行与不可见字符处理文本标准化里最常见的坑是空格而且是你看不见的那种。Excel里字符串经常带前后空格更麻烦的是非断行空格\u00A0和换行符\n。如果用户从网页表单里复制数据这类东西到处都是。你以为身份证号已经对齐了df[id_card].value_counts()一看同一个号码出现好几个变体就是因为前后多了空格。处理方式很直接用Series.str.strip()去首尾空格然后统一把其他不可见字符替换掉df[name_clean] df[name].str.strip() df[remark_clean] df[remark].str.replace(r[\n\r\t\u00A0], , regexTrue)str.strip()默认去掉的是普通空格、换行、制表符但\u00A0这类特殊空格它有时候处理不干净所以我会用正则替换兜底。这个细节我是在一次姓名匹配失败时发现的排查了半个小时问题就是一个看不见的空格。3.2 大小写、全半角与中文习惯英文文本需要统一大小写。邮箱和域名都是大小写不敏感的但如果你拿去关联账号大小写不一致就会匹配失败。str.lower()是最基本的操作df[email_clean] df[email].str.strip().str.lower()中文数据还有一个全半角问题。数字和字母有全角半角之分全角和半角ABC看起来一样实际上不是同一个字符。处理方案是做个映射表或者写个小函数把全角转半角。我在处理从不同系统导出的数据时经常遇到这种情况同一字段在A系统是全角在B系统是半角。3.3 用正则表达式提取关键子串数据里经常混着多余信息。比如联系电话字段有人填138-1234-5678有人填电话13812345678还有人填13812345678王先生。最稳妥的做法是用正则把数字提取出来df[phone_clean] df[phone].str.replace(r\D, , regexTrue)\D表示非数字替换成空字符串之后电话号码就只剩纯数字。这个操作用途很广身份证号、银行卡号、订单号都可以这样处理。再比如薪资字段10k-15k要统一成数值。先提取两个数字salary_parts df[salary].str.extract(r(\d)\s*[kK万]?\s*[-~至]?\s*(\d*)\s*[kK万]?)提取之后判断单位是k还是万再乘对应的倍数。正则这块不用背记住str.extract是按组提取、str.replace配合regexTrue是做替换碰到复杂场景直接搜现成表达式改就行。3.4 类别字段的别名归一与单位统一分类字段的别名归一是做数据清洗时我觉得最业务的一步。城市北京北京市beijingBJ都指同一个地方性别M男男性也是同一个含义。处理方式是用映射字典统一city_map { 北京: 北京, 北京市: 北京, beijing: 北京, bj: 北京 } df[city_clean] df[city].str.strip().str.lower().replace(city_map)注意这里有个顺序先strip、再lower、最后replace。因为beijing可能带大写Beijing先统一小写后映射的key只要写小写就够了。单位统一又是一个典型场景。身高字段有人填175cm有人填1.75m体重有人填60kg有人填120斤。理论上应该在数据采集端就统一但现实是已入库的数据什么形态都有。我的做法是先提取数字和单位然后写个简单逻辑把单位换算成标准单位。单位换算规则建议集中放在一个配置文件或函数里不要散落在各处否则后面维护成本很高。4. 看清重复的三种形态去重才不会误伤4.1 完全重复、部分重复、语义重复去重之前先搞清楚一件事你面对的是哪种重复。我把它分成三类。第一类是完全重复行。整行数据一模一样没有任何列有差异。这种最简单df.drop_duplicates()直接删就行基本没有争议。第二类是部分重复。比如同一个user_id出现两次但邮箱、地址、手机号更新过。这不是脏数据可能是系统在不同时间点导出的快照。此时你以为在去重实际是在决定保留哪一条记录。第三类是语义重复。两条记录的user_id不同但手机号相同或者公司名一个是字节跳动一个是字节跳动科技有限公司。这种最隐蔽因为分析上常常把它们当作不同对象。分清三类之后你会发现去重策略不是drop_duplicates()一个函数能解决的。它考验的是你对业务主键的定义。4.2 duplicated与drop_duplicates配合使用很多人只知道drop_duplicates()却忽略了duplicated()。这两个函数本质上是同一个检测逻辑区别只是duplicated()返回一个布尔Seriesdrop_duplicates()直接返回去除重复后的DataFrame。实际项目里我常用duplicated()来做检查而不是直接删。真正删之前先看看哪些行会受影响dup_mask df.duplicated(subset[user_id], keepFalse) df[dup_mask].sort_values(user_id).head(20)keepFalse会把所有重复行包括第一次出现的都标成True。这样可以完整看到重复分组的全貌而不是只看后面的行。确认哪些是真正要保留的之后再用drop_duplicates()做最终清理。这个先查后删的习惯比直接删数据安全得多。这两个函数的判断逻辑是基于哈希的效率很高比遍历行快几个数量级。4.3 subset和keep的正确理解subset参数决定你拿哪些列作为判断依据。很多人忽略它默认连整行一起判断。但实战中整行重复的发生概率其实不高因为几乎每一行都有时间戳或自增ID这类唯一字段整行相等的记录反而少见。真正有业务意义的是指定关键列重复。keep参数有三个选项first保留第一次出现的、last保留最后一次出现的、False删掉所有重复行。我见过很多人默认不写那保留的就是第一条。但保留第一条不一定对如果这是一个订单表你很可能应该保留最后一条因为后面才有状态更新。这里和SQL对比一下会理解得更深df.drop_duplicates()≈SELECT DISTINCT *df.drop_duplicates(subset[user_id], keepfirst)≈ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY 某列)然后取第一条这也是为什么我说去重不是删行本质是选择保留哪行。5. 按业务定去重策略排序、保留与聚合5.1 时间序列数据怎么去重埋点日志、传感器数据这类时间序列经常出现同一时间戳有多条数据或者同一实体在短时间内重复上报。此时不能简单保留第一条。我的处理逻辑是先按业务时间排序再按实体去重比如一个设备每10秒上报一次但报表只需要每分钟一条df_sorted df.sort_values(event_time) df_dedup df_sorted.drop_duplicates(subset[device_id], keeplast)保留last还是first取决于业务口径。如果每条记录是状态快照保留最后一条能反映最新状态如果是流水记录保留第一条更符合发生顺序。没有绝对正确只有口径明确。我习惯在代码注释里写清楚当初为什么选这个参数避免三个月后自己都忘了。5.2 排序优先让保留的那一行更有价值同一主键下有多条记录时保留哪一行大概率决定了你后续分析的质量。比如CRM客户表里一个客户有多条跟进记录。有的记录里手机号是空的有的记录里公司规模填了但客户姓名写错了。你直接keepfirst保留下来的不一定是信息最全的那条。解决思路是给每条记录算一个信息完整度分数然后排序让分数最高的记录排在选中的位置df[complete_score] df.notna().sum(axis1) df[source_priority] df[source].map({系统: 10, 人工: 5, 导入: 1}).fillna(0) df[total_score] df[complete_score] df[source_priority] df df.sort_values(total_score, ascendingFalse) df df.drop_duplicates(subsetuser_id, keepfirst)这样保留下来的字段填充度更高来源也更可信。我一直觉得去重这个动作不该是随机碰运气而应该带着评分逻辑去设计。5.3 不应该删行的情况用聚合合并有一种重复是你绝对不能用drop_duplicates去处理的同一主键下的多行是有意义的明细只是你想压缩成一行汇总。比如一个用户买了三件商品订单表里三行user_id相同。用drop_duplicates(subsetuser_id)会把三行变成一行直接把用户买过的其他商品删没了。正确做法是聚合user_order_summary df.groupby(user_id).agg( order_count(order_id, count), total_amount(amount, sum), last_order_time(order_time, max) ).reset_index()所以做去重决策前先问自己这组重复行里的其他字段是被更新的旧值还是独立有意义的信息如果是前者去重如果是后者聚合。5.4 多字段联合判断与空值兜底单字段去重最怕空值。直接用user_id去重时如果多条记录的user_id都是空它们会被视为不同行去不掉反过来如果有一条是真正的重复记录但user_id刚好缺失又会被误当成新客户。我的做法是组合字段判断。比如用户信息表用手机号作为主键但当手机号缺失时改用邮箱判断邮箱也缺失再退化到姓名公司名。这个逻辑一句话写不清我会拆成几步def dedup_users(df): # 先处理有手机号的 mask_phone df[phone_clean].notna() df_valid_phone df[mask_phone].drop_duplicates(subsetphone_clean, keeplast) # 手机号为空但邮箱有值的 mask_email (~mask_phone) df[email_clean].notna() df_valid_email df[mask_email].drop_duplicates(subsetemail_clean, keeplast) # 都为空但姓名公司相同 mask_other df[phone_clean].isna() df[email_clean].isna() df_valid_other df[mask_other].drop_duplicates(subset[name_clean, company_clean], keeplast) return pd.concat([df_valid_phone, df_valid_email, df_valid_other])如果连姓名和公司都匹配不上建议不要硬删而是标记一个疑似重复标签留给业务方人工确认。数据清洗不是数学题给不了100%确定的答案。6. 大数据量下的清洗性能与内存优化6.1 向量化操作是省时的第一原则清洗百万行数据时最忌讳的就是用for循环逐行处理。Pandas底层的很多操作是向量化的一次处理一整个Series比Python逐行循环快几十倍不止。一个非常典型的例子把工资列的字符串转成数字。用for循环跑一百万行可能要几分钟用pd.to_numeric加str方法几秒钟就结束。这不是代码技巧的差异而是执行模型的不同。Series.str下面的方法、replace、map、where、mask这些都应该成为你的默认选择而不是iterrows()。真遇到无法向量化的复杂逻辑也别直接apply到底。先groupby分组再在组内用向量化操作能省很多时间。我在实际项目里见过一个清洗脚本从20分钟优化到不到1分钟改动就是把一个逐行判断逻辑换成了groupby().transform()。6.2 看懂SettingWithCopyWarning用Pandas时经常遇到SettingWithCopyWarning这个警告看着吓人其实是提醒你你改的可能只是副本不是原DataFrame。最常见的是链式赋值比如df[df[age] 30][level] high这一行你以为改了df实际上Pandas可能先复制了一个子集再赋值原数据根本没变化。正确做法是用locdf.loc[df[age] 30, level] high或者先明确复制一份再改df_clean df.copy() df_clean[level] high我现在的习惯是一旦要做清洗第一步就df.copy()所有后续操作都基于副本。这样既避开警告也保证原始数据不被意外污染排查问题时有原始数据可以对照。6.3 用dtype和chunk控制内存几百万行数据在Pandas里不算大但如果每列都是object类型内存会膨胀得很快。我常用三个手段控内存。第一低基数的分类列转category。比如城市列虽然有几百万行但取值可能就几十个。转成category后内存占用大幅下降而且排序、分组性能也会提升df[city_clean] df[city_clean].astype(category)第二数值列按精度收缩。确定整数范围后int64转int32甚至int16浮点列没有太高精度需求时float64转float32。一千万行数据省下来的内存非常可观。第三分块读取。一次读不进内存时用chunksize分批处理chunks pd.read_csv(huge.csv, chunksize100000) for chunk in chunks: chunk_clean clean(chunk) chunk_clean.to_csv(clean_output.csv, modea, headerFalse, indexFalse)边读边清洗边写内存不会爆。这个套路我处理几个GB的日志文件时经常用。6.4 单机Pandas与大平台的分工关于数据太大是不是该上Spark这个问题我的观点很务实Pandas最适合单机、GB级以下、需要快速验证清洗规则的数据。网约车行业那种日增量几百GB的数据确实不是Pandas的主场那种场景通常交给Spark的DataFrame做分布式清洗逻辑上和Pandas非常接近——都是列式操作、都讲转换和行动。但即使是Spark项目我也建议先在单机用Pandas把清洗逻辑验证跑通再翻译成Spark代码。原因很简单Pandas迭代快调试方便结果可以肉眼检查。把Pandas和Spark当成一套思路的两种实现而不是非此即彼的工具才是数据工程该有的心态。7. 招聘数据清洗实战从脏表到干净的标准化表7.1 构造一份接近真实业务的脏数据为了把前面所有方法串起来我造一份招聘网站的简历数据。字段包括candidate_id、name、city、education、work_years、salary、phone、apply_date。脏点我故意安排了四种城市字段混着北京北京市Beijing工作年限有的是3年有的是5年以上有的是未知薪资有10k-15k、20k以上、还有空白手机号有的是138-1234-5678有的是861381234567日期有两种格式2024/1/5和20240105部分行完全重复部分行candidate_id相同但薪资和日期更新过这基本就是一份常见的脏表拿来演示清洗流程最合适。7.2 标准化处理的完整流水线第一步先观察。df.info()看每列类型和缺失情况df.head()扫一眼前后空格和格式混乱。第二步类型标准化。日期列统一解析成datetime64df[apply_date_clean] pd.to_datetime(df[apply_date], format%Y/%m/%d, errorscoerce)这里有个问题20240105这个格式会被errorscoerce转成NaN。所以我要单独补一步先把纯数字的日期字符串提取出来手动组装成%Y/%m/%d再解析。这两个分支都处理完apply_date_clean的缺失值才算真正补全。第三步文本和取值标准化。城市和姓名先strip城市再统一大小写并映射df[city_clean] df[city].str.strip().str.lower().replace(city_map) df[name_clean] df[name].str.strip()手机号去非数字、去掉中国的86前缀df[phone_clean] df[phone].str.replace(r\D, , regexTrue) df[phone_clean] df[phone_clean].str.replace(r^86, , regexTrue)第四步工作年限和薪资解析。年限提取数字遇到5年以上要把区间下限当成5未知转NaN。薪资统一解析成区间上下限salary_range df[salary].str.extract(r(\d)\s*[kK]?\s*[-~]?\s*(\d*)\s*[kK]?) df[salary_lower] pd.to_numeric(salary_range[0], errorscoerce) * 1000 df[salary_upper] pd.to_numeric(salary_range[1], errorscoerce) * 1000 df[salary_mid] df[[salary_lower, salary_upper]].mean(axis1)注意20k以上这类单边数据提取后salary_upper是NaN取中值时自动按下界算也说得过去。7.3 去重与结果校验标准化完成后开始去重。先处理完全重复行df df.drop_duplicates()再处理业务上的重复。这里的业务主键我选择phone_clean因为手机号在招聘场景中基本是唯一标识。注意先按apply_date_clean排序保证保留的是最新简历df df.sort_values(apply_date_clean, ascendingFalse) df df.drop_duplicates(subsetphone_clean, keepfirst)去重完成后做校验。我会至少检查三件事去重前后行数对比计算去重比例df[city_clean].value_counts()确认城市取值收敛df[salary_mid].describe()看薪资分布是否合理这个清洗后校验步骤很容易被跳过但它是确保清洗规则没写错的关键。规则写错最严重的后果不是报错而是数据表面变好看了实际更混乱。所以我每次清洗完都会把前后的统计数字放在一起对比而不是只看清洗后的结果。最后把所有清洗规则整理成函数放在一个脚本里。后续新数据进来直接跑同一套清洗流程结果可复现。这一点在团队协作中价值极高清洗逻辑不是散落在Excel里的手动操作而是可追溯、可审计的代码。我个人做了几年数据清洗最大的体会是去重之前不先做标准化等于白做标准化做得越细后续分析能发现的业务问题越多。数据清洗看起来是最不起眼的环节但它决定了你后面的分析结论是否站得住脚。宁可花一整天把清洗规则磨清楚也不要赶着用一套差不多的脏数据跑模型——那才是真正的浪费时间。