清洗10万条训练数据后,我恨不得掐死5天前的自己

30秒速览

  • pandas的read_csv()默认不校验数据有效性,埋雷高手
  • groupby().first()会静默覆盖数据,用nunique()先检测
  • 逐行apply处理10万条数据等于给自己挖坑
  • 可视化分布检查比假设检验更直观
  • 保留原始数据副本!保留原始数据副本!保留原始数据副本!

「数据质量比算法重要」这句话我听了100遍,但这次真的信了

上周给一个电商推荐系统做升级,客户给了10万条用户行为数据。我看了眼格式还算规整,直接用pandas加载就开始训练。三天后发现模型在测试集上表现飘忽不定,有时候AUC能到0.89,有时候直接掉到0.72。排查到最后发现是数据里混着:

  • 同一用户ID对应3种不同设备类型
  • 时间戳有2025年的记录(数据采集时间是2023年)
  • 点击事件duration出现负值

最坑的是这个Python代码居然没报错:

# 错误示范:直接读CSV不做校验
import pandas as pd
df = pd.read_csv('user_behavior.csv')  # 这里埋着定时炸弹
df['event_duration'] = df['end_time'] - df['start_time']  # 会产生负数但不会报错

写数据校验脚本时,我犯了个低级但致命的错误

发现问题后,我决定写个数据清洗脚本。第一版是这样的:

# 第一版清洗逻辑(问题很大)
def clean_data(df):
    # 过滤异常时间戳
    df = df[df['timestamp'] < '2023-12-31']  
    # 处理设备类型冲突
    df['device_type'] = df.groupby('user_id')['device_type'].transform('first')
    return df

这个实现有两个大坑:

  1. 直接修改原DataFrame导致后续无法追溯脏数据
  2. 用groupby的first()方法粗暴覆盖设备类型,可能丢失真实的多设备用户

后来改成了更安全的版本:

# 改进版:保留原始数据副本
def clean_data(raw_df):
    clean_df = raw_df.copy()
    dirty_mask = (clean_df['timestamp'] > '2023-12-31') | 
                 (clean_df['event_duration'] < 0)
    
    # 记录脏数据行号
    dirty_index = clean_df[dirty_mask].index
    clean_df = clean_df[~dirty_mask]
    
    # 对设备类型做更精细的处理
    device_counts = clean_df.groupby('user_id')['device_type'].nunique()
    multi_device_users = device_counts[device_counts > 1].index
    clean_df = clean_df[~clean_df['user_id'].isin(multi_device_users)]
    
    return clean_df, dirty_index

那个让我加班到凌晨两点的Pandas性能陷阱

处理到第8万条数据时,脚本突然变得巨慢。原来我写了这样的代码:

# 性能杀手:逐行apply
def calculate_user_stats(row):
    # 这里用了5个groupby操作...
    return pd.Series([avg_value, max_value])

df = df.apply(calculate_user_stats, axis=1)  # 10万行要跑15分钟

最终用向量化操作重写,速度提升200倍:

# 向量化版本
user_stats = df.groupby('user_id').agg({
    'click_count': ['mean', 'max'],
    'purchase_amount': 'sum'
})
user_stats.columns = ['avg_clicks', 'max_clicks', 'total_spent']
df = df.merge(user_stats, on='user_id')  # 现在只要4秒

数据分布可视化救了我的模型

清洗完数据后,我习惯性做了分布检查,结果发现:

特征 清洗前异常值占比 清洗后
点击时长 12.7% 0.3%
页面滚动深度 8.2% 1.1%

用seaborn画的对比图:

import seaborn as sns
# 对比清洗前后分布
plt.figure(figsize=(12,6))
plt.subplot(1,2,1)
sns.histplot(raw_df['click_duration'], kde=True)
plt.title('Before Cleaning')

plt.subplot(1,2,2)
sns.histplot(clean_df['click_duration'], kde=True)
plt.title('After Cleaning')

最终效果:AUC从0.72飙到0.91的五个关键步骤

完整的数据清洗流水线:

  1. 数据快照备份(永远保留原始数据)
  2. 异常值检测(用IQR而不是简单阈值)
  3. 时间窗口过滤(业务逻辑校验)
  4. 用户行为序列验证(检查事件流合理性)
  5. 特征工程前再次抽样检查

最终模型效果对比:

| Metric       | Dirty Data | Clean Data |
|--------------|------------|------------|
| AUC          | 0.72       | 0.91       |
| Precision@10 | 0.31       | 0.58       |
| Recall@50    | 0.42       | 0.83       |

那些年踩过的数据坑

