第三章 特征工程
一、数据清洗
1.1 缺失值处理
缺失的来源与含义
数据缺失并非简单的"空"------它本身就是信息。常见原因:
- 结构缺失:该字段对当前样本本就不适用(如儿童的"配偶收入"列)。
- 随机缺失(MCAR):缺失与任何变量(观测或未观测)都无关。
- 随机缺失(MAR):缺失与已观测变量有关,与未观测值无关。
- 非随机缺失(MNAR):缺失与未观测值本身有关(如高收入者更可能拒绝填写收入)。
不同的缺失机制决定了处理策略:MNAR 直接用均值/中位数填补会引入严重偏差。
检测与可视化
python
import pandas as pd
import numpy as np
df = pd.DataFrame({
"a": [1, 2, np.nan, 4],
"b": [np.nan, np.nan, np.nan, 1],
"c": ["x", None, "z", "w"],
})
# 每列缺失率
df.isna().mean()
# 缺失模式热力图
import seaborn as sns
sns.heatmap(df.isna(), cbar=False)
常见处理策略
| 策略 | 描述 | 适用 | 注意点 |
|---|---|---|---|
| 直接删除 | 丢弃含缺失的样本或特征 | 缺失率 < 5% 且 MCAR | 数据浪费,可能引入偏差 |
| 简单填充 | 均值 / 中位数 / 众数 | MCAR(无偏但低估方差);MAR 需借助已观测变量做分层/回归插补 | 忽略特征间关系;降低方差 |
| 前向/后向填充 | 用时序相邻值填补 | 时序数据 | 强假设时序平稳 |
| 模型预测填充 | 用其他特征训练模型预测缺失值 | 特征间强相关 | 训练成本高;泄漏风险(见第九章 §3.1) |
| 缺失指示特征 | 新增"是否缺失"二值列 | MNAR | 保留"缺失"信息;增加维度 |
| 多重插补(MICE) | 多次插补建立分布 | 需不确定性量化 | 计算成本高 |
scikit-learn 实现
python
from sklearn.impute import SimpleImputer, KNNImputer
# 中位数填充
imp_median = SimpleImputer(strategy="median")
X_imp = imp_median.fit_transform(X)
# KNN 填充(用 K 个最近邻的平均/中位数)
imp_knn = KNNImputer(n_neighbors=5)
X_imp = imp_knn.fit_transform(X)
# 缺失指示
from sklearn.impute import MissingIndicator
mask = MissingIndicator().fit_transform(X) # 1 表示该位置原本缺失
与管道的衔接
预处理必须放进 Pipeline / ColumnTransformer,否则在交叉验证中会泄漏训练集的信息到验证集。详见第九章 §3.1。
易错点
- 对测试集重新 fit :必须
fit在训练集上,再transform验证/测试集。fit_transform在训练集,transform在测试集,顺序反了会引入数据泄漏。 - 类别特征与数值特征混合时 :用
ColumnTransformer分别填充(数值填中位数、类别填众数或专门类)。 - 缺失值与 0、"" 的混淆 :pandas 中
0、""、pd.NA、np.nan是不同的;先df.replace(...)统一再处理。
1.2 异常值处理
识别异常值
异常值(outlier)是显著偏离主体分布的样本。识别方法有两类:
- 统计方法 :基于分布假设
- Z-score:∣x−μ∣/σ>3|x - \mu| / \sigma > 3∣x−μ∣/σ>3(高斯假设)。
- IQR:x<Q1−1.5IQRx < Q1 - 1.5 \text{IQR}x<Q1−1.5IQR 或 x>Q3+1.5IQRx > Q3 + 1.5 \text{IQR}x>Q3+1.5IQR(Tukey 围栏)。
- 修正 Z-score(Modified Z-score):z~i=0.6745⋅(xi−x~)/MAD\tilde z_i = 0.6745 \cdot (x_i - \tilde x) / \text{MAD}z~i=0.6745⋅(xi−x~)/MAD,MAD=median(∣x−median(x)∣)\text{MAD} = \text{median}(|x - \text{median}(x)|)MAD=median(∣x−median(x)∣),0.67450.67450.6745 是高斯分布 MAD 与 σ\sigmaσ 的比值(使 MAD 在高斯下与 σ\sigmaσ 等价),常用阈值 ∣z~∣>3.5|\tilde z| > 3.5∣z~∣>3.5。对异常值更鲁棒。
- 模型方法 :基于密度/距离
- Isolation Forest:通过随机切分隔离点的难易度衡量异常。
- Local Outlier Factor(LOF):样本的局部密度与邻居密度比值。
- DBSCAN:将低密度区域点标为噪声(详见第七章 §3.2)。
处理策略
| 策略 | 描述 | 注意 |
|---|---|---|
| 删除 | 直接移除异常样本 | 仅当确定是数据错误时使用 |
| 截尾(Winsorize) | 把超过某分位的值替换为分位值 | 保留样本数;常用 1% / 99% 截尾 |
| 变换 | 取对数、Box-Cox、Yeo-Johnson 减轻右偏 | 有时比"删除"更安全 |
| 单独建模 | 把异常作为另一类/单独回归分支 | 适用于异常本身有意义(如欺诈) |
| 鲁棒模型 | 使用 Huber 损失、RANSAC 等 | 让模型自身对异常鲁棒 |
箱线图快速诊断
python
import seaborn as sns
sns.boxplot(x=df["income"]) # 须先去除 NaN
Isolation Forest 示例
python
from sklearn.ensemble import IsolationForest
# contamination: 期望异常比例上界 ∈ (0, 0.5];scikit-learn 0.22+ 默认 'auto'(按原始 Liu et al. 2008 取阈值 -0.5)
clf = IsolationForest(contamination=0.05, random_state=0)
mask = clf.fit_predict(X) # -1 表示异常
X_clean = X[mask == 1]
易错点
- 不可逆删除:删除后无法恢复,应先备份原始数据或在 Pipeline 中按标记 mask 处理。
- 异常 ≠ 错误:金融欺诈、罕见病阳性样本都是"异常"但有标签意义,直接删除会丢失关键信息。
- 与缺失值的耦合:异常处理后缺失率可能变化(删除样本导致缺失),先处理缺失再处理异常更稳妥。
二、特征变换
2.1 标准化与归一化
为何需要缩放
许多模型对特征尺度敏感:
- 基于距离的模型:KNN、K-Means、SVM(带 RBF 核)、PCA------大尺度特征主导距离。
- 基于梯度的模型:线性回归、逻辑回归、神经网络------大尺度特征导致梯度方向偏斜、收敛慢。
- 基于正则化的模型:L1/L2 正则对各特征同等惩罚,尺度差异会被放大。
主要方法对比
| 方法 | 公式 | 输出范围 | 适用 |
|---|---|---|---|
| StandardScaler(z-score) | x′=(x−μ)/σx' = (x - \mu) / \sigmax′=(x−μ)/σ | μ=0,σ=1\mu=0, \sigma=1μ=0,σ=1 | 多数线性/核模型 |
| MinMaxScaler | x′=(x−xmin)/(xmax−xmin)x' = (x - x_{\min}) / (x_{\max} - x_{\min})x′=(x−xmin)/(xmax−xmin) | 0,10, 10,1 | 神经网络激活前 |
| RobustScaler | x′=(x−median)/IQRx' = (x - \text{median}) / \text{IQR}x′=(x−median)/IQR | 中位数 0,IQR 1 | 含异常值 |
| MaxAbsScaler | $x' = x / \max_{\text{train}}( | x | )$ |
| Normalizer(L2) | $x' = x / | x | _2$ |
scikit-learn 标准做法
python
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
pipe = Pipeline([
("scale", StandardScaler()),
("clf", LogisticRegression(max_iter=1000)),
])
pipe.fit(X_train, y_train)
对异常值的差异
- StandardScaler 用均值/标准差,对异常敏感。
- RobustScaler 用中位数/IQR,对异常鲁棒。
- MinMaxScaler 受极值影响严重,单一异常值可将所有值挤到相近区间。
树模型例外
随机森林、XGBoost、LightGBM 等树模型对特征缩放不敏感------它们只关心特征的排序而非绝对值。对树模型做 StandardScaler 不会改变结果,但也不会有害;为管道的统一性与未来切换模型的可能性,建议保留。
易错点
- fit / transform 顺序 :训练集
fit_transform、测试集只transform。Pipeline 自动处理,但手工写代码时易出错。 - 稀疏矩阵 :StandardScaler 会破坏稀疏结构(减均值 ≠ 0)。应使用
MaxAbsScaler或不缩放。 - 目标变量 y 也要缩放吗 :回归目标可缩放以提升数值稳定性,但最终预测必须逆变换回原尺度 才能与业务指标对齐。对数目标推荐
sklearn.compose.TransformedTargetRegressor(regressor=..., func=np.log, inverse_func=np.exp),训练与逆变换自动衔接。
2.2 类别特征编码
机器学习模型需要数值输入。类别特征必须转换为数值。编码方式的选择会显著影响模型表现与距离度量。
常用方法
| 方法 | 描述 | 优 | 劣 |
|---|---|---|---|
| 标签编码(Label Encoding) | 每个类别映射为一个整数 | 简单,保留序关系(若类别有序) | 对无序类别引入伪序 |
| 独热编码(One-Hot) | 每个类别一个二值列 | 不引入伪序 | 高基数时维度爆炸 |
| 频次编码(Frequency) | 用出现频次替代类别 | 单列、抗高基数 | 频次相同的不同类别被合并 |
| 目标编码(Target Encoding) | 用目标变量在该类别的均值替代 | 信息丰富;抗高基数 | 严重的数据泄漏风险 |
| 嵌入编码(Embedding) | 学习低维稠密向量 | 表达力强 | 需要训练;通常用于深度学习 |
| 二进制编码(Binary) | 类别号转二进制,每位一列 | 维度 log2K\log_2 Klog2K | 信息密度低 |
独热编码的实现
python
import pandas as pd
df = pd.DataFrame({"city": ["BJ", "SH", "GZ", "BJ"]})
# pandas
pd.get_dummies(df, columns=["city"])
# scikit-learn
from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(sparse_output=False, handle_unknown="ignore")
ohe.fit_transform(df[["city"]])
handle_unknown="ignore" 让模型在推理时遇到训练集未见的类别返回全 0 向量,而非报错。
目标编码的正确姿势
直接用目标均值替换类别会导致严重的数据泄漏 (同一类别在训练时已"看"到自己的标签)。正确做法依赖双重防护:
- 外层 CV 隔离 :在交叉验证的每个 fold 内,仅用训练折样本计算 yˉc\bar{y}_cyˉc 与 ncn_cnc,再用此编码训练模型。
- 折内贝叶斯平滑 :即使单折内 ncn_cnc 极小也要平滑,避免小样本类别取极端均值:
enc(c)=nc⋅yˉc+α⋅yˉglobalnc+α\text{enc}(c) = \frac{n_c \cdot \bar{y}c + \alpha \cdot \bar{y}{\text{global}}}{n_c + \alpha}enc(c)=nc+αnc⋅yˉc+α⋅yˉglobal
其中 ncn_cnc 是类别 ccc 的样本数,α\alphaα 是平滑强度(先验样本数)。推理时用全量训练集重新计算 yˉc(all)\bar{y}_c^{(all)}yˉc(all) 与 nc(all)n_c^{(all)}nc(all) 后编码测试样本。
category_encoders.TargetEncoder 默认含 smoothing 与 CV 防护;手写实现务必保持"训练折计算、推理全量"的非对称。
高基数类别
类别数 KKK 很大时(城市 ID、商品 SKU、URL 域名):
- 独热编码不可行(维度爆炸)。
- 频次编码、目标编码、哈希编码(Hashing Trick)是常用替代。
- 第三方库
category_encoders.HashingEncoder(非 scikit-learn 内置)将类别哈希到 nnn 个桶内,冲突概率可控、维度固定。
有序类别
教育程度(小学/中学/大学)、评分(差/中/好)天然有序。标签编码 0/1/2 即可,模型会学到序关系。不应对有序类别做独热------会破坏顺序信息。
与管道的衔接
类别编码必须用 ColumnTransformer 区分数值与类别列:
python
from sklearn.compose import ColumnTransformer
pre = ColumnTransformer([
("num", StandardScaler(), num_cols),
("cat", OneHotEncoder(handle_unknown="ignore"), cat_cols),
])
易错点
- 对训练集用全部样本计算编码(如目标编码用全量均值)→ 数据泄漏,模型评估虚高。
- 测试集出现训练集未见的类别 → 独热编码会报"未注册";用
handle_unknown="ignore"或频次编码兜底。 - 类别字符串大小写/拼写不一致 ("Beijing" vs "beijing")→ 编码前先
str.lower().str.strip()统一。
2.3 特征构造
特征构造是利用业务知识、领域直觉、数据本身的结构 ,从原始字段衍生出新特征。往往是表格数据建模中收益最大的环节。
常见构造思路
- 代数变换 :对偏态特征取对数、平方根、Box-Cox;对周期性特征用 sin/cos\sin/\cossin/cos 编码。
- 比例与差分:A/B、A-B、A/(A+B)。
- 聚合:按某分组键聚合,例如"用户近 7 天的下单金额均值"。
- 时间特征:从时间戳提取 hour、dayofweek、is_weekend、距离节假日的天数。
- 文本特征:TF-IDF、词袋、句向量(详见 NLP 资料)。
- 交叉特征 :离散特征的两两组合(如
城市 × 星期独热交叉)。 - 离散化(分箱):连续特征分桶(年龄→年龄段),便于树模型与线性模型。
- 目标相关变换:在对数尺度上的目标(销售、点击)通常比原尺度更易建模。
示例:时间与聚合
python
df["order_ts"] = pd.to_datetime(df["order_ts"])
df["hour"] = df["order_ts"].dt.hour
df["dayofweek"] = df["order_ts"].dt.dayofweek
df["is_weekend"] = df["dayofweek"].isin([5, 6]).astype(int)
# 用户近 7 天订单总额
df["user_7d_sum"] = (
df.sort_values("order_ts")
.groupby("user_id")["amount"]
.rolling("7D", on="order_ts")
.sum()
.reset_index(level=0, drop=True)
)
周期性编码
对 hour、dayofweek 等环形特征,简单的 0--23 整数会让模型以为 23 与 0 相距很远。更合理的做法是用 sin\sinsin、cos\coscos 编码:
python
df["hour_sin"] = np.sin(2 * np.pi * df["hour"] / 24)
df["hour_cos"] = np.cos(2 * np.pi * df["hour"] / 24)
# 通用公式:sin(2π·x/T)、cos(2π·x/T),T 为周期长度
# hour → T=24, dayofweek → T=7, minute → T=60, month → T=12(但月有年内强弱周期,需另行考虑)
离散化(分箱)
- 等宽分箱 :
pd.cut,按值域均匀分。 - 等频分箱 :
pd.qcut,按分位数分。 - 基于决策树的分箱 :用决策树自动找最优切分点(
sklearn.tree.DecisionTreeRegressor)。 - WOE 编码 (Weight of Evidence):金融风控经典,将分箱映射为 log(P(bad)/P(good))\log(P(\text{bad})/P(\text{good}))log(P(bad)/P(good)),直接对接逻辑回归。
特征构造的常见陷阱
- 时间泄漏:聚合时如果包含未来数据(如用未来 7 天数据构造今天特征)→ 训练与测试分布不一致。
- 过拟合风险:构造了过多与训练集噪声相关的特征,模型在训练集表现好但泛化差。
- 可解释性 vs 表现:高度交叉的特征会显著提升模型表现,但同时让解释与调试变难。
易错点
- 离散化后再做线性模型:分箱会把连续特征变成有序整数,线性模型可以学;如果直接独热每个分箱,会引入大量列。一般决策树无需分箱。
- 构造的特征未在测试集做同处理 :训练/推理代码必须共享特征构造函数;用函数封装 +
apply是常见做法。 - 与目标强相关但实际是泄漏:例如用"用户最近一次购买距今天数"预测"明天是否购买"------若该特征在数据生成时已用到了"明天"信息,则必须重做。
三、特征选择
3.1 过滤式与包裹式
特征选择在数据维度高、冗余多的场景下至关重要。它能:
- 降低过拟合风险(减少模型可用的自由度)。
- 提升训练与推理速度。
- 改善可解释性。
三大类方法对比
| 类别 | 思想 | 与模型关系 | 优 | 劣 |
|---|---|---|---|---|
| 过滤式(Filter) | 按统计量对特征打分 | 模型无关 | 快、可解释 | 不考虑特征组合 |
| 包裹式(Wrapper) | 用模型性能挑选子集 | 模型相关 | 考虑特征交互 | 计算昂贵、过拟合风险 |
| 嵌入式(Embedded) | 训练过程中自动选择 | 模型内嵌 | 计算与效果平衡 | 模型特定 |
过滤式:单变量统计
- 方差阈值 :去掉方差为 0 的特征(所有样本同一值)。
VarianceThreshold(threshold=0)。 - Pearson 相关系数 :仅衡量线性关系,范围 −1,1-1, 1−1,1。
- 互信息:可衡量任意依赖,详见第二章 §4.2。
- 卡方检验:分类特征与分类标签的独立性。
- ANOVA F 值:数值特征与分类标签的组间均值差异。
python
from sklearn.feature_selection import (
VarianceThreshold, SelectKBest, f_classif, mutual_info_classif
)
# 去掉方差 < 0.01 的特征
vt = VarianceThreshold(threshold=0.01)
X_v = vt.fit_transform(X)
# F 值选 top 20 特征
skb = SelectKBest(f_classif, k=20)
X_k = skb.fit_transform(X, y)
过滤式:多变量
- 相关矩阵去冗余:若两特征相关系数 > 0.95,删其一。
- VIF(方差膨胀因子):检测多重共线性。VIF > 5 或 10 视为强共线性。
- mRMR(最小冗余最大相关):选择与目标高相关、彼此低冗余的特征。
包裹式:搜索策略
- 前向选择(Forward Selection):从空集开始,每次加入使评估指标提升最大的特征。
- 后向消除(Backward Elimination):从全集开始,每次移除影响最小的特征。
- 穷举 / 双向:计算量爆炸,仅在特征数极少(<20)时可行。
- 递归特征消除(RFE) :用模型系数或特征重要性递归剔除。
sklearn.feature_selection.RFE。 - RFECV:RFE + 交叉验证自动确定最佳特征数。
python
from sklearn.feature_selection import RFE
from sklearn.linear_model import LogisticRegression
rfe = RFE(estimator=LogisticRegression(max_iter=1000),
n_features_to_select=15,
step=0.1) # step 可为整数或 (0,1] 比例;step=0.1 表示每轮剔除 10% 特征
X_rfe = rfe.fit_transform(X, y)
过滤式 vs 包裹式的工程取舍
- 过滤式快、稳,但忽略特征组合------两个弱特征组合后可能很强。
- 包裹式考虑组合,但计算昂贵、易过拟合(选出的子集对训练数据敏感)。
- 实操建议:先用过滤式砍掉 80% 冗余特征,再用包裹式精修。
易错点
- 数据泄漏 :用全量数据计算方差/相关系数再划分训练/测试,泄漏了测试集信息。必须
fit在训练集,transform在测试集。 - 包裹式对评估的依赖:包裹式直接优化某个评估指标,所以评估指标的选取决定了选出的特征集。指标与业务目标不一致时,会选出"指标好但业务差"的特征。
3.2 嵌入式方法
嵌入式方法在模型训练过程中自动完成特征选择,把选择与建模融为一体。
L1 正则化(稀疏解)
Lasso 回归在线性回归损失上加 L1 范数惩罚:
minw∣Xw−y∣22+λ∣w∣1\min_\mathbf{w} | \mathbf{X}\mathbf{w} - \mathbf{y} |_2^2 + \lambda |\mathbf{w}|_1wmin∣Xw−y∣22+λ∣w∣1
L1 的几何特性使最优解常落在坐标轴上------某些系数严格为 0,对应特征被"自动剔除"。
sklearn.linear_model.Lasso调大alpha→ 更多系数为 0。LogisticRegression(penalty="l1", solver="liblinear")同样有 L1 效应。- 适用:特征数远大于样本数、需要可解释的稀疏模型。
L2 正则化(收缩但不稀疏)
Ridge 回归加 L2 范数惩罚:
minw∣Xw−y∣22+λ∣w∣22\min_\mathbf{w} | \mathbf{X}\mathbf{w} - \mathbf{y} |_2^2 + \lambda |\mathbf{w}|_2^2wmin∣Xw−y∣22+λ∣w∣22
L2 不会让系数严格为 0,但会缩小系数的绝对值,从而降低方差。L2 是特征"正则化"而非"选择"------所有特征都保留,只是权重被压小。
树模型特征重要性
- 不纯度减少(Mean Decrease Impurity, MDI) :训练时该特征带来的不纯度(基尼/熵)下降总和。
RandomForestClassifier().feature_importances_即此值。MDI 对高基数特征(独热编码后的多列类别)有偏,会过度重视。 - 置换重要性(Permutation Importance,又称 Mean Decrease Accuracy, MDA) :把单列打乱后看模型性能下降多少。
sklearn.inspection.permutation_importance。MDA 比 MDI 更稳健,且不依赖具体模型。 - SHAP :基于 Shapley 值,可解释单个预测的每个特征贡献。
shap库。
python
from sklearn.linear_model import Lasso
from sklearn.ensemble import RandomForestClassifier
import numpy as np
lasso = Lasso(alpha=0.1)
lasso.fit(X_train, y_train)
selected = np.where(lasso.coef_ != 0)[0]
print("Lasso 选中的特征数:", len(selected))
rf = RandomForestClassifier(n_estimators=200, random_state=0)
rf.fit(X_train, y_train)
importances = rf.feature_importances_
top10 = np.argsort(importances)[-10:]
基于梯度提升的特征选择
XGBoost / LightGBM 提供 plot_importance、get_score(importance_type="gain"),返回特征对总损失的贡献。与 SHAP 结合可解释每个特征在不同样本上的贡献分布。
递归特征消除 + 交叉验证(RFECV,嵌入式与包裹式的混合)
严格说,RFE/RFECV 属于 §3.1 介绍的包裹式 方法(它通过反复训练模型 + 评估来挑子集)。但因为它常与 L1 / 树模型重要性等嵌入式打分结合使用("嵌入式打分 + 包裹式消除"),工程上常归到嵌入式讨论。sklearn.feature_selection.RFECV 把两者合一:
python
from sklearn.feature_selection import RFECV
from sklearn.linear_model import LogisticRegression
rfecv = RFECV(
estimator=LogisticRegression(max_iter=1000),
step=1,
cv=5,
scoring="roc_auc",
)
rfecv.fit(X_train, y_train)
print("最佳特征数:", rfecv.n_features_)
嵌入式 vs 过滤式/包裹式
- 嵌入式与模型耦合,选出的特征只在当前模型下最优。换模型后可能需要重新选择。
- 嵌入式在训练中完成选择,不增加额外计算(Lasso 训练成本约略高于线性回归)。
- 包裹式可发现更优子集但昂贵;过滤式快但粗。实操上三者常组合使用:先用过滤式粗筛 → 再用嵌入式精筛 → 必要时用包裹式终审。
易错点
- Lasso 在高度相关特征上不稳定:若两特征高度相关,Lasso 会随机选一个、另一个置 0;改用 ElasticNet(L1 + L2)更稳定。
- 树模型重要性的高基数偏置:类别独热后的高基数特征(独热多列)会被过度重视;优先用目标编码 / 频次编码合并。
- 嵌入式选择的特征不通用:用 Lasso 选出的特征在 XGBoost 上未必是最佳------两者优化的目标函数不同。