数据预处理
这一课你将能做到
- 处理缺失值和异常值:isna 排查、SimpleImputer 填充、分位数截断
- 用 OneHotEncoder 编码类别特征,用 StandardScaler 标准化数值特征,并说清为什么这么干
- 正确划分训练/测试集,理解并规避「数据泄漏」这个最隐蔽的错误
为什么要学
模型不挑食的前提是你把饭做好。真实做题记录里充满缺失(用户中途退出没记录用时)、异常(挂后台 99999 秒)、类别(「循环」「集合」没法直接进公式)。预处理做错一步,轻则精度下降,重则「测试集表现虚高」——训练时偷看了答案(数据泄漏),上线直接翻车。
面试里「项目效果为什么线上掉那么多」,一半的答案藏在数据泄漏里。这一课教你把饭做干净。
先建立图像
原始数据 预处理流水线 干净数据
┌─────────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ seconds: NaN │ │ 1 缺失值 → 中位数填充 │ │ 数值已标准化 │
│ seconds: 99999 │──▶│ 2 异常值 → 截断 │──▶│ 类别已 One-Hot │
│ topic: "多线程" │ │ 3 类别 → One-Hot │ │ 无缺失 │
│ attempts: 5 │ │ 4 数值 → 标准化 │ │ 已切分 │
│ ... │ │ 5 切分 → train/test │ └──────────────┘
└─────────────────┘ └──────────────────────┘
跟着做
步骤 1:造一份「脏」的做题数据
import pandas as pd
import numpy as np
df = pd.DataFrame({
"topic": ["循环", "集合", "多线程", "循环", "集合", "多线程"],
"seconds": [42, np.nan, 99999, 38, 66, 145],
"attempts": [12, 8, 3, 15, 9, 2],
"correct": [1, 1, 0, 1, 1, 0],
})
print(df.isna().sum()) # 每列缺失几个
print(df.describe()) # seconds 最大 99999,一眼假
步骤 2:缺失值——先问为什么缺,再决定怎么填
from sklearn.impute import SimpleImputer
imp = SimpleImputer(strategy="median") # 用时这种右偏数据用中位数
df["seconds"] = imp.fit_transform(df[["seconds"]])
print(df["seconds"].tolist()) # NaN 变成 60.0
填均值还是中位数:用时、收入这类长尾分布用中位数(抗极端值);接近正态的用均值。类别列用 strategy="most_frequent" 或单独加一列「是否缺失」——缺失本身往往就是信号(比如超时退出)。
步骤 3:异常值——用业务规则截断
upper = 300 # 业务上限:3 分钟还没答完就当挂后台
df["seconds"] = df["seconds"].clip(upper=upper)
print(df["seconds"].max()) # 300
真实数据建议双管齐下:业务规则(如单题最长 30 分钟)+ 统计规则(3σ 或 1% 分位截断)。注意:异常值不一定是错,可能是「这道题真的很难」,截断前先想业务。
步骤 4:类别编码——把「多线程」变成数字
from sklearn.preprocessing import OneHotEncoder
enc = OneHotEncoder(sparse_output=False)
topic_oh = enc.fit_transform(df[["topic"]])
print(enc.get_feature_names_out()) # ['topic_多线程' 'topic_循环' 'topic_集合']
df_oh = pd.DataFrame(topic_oh, columns=enc.get_feature_names_out())
df = pd.concat([df.drop(columns="topic"), df_oh], axis=1)
print(df)
One-Hot 的意思是「独热」:每行只有一列是 1,其余是 0。绝不推荐给无序类别直接编 1/2/3——模型会误以为「多线程(3) > 循环(1)」,这种顺序是编造的。
步骤 5:标准化 + 切分——防泄漏的关键一步
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
X = df.drop(columns="correct")
y = df["correct"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
scaler = StandardScaler()
X_train[["seconds", "attempts"]] = scaler.fit_transform(X_train[["seconds", "attempts"]])
X_test[["seconds", "attempts"]] = scaler.transform(X_test[["seconds", "attempts"]]) # 只 transform!
print("训练集均值:", scaler.mean_.round(2)) # 这组均值来自训练集,测试集必须用它
最关键的一行是测试集「只 transform 不 fit」。如果在全量数据上 fit scaler,测试集的统计信息就渗进了训练过程——这就是数据泄漏的最小复现样板。
讲清楚要点
- 数据泄漏是预处理阶段的第一大罪:任何「统计量」(均值、方差、类别表、目标编码)只要用了测试集/未来数据的信息,评估结果就作废。铁律:fit 只在训练集,transform 可以两边跑。
- 编码方式取决于类别有没有序:难度等级(简单<中等<困难)可用有序编码;知识点、题目 ID 这类无序类别用 One-Hot;类别特别多(如题目 ID 上千个)要换目标编码或 embedding,One-Hot 会维度爆炸。
- 标准化 vs 归一化:StandardScaler 减均值除标准差,适合大多数场景(量纲统一,梯度下降更稳);MinMaxScaler 压到 [0,1],适合指定范围的场景(如像素)。
- 树模型不挑量纲:决策树按阈值分裂,「秒」和「分钟」对它没区别,可以不标准化;但线性模型、逻辑回归、KNN、K-Means、神经网络必须标准化。
stratify=y保住类别比例:标签不平衡时(比如 95% 未掌握),不加分层参数的随机切分可能让测试集比例失真,评估随之失真。
你可能会懵的地方
- 为什么 fit 只能在训练集?我又没用标签。 泄漏的不是标签,是「分布」。测试集的均值/方差/类别集合也属于未知数据的信息,训练时知道了它,等于提前摸了考卷的出题风格,评估分数会系统性虚高。
- 缺失值直接删行不行?
缺失比例小(<5%)且随机缺失时可以删。但做题记录的缺失往往不随机(越难的题越容易超时缺失),删掉会引入偏差。先看
df.isna().mean(),再决定删、填还是加「缺失指示列」。 - One-Hot 后列名丢了怎么对回来?
enc.get_feature_names_out()返回生成列的名字(见步骤 4),拼 DataFrame 时用它做列名,后面排查特征重要性才不懵。 - 标准化之后数据「失真」了吗? 只改变了尺度和原点,不改变分布形状和相对关系。模型学到的是「相对水平」,对预测没有损失;代价是特征失去原始单位,解释时要先换算回去。
- sklearn 版本不同参数报错怎么办?
OneHotEncoder(sparse_output=False)是 1.2+ 的写法,旧版是sparse=False。报TypeError时先import sklearn; print(sklearn.__version__),再对照官方文档 API 表。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 全量数据上 fit scaler 再切分 | 测试集信息泄漏 | 先切分,训练集 fit、测试集只 transform |
| 无序类别用 OrdinalEncoder 编 1/2/3 | 编造了不存在的大小关系 | One-Hot;高基数类别用目标编码/Embedding |
| get_dummies 在切分后各自执行 | 训练/测试列不一致 | 用 OneHotEncoder(handle_unknown="ignore") 统一处理 |
| 缺失值无脑删行 | 引入偏差、丢数据 | 先分析缺失机制,填中位数/众数或加缺失指示列 |
| 异常值一律删除 | 误杀真实信号(难题用时就是长) | 业务规则 + 分位数截断,保留极端但合理的值 |
卡点急救
TypeError: got unexpected keyword 'sparse_output':sklearn < 1.2,改用sparse=False,或pip install -U scikit-learn。- 切分后训练/测试列数对不上:One-Hot 某个类别只出现在一边。用
handle_unknown="ignore"让编码器对没见过的类别输出全 0,而不是报错。 - 混合类型列(数字+字符串)报错:数值和类别列要分开处理,
ColumnTransformer专治这个(ml-sklearn 的 Pipeline 会实战)。
面试常问
- 什么是数据泄漏?举个例子,怎么避免? 训练过程用到了预测时拿不到的信息。例:在全量数据上 fit 标准化器/做目标编码,或用「当天的统计量」预测当天结果。避免:切分先行、所有统计量只在训练集上算、按时间切分时特征只能用历史窗口。
- One-Hot 和 Label Encoding 怎么选? 无序类别用 One-Hot(避免编造顺序);有序类别(难度等级)可用有序编码;线性/距离类模型对 One-Hot 更友好,树模型两者都能吃;高基数类别 One-Hot 维度爆炸,改用目标编码或 embedding。
- 缺失值有哪些处理策略?怎么选? 删除(缺失少且随机)、统计填充(中位数/众数)、模型填充(KNN/迭代填补)、缺失指示列(缺失本身有信息时)。选择依据:缺失比例、缺失机制(随机与否)、业务含义。
自测三问
- 「在测试集上只调用 transform、不调用 fit」防的是什么问题?
- 「知识点」和「难度等级」两个类别列分别该用什么编码?
- 为什么树模型可以不标准化,逻辑回归不行?
小结一句话
预处理四件事:缺失要填、异常要截、类别要编码、数值要标准化;所有 fit 只碰训练集——数据泄漏防住了,评估才可信。
延伸
- scikit-learn 预处理文档
- 下一课:ml-supervised 监督学习——线性回归、逻辑回归、决策树、随机森林、XGBoost 五件套。