数据处理与特征工程
前面的样本很整齐:每行都有页数,单位一致,也没有奇怪的字符串。真实数据通常会在这里给我们一点惊喜。
有一条记录的页数没填;队列长度出现 −3;打印模式写成了“color”;另一批数据却使用“彩色”。如果把这些内容原封不动塞进矩阵,模型未必能理解我们想表达什么。
这一章要做的,是让输入成为定义清楚、处理一致的特征。重点不在于把表格清理得“看起来干净”,而是让训练与实际预测使用同一套含义。
1. 先读懂一行数据
假设收到这样一条记录:
| 页数 | 队列长度 | 打印模式 | 实际耗时 |
|---|---|---|---|
| 12 | 1 | mono | 58 秒 |
前三列是开始前可见的特征,最后一列是训练用标签。页数是计数,队列长度允许为零,模式是类别。
先为每列写下单位、合法范围和缺失的含义,会省去很多后续麻烦。页数 0 可能代表空文件,不能随手当成缺失;空白页数可能来自读取失败,也可能是历史系统没有记录。两种原因对预测的意义未必一样。
同样,整数编号不一定是数量。设备编号 1002 不表示比 501 “强两倍”,给它做加减乘除通常没有这种含义。
2. 空着的格子怎样处理
最简单的办法是删掉有缺失值的行。但缺失多时会损失大量样本;如果某类任务更容易缺失,删除还会改变数据组成。
另一种办法是填补。数值列可以用训练集的中位数:把观测值排序,取中间值;个数为偶数时取中间两数的平均。本例训练页数的已知值为 5、10、15、20、30,中位数是 15。
填上 15 不代表我们知道那份文档真的有 15 页,只是为后续计算提供一个约定值。还可以增加一个缺失标记:
这样,“真实观测到 15 页”和“因为缺失而填入 15 页”就有机会被区分。缺失标记也可能携带系统来源的信息,换系统后关系未必还成立。
所有填补规则都只从训练部分学习。不能先用全体中位数填好,再把数据分开。交叉验证时,每一折还要重新计算中位数。
本章的 SimpleImputer(add_indicator=True) 只为拟合时出现过缺失的列添加标记;它不会自动给每一列都准备一个标记。实际业务如果要求所有列始终有缺失标记,需要明确配置额外的转换。
3. 异常值不等于错误值
队列长度 −3 很可能不符合定义,可以回查数据来源。耗时 800 秒则未必错:也许中途卡纸了,而这种任务正是使用者最关心的情况。
删掉所有“离平均数很远”的样本,会让模型在困难任务上失去经验。更合适的顺序是先判断它代表什么:录入错误、单位混用、合法但少见的事件,还是任务范围之外的数据。
平均数容易被极端值拉动。例如 的平均数为 24.4,中位数为 6。用中位数和四分位距缩放,能减弱极端值对尺度估计的影响,但不能把错误记录自动变正确。
有时会对很长的耗时取 。这改变了训练目标:在对数尺度上相近,不等于在秒数上相近;把预测再取指数,也不一定得到原尺度的条件均值。变换可以尝试,最终仍要在使用者关心的单位与指标上评估。
4. 数值要用一致的尺子
标准化使用训练均值 和尺度 :
下标 表示列。几千量级的文件大小和个位数的队列长度,都可以转换到较方便优化的尺度。
标准化不会把任何数据都变成正态分布,也不会自动消除异常值。它只是改变中心和尺度。树模型通常不依赖这样的缩放;依靠距离、梯度或权重惩罚的方法则常常需要认真考虑它。
顺序也有影响。本例先填补,再缩放,因此填入的值也参与训练均值和尺度的计算。预测时严格重用同一个顺序。
5. 类别怎样变成向量
把黑白设为 1、彩色设为 2、草稿设为 3,会暗示一种数值顺序和等距关系。若模式没有这种关系,可以使用独热编码:
| 模式 | 黑白列 | 彩色列 | 草稿列 |
|---|---|---|---|
| 黑白 | 1 | 0 | 0 |
| 彩色 | 0 | 1 | 0 |
| 草稿 | 0 | 0 | 1 |
一列类别变成多个数值列。模型可以分别学习它们的作用,而不必把“草稿减彩色”理解成某个数量。
如果训练时只有黑白和彩色,实际预测却来了草稿,怎么办?本例使用 handle_unknown="ignore",把新类别在这组独热列中编码成全零。程序能够继续运行,但模型并没有因此学会草稿模式的特点。真实系统还可以考虑明确的“其他”类别,或收集新类别数据后更新。
若类别本来就有顺序,例如“小、中、大”,也可以使用有序编码,但要分清“有先后”与“相邻间隔一样大”。邮政编码、用户名等高基数类别,也不能只因能独热就无限展开;存储、泛化和未见类别都需要考虑。
6. 把处理规则和模型装进同一条流程
图 1:训练时各步骤学习自己的参数;验证和预测时只应用已有规则。
scikit-learn 中,ColumnTransformer 负责把不同列送进不同处理流程,Pipeline 负责把步骤按顺序连接起来。
它们有两个关键动作。fit 从数据学习规则,例如中位数、均值和回归系数;transform 用已学规则转换输入。整个流水线的 predict 会先转换,再调用末尾模型进行预测。
完整脚本使用前章安装的环境:
python src/examples/data_features.py
"""第 9 章:把数值、缺失值和类别一起放进训练流程。"""
import numpy as np
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
def data():
# 列依次为页数、队列长度、打印模式;全部为教学构造数据。
train_x = np.array([
[5, 0, "mono"], [10, 2, "color"], [15, np.nan, "mono"],
[20, 1, "color"], [np.nan, 3, "mono"], [30, 2, "color"],
], dtype=object)
train_y = np.array([35, 64, 69, 92, 101, 124])
validation_x = np.array([[12, 1, "mono"], [18, np.nan, "color"],
[24, 2, "draft"]], dtype=object)
validation_y = np.array([58, 88, 96])
return train_x, validation_x, train_y, validation_y
def make_model():
numeric = make_pipeline(
SimpleImputer(strategy="median", add_indicator=True),
StandardScaler(),
)
categorical = make_pipeline(
SimpleImputer(strategy="most_frequent"),
OneHotEncoder(handle_unknown="ignore", sparse_output=False),
)
preprocessing = ColumnTransformer([
("numeric", numeric, [0, 1]),
("category", categorical, [2]),
])
return make_pipeline(preprocessing, Ridge(alpha=1.0))
def main():
train_x, validation_x, train_y, validation_y = data()
model = make_model().fit(train_x, train_y)
preprocessing = model.steps[0][1]
numeric = preprocessing.named_transformers_["numeric"]
medians = numeric.steps[0][1].statistics_
transformed = preprocessing.transform(validation_x)
predictions = model.predict(validation_x)
print("训练集填补中位数:" + ", ".join(f"{x:.1f}" for x in medians))
print(f"原始列数={train_x.shape[1]},转换后列数={transformed.shape[1]}")
print("新类别 draft 的独热部分:" + str(transformed[-1, -2:].tolist()))
print("验证预测:" + ", ".join(f"{x:.2f}" for x in predictions))
print(f"验证 MAE={mean_absolute_error(validation_y, predictions):.3f} 秒")
if __name__ == "__main__":
main()
输出为:
训练集填补中位数:15.0, 2.0
原始列数=3,转换后列数=6
新类别 draft 的独热部分:[0.0, 0.0]
验证预测:62.84, 82.74, 101.94
验证 MAE=5.348 秒
六列由什么组成?两列填补后的数值、两列缺失标记、两列模式编码。本例数值流程会把缺失标记也一起标准化,因此转换后的标记不再只取 0 和 1,但仍保留“是否缺失”的信息。
这份数据只有六条训练记录,作用是展示处理过程,不能用 5.348 秒的分数证明这个方案普遍可靠。我们也没有在这里继续用验证集挑参数,后面的篇末实践会给出更完整的比较。
7. 有用的特征从问题中来
把页数与模式分别交给线性模型,只能学到“彩色平均多花一个固定时长”。但如果彩色每打印一页都更慢,额外耗时就会随页数增加。
可以增加一个交互特征:
指示符 在条件成立时取 1,否则取 0。这样,模型能够为彩色与黑白学习不同斜率。这比堆上毫无含义的十次方更贴近问题。
特征工程的价值在于给模型提供容易使用的信息。但任何根据数据决定的选择,例如保留哪些列、怎样设分箱边界,都应放在开发流程内部评估。如果通过查看测试误差反复发明新特征,测试集同样会参与训练之外的选择过程。
实际交付时要一起保留特征名称、顺序、单位、处理规则和模型。训练用“秒”,线上传“毫秒”,即使程序没有报错,预测也可能完全失去意义。
8. 自测与小实验
- 用训练中位数填补缺失,是否意味着缺失的真实值就是中位数?
- 的中位数是多少?为什么不等于平均数?
- 标准化后,数据一定服从正态分布吗?
- 把设备编号当连续数值,可能引入什么不合理含义?
- 未见类别被编码为全零,是否意味着模型知道了新类别的表现?
- 为什么交叉验证应接收整个 Pipeline,而不是预先转换好的全部数据?
- 想表达“彩色每页更慢”,可以增加哪个特征?
展开参考答案
- 不是。填补是计算约定,真实值仍未知。
- 为 6;平均数使用全部数值求和,受 100 的影响更大。
- 不一定,它只改变中心和尺度,不能把任意形状变成正态分布。
- 会暗示编号之间有数量差距、比例或顺序,而编号可能只是标识。
- 不是。它只避免编码错误,没有提供关于新类别的观测。
- 让每一折只从该折训练数据学习填补、缩放和编码规则,减少泄漏。
- 页数乘以“是否彩色”的指示变量。
选做:给脚本增加一条页数为 1000 的训练记录,先分别把它当作录入错误和真实大任务,写下两种情况下应该怎样处理。再观察它对均值、中位数和预测的影响。先解释含义,再动手删除。
下一章的决策树与集成学习会用另一种方式处理非线性关系。处理混合类型数据的接口可参阅 ColumnTransformer 文档,缺失值策略见官方填补指南。