上周三下午,我正端着咖啡刷着技术论坛,运营组的小姐姐突然在钉钉上弹我:“大神,数据我们已经帮你清洗过了,直接跑模型就行,急,今天要结果。” 附带一个 CSV 文件,名字叫final_clean_v2_最终版.csv。我放下咖啡,满怀期待地pd.read_csv,然后df.head()。第一行user_age列赫然写着 “-3”,第二行是 “二百”,第三行是NULL字符串 —— 注意是字符串 “NULL”,不是 NaN。register_date那列更离谱:2023-02-30,明天,还有2020/13/01。我当时一口咖啡直接喷在了屏幕上,擦的时候就在想:你们管这玩意儿叫清洗过?这简直是把拖把在脏水里涮了一下就拿来擦脸。
干数据这行久了你就明白一个铁律:永远不要相信任何人交给你的数据,包括你自己上个月存的。数据清洗这个词被说得太轻巧,好像真是把白菜上的泥冲掉那么简单。实际上,每一次“清洗”都像是在拆盲盒,你永远不知道下一个坑是业务逻辑的扭曲、系统迁移的伤痕,还是纯粹的人类手滑。今天这篇,我就把那些年踩过的脏数据坑揉碎了讲,咱们不列什么“清洗三步法”,就聊聊那些让你血压飙升的瞬间和怎么擦干净屁股。
一、缺失值从来不是空着的那么简单
新人最爱问的一句话:缺失值怎么处理?教科书上写得明明白白:删掉、均值/中位数填充、模型预测填充。实际情况呢?你先得搞清楚它为什么缺失。
我做过一个线下门店的会员分析项目,有个字段叫“婚姻状况”,40% 是空值。运营说这些肯定都是单身,因为已婚的会填配偶信息。结果我查了一下原始录入日志,发现门店的平板系统有个 bug,当屏幕键盘弹出来的时候,“已婚”选项刚好被挡住,收银员懒得滑屏幕,直接点了下一步,系统就默认跳过了。这 40% 的缺失根本不是单身贵族,而是被 UI 坑了的已婚人士。如果随手用众数填“未婚”,后面所有家庭型消费的关联挖掘全都会歪掉。
所以处理缺失值的第一原则是:找到缺失的元凶,而不是着急补窟窿。怎么找?问业务、看日志、交叉验证。有一次我甚至拉了用户绑定的第三方账号信息,发现有些用户手机号为空,但在微信 OpenID 里绑着手机,只是接口没同步过来,这种缺失补起来就有底气。
实在查不出来,我再教你一个用惯了的粗暴分层法:把缺失本身就当成一个特征。比如在预测用户流失时,“最近一次登录设备” 缺失,往往代表该用户长期没登录,或者是远古注册用户根本没激活,这个缺失本身的信息量就很大。我会单独建一列is_device_missing,然后把原字段用中位数填上,放进模型里跑,特征重要性往往比你想象的高。再不行,你就分桶,把缺失作为一个单独类别扔给 CatBoost 这种天然支持缺失值的模型,省心。
千万别上来就df.dropna()。我曾经亲眼见过一个实习生把包含 30% 缺失值的关键字段直接删掉,样本量从 50 万变成 3 万,后来我问他为啥,他说:“网上说缺失多了就得删。” 网上还说一天八杯水呢,你喝一个我看看。
二、异常值不是数学定义的,是业务定义的
3σ 原则、箱线图 1.5 倍 IQR,这些统计学工具是死的。你拿它去套业务数据,能把自己套成傻子。
讲个真实案例:有次做二手车价格预测,车辆行驶里程mileage有不少超过 100 万公里的。用箱线图一看,远超上须,实习生二话不说当异常值全砍了。我回来检查差点没背过气去——那些全是跑了八九十万公里的营运车辆,有几辆出租车改的二手网约车,它们的价格逻辑跟普通家用车完全不同,是市场上真实存在的交易,砍掉它们就等于把整个商用二手车市场给剔除了。最后我们把这些“异常”样本单独建模,价格预估准确率直接提了 8 个点。
所以异常值不是毒药,是情报。处理之前先灵魂拷问:这个异常在业务上有可能发生吗?如果可能,它可能就是你的金矿,比如金融风控里的欺诈交易,恰恰长着一副异常的面孔;如果不可能,比如年龄字段冒出来个 245 岁,那才是真正的脏数据。
我还遇到过用户年龄全是 2023 年的数据集,一查发现录入页面年月日三个下拉框默认值是当前年份,用户懒得改,直接点确认,所有懒汉都变成了 2023 年出生。这种情况怎么洗?我当时的办法是:取该用户第一次消费记录的时间,用消费时的年龄倒推出生年,虽然没法精确到日,但至少把婴儿变成了成年人,不至于在亲子类目推荐里给一个 30 岁壮汉推奶粉。
说个私藏的土办法:对于连续变量,我会先画一张足够长的直方图,不做任何过滤,就盯着尾巴看。有一次在用户注册时长分布里,看到尾巴上有几个十亿秒的值,算了一下大概是 30 多年,而 App 才上线 5 年,这百分百是时间戳取错了字段,可能是把毫秒当秒存了。这种一眼假的东西,写个简单的逻辑df.loc[df['reg_seconds'] > 5*365*24*3600, 'reg_seconds'] = np.nan直接干掉,不用纠结。
三、重复数据,麻烦制造机
重复数据里最恶心的一类是“不完全重复”,你以为用drop_duplicates就能搞定?太年轻。
电商订单数据里,同一个订单号可能因为用户分两次支付出现了两行,一行支付 30,一行支付 70,总额 100,但订单号是同一个。业务上这是同一个订单,数据上却是两条记录,你直接去重会丢金额,不去重会重复计算订单量。这种得先聚合,用groupby把金额 sum 起来,状态取最新的,时间取最早的。
还有一种幽灵重复:CRM 系统里同一个用户有两个 ID,因为人家先在小程序注册,又在 App 用手机号注册,系统没做打通。他的行为数据散落在两个 ID 下面,你分析用户画像时要是没合并,就会得出一个分裂的结论:这个用户既爱买高端护肤品又狂囤尿不湿,以为是个精致奶爸,其实人家是个宝妈,只不过两个号分别记录了她在不同阶段的购物偏好。处理这种问题的唯一办法是建立统一的用户标识体系,把手机号、设备 ID、微信 UnionID 做成映射表,极其痛苦,每次跑全量要十几个小时,但这就是地基,地基不牢,后面所有的分析都是空中楼阁。
我一般会留个心眼:拿到任何新数据,先按业务主键groupby计数,df.groupby('order_id').size().sort_values(ascending=False),凡是出现次数大于 1 的,抓几条出来瞪眼看,三五分钟就能发现是不是重复插入、是不是有父子订单关系,比直接上来就建模强一百倍。
四、格式地狱,没人能活着走出来
日期格式这个坑,我能单独写本书。2023-02-30都算温柔的,我见过2023年2月30日、30/2/2023、Feb 30, 2023同时出现在同一个 CSV 文件里,数据源是三个不同国家的运营团队手动填的。处理办法是用pd.to_datetime加errors='coerce',但这个只能对付日期的合法性,对付不了明天这种中文。为此我专门养了个正则小本本,碰到中文日期描述就上规则:昨天= 运行日期-1,明天= 运行日期+1,上个月这种我直接扔给 GPT 的 API 去解析,没办法,时间成本太高。
全角半角问题更是防不胜防。有一回做搜索日志分析,发现macbook pro和macbook pro(全角字符)被系统当成两个不同的搜索词,导致高频词统计少了一半。后来我养成了习惯,所有文本字段进来,第一步先做str.normalize('NFKC'),这是 Python 标准库unicodedata提供的 Unicode 正规化,能把全角字母数字自动转半角,省大事。
还有空格,肉眼看不见的空格。Excel 里复制出来的数据经常带个不间断空格\xa0,看着没毛病,df['name'].unique()一跑发现 “张三” 和 “张三 ” 是两条,气得肝疼。我现在数据读进来的第一个 cell 永远是:
python
for col in df.select_dtypes(include='object').columns: df[col] = df[col].str.replace('\xa0', ' ').str.strip()这行代码救过我的命。
五、脏,是因为数据从娘胎里就是脏的
很多时候我们以为自己在清洗数据,其实我们只是在清洗录入环节的排泄物。真正的源头是那个该死的埋点文档,是那个根本没做限制的前端表单,是那个“随手记一下”的运营后台。
我介入过一个 App 改版项目,新功能上线后page_view事件突然暴增三倍。产品经理开心地汇报说功能受用户欢迎,我一看数据,同一个用户在同一秒内会产生几十条一模一样的曝光事件。抓了前端开发一问,原来他为了保险,把埋点代码写在了视图刷新的回调里,列表每划一下,可见区域全部控件重新上报一次。这些数据能拿来做行为分析吗?不能,但产品日报里已经写进 PPT 了。最后我们花了整整两天给事件打上去重逻辑,加了时间窗口限制,才算勉强把数据掰回到能用的程度。
所以我现在跟新人讲得最多的一句话是:数据清洗不是建模前的预热动作,它是数据从采集那一刻就该开始的生命线。你能往前端表单加一个正则校验,就别让后端日志接收垃圾;你能让埋点工程师发版前复测一次,就别等数据落到数仓里再哭着洗。可惜理想丰满,现实是数仓的 ODW 层常年堆满屎山,你洗,或者不洗,模型就在那里,被屎淹死。
说到底,数据清洗考验的不是你会多少 Pandas 骚操作,而是你对业务的理解深度和怀疑一切的本能。看到数据,先别急着describe,先想想:这个字段为什么会出现?谁填的?什么流程产的?想不明白就去问,问到明白为止。那些你以为的脏,可能只是开胃菜,真正的硬菜还在后面。但好消息是,只要脏数据没把你劝退,你就是一个合格的数据矿工——灰头土脸,但眼里有光。