1特征工程的重要性
特征工程是机器学习中最关键也最容易被低估的环节。业界常说"数据和特征决定了模型的上限,而算法只是逼近这个上限"。好的特征能让简单模型(如线性回归)战胜未经良好特征处理的复杂模型(如深度神经网络)。
特征工程包含多个环节:数据清洗、缺失值处理、编码、缩放、选择和构造。每个环节都直接影响模型的性能。实践中,数据科学家往往花费 60% 到 80% 的时间在数据预处理上,这充分说明了其重要性。掌握系统的特征工程方法论,远比盲目调参更能提升模型效果。
import pandas as pd
import numpy as np
from sklearn.datasets import load_breast_cancer
# 加载示例数据
data = load_breast_cancer()
X = pd.DataFrame(data.data, columns=data.feature_names)
y = pd.Series(data.target, name="target")
print(f"数据形状: {X.shape}")
print(f"特征列数: {X.shape[1]}")
print(f"\n前 3 个特征统计:")
print(X.iloc[:, :3].describe())# 特征质量评估
from sklearn.model_selection import cross_val_score
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
# 对比简单模型在不同特征子集上的表现
lr_full = LogisticRegression(max_iter=1000)
lr_score = cross_val_score(lr_full, X, y, cv=5, scoring="accuracy").mean()
print(f"逻辑回归(全部特征): {lr_score:.4f}")
# 仅使用前 10 个特征
lr_subset = LogisticRegression(max_iter=1000)
subset_score = cross_val_score(lr_subset, X.iloc[:, :10], y, cv=5, scoring="accuracy").mean()
print(f"逻辑回归(前10个特征): {subset_score:.4f}")| 环节 | 作用 | 影响程度 |
|---|---|---|
缺失值处理 | 消除数据不完整的影响 | 高 |
类别编码 | 将文本转为数值 | 高 |
特征缩放 | 消除量纲差异 | 中到高 |
特征选择 | 剔除冗余和噪声 | 高 |
特征构造 | 创造更有表达力的特征 | 极高 |
始终先做探索性数据分析(EDA),了解数据分布和特征关系后再选择处理方法。
不要用测试集来指导特征工程决策,会导致数据泄露和过拟合。
2缺失值处理
现实世界中的数据几乎总是包含缺失值。缺失值的产生机制分为三种:完全随机缺失(MCAR)、随机缺失(MAR)和非随机缺失(MNAR)。不同的缺失机制决定了最优的处理策略。
简单的删除法适用于缺失比例极低(小于 5%)的情况。填充法则更为常用:均值/中位数填充适合数值特征,众数填充适合类别特征。更高级的方法包括 KNN 填充、迭代填充(IterativeImputer)以及将缺失值作为独立类别处理。对于 MNAR 数据,缺失本身可能包含重要信息,此时应保留缺失指示变量。
from sklearn.impute import SimpleImputer, KNNImputer
import pandas as pd
# 创建含缺失值的示例
df = pd.DataFrame({
"age": [25, np.nan, 35, np.nan, 45],
"income": [50000, 60000, np.nan, 80000, 90000],
"city": ["北京", "上海", np.nan, "北京", np.nan]
})
# 均值/中位数填充
num_imputer = SimpleImputer(strategy="median")
df[["age", "income"]] = num_imputer.fit_transform(df[["age", "income"]])
# 众数填充
cat_imputer = SimpleImputer(strategy="most_frequent")
df[["city"]] = cat_imputer.fit_transform(df[["city"]])
print(df)from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer
# KNN 填充(利用相似样本填补)
knn_imputer = KNNImputer(n_neighbors=3)
df_knn = pd.DataFrame(
knn_imputer.fit_transform(df[["age", "income"]]),
columns=["age", "income"]
)
print("KNN 填充结果:")
print(df_knn)
# 迭代填充(使用回归模型预测缺失值)
iter_imputer = IterativeImputer(max_iter=10, random_state=42)
df_iter = pd.DataFrame(
iter_imputer.fit_transform(df[["age", "income"]]),
columns=["age", "income"]
)
print("\n迭代填充结果:")
print(df_iter)| 方法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
删除 | 缺失 < 5% | 简单快速 | 可能丢失信息 |
均值/中位数填充 | 数值特征,MCAR | 保留样本量 | 低估方差 |
KNN 填充 | 特征间有相关性 | 利用样本关系 | 计算开销大 |
迭代填充 | 复杂缺失模式 | 建模精度高 | 可能过拟合 |
添加缺失指示列 | MNAR 机制 | 保留缺失信息 | 增加维度 |
对数值特征用中位数填充比均值更稳健,尤其当数据存在异常值时。
用训练集的统计量拟合 Imputer,再 transform 测试集,绝对不能反过来。
3类别编码
机器学习算法只能处理数值输入,因此类别特征必须转换为数值形式。最常见的编码方式包括独热编码(One-Hot Encoding)、标签编码(Label Encoding)和目标编码(Target Encoding)。
独热编码适用于无序类别且类别数较少(通常少于 15)的场景,它会为每个类别创建一个二元列。标签编码适用于有序类别,但用在无序类别上会引入不存在的序关系。目标编码用目标变量的统计量替代类别值,表达能力强但容易过拟合,需要配合交叉验证平滑。对于高基数类别特征(如城市、用户 ID),嵌入编码(Embedding)是更高级的解决方案。
import pandas as pd
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
df = pd.DataFrame({
"color": ["红", "蓝", "绿", "蓝", "红"],
"size": ["S", "M", "L", "M", "XL"],
"city": ["北京", "上海", "北京", "广州", "上海"]
})
# 独热编码(无序类别)
ohe = OneHotEncoder(sparse_output=False, drop="first")
encoded = ohe.fit_transform(df[["color"]])
print("独热编码列:", ohe.get_feature_names_out(["color"]))
print(encoded)
# 有序编码(有序类别)
ordinal = OrdinalEncoder(
categories=[["S", "M", "L", "XL"]]
)
df["size_encoded"] = ordinal.fit_transform(df[["size"]])
print(df)# 目标编码(需要 category_encoders 库)
import category_encoders as ce
df_target = pd.DataFrame({
"city": ["北京", "上海", "北京", "广州", "上海", "北京"],
"target": [1, 0, 1, 0, 1, 0]
})
# 带平滑的目标编码,防止过拟合
te = ce.TargetEncoder(smoothing=10)
df_target["city_encoded"] = te.fit_transform(
df_target[["city"]], df_target["target"]
)
print(df_target)
# pandas 原生方法:groupby 均值
df_target["city_mean"] = df_target.groupby("city")["target"].transform("mean")
print(df_target[["city", "city_mean"]])| 编码方式 | 基数要求 | 输出维度 | 适用场景 |
|---|---|---|---|
独热编码 | 低 (< 15) | 类别数 - 1 | 无序类别 |
标签编码 | 任意 | 1 | 有序类别 |
目标编码 | 中高 | 1 | 与目标相关的类别 |
频率编码 | 任意 | 1 | 高基数类别 |
嵌入编码 | 极高 | 可配置 | 深度学习模型 |
决策树和随机森林对独热编码不敏感,直接用标签编码即可,因为它们不假设数值大小关系。
目标编码必须用交叉验证方式拟合,否则会将目标变量信息泄露到特征中,导致严重的过拟合。
4特征缩放
特征缩放将不同量纲和范围的特征转换到统一的尺度。这对基于距离和梯度的算法至关重要。KNN、SVM、K-Means 和神经网络等算法对特征尺度非常敏感,未缩放的特征会导致模型被大数值特征主导。
标准化(StandardScaler)将特征转换为均值为 0、标准差为 1 的分布,是最常用的缩放方法。归一化(MinMaxScaler)将特征压缩到 [0, 1] 区间,适合有明确边界的数据。鲁棒缩放(RobustScaler)使用中位数和四分位数,对异常值不敏感,适合存在离群点的场景。基于树的模型(如随机森林、XGBoost)不需要特征缩放,因为它们基于特征值的分裂而非距离计算。
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
import numpy as np
# 创建含异常值的示例数据
X = np.array([
[1.0, 100],
[2.0, 200],
[3.0, 150],
[4.0, 10000], # 异常值
[5.0, 180]
])
# 标准化(Z-score)
std_scaler = StandardScaler()
X_std = std_scaler.fit_transform(X)
print(f"标准化后均值: {X_std.mean(axis=0).round(4)}")
print(f"标准化后方差: {X_std.var(axis=0).round(4)}")
# 归一化
mm_scaler = MinMaxScaler()
X_mm = mm_scaler.fit_transform(X)
print(f"归一化范围: [{X_mm.min()}, {X_mm.max()}]")# 鲁棒缩放(对异常值稳健)
robust_scaler = RobustScaler()
X_robust = robust_scaler.fit_transform(X)
print("鲁棒缩放结果:")
print(X_robust.round(3))
print("\n对比异常值行 (index=3) 的处理:")
print(f"原始值: {X[3]}")
print(f"标准化: {X_std[3].round(3)}")
print(f"归一化: {X_mm[3].round(3)}")
print(f"鲁棒缩放: {X_robust[3].round(3)}")
# 哪种缩放适合哪种算法
algo_map = {
"KNN": "StandardScaler",
"SVM": "StandardScaler",
"神经网络": "MinMaxScaler 或 StandardScaler",
"K-Means": "StandardScaler",
"PCA": "StandardScaler",
"逻辑回归": "StandardScaler",
"随机森林": "不需要",
"XGBoost": "不需要"
}
for algo, scaler in algo_map.items():
print(f"{algo}: {scaler}")| 缩放方法 | 公式 | 对异常值 | 适用算法 |
|---|---|---|---|
StandardScaler | (x - mean) / std | 敏感 | KNN, SVM, PCA, 逻辑回归 |
MinMaxScaler | (x - min) / (max - min) | 非常敏感 | 神经网络, 图像处理 |
RobustScaler | (x - median) / IQR | 稳健 | 含异常值的数据 |
MaxAbsScaler | x / max(|x|) | 敏感 | 稀疏数据 |
不需要 | 随机森林, XGBoost, LightGBM |
当不确定用哪种缩放时,StandardScaler 是最安全的选择,它在大多数场景下都表现良好。
缩放器必须在训练集上 fit,在测试集上 transform。如果在整个数据集上 fit,会导致数据泄露。
5特征选择
特征选择的目标是从原始特征中挑选出最有价值的子集,以减少过拟合、提高模型性能和可解释性。方法分为三大类:过滤法、包裹法和嵌入法。
过滤法基于统计指标独立评估每个特征与目标变量的相关性,计算效率高但忽略特征间的交互作用。包裹法通过实际训练模型来评估特征子集的效果,精度高但计算开销大。嵌入法在模型训练过程中自动进行特征选择,是效率和效果的平衡。L1 正则化和基于树的重要性评估是最常用的嵌入法。
from sklearn.feature_selection import (
SelectKBest, f_classif, mutual_info_classif,
RFE, SelectFromModel
)
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_breast_cancer
# 加载数据
data = load_breast_cancer()
X, y = data.data, data.target
# 过滤法:ANOVA F 检验
selector_f = SelectKBest(f_classif, k=10)
X_f = selector_f.fit_transform(X, y)
print("F 检验选中特征数:", X_f.shape[1])
print("最高 F 分数:", selector_f.scores_.max().round(2))
# 过滤法:互信息
selector_mi = SelectKBest(mutual_info_classif, k=10)
X_mi = selector_mi.fit_transform(X, y)
print("互信息选中特征数:", X_mi.shape[1])# 包裹法:递归特征消除 (RFE)
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rfe = RFE(rf, n_features_to_select=10, step=2)
rfe.fit(X, y)
selected = data.feature_names[rfe.support_]
print("RFE 选中的特征:")
for f in selected:
print(f" - {f}")
# 嵌入法:基于 L1 正则化
from sklearn.linear_model import LogisticRegression
lr_l1 = LogisticRegression(penalty="l1", solver="liblinear", random_state=42)
sfm = SelectFromModel(lr_l1, prefit=False)
sfm.fit(X, y)
print(f"\nL1 正则化选中 {sfm.get_support().sum()} 个特征")
# 嵌入法:基于树的重要性
sfm_rf = SelectFromModel(rf, threshold="median", prefit=False)
sfm_rf.fit(X, y)
print(f"树模型选中 {sfm_rf.get_support().sum()} 个特征")| 方法 | 原理 | 计算开销 | 考虑特征交互 |
|---|---|---|---|
过滤法 (F 检验) | 统计显著性 | 低 | 否 |
过滤法 (互信息) | 信息论度量 | 中 | 部分 |
包裹法 (RFE) | 递归消除 | 高 | 是 |
包裹法 (前向选择) | 逐步添加 | 极高 | 是 |
嵌入法 (L1) | 正则化稀疏 | 中 | 部分 |
嵌入法 (树模型) | 重要性排序 | 中 | 是 |
实践中推荐组合使用:先用过滤法快速剔除明显无关特征,再用嵌入法或包裹法做精细选择。
包裹法在特征数量多时计算量会爆炸,超过 50 个特征时不建议使用穷举搜索。
6特征构造
特征构造是从已有特征中创造新特征的过程,是提升模型表现最有效但也最需要领域知识的手段。好的构造特征能揭示数据中隐藏的模式,让模型更容易学习。
交互特征通过组合两个或多个特征来捕捉它们之间的协同效应。多项式特征通过生成特征的高次项和交叉项来引入非线性关系。时间特征可以从日期时间中提取星期、月份、是否周末等信息。比率特征(如人均收入、密度)往往比原始特征更具解释力。分箱(Binning)将连续特征离散化,能降低噪声影响并引入非线性。
from sklearn.preprocessing import PolynomialFeatures, KBinsDiscretizer
import pandas as pd
import numpy as np
df = pd.DataFrame({
"length": [1.2, 2.5, 3.1, 1.8, 2.9],
"width": [0.5, 1.2, 0.8, 0.6, 1.1],
"height": [0.3, 0.7, 0.5, 0.4, 0.6],
"price": [100, 500, 300, 150, 450]
})
# 交互特征:面积 = 长 * 宽
df["area"] = df["length"] * df["width"]
df["volume"] = df["length"] * df["width"] * df["height"]
df["price_per_volume"] = df["price"] / df["volume"]
print("构造后的特征:")
print(df)
# 多项式特征(自动生成交互项和平方项)
poly = PolynomialFeatures(degree=2, include_bias=False)
X_poly = poly.fit_transform(df[["length", "width"]])
print("\n多项式特征名:", poly.get_feature_names_out(["length", "width"]))# 分箱(离散化)
discretizer = KBinsDiscretizer(n_bins=4, encode="ordinal", strategy="quantile")
df["price_bin"] = discretizer.fit_transform(df[["price"]])
print("价格分箱结果:")
print(df[["price", "price_bin"]])
# 日期时间特征提取
df_dates = pd.DataFrame({
"date": pd.date_range("2025-01-01", periods=10, freq="D")
})
df_dates["year"] = df_dates["date"].dt.year
df_dates["month"] = df_dates["date"].dt.month
df_dates["day"] = df_dates["date"].dt.day
df_dates["dayofweek"] = df_dates["date"].dt.dayofweek
df_dates["is_weekend"] = df_dates["dayofweek"].isin([5, 6]).astype(int)
print("\n日期特征提取:")
print(df_dates)
# 聚合特征(groupby 统计)
df_agg = pd.DataFrame({
"user_id": [1, 1, 2, 2, 2, 3],
"purchase": [100, 200, 50, 150, 300, 80]
})
agg = df_agg.groupby("user_id")["purchase"].agg(["mean", "std", "count"])
print("\n用户聚合特征:")
print(agg)| 构造类型 | 示例 | 适用场景 | 风险 |
|---|---|---|---|
交互特征 | 面积 = 长 x 宽 | 特征间有协同效应 | 维度爆炸 |
多项式特征 | x, x^2, xy | 需要非线性 | 过拟合 |
比率特征 | 人均 GDP | 消除规模影响 | 除零错误 |
分箱 | 年龄段 | 降低噪声 | 信息损失 |
时间特征 | 星期/月份 | 周期性模式 | 类别膨胀 |
构造特征后务必做特征选择,因为不是所有构造出的特征都有用,盲目添加会降低模型泛化能力。
多项式特征会使特征数量呈指数增长,degree=3 时 10 个原始特征会变成 219 个,务必先做特征选择再用。
7sklearn Pipeline 实战
sklearn 的 Pipeline 将数据预处理步骤和模型串联成一个可复用的对象,是特征工程的最佳实践。Pipeline 确保每个步骤都严格按照训练-测试分割执行,避免了数据泄露。
Pipeline 的核心优势有三点:一是防止数据泄露,所有预处理步骤的 fit 只在训练数据上执行;二是简化代码,将复杂的预处理流程封装为单一对象;三是支持网格搜索,可以对 Pipeline 中任何步骤的超参数进行调优。配合 ColumnTransformer,可以对不同类型的特征应用不同的预处理策略,实现灵活而健壮的特征工程流程。
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split, GridSearchCV
import pandas as pd
import numpy as np
# 创建混合类型的示例数据
np.random.seed(42)
df = pd.DataFrame({
"age": np.random.randint(18, 70, 200),
"income": np.random.randint(20, 150, 200) * 1000,
"score": np.random.randn(200) * 20 + 70,
"city": np.random.choice(["北京", "上海", "广州"], 200),
"education": np.random.choice(["本科", "硕士", "博士"], 200)
})
df.loc[np.random.choice(200, 20), "age"] = np.nan # 引入缺失值
df.loc[np.random.choice(200, 15), "city"] = np.nan
# 引入目标变量(示例)
df["target"] = (df["income"] > df["income"].median()).astype(int)
# 分离特征和目标
X = df.drop("target", axis=1)
y = df["target"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)# 定义数值和类别特征的预处理
num_features = ["age", "income", "score"]
cat_features = ["city", "education"]
# 数值特征管道:填充 + 缩放
num_pipeline = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler())
])
# 类别特征管道:填充 + 独热编码
cat_pipeline = Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore", drop="first"))
])
# 组合管道
preprocessor = ColumnTransformer([
("num", num_pipeline, num_features),
("cat", cat_pipeline, cat_features)
])
# 完整 Pipeline
full_pipeline = Pipeline([
("preprocessor", preprocessor),
("classifier", RandomForestClassifier(random_state=42))
])
# 训练与评估
full_pipeline.fit(X_train, y_train)
train_score = full_pipeline.score(X_train, y_train)
test_score = full_pipeline.score(X_test, y_test)
print(f"训练集准确率: {train_score:.4f}")
print(f"测试集准确率: {test_score:.4f}")
# Pipeline + GridSearchCV 调参
param_grid = {
"classifier__n_estimators": [50, 100, 200],
"classifier__max_depth": [None, 5, 10],
"preprocessor__num__imputer__strategy": ["mean", "median"]
}
grid = GridSearchCV(full_pipeline, param_grid, cv=5, n_jobs=-1)
grid.fit(X_train, y_train)
print(f"\n最佳参数: {grid.best_params_}")
print(f"最佳交叉验证分数: {grid.best_score_:.4f}")| Pipeline 组件 | 作用 | 对应 sklearn 类 |
|---|---|---|
缺失值填充 | 处理不完整数据 | SimpleImputer |
特征编码 | 类别转数值 | OneHotEncoder / OrdinalEncoder |
特征缩放 | 统一量纲 | StandardScaler / MinMaxScaler |
特征选择 | 剔除冗余特征 | SelectKBest / SelectFromModel |
ColumnTransformer | 多类型特征分别处理 | ColumnTransformer |
Pipeline | 串联所有步骤 | Pipeline |
Pipeline 中任何步骤的超参数都可以通过 '步骤名__参数名' 的格式在 GridSearchCV 中调优。
Pipeline 的 fit 方法只能在训练数据上调用,预测时用 predict 自动复用训练时学到的变换参数,绝对不要对测试数据重新 fit。