当我发现时间戳问题只是冰山一角时,后背突然一阵发凉。用df.describe()扫了一眼数值字段,发现某商品价格字段的最大值赫然显示着9999999——这显然不是正常人类会买的电饭煲价格。更可怕的是,用sns.boxplot画箱线图时,整个画布都被离群点撑爆了。

最讽刺的是,就在清洗数据的前一天,我还在团队周会上信誓旦旦地说:”PySpark的isNull()足够应对缺失值”。结果第二天就被打脸——有17%的用户行为记录里,关键的item_id字段虽然不为空,但填的都是”NULL”字符串!这种脏数据用常规的dropna()根本筛不出来,最后还是靠正则表达式r'^NULL$'才揪出这些伪装者。

数据清洗的血泪史

为了解决设备类型冲突的问题,我写了下面这段看似聪明的代码:

def resolve_device_conflict(user_group):
    # 取最新设备类型
    latest_device = user_group.sort_values('timestamp').iloc[-1]['device_type']
    user_group['device_type'] = latest_device
    return user_group

df = df.groupby('user_id').apply(resolve_device_conflict)

结果运行到第8万条数据时内存直接爆了。后来才知道groupby().apply()在Pandas里是性能黑洞,改成sort_values()+drop_duplicates()组合后,处理时间从47分钟降到了12秒。

当异常值成为常态

最让我崩溃的是发现”用户停留时长”这个关键特征有问题。理论上电商页面停留超过1小时就很反常了,但数据里居然有3.6%的记录显示用户连续停留超过24小时!开始以为是爬虫行为,后来排查发现是APP客户端的埋点bug——当用户切到后台时,计时器竟然没有暂停。

处理这类问题不能简单截断或删除,因为真实场景确实存在长时间浏览的用户。最终解决方案是结合业务逻辑分段处理:

  • 0-1小时:原始值
  • 1-4小时:取对数变换
  • 4小时以上:视为异常会话,但保留”是否超长停留”布尔特征

数据血缘的蝴蝶效应

在修复用户地址字段时,我偶然发现广东省的订单里有21条记录写着”GD-广东”和”Guangdong”两种格式。本以为用replace()统一处理就完事了,直到下游的风控同事找上门——他们的反欺诈规则里特意区分了这两种写法,因为历史数据表明”Guangdong”拼写的订单欺诈率高出47%。

这个教训让我养成了新习惯:现在每次做数据清洗前,都会先用df['column'].value_counts(dropna=False).head(20)把所有字段的取值分布看一遍,特别是那些看似已经标准化的类别型字段。

自动化测试拯救了我

经历这次事件后,我给数据预处理流程加了三道保险:

  1. 字段范围断言:assert df['age'].between(12,100).all()
  2. 业务规则校验:assert (df['order_date'] >= df['register_date']).all()
  3. 特征相关性检查:assert df.corr()['click_rate']['purchase_rate'] > 0.3

现在每次跑批处理,日志里都会打印这样的检查点报告。虽然增加了15%的运行时间,但再也不用半夜被警报叫醒去救火了。

那些教科书不会告诉你的事

翻遍各种机器学习教材,从来没人说过真实数据里会有这么多”创意”错误。比如:

  • 用”-1″表示缺失值(和真实负值混在一起)
  • 性别字段同时存在”M”/”F”和”男”/”女”
  • JSON字符串里混着HTML转义字符&amp;
  • 用”1900-01-01″表示未知日期(导致年龄计算出错)

现在我的预处理代码里永远会有这样一个函数:

def detect_anomalies(series):
    """识别各种伪装成正常值的异常值"""
    # 检查占位符
    placeholders = ['-1', 'NULL', 'NaN', '未知', '1900-01-01']
    # 检查混合编码
    mixed_encodings = len(set(type(x) for x in series)) > 1
    # 检查隐藏字符
    has_hidden_chars = any('x00' in str(x) for x in series)
    return {
        'placeholder_count': sum(x in placeholders for x in series),
        'has_mixed_encodings': mixed_encodings,
        'has_hidden_chars': has_hidden_chars
    }

那些年我们踩过的脏数据坑

当我开始逐条检查那10万条数据时,才发现自己掉进了怎样一个数据沼泽。最让我头皮发麻的是用户ID重复问题——不是简单的重复记录,而是同一个user_id在不同时间段对应着完全不同的设备指纹。

# 检查用户设备异常的例子
duplicate_users = df.groupby('user_id')['device_type'].nunique()
anomalies = duplicate_users[duplicate_users > 1].index.tolist()

print(f"发现{len(anomalies)}个用户存在设备冲突")
# 输出:发现1428个用户存在设备冲突

更可怕的是,这些冲突设备中居然有17个用户同时出现了iOS、Android和Web三种设备类型。这要么是数据采集系统出了bug,要么就是有人在恶意刷单。我专门写了个异常检测函数来抓这些”变形金刚”:

