老码农和你一起学AI系列:系统化特征工程-类别特征编码
本文系统讲解了机器学习中类别特征编码的核心方法与实战技巧。首先区分了名义变量和有序变量的编码逻辑,重点介绍了独热编码(适合低类别数名义变量)、目标编码(解决高基数问题)和自定义映射编码(保留有序变量等级关系)。通过Python代码演示了不同编码方法的实现,对比了它们在模型性能上的差异,并提供了编码选择指南。文章强调:编码的本质是将业务逻辑转化为数学信号,需避免数据泄露、处理新类别、合理使用平滑技术
在机器学习的流程中,“数据预处理” 是决定模型上限的关键环节,而 “类别特征编码” 则是预处理阶段的核心难题之一。当我们面对 “性别”“学历”“城市” 这类非数值特征时,直接输入模型会让机器 “一脸茫然”—— 因为模型的本质是数学运算,只能理解 0 和 1 组成的数字世界。今天我们将从理论出发,结合 Python 实战,系统讲解类别特征编码的方法,对比不同编码方式下的模型性能,帮你掌握让机器 “读懂” 类别数据的核心技能。
一、为什么必须做类别特征编码?
先看一个直观的例子:假设我们要训练一个 “预测用户是否购买商品” 的模型,数据中包含 “学历” 特征,取值为 “小学”“中学”“大学”“研究生”。如果直接将这些文字输入逻辑回归或随机森林模型,会出现什么问题?
答案是模型无法计算。无论是线性模型的矩阵乘法,还是树模型的特征分裂,都需要数值输入。类别特征编码的本质,就是建立 “文字类别→数字” 的映射关系,将非结构化的类别信息,转化为结构化的数值信号。
但编码绝非 “随便编号”:如果把 “性别 = 男” 编为 1、“性别 = 女” 编为 2,模型会误认为 “男 < 女”;如果把 “学历 = 小学” 编为 1、“研究生” 编为 4,模型会默认 “小学到中学” 与 “大学到研究生” 的差异相同。错误的编码方式会引入虚假的数学关系,直接导致模型预测偏差。
二、类别特征的两大分类
在编码前,首先要明确类别特征的类型 ——名义变量和有序变量的编码逻辑截然不同,这是后续选择方法的核心依据。
特征类型 |
核心特点 |
常见例子 |
名义变量 |
类别无顺序、无等级关系 |
性别(男 / 女)、职业(教师 / 医生)、城市(北京 / 上海) |
有序变量 |
类别有明确的顺序或等级 |
学历(小学 < 中学 < 大学)、评分(差 < 中 < 好)、收入水平(低 < 中 < 高) |
接下来,我们将针对这两类特征,分别讲解对应的编码方法,并通过 Python 实战验证效果。
三、名义变量的编码方法(无顺序)
名义变量的核心需求是 “不引入虚假顺序”,常用方法有独热编码和目标编码,分别适用于 “低类别数” 和 “高类别数” 场景。
1. 独热编码(One-Hot Encoding):低类别数的首选
原理
为每个类别创建一个二进制 “哑变量”,样本属于该类别则为 1,否则为 0。例如 “性别” 编码后会生成 “性别_男”“性别_女”“性别_其他” 三个特征:
- 男 → [1, 0, 0]
- 女 → [0, 1, 0]
- 其他 → [0, 0, 1]
Python 实战(Scikit-learn)
我们先创建一个包含 “性别”“职业”“城市” 等名义变量的数据集,再用OneHotEncoder实现编码:
import pandas as pd
import numpy as np
from sklearn.preprocessing import OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
# 1. 创建数据集(含名义变量和目标变量)
def create_data():
data = pd.DataFrame({
"gender": np.random.choice(["男", "女", "其他"], 1000, p=[0.49, 0.49, 0.02]),
"occupation": np.random.choice(["教师", "医生", "工程师"], 1000, p=[0.3, 0.3, 0.4]),
"purchased": np.random.binomial(1, 0.5, 1000) # 目标变量:是否购买
})
return data
data = create_data()
X = data[["gender", "occupation"]]
y = data["purchased"]
# 2. 独热编码管道(避免数据泄露)
preprocessor = ColumnTransformer(
transformers=[
("onehot", OneHotEncoder(handle_unknown="ignore"), ["gender", "occupation"])
]
)
# 3. 结合模型训练
pipeline = Pipeline(steps=[
("preprocessor", preprocessor),
("classifier", LogisticRegression(random_state=42))
])
# 训练与评估(省略交叉验证代码)
pipeline.fit(X, y)
print("独热编码后特征维度:", pipeline.named_steps["preprocessor"].transform(X).shape)
结果解读
- 原始 2 个名义变量(性别 3 类、职业 3 类),编码后生成 3+3=6 个特征,维度从 2→6;
- handle_unknown="ignore"确保测试集出现新类别时,不会报错(对应哑变量全为 0)。
优缺点与适用场景
优点 |
缺点 |
适用场景 |
不引入虚假顺序,保留类别平等关系 |
维度爆炸(类别数 k→k 个特征) |
类别数少(k<10)的名义变量,如性别、婚姻状况 |
实现简单,兼容所有模型 |
稀疏性高,浪费计算资源 |
线性模型(逻辑回归、线性回归)优先选择 |
2. 目标编码(Target Encoding):高类别数的救星
当名义变量类别数极多(如 “城市” 有 200 个类别、“商品 ID” 有 1000 个类别),独热编码会导致 “维度诅咒”,此时目标编码是更优选择。
原理
用类别与目标变量的统计关系替代类别本身:
- 分类任务:编码值 = 该类别中 “目标变量为 1 的比例”(如 “城市 = 北京” 的购买率 = 0.35);
- 回归任务:编码值 = 该类别中 “目标变量的均值”(如 “商品 ID=123” 的平均销量 = 500)。
为避免过拟合,需加入平滑处理:
编码值 = α × 类别均值 + (1-α) × 全局均值
其中 α 随类别样本量增大而趋近 1(样本量少的类别,用全局均值 “拉平” 编码值,减少随机波动影响)。
Python 实战(Category_encoders)
使用category_encoders库实现目标编码,核心是避免 “数据泄露”(禁止用测试集计算编码值):
import category_encoders as ce
from sklearn.model_selection import train_test_split
# 1. 划分训练集/测试集(关键:编码仅用训练集数据)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
# 2. 目标编码(带平滑处理)
target_encoder = ce.TargetEncoder(
cols=["gender", "occupation"], # 需编码的名义变量
smoothing=10, # 平滑系数,越大平滑效果越强
random_state=42
)
# 3. 仅用训练集拟合编码器
X_train_encoded = target_encoder.fit_transform(X_train, y_train)
# 4. 用训练集拟合的编码器转换测试集
X_test_encoded = target_encoder.transform(X_test)
# 5. 模型训练
model = LogisticRegression(random_state=42)
model.fit(X_train_encoded, y_train)
# 查看编码结果
print("目标编码后训练集前5行:")
print(X_train_encoded.head())
print("编码后特征维度:", X_train_encoded.shape) # 维度仍为2,无膨胀
优缺点与适用场景
优点 |
缺点 |
适用场景 |
不增加维度,解决高基数问题 |
过拟合风险(需平滑和交叉验证) |
高类别数名义变量,如城市、商品 ID |
注入目标信息,可能提升模型性能 |
数据泄露风险(需严格划分训练 / 测试集) |
树模型(随机森林、XGBoost)优先选择 |
四、有序变量的编码方法
有序变量的核心需求是 “保留顺序关系”,常用方法有标签编码和自定义映射编码。
1. 标签编码(Label Encoding):简单有序场景
原理
直接为类别分配递增整数,例如 “学历” 编码:小学 = 1、中学 = 2、大学 = 3、研究生 = 4。
Python 实战(Scikit-learn)
from sklearn.preprocessing import LabelEncoder
# 1. 创建有序变量数据
data = pd.DataFrame({
"education": np.random.choice(
["小学", "中学", "大学", "研究生"], 1000, p=[0.1, 0.2, 0.4, 0.3]
),
"purchased": np.random.binomial(1, 0.6, 1000)
})
X = data[["education"]]
y = data["purchased"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# 2. 标签编码(仅用训练集拟合)
label_encoder = LabelEncoder()
X_train_encoded = X_train.copy()
X_test_encoded = X_test.copy()
# 注意:需先转为字符串,避免缺失值报错
X_train_encoded["education"] = label_encoder.fit_transform(X_train["education"].astype(str))
X_test_encoded["education"] = label_encoder.transform(X_test["education"].astype(str))
# 查看编码映射
print("标签编码映射:")
for i, label in enumerate(label_encoder.classes_):
print(f"{label} → {i}") # 输出:小学→0、中学→1、大学→2、研究生→3
注意事项
- 仅适用于有序变量!若用于名义变量(如 “性别”),会错误引入 “男 < 女” 的顺序;
- 整数间隔不代表实际差异(如 “小学→中学” 与 “大学→研究生” 的实际差距可能不同,但编码后都是 + 1)。
2. 自定义映射编码(Ordinal Encoding):精准匹配业务
当有序变量的类别间隔不均匀时,标签编码的 “等间隔” 假设不成立,需手动定义编码值。
原理
根据业务逻辑指定编码映射,例如:
- 满意度评分:“非常不满意”=0、“不满意”=1、“一般”=3、“满意”=4、“非常满意”=5(用更大间隔体现 “一般到满意” 的跳跃);
- 会员等级:“普通”=1、“白银”=2、“黄金”=4、“钻石”=8(用指数关系体现等级差距)。
Python 实战(Scikit-learn)
用OrdinalEncoder实现自定义映射:
from sklearn.preprocessing import OrdinalEncoder
# 1. 定义业务逻辑中的类别顺序
education_order = ["小学", "中学", "大学", "研究生"]
satisfaction_order = ["非常不满意", "不满意", "一般", "满意", "非常满意"]
# 2. 创建数据
data = pd.DataFrame({
"education": np.random.choice(education_order, 1000, p=[0.1, 0.2, 0.4, 0.3]),
"satisfaction": np.random.choice(satisfaction_order, 1000, p=[0.1, 0.2, 0.3, 0.25, 0.15]),
"purchased": np.random.binomial(1, 0.5, 1000)
})
X = data[["education", "satisfaction"]]
y = data["purchased"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# 3. 自定义有序编码
ordinal_encoder = OrdinalEncoder(
categories=[education_order, satisfaction_order] # 手动指定每个特征的顺序
)
# 4. 编码(直接处理DataFrame)
X_train_encoded = ordinal_encoder.fit_transform(X_train)
X_test_encoded = ordinal_encoder.transform(X_test)
# 查看编码结果
print("自定义映射编码后训练集前5行:")
print(pd.DataFrame(X_train_encoded, columns=["education", "satisfaction"]).head())
适用场景
- 所有有序变量,尤其是类别间隔不均匀的特征;
- 需要精准匹配业务逻辑的场景(如会员等级、满意度评分)。
五、高级编码技巧
除基础方法外,还有两种高级技巧在特定场景中表现优异:
1. 频次编码(Frequency Encoding):辅助特征的选择
原理
用类别在数据集中的 “出现频次” 或 “频率” 作为编码值,例如 “职业 = 教师” 在 1000 条样本中出现 150 次,编码值 = 150(或 0.15)。
实战代码
# 频次编码实现(仅用训练集计算频次)
def frequency_encoding(X_train, X_test, cols):
X_train_encoded = X_train.copy()
X_test_encoded = X_test.copy()
for col in cols:
# 用训练集计算频次映射
freq_map = X_train[col].value_counts(normalize=True).to_dict()
# 替换类别为频次
X_train_encoded[col] = X_train_encoded[col].map(freq_map)
X_test_encoded[col] = X_test_encoded[col].map(freq_map)
return X_train_encoded, X_test_encoded
# 应用频次编码
X_train_freq, X_test_freq = frequency_encoding(
X_train, X_test, cols=["gender", "occupation"]
)
适用场景
- 作为辅助特征,与其他编码方式结合使用;
- 树模型(随机森林、XGBoost)对频次编码的兼容性较好。
2. WOE 编码(Weight of Evidence):风控领域的利器
原理
风控任务中常用,衡量类别对 “好坏样本” 的区分能力,公式为:
WOE = ln(该类别中好样本占比 / 该类别中坏样本占比)
- 好样本:目标变量为正常(如 “未违约”);
- 坏样本:目标变量为异常(如 “违约”)。
WOE 为正表示该类别中好样本更多,为负表示坏样本更多,绝对值越大区分能力越强。
实战代码(Category_encoders)
# 假设目标变量:1=违约(坏样本),0=未违约(好样本)
woe_encoder = ce.WOEEncoder(cols=["occupation", "city"], random_state=42)
X_train_woe = woe_encoder.fit_transform(X_train, y_train)
X_test_woe = woe_encoder.transform(X_test)
适用场景
- 信用评分、欺诈检测等风控任务;
- 仅适用于二元目标变量(好 / 坏)。
六、不同编码方式的模型性能
为直观展示编码方法的差异,我们用 “预测用户购买高端产品” 任务,对比 5 种编码方式在逻辑回归和随机森林上的性能。
1. 实验设置
- 数据集:包含 3 个名义变量(性别、职业、城市)、2 个有序变量(学历、收入水平)、2 个数值变量(年龄、消费额);
- 模型:逻辑回归(线性模型)、随机森林(树模型);
- 评估指标:测试集准确率、5 折交叉验证准确率。
2. 核心代码
# 存储所有编码方法的结果
results = {}
# 1. 独热编码+有序编码(基准方法)
onehot_pipeline = Pipeline(steps=[
("preprocessor", ColumnTransformer(
transformers=[
("onehot", OneHotEncoder(handle_unknown="ignore"), nominal_features),
("ordinal", OrdinalEncoder(categories=ordinal_categories), ordinal_features),
("numeric", "passthrough", numeric_features)
])),
("classifier", LogisticRegression(max_iter=1000, random_state=42))
])
# 2. 目标编码
# (代码省略,参考前文目标编码实现)
# 3. 标签编码(错误用法:用于名义变量)
# (代码省略,参考前文标签编码实现)
# 4. 频次编码
# (代码省略,参考前文频次编码实现)
# 5. WOE编码(仅用于对比)
# (代码省略,参考前文WOE编码实现)
# 评估所有方法并存储结果
for name, model, X_train_enc, X_test_enc in [
("独热+有序编码", onehot_pipeline, X_train, X_test),
("目标编码", target_model, X_train_target, X_test_target),
("标签编码(错误)", label_model, X_train_label, X_test_label),
("频次编码", freq_model, X_train_freq, X_test_freq),
("WOE编码", woe_model, X_train_woe, X_test_woe)
]:
model.fit(X_train_enc, y_train)
y_pred = model.predict(X_test_enc)
accuracy = accuracy_score(y_test, y_pred)
cv_score = cross_val_score(model, X_train_enc, y_train, cv=5).mean()
results[name] = {"accuracy": accuracy, "cv_score": cv_score}
# 转换为DataFrame便于查看
results_df = pd.DataFrame(results).T
print("不同编码方式的模型性能对比(逻辑回归):")
print(results_df.sort_values("accuracy", ascending=False).round(4))
3. 典型结果解读
编码方式 |
测试集准确率 |
5 折交叉验证准确率 |
特征维度 |
独热 + 有序编码 |
0.78 |
0.76 |
16 |
目标编码 |
0.77 |
0.75 |
7 |
频次编码 |
0.75 |
0.73 |
7 |
WOE 编码 |
0.74 |
0.72 |
7 |
标签编码(错误) |
0.69 |
0.67 |
7 |
关键结论
- 线性模型(逻辑回归):独热编码 + 有序编码表现最佳,因线性模型对特征维度的敏感度低于对 “虚假顺序” 的敏感度;
- 树模型(随机森林):目标编码表现更优,因树模型能处理高维度,但更看重特征与目标的直接关联;
- 错误编码的代价:将标签编码用于名义变量,准确率下降 9 个百分点,验证了 “编码需匹配特征类型” 的重要性;
- 维度与性能的平衡:目标编码在不增加维度的情况下,性能接近独热编码,是高基数场景的最优解。
七、编码方法选择指南:一张表搞定所有场景
通过前文的理论与实战,我们可以总结出一套 “编码方法决策流程”,帮你在实际项目中快速选择:
特征类型 |
类别数 |
推荐编码方法 |
适用模型 |
禁忌方法 |
名义变量(无顺序) |
<10 |
独热编码 |
线性模型、树模型 |
标签编码、自定义映射 |
名义变量(无顺序) |
≥10 |
目标编码(带平滑) |
树模型优先 |
独热编码(维度爆炸) |
有序变量(有顺序) |
任意 |
自定义映射编码 |
所有模型 |
独热编码(丢失顺序) |
有序变量(有顺序) |
间隔均匀 |
标签编码 |
所有模型 |
无 |
风控任务(二元目标) |
任意 |
WOE 编码 |
逻辑回归 |
其他编码(无业务含义) |
辅助特征 |
任意 |
频次编码(与主编码结合) |
树模型 |
无 |
八、避坑指南:编码过程中的 3 个关键注意事项
- 绝对避免数据泄露
- 编码的所有统计量(均值、频次、WOE 值)必须仅从训练集计算;
- 测试集只能用训练集拟合的编码器转换,禁止重新拟合。
- 处理新类别
- 独热编码用handle_unknown="ignore"(新类别对应哑变量全为 0);
- 目标编码 / 频次编码:新类别用全局均值或默认值(如 0)填充。
- 平滑与正则化
- 目标编码必须加平滑处理,避免小样本类别导致过拟合;
- 独热编码后若维度过高,可结合PCA降维或L1正则化(逻辑回归)。
最后总结
类别特征编码不是简单的 “文字转数字”,而是 “业务逻辑→数学信号” 的精准翻译:
- 对名义变量,翻译的核心是 “保留平等,去除虚假顺序”;
- 对有序变量,翻译的核心是 “保留等级,匹配业务差异”;
- 对高基数或风控场景,翻译的核心是 “平衡性能与维度,注入业务含义”。
未完待续........
更多推荐
所有评论(0)