引言
「模型决定上限,特征决定下限」——同一份数据、同一个算法,特征工程做得好坏可以拉开成倍的差距。很多「调参大师」在竞赛里拼不过「特征专家」,原因就在这。
特征工程是把原始数据加工成模型能高效学习的输入:清洗脏数据、构造新特征、编码类别、挑选有效特征。本文按生产流程四步走:数据清洗 → 特征构造 → 编码 → 特征选择,每一步都有可运行代码与决策表。最后重点讲防泄漏——这是特征工程里最容易犯、最致命、也最隐蔽的错误。
前置:[[ml]] 专题的环境与 pandas 基础(https://plumephp.com/ml-python-environment-setup/)。相关深度文章见 [[ai-ml]] 专题。
目录
- 1. 特征工程全景:它在模型链路里的位置
- 2. 数据清洗:缺失、异常与重复
- 3. 特征构造:数值、类别、时间与组合
- 4. 编码技巧:从独热到目标编码
- 5. 特征选择:过滤、包裹与嵌入法
- 6. 文本与图像特征:非结构化数据初探
- 7. 防泄漏:特征工程最致命的错误
- 8. 实战:一个端到端特征工程案例
- 9. 总结:特征工程的最佳实践清单
- 延伸阅读
1. 特征工程全景:它在模型链路里的位置
1.1 完整链路
原始数据 → 清洗 → 特征构造 → 编码 → 特征选择 → 训练
↑ 特征工程集中发力 ↑
特征工程的产出是训练特征矩阵 X,直接影响模型的可学性。
1.2 四大任务
| 任务 | 目标 | 典型手段 |
|---|---|---|
| 清洗 | 去掉脏数据 | 缺失/异常/重复处理 |
| 构造 | 造出更有信息量的特征 | 组合、聚合、分箱 |
| 编码 | 把非数值变成数值 | 独热、目标编码 |
| 选择 | 只留有效特征 | 过滤/包裹/嵌入 |
2. 数据清洗:缺失、异常与重复
2.1 缺失值处理决策
import pandas as pd
df = pd.read_csv('sales.csv')
print(df.isnull().sum()) # 看缺失分布
# 方案矩阵:
# 少量缺失数值列 → 均值/中位数填充
df['age'] = df['age'].fillna(df['age'].median())
# 类别列 → 众数或"未知"占位
df['city'] = df['city'].fillna('未知')
# 关键字段缺失 → 删行
df = df.dropna(subset=['order_id'])
2.2 缺失占比过高直接弃列
missing_ratio = df.isnull().mean()
drop_cols = missing_ratio[missing_ratio > 0.6].index
df = df.drop(columns=drop_cols)
print("弃列:", list(drop_cols))
2.3 异常值识别(IQR 法)
Q1 = df['amount'].quantile(0.25)
Q3 = df['amount'].quantile(0.75)
IQR = Q3 - Q1
low = Q1 - 1.5 * IQR
high = Q3 + 1.5 * IQR
outliers = df[(df['amount'] < low) | (df['amount'] > high)]
print("异常值数量:", len(outliers))
2.4 重复行
print("重复行:", df.duplicated().sum())
df = df.drop_duplicates()
3. 特征构造:数值、类别、时间与组合
3.1 数值特征:组合与分箱
# 组合特征(比值往往比原始值更有信息)
df['客单价'] = df['消费额'] / df['消费次数']
df['月均消费'] = df['消费额'] / (df['注册月数'] + 1)
# 分箱:连续值转区间
df['年龄段'] = pd.cut(df['age'], bins=[0, 18, 30, 45, 60, 100],
labels=['<18','18-30','30-45','45-60','60+'])
3.2 时间特征:拆出周期性
df['下单日期'] = pd.to_datetime(df['下单日期'])
df['年'] = df['下单日期'].dt.year
df['月'] = df['下单日期'].dt.month
df['星期'] = df['下单日期'].dt.dayofweek # 0=周一
df['小时'] = df['下单日期'].dt.hour
df['是否周末'] = (df['星期'] >= 5).astype(int)
3.3 聚合特征:跨多行提炼
# 每个用户的统计特征
agg = df.groupby('user_id').agg(
order_count = ('order_id', 'count'),
total_amount = ('amount', 'sum'),
avg_amount = ('amount', 'mean'),
).reset_index()
df_user = df.merge(agg, on='user_id')
3.4 组合特征:交互项
# 显式交互(线性模型尤其需要)
df['消费额_x_频次'] = df['消费额'] * df['消费次数']
构造特征的原则:从业务逻辑出发。问「这个场景里什么因素真正驱动目标」,而不是盲目造一堆组合。
4. 编码技巧:从独热到目标编码
4.1 低基类别:独热编码
from sklearn.preprocessing import OneHotEncoder
df = pd.get_dummies(df, columns=['城市'], prefix='城市')
# 城市 → 城市_北京, 城市_上海, 城市_深圳 ...
4.2 独热的代价
基数值大时(城市 100 个),独热会制造 100 列稀疏特征,训练慢且过拟合风险高。此时考虑目标编码。
4.3 高基类别:目标编码
用目标均值作为类别特征:
# 简单的目标编码(训练集)
city_target = df.groupby('城市')['是否复购'].mean()
# ⚠️ 必须加平滑,防止小样本类别的均值过拟合
smooth = 10
global_mean = df['是否复购'].mean()
df['城市_编码'] = df['城市'].map(
lambda c: (city_target[c]*df['城市'].eq(c).sum() + global_mean*smooth)
/ (df['城市'].eq(c).sum() + smooth)
)
4.4 时序/序号:直接数值化
# 有序类别直接映射
df['等级'] = df['等级'].map({'普通':0, '银卡':1, '金卡':2, '铂金':3})
4.5 编码选择速查
| 类别基数 | 建议 |
|---|---|
| ≤10 | 独热 |
| 10-100 | 独热 or 目标编码 |
| >100 | 目标编码 / 嵌入 |
| 有序类别 | 直接数值映射 |
5. 特征选择:过滤、包裹与嵌入法
5.1 为什么需要选择
特征太多:训练慢、过拟合、可解释性差。三种思路:
| 方法 | 思路 | 例子 |
|---|---|---|
| 过滤法 | 独立评估每个特征 | 方差、相关性、卡方 |
| 包裹法 | 用模型评估子集 | 递归特征消除 RFE |
| 嵌入法 | 训练中自动挑 | Lasso、树模型重要性 |
5.2 过滤法:方差与相关性
# 方差极低(几乎不变)的特征无信息
from sklearn.feature_selection import VarianceThreshold
selector = VarianceThreshold(threshold=0.01)
X_reduced = selector.fit_transform(X)
# 与目标高度相关 → 保留;两特征间高度相关 → 去冗余
corr = pd.DataFrame(X).corrwith(y).abs().sort_values(ascending=False)
5.3 嵌入法:树模型特征重要性
from sklearn.ensemble import RandomForestClassifier
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X, y)
importance = pd.Series(rf.feature_importances_, index=X.columns)
keep = importance[importance > 0.02].index.sort_values()
print("保留特征:", list(keep))
5.4 包裹法:RFE
from sklearn.feature_selection import RFE
from sklearn.linear_model import LogisticRegression
rfe = RFE(LogisticRegression(max_iter=1000), n_features_to_select=20)
rfe.fit(X, y)
print("选中的特征:", X.columns[rfe.support_])
6. 文本与图像特征:非结构化数据初探
6.1 文本:TF-IDF 向量化
from sklearn.feature_extraction.text import TfidfVectorizer
tfidf = TfidfVectorizer(max_features=1000, stop_words='english')
X_text = tfidf.fit_transform(df['评论'])
6.2 图像:预训练模型提取
# 用预训练 CNN 的倒数第二层做特征(迁移学习)
from torchvision.models import resnet18
import torchvision.transforms as T
model = resnet18(pretrained=True)
model.fc = torch.nn.Identity() # 去掉分类头,输出特征向量
6.3 结论
非结构化数据的「特征工程」正在被表示学习(预训练模型)取代:与其手工造特征,不如用模型学到的高层表示。手工特征工程的主战场仍在结构化表格数据。
7. 防泄漏:特征工程最致命的错误
7.1 什么是泄漏
特征里混入了「只有知道答案才知道的信息」,让训练指标虚高、上线暴跌。典型三例:
# ❌ 泄漏1:用全量数据归一化/填充
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X) # fit 用到了测试集统计量
# 正确:fit 只用训练集
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
# ❌ 泄漏2:目标编码用全量目标均值
city_target = df.groupby('城市')['是否复购'].mean() # 用到 y 了
# 正确:在训练集内算,测试集用训练集映射
city_target = df_train.groupby('城市')['是否复购'].mean()
# ❌ 泄漏3:时序问题用了未来信息
# 训练某天的预测,特征里却包含当天之后的统计量
7.2 泄漏检测信号
| 信号 | 含义 |
|---|---|
| 训练准确率 >99% | 高度怀疑泄漏 |
| 测试远低于训练 | 或过拟合、或泄漏 |
| 特征名带未来语义 | 检查时序边界 |
7.3 防泄漏纪律
- 一切 fit 只在训练集。
- 特征工程的统计量都从训练集算。
- 时序数据按时间切分,禁止穿越。
8. 实战:一个端到端特征工程案例
8.1 任务:预测用户是否复购
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score
# 1. 加载
df = pd.read_csv('users.csv')
# 2. 清洗
df['age'] = df['age'].fillna(df['age'].median())
df = df.dropna(subset=['user_id'])
df = df.drop_duplicates()
# 3. 构造
df['注册月数'] = (pd.Timestamp.now() - pd.to_datetime(df['注册日期'])).dt.days / 30
df['客单价'] = df['消费额'] / (df['消费次数'] + 1)
df['是否周末下单'] = (pd.to_datetime(df['最近下单日期']).dt.dayofweek >= 5).astype(int)
# 4. 编码
df = pd.get_dummies(df, columns=['城市'], prefix='城市')
# 5. 选择特征
features = ['age','消费额','消费次数','客单价','注册月数',
'是否周末下单'] + [c for c in df if c.startswith('城市_')]
X = df[features]
y = df['是否复购']
# 6. 切分 + 标准化(防泄漏)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42)
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
# 7. 训练 + 评估
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train_s, y_train)
print("AUC:", roc_auc_score(y_test, rf.predict_proba(X_test_s)[:,1]).round(4))
9. 总结:特征工程的最佳实践清单
9.1 完整 checklist
□ 缺失值:占比/列重要性决定 填充/删除
□ 异常值:IQR/业务规则 处理
□ 重复:去重
□ 构造:业务驱动的组合/聚合/分箱
□ 编码:按基数选 独热/目标/映射
□ 选择:过滤+嵌入 组合
□ 防泄漏:所有 fit 只在训练集
9.2 黄金三条
| 原则 | 说明 |
|---|---|
| 业务优先 | 特征构造从业务逻辑出发 |
| 简洁优先 | 特征宁少勿滥 |
| 防泄漏优先 | 错一次,全部白做 |
9.3 一句话心法
特征工程 = 把「业务知识」翻译成「模型语言」,而防泄漏是这条翻译链上的安全底线。
延伸阅读
- https://plumephp.com/ml-supervised-regression/ — 特征工程服务于的模型训练
- https://plumephp.com/ml-unsupervised-clustering/ — 特征工程与聚类/降维协作
- https://plumephp.com/ml-model-evaluation/ — 特征有效性最终要靠评估验证
- [[ai-ml]] 专题的特征工程深度文章
- scikit-learn 特征工程指南
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。