接到手里的数据十有八九是没法直接用的,这不是夸张,是常态。做数据分析、跑模型、出报表,真正花时间的地方往往不在算法和可视化,而是耗在“把数据收拾到能看”这一步上。数据清洗(Data Cleansing)干的就是这个活:把缺失的补上、错误的纠正、重复的去掉、格式不统一的收拾齐整,让数据从“能打开”变成“能信、能用、能算”。
这篇东西,我不打算写成教科书式的概念科普,而是按我这些年实际摸爬滚打的经验来聊。内容会覆盖三个层面:一是清洗的整体思路和通用流程,让你拿到任何一份脏数据都有章可循;二是基于 pandas 的手工清洗实操,适合数据量不大、需要精细处理的场景;三是 DataX 这类批处理工具在数据同步链路里做清洗的玩法,适合生产环境里天天跑的活儿。最后单独讲一下工业传感器数据清洗——这是我个人认为“最脏”的数据类型,没有之一。无论你是刚入行的分析师、数据工程师,还是做物联网平台开发的,这篇文章里总有一部分能用得上。
1. 数据清洗:先搞清楚到底在清什么
很多人一上来就写代码,这是最大的误区。你连数据是怎么脏的都没弄明白,写出来的清洗逻辑大概率是拍脑袋,拍完还得返工。我习惯先做一次“数据勘察”,把脏数据的类型摸清楚,再决定用什么手段处理。
1.1 脏数据从哪来:不是所有“脏”都长一样
我经常把脏数据分成六类,每一类的成因和处理思路都完全不同:
- 缺失值:这最常见,单元格是空的,或者填了“N/A”“NULL”“-”这类占位符。成因一般是采集漏了、接口返回字段为空、系统迁移时丢了。处理思路是删除、填充、还是单独标记,得看业务含义,不能一刀切。
- 重复数据:同一件事被记了两遍甚至更多遍。成因很杂,比如数据重复上报、多表关联时产生了笛卡尔积、ETL任务重复跑了一次但没做幂等。需要注意的是,完全相同的重复好去,就怕“看起来不一样其实是同一件事”的重复,例如“张三”和“张 三”、“北京”和“北京市”。
- 格式不一致:同一个字段里幺蛾子最多。日期有“20230101”“2023-01-01”“2023/1/1”各种写法;手机号有的带86前缀、有的带空格;金额有“1000”“1,000”“壹仟元”。这类问题必须在进入分析前统一,否则一排序、一聚合就翻车。
- 异常值:超出合理范围的值。比如人的年龄填了230,传感器温度读出来是
-9999,订单金额是负数。异常值不一定是错,也可能代表着真实的异常事件(比如设备故障、用户退单),所以要分清楚是“录入错误”还是“真实离群”。 - 逻辑矛盾:最隐蔽的一类。比如注册时间晚于最后登录时间、订单状态标了“已发货”但没有物流单号、身份证号码里的出生日期和生日字段对不上。机器检查很难全发现,多半得靠业务规则。
- 编码与字符问题:中文乱码、全角半角混用、
\n和回车符混在文本里、BOM头干扰列名识别。这类问题不处理,轻则匹配不上,重则整个文件读不进来。
还有一个经常被忽略的来源:多表关联时字段语义对不上。小A表里的“客户ID”在小B表里叫“user_id”,一边是字符串一边是整数,一边是内部编号一边是业务编号——这种清洗不是修单元格,而是要重建字段映射关系,属于广义的数据一致性清洗。
1.2 清洗不是“一把梭”,而是五段式流程
我个人习惯把清洗拆成五个阶段,每一步都有明确产出,这样既不会漏,也方便跟同事对齐进度:
- 数据探查:拿到数据先不动手,用
info()、describe()、nunique()这类函数把数据集的形状、字段类型、缺失情况、取值分布摸一遍。目标是在动手前就意识到:表多大、哪些列隐患大、哪些列基本是废列可以直接丢。 - 规则制定:根据业务含义,为每个重点字段制定清洗规则。比如“年龄字段,超过100的按缺失处理”“金额字段,小于0的先查退款记录再定”“时间字段,统一解析为
YYYY-MM-DD HH:MM:SS”。规则一定要写成文档,哪怕是临时笔记,这个习惯在项目交接时能救命。 - 执行清洗:按规则写代码处理,先复制一份原始数据再动手,原始数据始终保持只读。这一步里我会把处理逻辑封装成函数,处理的每一步都打印变更前后的行数,保证每步都可追溯。
- 质量验证:清洗完不等于完事。用交叉统计验证:清洗后唯一值数量对不对、各组分布比例是否异常、抽样50条肉眼复核一遍。我还会跑一遍下游常用逻辑(比如按月汇总、按用户去重),看看输出结果是否符合常识。
- 回归与留痕:把清洗脚本连同参数、执行时间、影响行数一起记录下来。数据是活的,明天新数据进来,同一个脚本还得再跑,不留痕等于白干。
这五步看起来简单,但大多数清洗项目翻车都翻在第一步和第二步——没探查清楚就动手,规则又没和业务对齐,清洗完数据比原来还难用。
2. pandas清洗实操:从DataFrame到干净数据
pandas是Python生态里做结构化数据清洗最顺手的工具,没有之一。这一节我用一个客户订单表为例,按实际操作的顺序讲一遍核心代码和背后的判断逻辑。示例数据包含:customer_id、name、phone、province、order_date、amount、status这些字段。
2.1 读进来先别急着跑:dtype、索引与缺失值普查
很多同学习惯pd.read_csv('data.csv')接一行df.head()就开干,省掉的几步恰恰是后面翻车的根源。
import pandas as pd df = pd.read_csv( 'orders.csv', encoding='utf-8-sig', # 处理BOM,避免第一列列名乱码 dtype={'customer_id': str}, # ID按字符串读,防止前导0丢失 parse_dates=['order_date'], # 日期列直接解析 )读进来之后,这三行必看:
df.info() # 每列的非空数量、dtype,扫一眼就知道哪些列是重灾区 df.describe() # 数值列的分布,min/max异常一眼可见 print(df.isnull().sum()) # 精确的缺失统计这里有一个新手容易踩的坑:customer_id不要用默认方式读。Excel里的00123经过pandas读取后可能变成123,数字ID如果后面要做字符匹配,直接对不上。所以读的时候指定dtype={'customer_id': str}是常态操作。同理,手机号、身份证号这类不该参与数值计算的字段,都要显式按字符串读。
2.2 缺失值处理的三种思路,按业务场景选
缺失值不是只能删或者只能填,完整的选择是“删除、填充、标记”三选一,具体怎么选要看这列数据是干什么用的。
**删除,适用于缺失比例过高或无分析价值的列。**如果一列有超过70%都是空的,除非它是有特定含义的稀疏标记(比如“退单原因”),否则它对分析结果只有噪音没有信号,建议直接删。删除行的场景主要是:关键字段为空且后端表也补不回来,比如订单表里连订单ID都丢了,这种记录留着没有任何用处。
**填充,适用于数值型字段且有合理填补策略。**最常用的是中位数、均值、众数、前后值填充,但很多人忽略了一个前提:你用什么值填,取决于数据分布和业务逻辑。收入字段严重右偏时用均值填充会被极少数高收入人群拉高,中位数更稳;时间序列里的缺失用前向填充(ffill)通常比全局均值更合理,因为相邻时刻的值往往更接近。
# 金额缺失:用该省份客户的金额中位数填充 df['amount'] = df['amount'].fillna( df.groupby('province')['amount'].transform('median') ) # 时间序列演示:前向填充,订单日期缺失的,用上一个客户的日期兜底 df['order_date'] = df['order_date'].ffill()**标记,适用于“缺失本身代表一种状态”的场景。**比如营销表里的“点击时间”为空,不代表数据丢失,而是代表用户压根没点过。这时候把缺失值填成0或某个特定值,反而扭曲了语义。正确的做法是保留空值,或者加一列is_clicked作为分类特征。
2.3 重复值、格式统一与类型转换的实战细节
重复值处理,很多人就drop_duplicates()一把梭。问题在于:完全一样的记录你当然可以直接删,但业务上更常见的是“某些关键字段重复,其他字段不同”。比如一个用户下了两单,订单字段都不同,用户字段相同——这算不算重复?当然不算。所以一定要用subset参数指定判断重复的字段集合:
# 对同一天、同一个客户、同一个金额的订单去重,保留最新一条 df = df.drop_duplicates( subset=['customer_id', 'order_date', 'amount'], keep='last', )格式统一是脏数据重灾区,尤其手机号、文本列。我写过一套“统一手机号格式”的流程:先去空格和全角字符,去掉+86/86前缀,统一为11位,最后用正则校验。这一步看着麻烦,但省掉了后面接各种短信平台、CRM系统时的一堆幺蛾子。
df['phone'] = ( df['phone'] .astype(str) .str.replace(r'\D', '', regex=True) # 去掉所有非数字字符 .str.replace(r'^86', '', regex=True) # 去掉86前缀 ) # 校验:筛选出长度不是11位的,大概率是脏数据 bad_phone = df[df['phone'].str.len() != 11] print(f'异常手机号数量: {len(bad_phone)}')类型转换的坑更多。字符串转数值时,'1,000'直接astype(float)会报错,得先去掉千分位逗号;bool列转int时,True/False在pandas 2.x下的表现跟老版本有差异;日期字符串格式不统一时,to_datetime会飘红,这种情况要么用format参数指定,要么先做一次格式规整再转。
# 处理金额列中的千分位逗号和人民币符号 df['amount_clean'] = ( df['amount'] .astype(str) .str.replace('¥', '', regex=False) .str.replace(',', '', regex=False) .astype(float) )3. DataX批处理清洗:让清洗进入生产流水线
pandas处理几万、几十万行的数据很舒服,但到了每天几千万行的数据同步场景,单机跑DataFrame就力不从心了。这时候我需要的是能挂在调度平台上的批处理工具,DataX就是我用得比较顺手的一个。它本身是异构数据源同步工具,核心能力是把数据从MySQL、Oracle、HDFS、Hive等地方搬来搬去,但它的Transformer机制可以在搬运过程中顺便做清洗。
3.1 为什么单机脚本不够用
先说痛点。在数据仓库项目里,最常规的需求是每天凌晨把业务库的增量数据同步到数仓ODS层。用pandas脚本处理的话,有四个让人头大的问题:
- 数据量大:单表几千万行,pandas全量load进内存,服务器内存分分钟爆掉。
- 异构数据源:源库是Oracle,目标库是Hive,中间还有SQLServer的老系统,靠写连接器逐套对接,维护成本高到崩溃。
- 并发与调度:生产环境里几十张表要同时同步,每张表一个pandas进程,光进程管理就够受的。一旦某张表失败,重跑逻辑还得自己写。
- 资源利用:pandas的处理是单机的,机器配置再高,也只能垂直扩展,撑不住横向扩展的流量。
DataX解决的是同步的骨架问题:它用插件机制对接各种数据源,由调度平台统一管理任务,跑在分布式环境里,天然支持水平扩展。而数据清洗这件事,正好可以借用它的Transformer机制在同步过程中顺手完成。
3.2 DataX核心机制与本地跑通
DataX的工作模型很清晰:一个作业(Job)分成若干个Task,每个Task由三个核心组件构成——Reader负责从源端读取、Transformer负责行级数据处理、Writer负责写入目标端。你配置一个JSON文件描述“从哪读、做什么处理、写到哪”,DataX的框架就帮你把并发、重试、断点这些都管好了。
一个最简单的DataX任务配置长这样:
{ "job": { "content": [ { "reader": { "name": "mysqlreader", "parameter": { "username": "etl_user", "password": "******", "column": ["customer_id", "phone", "order_date", "amount", "status"], "connection": [ { "jdbcUrl": ["jdbc:mysql://192.168.1.100:3306/business"], "table": ["orders"] } ] } }, "writer": { "name": "hdfswriter", "parameter": { "defaultFS": "hdfs://nameservice1", "fileType": "text", "path": "/warehouse/ods/orders", "writeMode": "append", "fieldDelimiter": "\t", "column": [ {"name": "customer_id", "type": "string"}, {"name": "phone", "type": "string"}, {"name": "order_date", "type": "string"}, {"name": "amount", "type": "double"}, {"name": "status", "type": "string"} ] } } } ], "setting": { "speed": { "channel": 4 } } } }配置本身不复杂,真正有讲究的是channel数。它决定了并发度,调太低了同步慢,调太高了会给源库造成查询压力。我一般先按源表行数预估,千万级以内的表先跑4个channel试试,观察源库的CPU和IO负载再往上加,不建议一上来就开16甚至32。
3.3 用Transformer在同步中完成轻度清洗
DataX内置了几种Transformer,包括dx_replace(正则替换)、dx_substr(截取)、dx_filter(行过滤)、dx_groovy(写Groovy脚本做任意处理)。光靠这几个,就能覆盖掉一部分常规清洗需求。
比如订单状态字段里混了'已支付 '带空格、'已支付。'带了中文句号,直接在写Hive前统一掉:
"transformer": [ { "name": "dx_replace", "parameter": { "columnName": "status", "replaceWith": "'已支付'", "judgeValue": "['已支付 ', '已支付。', ' 已支付']" } }, { "name": "dx_filter", "parameter": { "condition": "amount > 0" } } ]dx_filter那一段表示只同步金额大于0的订单,负数金额的直接在管道里过滤掉,下游就不用来回排查了。
但说实话,内建Transformer的表达式能力有限,碰到“需要关联另一张表来补字段”这类清洗,光靠它不够。我的做法是:DataX负责同步和粗清洗,复杂清洗逻辑放到下游的SQL或Spark任务里做。各层各司其职,不要试图把所有事情都塞进一个工具。有一个原则我踩坑后总结出来的:同步管道里的Transformer越简单越好,复杂到需要调试半天的逻辑,就别放在同步链路里,否则每次同步任务失败了,你都不知道是源库的问题、网络的问题,还是Transformer表达式写错了。
4. 工业传感器数据清洗:最脏场景的实战经验
做过互联网业务数据,再去做工业传感器数据清洗,那感觉就像从客厅进了煤窑。业务数据再脏,顶多是缺字段、格式乱,传感器的数据是真真切切的物理世界噪声:信号漂移、设备停机、通信断断续续、量纲五花八门。这一节单独拿出来讲,是因为它跟前面讲的表格式数据清洗在方法论上有本质差异。
4.1 传感器数据为什么特殊:时间序列的脏法不一样
传感器数据的第一个核心特征:它有天然的顺序属性。一个温度传感器每秒钟上报一次数据,今天早上10:00:01来了,10:00:02可能就断了,10:00:03又回来了。这个“断”在业务表里可能表现为缺失行,但当你做趋势分析时缺的不是一行,而是一个时间段——清洗时必须考虑时间窗内的连续性,而不能像处理客户表那样简单地删掉一行。
第二个特征是脏值往往有模式。传感器数据里的异常值不是随机冒出来的,常见的有这么几类:
- 数值饱和:传感器量程上限是100℃,读出来一直是999或-9999,那是超量程标记。
- 阶跃跳变:正常温度是20℃上下波动,某条记录突然跳到80℃,下一跳又回到21℃。这大概率是传感器受到瞬时干扰,或者前端信号处理出了毛刺。
- 斜率异常:温度在10秒内上升了50℃,物理上不可能,即便读出来的每个点本身都在合理量程内。
- 滞后/冻结:连续N条记录数值完全一样,可能是传感器卡死或设备停机了。
- 时间戳紊乱:设备断电重启后,内部时钟没同步,上报的数据时间戳忽前忽后,甚至出现2030年的“未来时间”。
4.2 时间戳对齐与异常值判定的工程经验
处理传感器数据的第一步,永远是时间戳对齐。设备上报频率可能不固定(启动时快到毫秒级,稳定后慢到秒级),分析前必须先重采样到统一时间基准。
import pandas as pd # 原始数据:ts列为时间戳,val列为数值 df['ts'] = pd.to_datetime(df['ts'], utc=True, errors='coerce') df = df.dropna(subset=['ts']) df = df.set_index('ts').sort_index() # 重采样到1秒间隔,缺失值生成NaN后再插值 df_aligned = df['val'].resample('1S').mean() df_aligned = df_aligned.interpolate(method='time', limit_direction='both')resample里我选了mean聚合,是为了处理一秒内有多条记录的情况。然后interpolate(method='time')是按时间间隔做线性插值,比简单的ffill更能保留趋势。但要注意,插值只适用于短时间缺失,如果设备停了整整一小时,线性插值会把这段补成一条斜线,对后续诊断完全没意义。所以正确做法是先设定阈值,缺失时长超过比如5分钟,这一整段就标记为“设备停机”,不打补丁。
异常值判定方面,除了设定物理上下限(温度不可能零下100℃),我更推荐用滑动窗口做局部异常检测。全局限值能抓出明显错误,但抓不住“局部跳变”。最简单有效的方法是:
# 计算每个点的局部均值和标准差(窗口30秒) rolling_mean = df['val'].rolling(window=30, center=True).mean() rolling_std = df['val'].rolling(window=30, center=True).std() # 超过局部均值±3倍标准差,记为异常 df['is_anomaly'] = (df['val'] - rolling_mean).abs() > 3 * rolling_std3倍标准差是个经验值,具体怎么调取决于业务容忍度。报警类应用宁可多报(2.5倍),趋势分析宁可少报(4倍)。我建议先用3倍跑一遍,统计异常比例,如果异常占比超过3%,基本可以断定窗口或阈值设置不合理,而不是现场真的有这么多故障。
4.3 状态标记比删除更重要:传感器数据的清洗观
做业务数据清洗时,我们的目标是让数据“变干净”。但传感器数据清洗,我的核心原则是:不要轻易删除任何一条记录,而是要给它打状态标签。原因有三:
第一,传感器的异常值可能本身就是最重要的事件信号。一次振动传感器记录的振幅尖峰,在清洗阶段被当成“异常”删掉了,后面的故障诊断就无法复现当时工况。第二,删除会破坏时间序列的连续性,下游做频谱分析、特征提取时会踩坑——缺失片段和标记异常片段,对算法的含义完全不同。第三,清洗过程要可审计。设备供应商半夜甩锅说“你的清洗逻辑把我正常数据都删了”,如果你保留原始数据+清洗标记,可以直接拉出标记字段对质。
所以我在工程上给传感器数据加的字段通常是:
is_valid:是否通过质量检查(0/1)quality_code:标记问题类型(1=正常,2=超量程,3=跳变,4=冻结,5=设备停机)signal_lost:数据缺失时间段(True/False)ts_repaired:时间戳是否经过校正
下游使用时,is_valid=1的数据可以直接进分析模型,is_valid=0的数据单独存放,留给设备组做故障定位。数据库的存储量可能多出10%,但换来的是整个过程的可追溯,非常值。
5. 常见问题与排查技巧实录
数据清洗这么多年,踩过的坑比写过的代码还多。这一节我把最典型的几个场景整理出来,做成一份“问题速查表”,你在实操中碰到类似的可以直接照着排查。
5.1 六个高频翻车现场速查
| 现象 | 常见原因 | 排查思路 |
|---|---|---|
read_csv读出来第一列列名多一个\ufeff | 文件带UTF-8 BOM头 | 改用encoding='utf-8-sig'读取 |
| 日期解析大面积报错 | 多种日期格式混用,或有非日期文本 | 用errors='coerce'定位坏值,先看格式分布再统一解析规则 |
drop_duplicates后行数比预期少很多 | 判断重复的字段选窄了,不同业务实体被误判为重复 | 重新确认业务唯一键,加上keep参数测试不同保留策略 |
| 聚合结果比手工算的差很多 | 有字符串形式的数值列(如'1000'不是1000)没转类型 | df.dtypes查一遍,astype(float)前先清洗千分位和货币符号 |
| 清洗后下游报表数据翻倍 | 多表关联产生了重复行,清洗阶段没有在关联前先去重 | 关联前先按业务键去重,关联后再duplicated()检查一次 |
| 传感器数据插值后出现“悬崖” | 对长时间缺失段做了线性插值 | 只有短时间缺失才插值,长时间缺失整段标记为停机 |
这里面的“清洗后数据翻倍”是我见过最坑的。有一次同事跑数仓任务,前一天报表还正常,第二天突然所有订单金额都翻了一倍。排查了半天,发现他新加一个关联表,源表里有历史重复记录,关联之后订单行数直接翻倍。后来我们把它固化成了规范:任何多表关联的操作之前,必须先确认两个表的粒度,关联完后立刻检查行数变化是否在合理范围内。
5.2 数据清洗的纪律性清单
工具和方法论都讲了,最后分享一份我自己的干活清单。“纪律不是束缚,是在你熬夜调数据时帮你兜底的保险丝”:
- 永远保留原始数据。不管原始数据多脏,全量备份一份只读副本,清洗脚本处理的是副本。宁可多花一倍存储,不要因为误删后悔。
- 每一步清洗都要打印变更记录。处理前多少行、处理后多少行、删了多少重复、填了多少缺失,这些数字是验证逻辑是否正确的最快途径。数据清洗如果跑完输出“成功”就完事,那等于没做。
- 规则先成文,再写代码。哪怕规则只写在笔记软件里,也好过直接写代码。因为清洗规则本质上是业务逻辑,代码是给机器看的,规则文档是给人看的,两者缺一不可。
- 抽检不可省。清洗完别急着送下游,随机抽50~100条肉眼过一遍。我每次抽检都能发现至少一个漏网之鱼,已经养成习惯了。
- 脚本可重复执行。数据每天都会更新,清洗脚本应该设计成可重复跑的。有人用一次性硬编码临时表,第二天新数据来了又要重构一次——这是最大的时间浪费。
数据清洗不像建模、可视化那么有成就感,它枯燥、琐碎、看不见尽头,但恰恰是这些“看不见”的活儿,决定了整个数据链路的地基稳不稳。我个人的体会是,做数据清洗最需要的能力不是代码写得花哨,而是对业务的理解、对异常数据的嗅觉,以及那点不达目的不罢休的轴劲。一个数据团队,前期的清洗功夫下得越深,后期的分析、建模就越省心,这个道理,做久了自然就懂。