做了快五年数据相关的工作,有个体会越来越深:模型调参调到头也就那样,真正决定上限的往往是训练集本身的质量。我记得有一次用一套挺复杂的模型做预测,结构照论文搭的,调参工具也用得很熟练,可精度就是卡着不动。折腾了快一周,最后把训练数据集翻了个底朝天,才发现问题出在几十个样本的异常值和一列类型存错的日期上。这就是标题里那件事——用Pandas做数据清洗与数据预处理,再把数据标准化成模型真正吃得动的样子。基本功不扎实,后面所有花哨操作都是空中楼阁。这篇就聊聊我自己在这一整套流程里的完整做法和踩坑经验。
1. 数据预处理为什么这么重要:先说一个让我记忆深刻的教训
1.1 模型效果差,真的不全是模型的问题
很多人一上来就抱着调参神器不放,网格搜索、贝叶斯优化一通操作,最后提升零点几个点就欢呼雀跃。可实际上,训练集里的脏数据对模型的影响,远比超参数大得多。举个例子,我处理过一份销售数据,里面有一列"成交金额",看着是数值型,实际上有相当一部分是字符串格式,还混着"1,200元"、"暂无"这种内容。如果直接拿去训练,pandas会把这列当成object类型,模型要么报错,要么把这些文本当成了类别特征处理,结果可想而知。
还有一次是重复样本的问题。数据是从多个渠道合并的,同一个用户出现了三回,每回的标签还不一样。模型在训练集里见过这个用户,到了测试集换了个渠道的收录方式,特征分布就变了。这不是玄学,是数据泄漏的一种变体。数据预处理阶段把这些清理干净,后面能省下大量调模型的时间。
1.2 数据预处理在整个机器学习流程里的位置
如果你画一条流水线:数据采集 → 数据清洗 → 特征工程 → 模型训练 → 评估 → 上线,那么数据清洗和标准化就是连接原始数据和模型的桥梁。Pandas在这个阶段扮演的角色,不是简单地"去掉空值",而是做三件大事:整理结构(类型转换、字段拆分)、修正内容(缺失值、重复值、异常值)、统一尺度(标准化、归一化)。
从这个角度看,你就可以理解为什么很多招聘数据岗的JD里都会写"熟悉Pandas,具备数据清洗经验"。这真不是门槛低,而是数据预处理这件事直接决定了下游能不能顺利跑起来。你不会希望在训练的第五分钟才被一个TypeError打断思路,对吧?
2. 数据体检先行:训练集清洗前必做的四步摸底
2.1 别急着清洗,先用Pandas把数据全貌看透
拿到一份陌生的训练集,我的习惯是先做"数据体检"。和人体检一样,目的是知道哪里有问题、问题有多严重,而不是直接开药。
第一步是加载数据后立刻查看规模和字段。示例代码:
import pandas as pd df = pd.read_csv('train_data.csv') print(df.shape) print(df.columns.tolist())然后是逐列的类型与缺失情况,我通常这么写:
df.info()df.info()会告诉你每一列的dtype、非空数量、内存占用。别小看这个输出,它一次性解决了三个疑问:有多少列是object类型(大概率藏着脏数据)、哪些列有缺失、整体内存够不够。如果你发现一个"年龄"字段被推断成object,这就是类型不规范的第一个信号。
2.2 数据类型检查里藏着的隐性问题
这里我多说一句dtypes的问题。很多人在体检阶段会忽略的一件事是:数值列里混入字符串,会被pandas直接推断为object或字符串类型,但反过来,如果一列确实应该是数值,却因为个别非法值变成了object,那你后面做聚合、算均值都会出问题,甚至连排序都会按字符串排。
我习惯再跑一段代码,把所有object列的独取值数量打出来:
object_cols = df.select_dtypes(include=['object']).columns for col in object_cols: print(col, df[col].nunique(dropna=False))nunique可以快速判断哪些列看起来是类别、哪些列其实是被当成了文本的数值。比如一个"收入等级"列有5个值,那是正常类别;如果"收入"列出现上千个不同的文本串,那基本就是需要转换的数值字段,只是被某些异常值污染了。
2.3 数值型字段的统计画像
做完类型摸底,再对数值列做一个统计画像:
df.describe()describe()默认输出count、mean、std、min、四分之一分位数、中位数、四分之三分位数、max。这里面最值得留意的是min和max,尤其是那些"明摆着不合理"的极值,比如人的年龄出现999,或者房价出现负数。describe阶段发现异常值,比建模阶段发现要划算得多。
3. 缺失值处理策略:不要一个fillna走天下
3.1 缺失率统计:先判断该删还是该填
缺失值几乎是每个真实数据集都逃不掉的问题,但处理方法完全取决于缺失率。我通常用一行代码算缺失率:
missing = df.isnull().sum() missing_pct = (missing / len(df)) * 100 print(missing_pct.sort_values(ascending=False))我的经验是,缺失率达30%以上的字段,除非它是模型非常依赖的核心特征,否则优先考虑删除,因为这种高缺失率的列往往收集质量本身就很差,填充出来的值反而是噪音。缺失率在5%以下的列,删除缺失样本或者用简单填充都行。真正需要花心思设计填充方案的,是5%到30%这一档。
3.2 数值型缺失的几种填充方案与适用场景
在这个区间里的数值型字段,我用过这几种方案,各有各的适用条件:
| 填充方式 | 适用场景 | 我的代码习惯 |
|---|---|---|
| 均值填充 | 数据近似正态分布,缺失率低 | df['col'].fillna(df['col'].mean()) |
| 中位数填充 | 存在明显偏态或异常值 | df['col'].fillna(df['col'].median()) |
| 前向/后向填充 | 时间序列、顺序明显的样本 | df['col'].ffill() |
| 分组填充 | 有明确的组别维度,如按地区填平均值 | df.groupby('region')['col'].transform(lambda x: x.fillna(x.median())) |
均值填充有个容易忽视的问题:它让这一列的方法差变小,说白了就是人为压缩了数据波动。所以只要数据有偏态,我就优先考虑中位数。另外,分组填充的效果通常比全局填充好,因为"上海区域的缺失值用上海的平均水平来填"比"全国平均水平来填"更符合业务逻辑,模型也更容易学到区分度。
Pandas 2.x版本需要注意一个坑:fillna(method='ffill')这种写法已经不建议了,新版本直接调用df['col'].ffill()或df['col'].bfill()就好,别拿着老代码硬跑。
3.3 类别型缺失的填充逻辑
类别型字段的缺失,最常见也最简单的处理是单独填一个"Unknown"或"缺失"类别。有人会觉得"填个Unknown会不会让模型学到奇怪的东西",但我的看法是,对于树模型这类能处理类别特征的算法,"缺失"本身就是一个有效信息,说明数据采集方在这条记录上没拿到值,这往往是某种业务信号。
另一种更讲究的做法是"众数填充",但只适用于类别分布极其倾斜的场景。比如某个字段95%都是"A",那填"A"不会引入太多噪音。如果类别分布相对均匀,填哪个都可能引入错误,这时候建一个"缺失"类别反而是最中性的选择。
3.4 时间序列类数据的特殊处理
时间序列样本的缺失需要考虑前后时间点的相关性,所以前向填充几乎是默认选择。比如传感器数据,第10秒缺失了,用第9秒的值填,比用整列均值填要合理得多。不过有个细节,前向填充前一定要先按时间字段排序,否则填充顺序错乱就完全失真了。
遇到那种大段连续缺失的时间窗口,我会做标记列而不是硬填。具体做法是新增一列is_missing记录该位置是否缺失,这样即使数值本身填得不准,模型至少知道这段数据不可靠。
4. 重复值、异常值与一致性:最容易放过也最坑人的环节
4.1 重复样本不只会加大训练量,还会扭曲评估指标
数据从多个渠道合并时,重复样本几乎是必然的。drop_duplicates这个函数大多数人都会用,但坑在于它的参数。
df = df.drop_duplicates(subset=['user_id', 'order_time'], keep='first')subset决定了怎么判断"重复"。如果所有列都相同才是重复,那是完全重复样本,删掉没有争议;但更常见的是业务意义上的重复——同一个人、同一时间被录入了多条,而其中某些字段(比如备注、渠道来源)不同。这时用keep='first'保留第一条,或keep='last'保留最后一条,就有讲究了。我一般倾向于保留记录最完整的那一条:
df = df.sort_values('record_completeness', ascending=False).drop_duplicates( subset=['user_id', 'order_time'], keep='first' )这个技巧在处理带质量评分的数据时特别有用。
4.2 异常值的识别:从IQR到业务规则
异常值识别我常用的有两类方法。一类是纯统计的IQR法,也叫四分位距法:
Q1 = df['amount'].quantile(0.25) Q3 = df['amount'].quantile(0.75) IQR = Q3 - Q1 lower = Q1 - 1.5 * IQR upper = Q3 + 1.5 * IQR df = df[(df['amount'] >= lower) & (df['amount'] <= upper)]这套规则认为落在[lower, upper]之外的值是异常值,简单、可复现,是探索阶段的首选。另一类是基于业务规则,比如金额不能为负、年龄不能大于150、温度不能超过物理上限等。业务规则的优先级永远高于统计规则,因为统计规则可能会误删真实但极端的情况,比如小额高收益的理财产品、大促期间的超大订单,这些业务上完全合理。
4.3 不一致数据的清洗:正则表达式和统一格式是好朋友
训练集里最容易被忽视的是文本字段的不一致。比如国家字段里同时存在"中国"、"china"、"China "、"中 国",看起来是小事,但切分训练集后,模型会把这些当成完全不同的离散值,既浪费了模型容量,也破坏了特征分布的一致性。
我的常规操作分三步:去首尾空格、统一大小写、再做同义词映射。必要的时候用正则表达式做模式匹配,之前遇到一批电话号码里混着"138-1234-5678"和"13812345678"两种格式,全部用正则抽数字再统一,一步搞定:
import re df['phone'] = df['phone'].astype(str).str.replace(r'\D', '', regex=True)这条正则的含义是把所有非数字字符去掉,\D在正则里就是"非数字"的意思。字符串处理里str方法配合regex=True能解决大量不一致问题。
5. 数据类型转换:Pandas里最不起眼但最要命的操作
5.1 用to_numeric和astype兜住脏类型
数据胰岛素检查完,接下来要处理的就是为什么"数值列不是数值"的问题。最常见的场景:一列数字因为夹杂了空格、逗号、单位,变成了object类型。我的标准化套路是先清理再转换:
df['price'] = df['price'].astype(str).str.replace(',', '').str.replace('元', '') df['price'] = pd.to_numeric(df['price'], errors='coerce')注意errors='coerce'这个参数。它表示转换失败的位置直接变成NaN,而不是让程序崩溃。转换之后再统一走缺失值处理流程,把那些无法解析的异常值一并处理掉。这一步是我处理任何真实数据集必写的一行,因为它给数据质量兜了底。
学会看dtype也是基本功。df.info()里object类型越少,说明你的训练集越规整。但这里也有个反向操作:如果一列确实是类别,而且类别数量远小于样本量,我建议转成category类型:
df['city'] = df['city'].astype('category')category类型不仅省内存,训练时还能让部分模型知道这是一个有序离散特征,而不是连续值。另外,当一列数值其实是"等级1、等级2、等级3"时,也要考虑转成有序category,而不是当普通数值用。
5.2 把字符串类别变成模型能吃的数值
模型基本不能直接处理字符串,所以类别型字段要么转成category,要么做编码。最基础的方式是factorize:
df['city_code'] = pd.factorize(df['city'])[0]这会把"北京、上海、广州"映射成0、1、2。但要注意,这种方式编码出的数字本身没有大小含义,树模型通常能容忍,但线性模型或距离类模型就完全不行,必须配合独热编码。独热编码用Pandas的get_dummies可以直接生成:
df = pd.get_dummies(df, columns=['city'], drop_first=True)drop_first=True是为了避免多重共线性,在线性回归里尤其重要,不放出来会踩一个坑——模型训练出来系数诡异,那就是独热编码没有去重导致的。
5.3 日期时间转换与衍生特征
日期列是另一个重灾区。pd.to_datetime是必须用的,但格式问题比想象的复杂:
df['date'] = pd.to_datetime(df['date'], format='%Y-%m-%d')如果源数据里混了多种日期格式,直接指定format会报错,这时可以先不指定格式让pandas自动识别,再处理解析失败的值。日期转换完不代表预处理结束,我几乎总是会从日期里提取出更有用的特征:
df['year'] = df['date'].dt.year df['month'] = df['date'].dt.month df['day_of_week'] = df['date'].dt.dayofweek df['days_since'] = (pd.Timestamp('today') - df['date']).dt.days"距今多少天"这个特征在很多场景里比日期本身更有效,因为它给模型一个连续递减的趋势,尤其适合做流失预测、生命周期分析这类任务。
6. 标准化与归一化:怎么选、怎么做、怎么避免数据泄漏
6.1 标准化和归一化的区别:一句话版本
标准化与归一化经常被人混着叫,但它们在预处理里的地位不同。标准化(Standardization)通常指Z-score,把数据变成均值0、标准差1:
[ z = \frac{x - \mu}{\sigma} ]
归一化常指Min-Max缩放,把数据压缩到[0,1]区间:
[ x' = \frac{x - min}{max - min} ]
Pandas里沒有现成的StandardScaler,但我通常配合scikit-learn一起用,很少手写公式,因为手写容易忘掉"要保存训练集的均值和标准差,再用同一套参数处理新数据"这个关键点。
6.2 用scikit-learn还是手写:核心是别把参数算错数据集
这里必须说一个重要概念:数据泄漏。如果你直接用全量数据的均值和标准差做标准化,等于把测试集的信息提前泄露到了训练集里,模型精度的评估就失真了。正确做法是只用训练集拟合scaler:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)fit_transform中的fit是在训练集上计算均值和标准差,之后对测试集只做transform。这一步看起来很简单,却有非常多人做错。我自己早期也犯过,在验证集上看到指标不错,但上线之后完全对不上,后来排查才发现是标准化阶段把整个数据集一起fit了。
6.3 哪些模型吃这套,哪些模型不吃
不是所有模型都需要标准化。距离类模型像KNN、SVM,以及梯度下降类模型(神经网络、线性回归、逻辑回归)对特征尺度非常敏感,量纲不一致会导致大数值特征主导训练过程,必须标准化。而树模型(决策树、随机森林、XGBoost、LightGBM)本质上是对特征做切分,不受单调变换影响,所以标准不标准化,对树模型的结果影响很小。
但即便如此,我还是建议在特征工程完成后做一次整体检查:把量纲差异过大的特征拉到一个可比较的范围,不仅是为了模型,更是为了可解释性——所有特征都标准化后,特征重要性排序会干净很多,也更容易发现异常行为。
7. 导出训练集之前:最后一道校验工序和一些实在建议
7.1 导出前跑一遍完整性校验
清洗、转换、标准化全部做完之后,我不会立刻导出训练集,而是先跑一轮完整的校验脚本。这样做的好处是方便复现和排查,也符合"数据流水线可审计"的思路。
我的校验清单大概长这样:
- 检查是否还有object类型字段:
df.select_dtypes(include=['object']).columns.tolist(),如果有,说明还有字段没转换完。 - 检查缺失值是否清零:
df.isnull().sum().sum(),理论上清洗完成后缺失应该归零。如果仍有残留,说明某个环节漏处理了,不建议带着NaN去训练。 - 检查索引是否连续:连续构建特征时很容易因为drop或者筛选产生索引空洞,导出前用
df.reset_index(drop=True)重置掉,能避免训练时出现索引错位。 - 检查行数是否和预期一致:清洗过程中哪些样本被删除、被填充,最好用日志记下来,比到最后发现样本少了3000条再倒查要省力得多。
另外,特征和目标列要分开存储。我通常直接输出两个文件,特征X_train.csv和目标y_train.csv,需要时再合并,这样也更方便别人复用。之前见过有人把目标列混在特征里一起导出了,训练时忘了去掉,最后模型在训练集上准确率100%,一验证就崩,就是因为目标标签泄漏。
7.2 我在真实项目里踩过的坑,说几个给后来人
第一个坑是处理重复值时没有指定subset,结果把真正有价值的业务记录删了,全表几千行直接变成几百行。训练集行数暴减之后模型方差变大,效果根本无法稳定。后来我都是先确认业务主键,再决定按哪个字段组合去重。
第二个坑是填充缺失值时用了全局均值,结果遗忘了数据本身的分组结构。某接口维度的数据一行都不缺,但全表均值填充会把一组结构完全不同的样本偏移到不合理的数值上。后来我养成了先groupby再填充的习惯,宁可多写几行,不让数据失真太多。
第三个坑是关于标准化的顺序问题。标准化必须在数据集切分之后做,这话我说过很多次,但真正出问题的是:切分训练集和测试集之后,如果你要先做特征工程,再做标准化,那么特征工程过程中的中间结果千万别拿去直接fit。我见过有同事在交叉验证里对每个fold都重新fit了标准化器,导致每个fold的数值尺度都不一样,指标一塌糊涂。正确做法始终一致:所有统计参数(均值、方差、min、max)都只从当前fold的训练部分估计。
还有一个容易被漏掉的细节:做完独热编码之后,测试集和训练集生成的列名可能不一致。如果测试集某个类别没有出现在训练集里,get_dummies会产生不同的列,模型会直接报维度错误。稳妥的做法是reindex一下:
X_test = pd.get_dummies(X_test, columns=['city']) X_test = X_test.reindex(columns=X_train.columns, fill_value=0)这两行测试集补零的逻辑,能帮你在模型上线时省下大把对特征的对齐时间。
我在实际项目里还有一个不成文的习惯:数据预处理脚本永远以函数封装,输入原始路径,输出清洗后的训练集文件。一次清洗任务写成一个大流程,后面每次换数据只需要改字段名和规则,不用从头再读一遍几百行的处理逻辑。这样做既省时间,也避免下次复制粘贴代码时漏掉某个清洗步骤。
训练集的数据质量这件事,不会因为你的模型深、算力强就自动变好。把Pandas的清洗和标准化步骤做扎实,后面所有麻烦都会小很多。