在很多机器学习项目里,模型本身并不是最先决定效果的部分。真正拉开差距的,往往是特征工程:缺失值怎么处理、类别变量怎么编码、数值特征要不要标准化、训练集和测试集之间有没有数据泄漏。
这篇文章用 scikit-learn 的 Pipeline 和 ColumnTransformer 跑通一个小型特征工程流程。读完以后,你可以把缺失值、类别变量和数值标准化放进同一个可复用的训练流程里。
如果你还在纠结应该选哪个模型,可以先读 机器学习算法怎么选。本文关注的是另一个高频问题:机器学习特征工程怎么做。
一、什么是特征工程
特征工程就是把原始数据变成模型能稳定学习的输入。它不是简单地“清洗数据”,而是让数据结构更适合模型。
- 缺失值处理:例如年龄缺失、收入缺失、类别未知
- 类别编码:把城市、专业、设备类型这类文本类别转成模型可读形式
- 数值缩放:让不同单位的数值特征处在可比较的尺度上
- 特征组合:把已有字段组合成更有含义的新字段
- 防止数据泄漏:测试集信息不能提前参与训练阶段的统计
同一个模型,输入特征不同,效果可能完全不一样。特征工程的目标不是制造复杂字段,而是保留真实有用的信息,并让训练过程可复现。
二、为什么不要手动处理完整数据后再拆分
很多初学者会先对完整数据做均值填充、标准化、类别编码,然后再拆分训练集和测试集。这是一个常见的数据泄漏风险。
正确做法是:只在训练集上学习填充值、标准化参数和类别编码规则,然后把同一套规则应用到测试集。scikit-learn 的 Pipeline 正是为这个流程设计的。
三、准备一个混合特征数据集
下面用一个很小的示例数据集模拟真实表格数据。它同时包含数值特征、类别特征和缺失值。
import pandas as pd
data = pd.DataFrame({
"study_hours": [2.0, 5.5, 1.0, 7.0, None, 3.5, 6.0, 8.0],
"sleep_hours": [6.0, 7.5, None, 8.0, 5.0, 6.5, 7.0, 8.5],
"major": ["CS", "Math", "Art", "CS", "Business", "Math", None, "CS"],
"uses_planner": ["yes", "yes", "no", "yes", "no", "no", "yes", None],
"passed": [0, 1, 0, 1, 0, 0, 1, 1],
})
X = data.drop(columns="passed")
y = data["passed"]
这个例子不代表真实教育评价,只用于演示机器学习特征处理流程。真实项目里要避免把敏感变量、偏见变量或不合规数据直接放进模型。
四、用 ColumnTransformer 分开处理数值和类别特征
数值特征和类别特征不能用同一种处理方式。数值特征适合用中位数填充和标准化,类别特征适合用最常见类别填充和 one-hot 编码。
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
numeric_features = ["study_hours", "sleep_hours"]
categorical_features = ["major", "uses_planner"]
numeric_transformer = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
])
categorical_transformer = Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("encoder", OneHotEncoder(handle_unknown="ignore")),
])
preprocessor = ColumnTransformer([
("num", numeric_transformer, numeric_features),
("cat", categorical_transformer, categorical_features),
])
handle_unknown="ignore" 很重要。它表示测试集中如果出现训练时没见过的新类别,编码器不会直接报错,而是用一组空的 one-hot 编码处理。
五、把特征工程和模型放进同一个 Pipeline
接下来把预处理器和模型连接起来。这样训练、预测、交叉验证时都会自动使用同一套处理流程。
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import Pipeline
model = Pipeline([
("preprocess", preprocessor),
("classifier", LogisticRegression()),
])
scores = cross_val_score(model, X, y, cv=4, scoring="accuracy")
print("CV accuracy:", scores)
print("Mean accuracy:", scores.mean())
在真实项目中,你可以把 LogisticRegression 换成随机森林、梯度提升树或其他模型,但前面的预处理结构可以继续复用。
六、完整可运行代码
下面是整合后的完整版本,可以直接保存为 feature_engineering_demo.py 运行。
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
data = pd.DataFrame({
"study_hours": [2.0, 5.5, 1.0, 7.0, None, 3.5, 6.0, 8.0],
"sleep_hours": [6.0, 7.5, None, 8.0, 5.0, 6.5, 7.0, 8.5],
"major": ["CS", "Math", "Art", "CS", "Business", "Math", None, "CS"],
"uses_planner": ["yes", "yes", "no", "yes", "no", "no", "yes", None],
"passed": [0, 1, 0, 1, 0, 0, 1, 1],
})
X = data.drop(columns="passed")
y = data["passed"]
numeric_features = ["study_hours", "sleep_hours"]
categorical_features = ["major", "uses_planner"]
numeric_transformer = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
])
categorical_transformer = Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("encoder", OneHotEncoder(handle_unknown="ignore")),
])
preprocessor = ColumnTransformer([
("num", numeric_transformer, numeric_features),
("cat", categorical_transformer, categorical_features),
])
model = Pipeline([
("preprocess", preprocessor),
("classifier", LogisticRegression()),
])
scores = cross_val_score(model, X, y, cv=4, scoring="accuracy")
print("CV accuracy:", scores)
print("Mean accuracy:", scores.mean())
运行命令如下:
python3 -m venv .venv
source .venv/bin/activate
pip install scikit-learn pandas
python feature_engineering_demo.py
七、常见特征工程错误
- 先处理完整数据再拆分:容易让测试集信息泄漏到训练阶段
- 对类别变量直接编号:比如把城市编码成 1、2、3,模型可能误以为它们有大小关系
- 忽略训练和线上数据差异:线上出现新类别时,如果没有
handle_unknown,预测流程可能报错 - 删除太多缺失样本:缺失本身可能包含信息,直接删除会改变数据分布
- 没有保存预处理流程:训练时怎么处理,预测时也必须一样处理
八、特征工程和模型调参哪个更重要
如果数据很粗糙,优先做特征工程;如果数据结构已经合理,再做模型调参。很多项目里,补上正确的缺失值处理、类别编码和防泄漏流程,比盲目换模型更有效。
一个实用顺序是:
- 先做可复现的数据拆分。
- 用 Pipeline 固定预处理流程。
- 训练一个简单基线模型。
- 检查错误样本和重要特征。
- 最后再比较不同模型和参数。
九、特征工程审计表
特征工程最容易出问题的地方不是代码能不能运行,而是训练、验证、线上推理是否使用了同一套可追踪规则。下面的表可以作为每个表格机器学习项目的检查清单。
| 环节 | 检查内容 | 推荐做法 | 失败信号 |
|---|---|---|---|
| 拆分顺序 | 是否先拆分训练/验证/测试,再拟合 imputer、scaler 和 encoder | 把所有预处理步骤放进 Pipeline 和 ColumnTransformer |
在完整数据上先算均值、方差或类别集合,再做测试集拆分 |
| 类别变量 | 新类别、低频类别和缺失类别是否有明确处理策略 | 使用 handle_unknown="ignore",并记录低频类别合并规则 |
线上出现新城市、新设备或新专业时预测流程直接报错 |
| 数值缩放 | 哪些模型依赖尺度,哪些模型不依赖尺度 | 线性模型、KNN、SVM、神经网络优先标准化;树模型可作为对照 | 换一个单位后模型输出明显变化,但训练记录没有说明原因 |
| 可复现性 | 是否保存预处理对象、模型版本、字段顺序和训练随机种子 | 把完整 pipeline 序列化,并为输入 schema 写单元测试 | 训练脚本和预测服务各写一套预处理逻辑,字段顺序不一致 |
十、常见问题 FAQ
所有数值特征都要标准化吗?
线性模型、KNN、SVM 和神经网络通常更需要标准化。树模型对特征尺度不敏感,但标准化也不会总是有害,关键是保持训练和预测流程一致。
one-hot 编码会不会让特征太多?
如果类别数量很少,one-hot 编码通常没问题。如果一个字段有几千或几万个类别,就要考虑合并低频类别、目标编码、哈希技巧或其他表示方法。
缺失值应该用平均值还是中位数?
数值分布比较偏、异常值较多时,中位数通常更稳。类别特征常用最常见类别填充,也可以单独填成 Unknown。
十一、下一步阅读
特征工程能让模型看到更稳定的信息,但它不能解决所有问题。如果训练集效果很好、验证集效果明显下降,下一步应该读 过拟合和欠拟合怎么解决。