2026/10/21 12:39:22

数据清洗8种方法:缺失值、异常值、重复数据处理实战

数据清洗8种方法:缺失值、异常值、重复数据处理实战 1. 先搞清楚数据到底脏在哪8种清洗方法的作战地图我接手过不少数据分析项目卡在建模阶段的往往不是算法选型而是数据本身。有些表看起来结构完整实际上用户ID一会有13位一会有18位金额字段混着¥1,200和1200两种写法日期格式三种并存还有几个字段直接空了三分之一。这些问题如果不提前处理后面不管算平均值、做交叉分析还是建机器学习模型结果都会离谱。数据清洗的目标不是让表格看起来整齐而是让统计分析、机器学习、报表输出的结果可信。与其东一榔头西一棒子地零散处理不如把常用的清洗动作归纳成一套固定打法。下面这8种方法是我在实际项目中反复用到、也反复验证过效果的处理顺序序号清洗方法解决的问题1缺失值处理字段为空、占位符伪装成空值2重复数据处理完全重复行、主键冲突、近似重复3异常值处理离群点、明显不合理取值4数据类型纠正数字存成文本、ID被当成数值5文本格式统一空格、大小写、全半角混用6日期时间标准化多种日期格式并存、时间单位不统一7类别变量清洗与编码类别叫法混乱、低频类过多8一致性校验字段间逻辑矛盾、跨表对不上这8种方法不是流水线式的先后关系但大多数项目按缺失→重复→异常→类型格式→日期→类别→一致性的顺序来推效率最高。先解决单字段的有没有、是不是问题再处理字段之间的对不对问题逻辑上更顺。我在实际项目里还养成一个习惯每一步清洗都要记日志。哪个字段改了多少行、为什么这样改、删了什么数据都写清楚。因为清洗过程往往要反复迭代数据按不同口径处理过两三轮之后如果没记录你根本不知道自己手里的数据是怎么来的后面出了问题完全没法回溯。2. 缺失值处理删除、填充与什么都不做的边界缺失值处理是数据清洗里最常见也是最容易踩坑的一步。很多人拿到数据先看有没有空值有了就是填充或删除二选一但真正的问题往往是你真的把所有缺失值都识别出来了吗2.1 先把缺失的认识补齐在Python的pandas里NaN和None都会被判定为缺失但实际数据里还有一堆伪装缺失空字符串常见于手工录入的Excel表占位符比如-999、9999、N/A、NULL、unknown、未知业务上的无效值比如年龄字段填了0但0岁在这个场景下明显不是有效年龄全角空格或不可见字符处理的第一步是把这些伪装值统一转成真正的空值不然你统计缺失率的时候会严重低估。import pandas as pd import numpy as np df pd.read_csv(order_demo.csv) df df.replace([N/A, NULL, 未知, -999], np.nan) # 检查各列缺失情况 print(df.isna().sum()) print(df.isna().mean())识别完之后不要急着处理先回答三个问题缺失率是多少缺失是随机的还是跟某些字段有关这个字段在后续分析里有多重要这三个问题的答案直接决定你该删、该填、还是干脆不动。2.2 什么时候可以直接删除不是所有缺失值都必须填盲目填充反而会制造假数据。满足以下条件时删除是更诚实的做法缺失比例超过30%且这个字段对业务没有明确价值字段缺失与你要分析的目标变量无关删除后不会引入系统性偏差整体样本量足够大删除后不影响代表性举个例子你分析订单转化率用户年龄这个字段缺失了40%而你的分析根本不涉及年龄分组那直接删掉这列就行没必要花力气填。再比如备注字段有80%是空的这种字段对多数模型没有预测价值删掉比填充更合理。但删除必须记录清楚。我一般会在清洗日志里写明哪一步删了多少行、删了哪些列、删除依据是什么。否则数据量对不上时你连怎么跟业务方交代都不知道。2.3 填充方案怎么选如果决定填充那就要根据数据类型和业务场景选方法没有万能方案。我的选择逻辑通常是这样的填充方式适用场景注意点固定值填充业务上缺失有明确含义如无别用0随便填均值/中位数/众数数值型字段缺失率低偏态分布用中位数别用均值分组统计量填充字段在不同分组下差异明显先分组再填充前向/后向填充时间序列数据必须先排序线性插值数值趋势变化平滑的数据对突变数据不友好模型插补重要字段、缺失率不高KNN、迭代插补成本较高给你一段可以直接套用的代码覆盖了最常见的几种情况# 用中位数填充适合偏态分布的收入字段 df[income] df[income].fillna(df[income].median()) # 按分组统计量填充不同地区的销售额中位数不同 df[sales] df[sales].fillna( df.groupby(region)[sales].transform(median) ) # 时间序列先排序再向前填充 df df.sort_values(date) df[stock_price] df[stock_price].fillna(methodffill) # 线性插值适合连续变化的数值 df[temperature] df[temperature].interpolate()这里我想特别提醒一点用均值填充前一定要看分布。如果数据是右偏的少数特别大的值把均值拉高了用均值填充相当于给一大堆样本填了偏高的值后面做任何统计都会受影响。这时候中位数更稳健。如果数据是严重偏态且缺失率不高我甚至会考虑用分组中位数或模型预测来填。还有一种情况缺失值可以什么都不做。有些树模型天然能处理缺失值XGBoost、LightGBM在训练时会自动学习缺失值的最优分裂方向。如果你明确知道自己要用这类模型而且缺失本身可能包含信息比如客户没填写某项可能意味着某种行为特征那保留缺失比填充更好。这个思路很多人会忽略但你细想一下清不干净的数据有时候恰恰是特征本身。3. 重复数据完全去重背后还有模糊去重重复数据处理听起来最简单drop_duplicates一调就完事。但实际业务里的重复比这个复杂得多有完全重复的行有主键冲突的记录还有同一客户在不同时间提交的近似重复信息。处理错了要么丢掉有效数据要么保留脏数据。3.1 完全重复与主键去重先区分两种情况一种是整行所有字段完全相同这种直接去重没问题另一种是业务主键相同但其他字段有差异这种只保留其中一条就要动脑子了。# 整行完全重复 df_clean df.drop_duplicates() # 按业务主键去重保留最早的一条或最新的一条 df_clean df.drop_duplicates(subset[order_id], keepfirst) df_clean df.drop_duplicates(subset[order_id], keeplast)keepfirst还是keeplast取决于业务语义。比如你要分析用户最新状态保留最后一条你要还原订单创建时的最初记录保留第一条。这里有个容易忽略的细节去重前先检查主键是否真的唯一。很多时候你以为order_id是主键实际上同一订单号重复录入而且重复记录之间还有细微差异。我习惯先把这个小表打印出来看看duplicate_key df[df.duplicated(subset[order_id], keepFalse)] print(duplicate_key.shape) print(duplicate_key.sort_values(order_id).head(20))先看重复的样子再决定保留逻辑而不是直接去重。3.2 模糊去重同一客户的不同写法比完全去重更难的是模糊去重。比如客服系统里录入的客户名张三和张三 、张 三、zhangsan可能是同一个人但字符串完全不一样。再比如公司名腾讯科技和腾讯科技有限公司也在不同批次数据里混着。处理思路是先把文本统一去空格、统一大小写、去公司后缀再算相似度最后按相似度阈值合并。一个简单可用的做法from difflib import SequenceMatcher def text_normalize(s): if not isinstance(s, str): return s s.strip().lower() s s.replace( , ) s s.replace(有限公司, ).replace(有限责任公司, ) return s def similar(a, b): return SequenceMatcher(None, text_normalize(a), text_normalize(b)).ratio() # 示例判断两条客户记录是否近似重复 print(similar(深圳市腾讯科技, 腾讯科技有限公司))模糊去重算相似度的时候最怕一对多全量比较数据量稍微上来就是O(n²)的复杂度。实际项目里我会先按一个容易区分的字段分组比如把城市姓名相同的先聚到一起再在小范围内比较速度快很多。3.3 去重不等于删数据这件事必须单独说不是所有重复都该删。在某些场景下重复记录本身就是信息。比如订单流水表里一个订单可能有多次状态变更记录这时候一单多行是正常的去重反而毁了数据。所以动手之前要确认这个表是一个业务对象一行的主档表还是一个业务对象多行的流水表。主档表才需要去重流水表要去的是那些因为程序bug产生的重复写入。另外如果重复记录里各字段的信息分布在不同行更合理的做法是合并而不是删除。比如一行有手机号另一行有邮箱合并后才是完整客户档案。这时候我会按主键聚合把有效字段拼起来。4. 异常值不要一删了之先问它是噪音还是真相看到异常值时很多人的第一反应是删掉让数据看起来更好。但异常值可能是录入错误也可能是真实业务信号。我的原则是先识别再判断最后才决定怎么处理。4.1 箱线图与IQR方法IQR四分位距方法不依赖数据分布形态非常适合初步扫描。它的逻辑是把数据按大小排序找到25%分位数Q1和75%分位数Q3两者的差就是IQR凡是超出Q1-1.5×IQR或Q31.5×IQR范围的都标记为潜在异常值。Q1 df[sales].quantile(0.25) Q3 df[sales].quantile(0.75) IQR Q3 - Q1 lower_bound Q1 - 1.5 * IQR upper_bound Q3 1.5 * IQR outliers df[(df[sales] lower_bound) | (df[sales] upper_bound)] print(outliers.shape)画箱线图能直观看到离群点分布。如果你看到一组点远远甩在箱体外面先不要急着删去看这些样本对应的原始记录问业务方这是不是真实值。4.2 Z-score与3σ原则Z-score方法的核心是把数据标准化看每个值偏离均值多少个标准差。通常情况下Z-score绝对值超过3就被认为是异常值对应正态分布下大约99.7%的覆盖率。mean df[sales].mean() std df[sales].std() z_scores (df[sales] - mean) / std outliers df[z_scores.abs() 3]这个方法的适用前提是数据近似正态分布。如果数据严重偏态或者样本量很小Z-score很容易失灵。偏态分布下均值本身就被极值拉偏了标准差也偏大结果就是真正的异常值反而不容易被识别出来。所以我会在数据呈近似钟形分布时才优先用Z-score否则用IQR更稳。4.3 业务规则比统计方法更可靠统计方法只能告诉你这个值在分布上很极端但判断它是不是真的有问题最终要回到业务。我遇到过一个典型例子某天销售额突然涨了10倍IQR标它为异常值但查下去发现是当天一个大客户集中采购钱是真的入账了。这种异常值不仅不该删反而是业务亮点。所以实际项目中我会这么组合先用IQR或Z-score做候选扫描再写几条业务规则过滤。比如销售额为负数但业务上不存在负销售额 → 数据错误用户年龄大于120岁或小于0岁 → 数据错误订单量在特定秒杀时段出现尖峰 → 真实现象保留测试订单、内部订单混入业务数据 → 按标记字段删除# 业务规则示例 df df[df[sales] 0] # 销售额不能为负 df df[(df[age] 0) (df[age] 120)] # 年龄合理范围 df df[df[is_test_order] ! 1] # 排除测试订单处理方式上除了直接删除还有截尾、替换、打标记三种更温和的手段。比如超过99.9%分位的极大值可以截尾到99.9%分位再参与建模不影响整体分布明显录入错误的可以替换为缺失值交给处理缺失值那一步怀疑是真实但无法确认的增加一个is_outlier字段标记出来不删除分析时单独看。5. 数据类型纠正与格式统一能算、能比、能合并的底线这一节要解决的是数据能不能被正确使用的问题。我见过太多人跳过这一步结果后面做聚合时才发现金额是字符串、日期是字符串、订单号被当成了整数。5.1 数值类型的识别与转换最典型的问题read_csv读进来后金额字段因为带逗号或货币符号被识别成字符串而订单号、用户ID这类本该是字符串的字段反而被识别成整数导致前导零丢失、大数被截断。处理方法很直白看dtypes然后逐列纠正。# 查看所有字段类型 print(df.dtypes) # 金额字段去掉符号再转浮点 df[price] ( df[price] .astype(str) .str.replace(,, , regexFalse) .str.replace(¥, , regexFalse) .astype(float) ) # 用户ID必须是字符串并补齐前导零 df[user_id] df[user_id].astype(str).str.zfill(13)这里有个容易踩的坑转float之前先检查有没有非数字内容。比如金额字段里混着免费、面议之类的文本直接astype(float)会报错更稳妥的做法是用pd.to_numeric(errorscoerce)转换不了的自动变成NaN然后再走缺失值处理流程。df[price] pd.to_numeric(df[price], errorscoerce)订单号、商品编码这类标识字段我的建议是始终按字符串处理。它们不参与数学运算一旦被读成整数不仅可能丢失前导零还可能因为超出浮点精度产生尾数误差。这个坑排查起来非常隐蔽。5.2 文本格式统一文本清洗的常见问题包括首尾空格、内部连续空格、全角/半角混用、大小写不一致。这些问题最直接的危害是分组统计时同一个值被拆成多个组。# 去掉首尾空格和异常字符 df[name] df[name].str.strip() df[text] df[text].str.replace(r\s, , regexTrue) # 统一大小写 df[email] df[email].str.lower() df[code] df[code].str.upper() # 全角字符转半角常见于手工录入的段落 def full_to_half(text): if not isinstance(text, str): return text return .join( chr(ord(c) - 0xFEE0) if c else c for c in text ) df[comments] df[comments].map(full_to_half)文本清洗的原则是以终为始你先想清楚下游要拿这个字段做什么再决定统一的规则。如果只是做匹配那大小写和空格统一就够了如果要输出给用户看标点符号全角半角也都要统一。5.3 组合列与拆列格式统一的另一个常见需求是拆列和拼列。比如把2023-01-01 12:30:45拆成日期和时间两列把姓和名分开把地址拆成省份、城市、区县。pandas里用str.extract和str.split可以处理。# 拆分日期时间 df[date] pd.to_datetime(df[created_at]).dt.date df[time] pd.to_datetime(df[created_at]).dt.time # 提取省份 df[province] df[address].str.extract(r(北京|上海|广东|浙江|...), expandFalse)拆列前先确认字段的原始结构是否规整。如果地址字段本身就乱七八糟广东省深圳市南山区和深圳南山混在一起拆出来的结果也不会可信。这时候应该先回到格式统一再考虑拆列。6. 日期时间与类别变量两个最容易埋雷的隐性字段这一节把日期时间和类别变量放在一起说原因是它们都有一个共同点表面看没什么大问题一旦深入处理才发现坑比想象中多。6.1 日期时间解析统一格式只是第一步日期字段最常见的形态包括2023/1/5、2023-01-05、2023年1月5日、01/05/2023月/日还是日/月分不清还有Unix时间戳。pandas的to_datetime在多数情况下能自动识别但遇到混合格式时必须指定格式或使用formatmixed。# 常规转换 df[date] pd.to_datetime(df[date], errorscoerce) # 指定格式适合统一的字符串格式 df[date] pd.to_datetime(df[date], format%Y/%m/%d, errorscoerce) # 混合格式容错 df[date] pd.to_datetime(df[date], formatmixed, errorscoerce)转换后一定要检查有多少解析失败。我见过一个项目日期字段解析失败率高达10%就是因为混着20230105这种不带分隔符的写法to_datetime解析不出来结果直接变成NaN。解决办法是先统一字符串格式再转换。def clean_date(s): s str(s).strip() # 去掉分隔符统一为YYYYMMDD s re.sub(r[-/年月日\s:], , s) return s df[date_str] df[date].map(clean_date) df[date] pd.to_datetime(df[date_str], format%Y%m%d, errorscoerce)日期时间处理还要注意时区问题。做跨平台、跨地区数据分析时不同数据源可能存的是UTC、本地时间、带偏移量的时间不统一时区直接比较时间先后就是错的。如果只有北京时间可以先把tz信息处理好再转换。6.2 类别变量的清洗先合并再编码类别变量的问题主要集中在叫法不统一。同是城市北京、北京市、beijing出现在同一列里分组结果就会变成三组。处理思路是建立映射表而不是靠单个replace。city_map { 北京市: 北京, beijing: 北京, BJ: 北京, 上海市: 上海, shanghai: 上海, } df[city] df[city].str.strip().str.lower().replace(city_map)映射表的好处是可维护、可追溯。以后发现新的别名往里加一条就行不用改主逻辑。另一个常见问题是低频类别。比如商品类目有500个但80%的类目只出现过一两次。这种数据直接做独热编码会让特征矩阵极度稀疏模型效果反而差。我的做法是先统计频次把频次低于某个阈值的类别合并成其他。# 统计类别频次 counts df[category].value_counts() # 低于100次的合为other rare_categories counts[counts 100].index df[category] df[category].replace(rare_categories, other) # 再检查一下 print(df[category].value_counts().head())阈值怎么定看样本量和业务解释性。样本量小的时候阈值可以低一些但至少要保证合并后每个类别都有足够的样本支持分析。6.3 类别编码别踩进无序数字的坑类别清洗完之后就是编码。首先要区分定类变量城市、性别和定序变量教育程度、满意度前者用独热编码后者用标签编码。# 定序变量教育程度 edu_map {小学: 1, 初中: 2, 高中: 3, 本科: 4, 硕士: 5} df[education_level] df[education].map(edu_map) # 定类变量独热编码 from sklearn.preprocessing import OneHotEncoder import pandas as pd encoded pd.get_dummies(df[city], prefixcity) df pd.concat([df, encoded], axis1)这里有个常见误解把所有类别变量都转成0、1、2、3这样的整数。问题是如果变量是定类变量北京1、上海2、广州3会在模型里制造出上海2倍北京这种虚假的数值关系。所以定类变量必须用独热编码或哑变量只有定序变量才适合标签编码。7. 一致性校验跨字段的逻辑约束与闭环验证最后一种方法也是最容易被忽略的一步。前面的处理都是在单字段层面做文章一致性校验解决的是字段和字段之间的关系问题。7.1 常见的一致性规则数据内部的一致性规则通常不需要写多复杂的代码就是几条业务常识交易金额 单价 × 数量发货日期 ≥ 下单日期年龄 当前年份 - 出生年份允许一定误差子类目必须属于父类目同一用户的性别在两个表中不能互斥# 金额对不上 df[amount_mismatch] df[total_price] ! df[price] * df[quantity] bad_amount df[df[amount_mismatch]] print(bad_amount.shape) # 发货日期早于下单日期 bad_date df[df[shipping_date] df[order_date]] print(bad_date.shape) # 删除或标记逻辑矛盾的数据 df df[df[shipping_date] df[order_date]] df df[~df[amount_mismatch]]不一致的原因往往不只是录入错误还有系统间的时间差、状态机跳转的前后关系、不同系统口径差异等。发现不一致后不要全都当成脏数据删掉先和业务确认哪一方是可信源。比如ERP里的订单金额和CRM里的订单金额对不上通常以ERP为准那就在清洗时做字段对齐。7.2 清洗前后对比形成报告工程化清洗的核心是每一步都能被检查和复现。我建议每次都生成一份清洗报告至少包含以下对比检查项清洗前清洗后总行数120,000116,350缺失值总数8,420320重复行数1,5080异常值数量1,203432类型错误字段数60一致性校验失败数877生成这份表不需要额外工具pandas自带的统计就能完成。关键是形成固化脚本每次清洗都跑一遍然后人工看一眼数字变化是否合理。如果清洗后数据量变化太大比如删了原本的30%那就要警惕是不是删除规则太激进。summary pd.DataFrame({ 指标: [行数, 缺失值, 重复行, 异常值], 清洗前: [len(df_raw), df_raw.isna().sum().sum(), df_raw.duplicated().sum(), outlier_count], 清洗后: [len(df_clean), df_clean.isna().sum().sum(), df_clean.duplicated().sum(), new_outlier_count], }) print(summary)7.3 把清洗规则沉淀成函数库最后一条经验来自我多次手写清洗脚本的教训不要每次都用临时脚本一坨写完就扔。数据清洗规则是会复用的下个月来一批新数据规则大概率差不多。把常用的清洗动作封装成函数放在一个模块里之后就是按顺序调用。def clean_dataset(df): df clean_missing_values(df) # 缺失值处理 df remove_duplicates(df) # 去重 df detect_and_handle_outliers(df) # 异常值 df correct_dtypes(df) # 类型纠正 df standardize_text(df) # 文本格式 df standardize_dates(df) # 日期标准化 df clean_categories(df) # 类别变量 df validate_consistency(df) # 一致性校验 return df封装函数库还有一层好处清洗规则可以被review、被测试、被改进。比如clean_missing_values里的填充策略从均值改为中位数只需要改一处所有项目复用时同步生效而不是每个新项目重新踩一遍坑。我自己在工程里还会加一层清洗配置把缺失率阈值、去重主键、异常值倍数、类别合并阈值这些参数都抽出来。这样同一个清洗脚本可以在不同数据集上适配不需要动代码逻辑。清洗做完后我会习惯性地做个抽样验证随机抽几十条原始记录跟踪它们从原始状态到清洗后状态的每一步变化确认没有因为某条规则误删或误改。这一步看起来笨但确实是保证清洗质量最直接的办法尤其是数据量很大、规则很多的时候抽样验证能兜住大部分低级错误。