def detect_shape_shifters(dataframe):
    # 找出拥有3种以上设备类型的用户
    shape_shifters = (dataframe.groupby('user_id')['device_type']
                      .filter(lambda x: x.nunique() >= 3))
    return dataframe[dataframe['user_id'].isin(shape_shifters.unique())]

shape_shifters = detect_shape_shifters(df)
shape_shifters.to_csv('shape_shifters_users.csv', index=False)

时间旅行者的购物记录

时间戳问题比我想象的还要魔幻。除了明显的2025年未来数据,还有5%的记录时间戳比企业成立时间还早——这家电商2018年才注册,但数据里赫然躺着2016年的”用户行为”。

最绝的是时间戳格式的混乱程度:

  • 47%的时间戳是标准的Unix毫秒时间戳
  • 32%是ISO 8601格式但时区混乱(UTC+8和UTC-5混在一起)
  • 11%是”YYYY/MM/DD HH:MM”这样的自定义格式
  • 剩下10%根本就是乱码,比如”0000-00-00 00:00:00″

我不得不写了个时间戳清洗函数,光正则表达式就用了5组:

import re
from datetime import datetime

def clean_timestamp(raw_str):
    # 处理Unix时间戳
    if re.match(r'^d{13}$', str(raw_str)):
        return datetime.fromtimestamp(int(raw_str)/1000)
    
    # 处理ISO格式
    iso_pattern = re.compile(r'(d{4})-(d{2})-(d{2})[T ](d{2}):(d{2}):(d{2})')
    if iso_match := iso_pattern.search(str(raw_str)):
        return datetime(*map(int, iso_match.groups()))
    
    # 其他处理逻辑...
    # 此处省略20行各种奇葩格式的处理代码
    
    return None  # 实在解析不了就返回None

数据血缘追踪的噩梦

当我试图追踪这些脏数据的来源时,发现更大的问题——客户提供的10万条数据竟然来自6个不同的数据管道:

  1. 主站Web端埋点(占比42%)
  2. APP端SDK采集(占比31%)
  3. 第三方广告平台回传(占比15%)
  4. 线下门店POS系统同步(占比7%)
  5. CRM系统导出数据(占比3%)
  6. 来历不明的测试数据(占比2%)

最坑的是,每个渠道对”用户点击”的定义都不一样。Web端把鼠标悬停3秒就算点击,APP端需要真实触摸,而广告平台用的是曝光即点击的逻辑。这导致同样的”点击”行为,在不同渠道的数据里代表着完全不同的用户意图。

我画了张数据血缘关系图,线条密集得像蜘蛛网:

graph TD
    A[Web埋点] -->|Kafka| B(数据湖)
    C[APP SDK] -->|HTTP API| B
    D[广告平台] -->|SFTP| E[中间库]
    E -->|每日同步| B
    F[POS系统] -->|CSV导出| G[本地服务器]
    G -->|手动上传| B
    H[测试数据] -->|直接插入| B

数据清洗的十八般武艺

经过这次教训,我整理了一套数据清洗checklist,现在分享几个最实用的技巧:

1. 值域检查的进阶用法

不要只检查最大值最小值,要检查值分布是否符合业务逻辑。比如电商场景:

# 检查商品价格分布
price_stats = df['price'].describe()
iqr = price_stats['75%'] - price_stats['25%']

# 定义异常值边界
upper_bound = price_stats['75%'] + 3 * iqr
lower_bound = max(0, price_stats['25%'] - 3 * iqr)

# 找出异常价格
anomalies = df[(df['price'] > upper_bound) | (df['price'] < lower_bound)]
print(f"发现{len(anomalies)}条异常价格记录")

2. 跨字段逻辑校验

很多脏数据单独看每个字段都正常,但组合起来就暴露问题。比如:

# 检查"下单时间>付款时间"的异常记录
time_anomalies = df[df['order_time'] > df['payment_time']]

# 检查"已取消订单"却有物流信息的记录
cancel_anomalies = df[(df['status'] == 'cancelled') & 
                     (df['logistics_info'].notnull())]

3. 基于聚类的异常检测

对于高维数据,可以用聚类找出离群点:

from sklearn.cluster import DBSCAN
from sklearn.preprocessing import StandardScaler

# 选择需要检查的特征
features = ['session_duration', 'click_count', 'scroll_depth']
X = df[features].fillna(0)

# 标准化后聚类
X_scaled = StandardScaler().fit_transform(X)
clustering = DBSCAN(eps=0.5, min_samples=10).fit(X_scaled)

# 标记异常点(-1表示异常)
df['anomaly_flag'] = clustering.labels_ == -1

发表评论