数据清洗这件事,我在数学建模竞赛里栽过一次狠的:花了大把时间调算法,结果在数据预处理阶段漏掉了一批异常值,最终的模型预测结果被带偏,国奖直接没了下文。从那以后我形成了一个习惯——拿到赛题数据的第一件事,永远不是建模,而是先做数据清洗。
很多参赛队把数据清洗当成"填空"和"删行"的体力活,其实它的本质是数据审计。你得在动手之前搞清楚:这份数据是怎么产生的?采集过程中可能引入什么噪声?哪些字段是模型真正需要的?哪些值是"肉眼看着正常、但统计上一眼假"的?这些问题想清楚了,后续的模型效果、论文说服力、甚至赛题得分上限,都会完全不一样。
这篇博客是"数学建模算法案例精讲"系列里的基础篇,聚焦数学建模中最容易被低估的环节——数据清洗。我会用真实竞赛中常见的脏数据情况作为例子,带你走一遍从数据审查到缺失值处理、异常值判定、类型修正、文本和时间特征整理的完整流程,每一段都会给出可以直接复制运行的Pandas代码,并解释这一步背后的统计学和建模逻辑,而不是光给命令。适合正在准备国赛、美赛、华数杯等竞赛的同学参考。
1. 数据清洗在建模竞赛里的真实分量:为什么它是策略不是杂活
1.1 一个反直觉的现象:同一道题,两组算法相似,成绩差一档
参加过一次全国大学生数学建模竞赛的评审模拟后,我发现一个规律:评审专家看论文时,最先翻的往往是"数据预处理"那一节。如果预处理草草两行字带过,评委的第一印象就是"这个队对数据不敏感";反过来说,如果预处理部分有理有据——为什么删掉这5个样本、为什么对销售额取对数、为什么缺失值用中位数而不是均值填充——评委会在心里加分,甚至在你模型不完美的时候愿意给一个更好的评价。
这不是玄学。竞赛数据通常是从实际系统里采集的:传感器读数、销售流水、气象站观测、用户行为日志。这些数据的真实分布里必然有噪声、缺失、重复、单位混杂,不可能像教科书数据集那样干净。建模竞赛的核心能力之一,就是在不完美数据里提取出可以建模的信号。数据清洗的好坏,直接决定后续算法能跑到的上限——再好的XGBoost、再精美的LSTM,喂进去一堆脏数据,输出也是垃圾。
1.2 从评审角度倒推:数据清洗要留下"可审计"的痕迹
我在帮学弟学妹改参赛论文时,最常见的问题不是算法不会写,而是清洗过程"黑箱化":用了一堆dropna()、replace(),但论文里说不清为什么这么处理。评委看到的结果是——你到底删了多少行?为什么删?填充了哪些列?用了什么方法?如果你连这些都说不上来,评委会怀疑你的模型结果是否可靠。
所以我在自己的建模流程里,给数据清洗定了三条硬规矩:
- 每一步清洗操作都有"原因记录":比如"删除购买金额为负的样本,原因是业务上不可能出现负消费,且只有3条,占0.02%,不影响样本量"。
- 清洗前后各保存一份数据快照:方便回溯,也方便在论文里写"清洗前后对比表"。
- 每类字段单独审视,而不是全局一把梭:连续字段、离散字段、时间字段、文本字段,问题类型完全不同。
这三条规矩看起来费时间,实际上能帮你避免"洗过头"的灾难——我有一次把城市名的空格去掉后,发现"北京 "和"北京"不再重复,但实际上这两类如果在业务里代表同一个城市,应该合并成同一个编码,而不是当作两个类别放进模型。如果当时没有记录清洗逻辑,这个问题会一直藏到建模结束才发现。
1.3 数据清洗的常见误区:先把"万能模板"扔掉
现在网上一搜数据清洗,铺天盖地都是"十行代码搞定缺失值"这类速成模板。但竞赛数据不是Kaggle上的Titanic,不会那么规整地只缺Age和Cabin。真实赛题里经常会给你这样一些"惊喜":
- 一张表里日期格式五花八门,有
2023/1/1、2023-01-01、20230101、还有01/01/23。 - 明明应该是数值的列,读进来是
object类型,里面有"1,200"、"unknown"、"-"。 - 同一行数据在不同字段中出现了互相矛盾的值,比如"年龄=25"但"工龄=30"。
- 数据总量看起来很多,但去掉缺失、去掉异常、去掉重复之后,有效样本只剩一半。
这种时候套模板只会让问题更糟。数据清洗的第一原则不是"用哪种方法",而是"先搞清楚每列到底是什么,再决定怎么处理"。这也是接下来这一整篇的主线思路:先审查,再决策,最后执行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拿到脏数据的第一件事:结构审查与数据画像
2.1 不要急着一行代码都不写,先把数据结构"看"明白
我见过太多同学拿到赛题附件,第一件事就是pd.read_csv()然后画个相关性热力图。但相关性热力图是建模阶段看的,清洗阶段的第一件事是搞清楚这份数据的"体质"——它有多少行多少列、每列的类型是什么、缺了多少、取值范围是什么、分布是否畸形。
这一段我会用一个模拟的数据框来演示完整的"数据画像"流程。假设我们拿到一份销售数据,字段包括:订单ID、日期、城市、商品类别、销售额、客户年龄、客户评分。这份数据是从业务系统导出的,必然带着各种问题。
python复制import pandas as pd
import numpy as np
# 模拟一份包含脏数据的销售记录
df = pd.DataFrame({
'订单ID': ['A001', 'A002', 'A003', 'A004', 'A005', 'A006', 'A007', 'A001'],
'日期': ['2023/1/1', '2023-01-02', '20230103', '2023/01/04', '2023-01-05', '2023-01-06', '2023-01-07', '2023/1/1'],
'城市': ['北京', '上海', '广州', ' 北京', '深圳', '上海', '广州', '北京'],
'商品类别': ['手机', '电脑', '手机', '平板', '电脑', '手机', '平板', '手机'],
'销售额': ['1,200', '8999', '4500', '6000', '300', '25000', '99999', '1,200'],
'客户年龄': [25, 32, np.nan, 45, 29, 200, 35, 25],
'客户评分': [4.5, 3.2, 5.0, 2.1, 4.8, 4.0, '优', 4.5],
})
看到这份数据的第一眼,普通人会觉得"好像没什么问题"。但如果你先执行基础画像,问题立刻就会浮出水面:
python复制# 维度信息:行数、列数、每列非空值数量、数据类型
df.info()
code复制<class 'pandas.core.frame.DataFrame'>
RangeIndex: 8 entries, 0 to 7
Data columns (total 7 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 订单ID 8 non-null object
1 日期 8 non-null object
2 城市 8 non-null object
3 商品类别 8 non-null object
4 销售额 8 non-null object
5 客户年龄 7 non-null float64
6 客户评分 7 non-null object
dtypes: float64(1), object(6)
这一眼就能看出三个问题:
销售额和客户评分明明是数值列,却是object类型,说明里面有非数值内容。客户年龄缺了一个值。城市看起来是文本,但" 北京"前面有个空格,跟"北京"是两个不同值。
这些就是数据清洗的切入口。继续往下看描述性统计和缺失率:
python复制# 描述性统计
df.describe(include='all')
python复制# 逐列缺失值数量
df.isnull().sum()
2.2 数据画像的核心指标:缺失率、唯一值数、分布形态
除了info()和isnull(),我还会固定看三个指标,它们能帮你判断每一列"脏的程度":
第一,唯一值数量。 对离散字段(城市、商品类别、订单ID),看nunique()。如果订单ID出现了重复,说明数据有重复行或者同ID对应多条记录;如果城市名里"北京"和" 北京"同时存在,nunique()会告诉你城市数比实际多。这一步能直接发现"伪类别"。
python复制for col in df.columns:
print(f"{col}: {df[col].nunique()} unique values")
第二,频数分布。 对类别型字段,用value_counts()看看每个类别的样本数是否合理。比如一个城市只出现了一次,而其他城市出现几十次,这种低频类别要么是脏数据,要么需要在建模时做合并处理。尤其要注意那些占总数不到1%的类别——它们经常是拼写错误、大小写不一致、或者数据录入噪声。
第三,连续字段的统计量异常。 对客户年龄做describe(),如果最大值是200,最小值是-5,说明有超出物理常识的异常值;对销售额,如果均值是5000但中位数是800,说明分布右偏严重,后面建模可能需要取对数。我在清洗阶段就会把这些信息记录成一份"数据体检表",建模阶段直接用这张表指导特征工程。
python复制# 数值字段的分布统计
df['客户年龄'].describe()
# count 7.000000
# mean 54.428571
# min 25.000000
# max 200.000000
客户年龄平均54岁、最大200岁,这份数据显然不靠谱。所以,数据画像的目的不是让你直接开删,而是让你形成对数据的"直觉判断":哪些列可靠,哪些列需要整容,哪些列干脆不能进模型。这种直觉,就是清洗策略的决策依据。
3. 缺失值处理:从暴力删除到插值决策的完整链条
3.1 先回答三个问题:缺失多少、缺失机制、缺失变量是否入模
缺失值处理是数据清洗里最有技术含量的一环,也是很多参赛队做得最粗糙的一环。dropna()一行代码删光,对于缺失很少的数据可能没事,但如果一个关键变量缺失了30%,你直接删行会导致样本量骤减,后续建模必然过拟合;如果你拿均值硬填,又可能把分布形态彻底扭曲。
我在处理缺失值之前,会先问自己三个问题:
问题一:这个变量的缺失比例是多少? 缺失率<5%的列,直接删除缺失样本通常影响不大;缺失率在5%~30%之间的列,需要认真选择填充方法;缺失率>50%的列,我基本会废弃掉——除非这个变量在业务上极其关键,否则你填出来的值全是"脑补",模型的可靠性很低。
问题二:缺失机制是什么? 这是很多教材会讲但大家不爱用的知识点。如果某一行完全随机缺失(比如用户忘了填年龄,跟其他变量无关),叫MCAR(完全随机缺失),填充方法的选择比较自由;如果缺失跟某个变量相关(比如高消费用户更不愿意透露收入),叫MAR(随机缺失),直接删行会导致样本选择偏差;如果缺失本身跟这个变量的真实值相关(比如极端高收入人群收入字段缺失多),叫MNAR(非随机缺失),这时候任何简单填充都会有偏差。
竞赛里判断机制很难用统计检验做,我倾向于用业务逻辑推断。比如客户年龄缺失,可能是因为注册时没填,跟其他变量无关,可以近似当作MCAR;但如果缺失客户都是新用户,那就得小心了。
问题三:这个变量后面要不要进模型? 有些变量只是中间变量,清洗后不直接进模型,那处理方式可以简单粗暴;有些变量是核心特征(比如销售额、客户年龄),那就值得用更精细的填充方法。
3.2 不同场景下的填充方法选择:从简单到复杂
我按"简单到复杂"的顺序,把竞赛里常用的缺失值处理方法列一遍:
方法一:直接删除。 缺失率极低(<3%),且样本量足够大时,直接删。比如订单ID缺失,这种唯一标识字段删了就删了。
方法二:统计量填充。 连续变量用均值或中位数,离散变量用众数。注意:如果数据右偏严重(比如销售额),用中位数比均值稳健得多;如果分布对称且没有极端值,用均值更有效。实操代码如下:
python复制# 用中位数填充年龄(因为数据里有异常值,中位数比均值稳健)
median_age = df['客户年龄'].median()
df['客户年龄'] = df['客户年龄'].fillna(median_age)
方法三:分组填充。 这是竞赛中性价比最高的一种方法。一个变量缺失,往往可以按另一个分组变量来填充。比如"销售额"缺失,可以按"商品类别"分组,每组用各自的中位数填充——因为手机和电脑的销售额量级完全不同,全局中位数会误导模型。
python复制# 按商品类别分组填充销售额
df['销售额_num'] = pd.to_numeric(df['销售额'].str.replace(',', ''), errors='coerce')
df['销售额_num'] = df.groupby('商品类别')['销售额_num'].transform(lambda x: x.fillna(x.median()))
方法四:插值法。 数据是时间序列时(比如逐日温度、逐时水位),前后相邻的点往往有连续性,用线性插值比均值填充合理得多。Pandas的interpolate()默认线性插值,遇到单调趋势数据效果很好。
python复制# 时间序列数据用线性插值
df_time = df.set_index('日期')
df_time['温度'] = df_time['温度'].interpolate(method='linear')
方法五:模型预测填充。 将缺失变量当作目标变量,用其他完整变量训练一个回归模型来预测缺失值。这个方法效果好,但有过度工程的风险,竞赛中除非缺失率很高且变量重要,否则性价比不高。如果要用,我建议用KNN填充或者简单的随机森林,并注意别把测试集信息带进来(这是关键,见后面的数据泄露提醒)。
3.3 缺失值处理最容易踩的坑:数据泄露与填充顺序
我见过不少队伍在数据清洗时犯一个隐蔽错误:先对全量数据填充缺失值,再划分训练集和测试集。这会导致测试集的信息被"泄漏"进训练集——因为填充值是用全量数据的统计量算出来的,模型在训练时就已经看见了测试集的分布信息,评估分数虚高,到了真正的未知数据上就露馅。
正确的顺序是:先划分训练集/测试集,再分别对训练集和测试集做填充,且填充参数只用训练集计算。 如果你是在赛后复现,建议先切分再清洗。具体做法:
python复制from sklearn.model_selection import train_test_split
train, test = train_test_split(df, test_size=0.2, random_state=42)
# 用训练集的中位数填充,然后再应用到测试集
train_median = train['客户年龄'].median()
train['客户年龄'] = train['客户年龄'].fillna(train_median)
test['客户年龄'] = test['客户年龄'].fillna(train_median)
这个细节看似不起眼,但在评委眼里是专业性的体现。很多队伍在论文里写"对缺失值进行均值填充",但对数据泄露毫无概念。如果你能写清楚"先用训练集计算中位数,再填充到测试集",评审印象分会高不少。
4. 异常值检测:3σ、IQR、分位数之外,竞赛常用的组合判定法
4.1 为什么不要无条件用3σ法则:数据的正态假设陷阱
异常值检测是数据清洗里最容易被"公式化"误用的环节。很多人一上来就是"超过3倍标准差就是异常值",但3σ法则的前提是数据近似服从正态分布。如果数据本身是右偏分布(比如销售额、收入、点击量),用均值±3σ来找异常值会十分糟糕:右偏分布右侧的长尾会被整体带偏,导致异常值检测不出来,甚至把正常的高值样本误判为正常。
我见过一个典型案例:一份用户消费数据,90%的用户消费在50~200元之间,个别大客户消费在1万~20万元。如果用均值±3σ法则,均值会被几个大客户拉高到几千,标准差也巨大,算出来的3σ上界可能到了5万元,真正的大客户反而被掩盖了;而更可怕的是,左侧的下界可能是负的,买200元的中等用户反而离均值很远,看起来像"异常"。
所以,在竞赛里我检测异常值通常不只用一种方法,而是组合判断:先看分布形态,再选合适的检测器,最后用可视化交叉验证。
4.2 IQR箱线图法与3σ的对比:什么时候用哪个
IQR(四分位距)法不依赖正态分布假设,通过数据的四分位数来识别离群点:
python复制Q1 = df['销售额_num'].quantile(0.25)
Q3 = df['销售额_num'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
outliers = df[(df['销售额_num'] < lower_bound) | (df['销售额_num'] > upper_bound)]
print(outliers)
IQR法的优点是稳健,对偏态分布更友好;缺点是它把“超过1.5倍IQR”作为硬性标准,一些在业务上正常的离群点(比如大额订单)会被标记为异常。因此,竞赛里更好的做法是:先用IQR找出候选异常值,再结合业务含义决定是删除、修正、还是保留。
我做竞赛时有一个固定的"三级判定"流程:
- 机器初筛:分别用3σ、IQR、Z-score绝对值>3,把三种方法标记的异常值交叉对比。
- 可视化确认:画箱线图、散点图、直方图,把初筛的异常值在图中标出来,用肉眼确认它们是不是"真离群"。
- 业务判定:对确认的异常值,看它是否可能是录入错误、传感器故障、数据合并错误;如果是,删掉或修正;如果它确实是真实高值客户,且对建模有帮助(比如预测销售额,大客户本身就是有效信息),那么保留而不是删除。
这一步我用一个小例子说明:在我们这份销售数据里,客户年龄的最大值是200,这显然不是"高龄真实用户",而是录入错误。它应该被修正或者删除,而不是当作有效样本。
python复制# 比如把200岁修正为缺失,然后按中位数填充
df.loc[df['客户年龄'] > 120, '客户年龄'] = np.nan
df['客户年龄'] = df['客户年龄'].fillna(df['客户年龄'].median())
注意:年龄>120这个阈值是业务常识,不是统计方法产出。竞赛里一定要善用领域知识,很多异常是"物理上不可能"或"业务上不合理"的,不需要复杂的算法就能识别。
4.3 竞赛进阶:用聚类和降维发现多维异常值
单变量异常检测只能捕捉一维上的离群点,但真实竞赛数据里,异常往往藏在多个变量的组合里。比如某用户年龄25岁,消费金额却高达50万元,单看"年龄"和"消费金额"两个变量单独分布都正常,组合起来却非常可疑。
应对多维异常,我推荐两种方法:
方法一:DBSCAN聚类。 DBSCAN通过密度聚类,自然把稀疏区域的样本标记为噪声。参数eps和min_samples需要调,但不需要事先指定聚类数,很适合异常检测场景。
python复制from sklearn.cluster import DBSCAN
from sklearn.preprocessing import StandardScaler
X = df[['客户年龄', '销售额_num']].dropna()
X_scaled = StandardScaler().fit_transform(X)
clustering = DBSCAN(eps=0.8, min_samples=5).fit(X_scaled)
# -1标签表示噪声点(异常值)
df['异常标签'] = clustering.labels_
方法二:孤立森林。 非常适合高维数据,通过不断随机划分特征空间,异常值更容易被"孤立"出来。
python复制from sklearn.ensemble import IsolationForest
iso = IsolationForest(contamination=0.05, random_state=42)
df['异常标签'] = iso.fit_predict(X_scaled)
这两种方法在比赛里写进论文会让"数据清洗"章节看起来专业得多,但要注意:聚类/降维出来的异常点不等于要删除的点。你必须逐个看标签为-1的样本是否真的在业务上不合理,再决定处理方式。你不能让算法替你做"是否删除"这个决策,这是清洗环节最核心的价值观。
4.4 异常值不只有删除一条路:截断、转换与分箱
很多队伍处理异常值只知道删除,但竞赛里异常值信息量大,删了可惜。应对真实存在的高值样本,我更推荐下面三种替代方案:
- 截断(Winsorize):把超过p99的值压到p99。这样既保留了样本,又减弱了极端值对模型的影响。
- 对数变换或Box-Cox变换:对严重右偏的变量取对数,可以压缩长尾,让模型学得更稳。
- 分箱:把连续变量切成离散区间。切分后异常值的影响被限制在一个区间内,模型稳定性更高。
这三种操作在论文里写出来,也是加分项。因为它说明你不只是单纯地"清洗"数据,而是在理解数据分布的基础上做特征工程。数据清洗和特征工程的边界本来就模糊,能做这层思考的队伍,通常能撑到国奖水平。
5. 类型、重复、文本、时间:最容易翻车的四个细节角落
5.1 类型错误:object里的"数字"是模型的最大敌人
很多队伍在建模阶段遭遇"ValueError: could not convert string to float",然后才回头清洗数据。这种类型错误的根源是:读入数据时,Pandas把本应是数值的列推断成了object,因为里面混入了非数字字符。
最常见的是千分位逗号:"1,200"、"8,999"。在中文业务系统里,还常见中文逗号、全角空格、单位("元"、"kg")混在数值列里、以及"-"代表缺失值的情况。
处理方法也很直接:正则替换掉非数字字符,再转数值:
python复制# 清理销售额列:去掉逗号和空白,转成float
df['销售额_num'] = df['销售额_num'].astype(str).str.replace(',', '', regex=True)
df['销售额_num'] = df['销售额_num'].str.replace('元', '', regex=False)
df['销售额_num'] = pd.to_numeric(df['销售额_num'], errors='coerce')
errors='coerce'这一步很关键:遇到无法解析的值会变成NaN,而不是报错中断。转完之后的NaN再走缺失值填充流程,这样一条流水线就串起来了。
5.2 重复值:你以为的重复,可能是伪重复
重复值处理看起来简单,drop_duplicates()一行搞定,但竞赛里容易翻车的是两类"伪重复":
第一类:一模一样但实际有意义。 比如一条传感器每10分钟记录一次,可能某段时间的数据一模一样(传感器没变化),但这在时间序列里是正常现象,不是重复行。直接drop_duplicates()会丢掉真实信息。
第二类:部分字段重复但主键不同。 比如同一个订单ID出现多次,一次代表下单,一次代表退款,字段不同但订单ID相同。这时候按订单ID去重会把退款记录删掉。
正确处理重复值的步骤是:
- 先判断哪些列是业务主键。
- 用
duplicated(subset=['订单ID'])检查主键重复情况。 - 如果主键重复但其他字段有差异,做数据合并或按业务规则保留最新状态,而不是直接删除。
python复制# 查看重复的订单ID
df[df.duplicated('订单ID', keep=False)]
竞赛里很多数据是"长表"(一行一个观测),和"宽表"(一行一个主体)互相转换产生的重复,逻辑复杂。我建议你在清洗之前,先把数据来源和表结构搞清楚,不要拿到就drop_duplicates()。
5.3 文本与类别数据的清洗:空格、大小写、全半角、同义合并
类别型字段的清洗看似简单,却能直接影响模型效果。一个城市名如果因为空格、大小写、全半角差异被Pandas当成不同类别,模型会多出一堆"罕见类别",严重影响泛化能力。
我习惯做的一组操作:
python复制# 去除首尾空格、统一大小写、全角转半角
df['城市'] = df['城市'].str.strip().str.lower()
# 特殊字符替换
df['城市'] = df['城市'].str.replace(' ', '') # 全角空格
df['城市'] = df['城市'].str.replace('北京', '北京市') # 同义归一
不过我要提醒:同义合并必须基于业务判断。你要是把"上海"和"上海市"归并没问题,但你把"苹果"(水果)和"苹果"(手机品牌)不假思索地合并,就会制造噪声。竞赛中我还遇到过"手机"和"手机通讯"混用的情况,合并需要对业务有足够理解,不是机械操作。
对于中文文本,还有一个细节:中文分词和英文不同,大小写统一对中文不是问题,但全半角一定是个问题。建议写一个全角转半角的函数,把全角数字、全角字母、全角标点都转成半角,避免"2023"和"2023"被当成两个不同值。
5.4 时间字段:解析、时区、周期特征提取
时间字段是数据清洗里"锦上添花"的一环,也是很多队伍最容易忽略的一环。日期数据如果只当成字符串用,你会白白丢掉大量信息——星期几、是否节假日、距离某个活动开始的天数,这些都是建模的强力特征。
第一步是把各种格式统一成标准时间格式:
python复制# 解析多种日期格式
df['日期解析'] = pd.to_datetime(df['日期'], format='mixed')
format='mixed'是Pandas 2.0以后才支持的特性,可以自动解析混合格式的日期列。如果用的是旧版本,需要先用正则把格式归一,再to_datetime()。日期解析完毕,就可以提取时间特征:
python复制df['年份'] = df['日期解析'].dt.year
df['月份'] = df['日期解析'].dt.month
df['星期'] = df['日期解析'].dt.dayofweek
df['小时'] = df['日期解析'].dt.hour
这里有一个竞赛中的关键经验:如果赛题给了"日期"和"时间"两个字段,先合并再解析,不要分别处理。我有一次就是分别把日期和时间存成两个字符串,结果排序、筛选时各种出错;合并成一个datetime之后,所有时间运算都顺了。
另一个容易翻车的点是时区。国内竞赛一般用北京时间,但如果数据采集自全球范围,时区不同会导致"同一天"的归属错乱。处理方式是在分析前先统一时区:
python复制df['日期解析UTC'] = df['日期解析'].dt.tz_localize('UTC')
df['日期解析北京'] = df['日期解析UTC'].dt.tz_convert('Asia/Shanghai')
时间特征提取完之后,别忘了把原始字符串列删掉——它们不仅不提供新信息,还在建模时占内存、被错误当成类别特征。
6. 从零到一:一份含噪声销售数据的完整清洗实战
6.1 实战数据准备:故意制造一个"真实"的场景
前面几章分别讲了缺失值、异常值、类型、重复、文本和时间的处理思路,但单独讲方法容易让人"只见树木不见森林"。这一节我用一份模拟数据走一遍完整的清洗流水线,模拟的场景是:一份电商平台的订单数据,包含订单ID、日期、城市、商品类别、销售额、客户年龄、客户评分七个字段,里面混入了上一节提到的各种脏数据。
不过,这份数据我不会用固定的脚本直接跑,而是按照"先画像、再分列处理、最后输出"的顺序来演示,因为这更符合竞赛中的实际操作习惯。
以下是完整的数据清洗流程,每一步都带注释和原因说明。
6.2 第一步:构建数据并做初步审查
python复制import pandas as pd
import numpy as np
df = pd.DataFrame({
'订单ID': ['A001', 'A002', 'A003', 'A004', 'A005', 'A006', 'A007', 'A001', 'A008'],
'日期': ['2023/1/1', '2023-01-02', '20230103', '2023/01/04',
'2023-01-05', '2023-01-06', '2023-01-07', '2023/1/1', 'bad-date'],
'城市': ['北京', '上海', '广州', ' 北京', '深圳', '上海', '广州', '北京', '南京'],
'商品类别': ['手机', '电脑', '手机', '平板', '电脑', '手机', '平板', '手机', '手机'],
'销售额': ['1,200', '8999', '4500', '6000', '300', '25000', '99999', '1,200', '8000'],
'客户年龄': [25, 32, np.nan, 45, 29, 200, 35, 25, 28],
'客户评分': [4.5, 3.2, 5.0, 2.1, 4.8, 4.0, '优', 4.5, 4.3],
})
先做数据画像:
python复制df.info()
df.isnull().sum()
从info()的输出可以看到:客户年龄有1个缺失,客户评分混入了非数值内容,城市里有带空格的值,日期列出现了一个bad-date。这些都是清洗目标。
6.3 第二步:逐列清洗并记录处理逻辑
日期处理:
python复制# 用errors='coerce'把无法解析的日期转成NaT
df['日期解析'] = pd.to_datetime(df['日期'], format='mixed', errors='coerce')
# bad-date变成NaT,后续作为缺失值剔除或填充
print(df['日期解析'])
城市处理:
python复制# 去空格,统一格式
df['城市'] = df['城市'].str.strip()
# 检查是否还有残留的伪类别
df['城市'].value_counts()
销售额处理:
python复制# 去逗号转数值
df['销售额'] = df['销售额'].astype(str).str.replace(',', '', regex=True)
df['销售额'] = pd.to_numeric(df['销售额'], errors='coerce')
# 检查异常值(用IQR)
Q1 = df['销售额'].quantile(0.25)
Q3 = df['销售额'].quantile(0.75)
IQR = Q3 - Q1
upper = Q3 + 1.5 * IQR
lower = Q1 - 1.5 * IQR
print(f"销售额异常值阈值:({lower:.2f}, {upper:.2f})")
print(df[(df['销售额'] < lower) | (df['销售额'] > upper)])
按这个阈值,99999这个值会被标记为异常。但在业务上它可能是一个企业采购大单,是否删除取决于你建模的目标。如果是做普通消费者行为分析,这个样本会严重干扰模型;如果是做全量销售预测,它又有可能是有效信息。这个决策必须由人来做,不能靠代码自动决定。
客户年龄处理:
python复制# 把物理上不可能的值(200岁)转为缺失,再填充
df.loc[df['客户年龄'] > 120, '客户年龄'] = np.nan
df['客户年龄'] = df['客户年龄'].fillna(df['客户年龄'].median())
客户评分处理:
python复制# "优"这类中文评级映射成数值
df['客户评分'] = df['客户评分'].replace('优', 5.0)
df['客户评分'] = pd.to_numeric(df['客户评分'], errors='coerce')
# 如果有NaN再走填充流程
df['客户评分'] = df['客户评分'].fillna(df['客户评分'].median())
6.4 第三步:删除或保留无效记录,生成清洗报告
日期解析失败的bad-date行,如果只有一条,而整体数据量不小,直接删除即可。但我会在清洗报告里注明:删除了1行,原因是日期字段无法解析、且该值在其他字段上没有补充价值。
python复制df_clean = df.dropna(subset=['日期解析'])
最后输出一份清洗前后对比表,这个是写论文时必备的。
| 字段 | 清洗前问题 | 清洗方法 | 清洗后状态 |
|---|---|---|---|
| 日期 | 格式混杂、含无效值 | to_datetime统一解析,无效值删除 |
标准datetime |
| 城市 | 含首尾空格 | strip()去空格 |
干净类别 |
| 销售额 | 含逗号、object类型 | 正则去逗号、转数值 | 数值型 |
| 客户年龄 | 1个缺失、1个异常值200 | 异常值置空,中位数填充 | 数值型,无缺失 |
| 客户评分 | 含中文字符"优" | 映射为5.0,再转数值 | 数值型,无缺失 |
| 订单ID | 存在重复 | 查看后确认是重复录入,删除 | 无重复 |
这一步走完,这份数据就可以进入特征工程和建模阶段了。
6.5 清洗环节的"底稿"意识:让论文数据预处理章节有说服力
最后多说一句:竞赛论文里的数据清洗部分,最重要的不是"用对了什么方法",而是"留下了完整的处理轨迹"。 我建议每次清洗都单独保存一个cleaning_log字典或者DataFrame,记录每个字段的原始问题、处理方式、处理前后统计量。这既是给你自己回溯用的,也是写论文时"数据预处理"章节的素材库。
我自己的清洗底稿通常长这样:
python复制cleaning_log = {
'日期': {'问题': ['格式混杂', '包含无效值'], '处理': ['统一解析', '删除无效'], '删除行数': 1},
'销售额': {'问题': ['含逗号', 'object类型'], '处理': ['正则去逗号', '转数值'], '删除行数': 0},
'客户年龄': {'问题': ['缺失1个', '异常值200'], '处理': ['异常置空', '中位数填充'], '删除行数': 0},
}
等论文写到"表1 数据清洗过程表"时,直接把这个底稿转成表格就是现成的素材,而且比临时编造的记录真实得多。
7. 数据清洗的一些经验补充:做了三年竞赛后的个人体会
数据清洗这件事,做多了之后你会发现它越来越像"侦探工作":拿到一份数据,先通过蛛丝马迹判断它来自什么样的系统、采集过程有什么问题、哪些字段可信、哪些字段需要修复。我做了三年竞赛,最大的体会是:数据清洗不是建模的准备工作,而是建模本身的一部分。你多花一小时在清洗上,后面建模、调参、写论文的时间能省下三四个小时。
几个补充经验:
经验一:清洗前先复制一份原始数据存着。我见过不止一个队伍在清洗时覆盖了原始数据,最后想反查某个原始值只能干瞪眼。正确做法是df_raw = df.copy(),所有的清洗操作都在新对象上做。如果数据特别大,可以保存成CSV文件存档。
经验二:多变量的异常值用"可视化+聚类"交叉验证。单变量箱线图能找到一维离群点,但二维散点图里那种"藏在正常变量组合中的异常点"才是更隐蔽的坑。直接用plt.scatter(x, y)把坐标画出来,异常点很多时候一眼就能看出,比盲目跑一堆统计检测直观得多。
经验三:不要为了清洗而清洗,一切以建模目标为导向。如果你做的是销量预测,个别异常高的销售记录不能随便删,因为它们可能是大促、爆款等真实业务信号;如果你做的是用户画像,那同一个异常记录可能就成了应该剔除的噪声。所以,清洗标准不是绝对的,而是和你后面的模型目标深度绑定的。
经验四:数据清洗代码的复用价值极高。一套完善的清洗脚本,在赛场上能帮你省出大量时间。建议你把"数据画像""缺失值处理""异常值检测""类型修正""时间字段解析"这些模块封装成函数,每次拿到新赛题就优先跑一遍,快速形成数据体检报告,再决定建模方向。我第一次参加国赛时就是吃了清洗太慢的亏,后来把脚本沉淀成自己的工具库,效率明显提升。
数据清洗写到这里,该讲的核心逻辑、实操代码和避坑经验都覆盖到了。下一篇文章我会基于清洗完成的数据,继续讲特征工程和模型选型。如果你在跑这篇代码时遇到报错或者拿不准某个字段该怎么处理,可以留言,我根据实际经验帮你看看。
