查看全课大纲(第 15 / 35 节)

特征编码:让类别数据参与建模

约 5 分钟

小象实战讲义 · 特征工程精讲与实践

计算机只认数字。模型训练时,像”性别:男/女”、“舱位:一等/二等/三等”这样的类别型特征(categorical feature)无法直接参与矩阵运算——特征编码(feature encoding)要做的,就是把类别取值变换成数值,同时尽可能保留类别中蕴含的信息。本节带你过一遍工业界最常用的五种编码方式:独热、序号、均值、目标与证据权重编码,学完你能按场景选对编码器,而不是逢类别就 one-hot。

💡 核心导读

  • 类别特征编码的本质:把字符串取值映射为数字,且映射方式要服务于后续建模
  • 无监督编码两条路:独热编码(平铺展开)与序号编码(顺序赋值),不使用标签信息
  • 监督编码三个进阶:均值编码、目标编码、WOE 编码,把标签信息引入特征
  • pandas 2.0 起 get_dummies 返回布尔型,需显式转整数;drop_first 用来规避虚拟变量陷阱
  • 金融风控场景的标配是 WOE 编码,基于好坏样本比例的对数

一、五种编码方式总览

按是否使用标签(目标值),常见编码方式分两大类:

编码方式类型核心思想典型场景
独热编码(one-hot encoding)无监督一个类别展开成一维 0/1 特征类别数不多、无顺序关系
序号编码(ordinal encoding)无监督按类别顺序赋予 0、1、2…类别本身有大小/等级关系
均值编码(mean encoding)监督用该类别下标签的均值替换取值连续值标签或二分类标签
目标编码(target encoding)监督先验与后验结合,带平滑类别多、样本不均衡
证据权重编码(WOE encoding)监督好数据比例与坏数据比例的对数金融风控(好坏客户)

前两种是”查表式”变换,后三种把标签信息(后验信息)引入特征,通常信息量更大,但也要防范标签泄漏。

二、独热编码与虚拟变量陷阱

先构造一份微型”泰坦尼克”数据(12 行精简样例,字段与思路同经典泰坦尼克数据集):

import pandas as pd

data = pd.DataFrame({
    'Pclass': [3, 1, 3, 1, 3, 3, 1, 2, 3, 2, 3, 1],
    'Sex': ['male', 'female', 'female', 'male', 'female', 'male',
            'female', 'male', 'female', 'female', 'male', 'male'],
    'Age': [22, 38, 26, 35, 35, 30, 28, 14, 21, 27, 5, 45],
    'Survived': [0, 1, 1, 0, 1, 1, 1, 0, 1, 0, 0, 1],
})
data_oh = pd.get_dummies(data, drop_first=True).astype(int)
print(data_oh.head(4))
   Pclass  Age  Survived  Sex_male
0       3   22         0         1
1       1   38         1         0
2       3   26         1         0
3       1   35         0         1

pd.get_dummies 会把 Sex 这一列展开成 Sex_male 一个 0/1 列。这里有两个细节值得说透:

  • drop_first=True:性别只有两个取值,male 与 female”非此即彼”,展开成两列的话,两列之和恒等于 1,相关性极高,会让线性模型陷入虚拟变量陷阱(dummy variable trap,即完全共线性)。所以保留一个就够。
  • astype(int):pandas 2.0 起 get_dummies 默认返回布尔型(True/False),而更早版本返回 0/1 整数。显式转成整数既让输出更接近传统形态,也避免布尔列在某些模型管线中被区别对待。

类别多于两个时,独热会展开成多个 0/1 列——取值互斥、无大小关系、类别数适中的特征最适合这条路。

三、序号编码与均值编码

序号编码:类别有顺序时用

序号编码(ordinal encoding)对每个类别赋予一个顺序数字,不再平铺展开。scikit-learn 的 OrdinalEncoder 按类别排序后依次赋 0、1、2…:

import pandas as pd
from sklearn.preprocessing import OrdinalEncoder

data = pd.DataFrame({
    'Pclass': [3, 1, 3, 1, 3, 3, 1, 2, 3, 2, 3, 1],
    'Sex': ['male', 'female', 'female', 'male', 'female', 'male',
            'female', 'male', 'female', 'female', 'male', 'male'],
    'Age': [22, 38, 26, 35, 35, 30, 28, 14, 21, 27, 5, 45],
    'Survived': [0, 1, 1, 0, 1, 1, 1, 0, 1, 0, 0, 1],
})

enc = OrdinalEncoder()
data_ord = data.copy()
data_ord['Sex'] = enc.fit_transform(data_ord[['Sex']]).astype(int)
print('编码顺序(按 0,1,2... 依次对应):', enc.categories_[0].tolist())
print(data_ord.head(4))
编码顺序(按 0,1,2... 依次对应): ['female', 'male']
   Pclass  Sex  Age  Survived
0       3    1   22         0
1       1    0   38         1
2       3    0   26         1
3       1    1   35         0

female 被编为 0、male 被编为 1——注意这是按类别排序赋值,并不代表”男性比女性大”。对”学历:高中/本科/硕士”这类天然有序的类别,序号编码才能正确表达等级关系;对性别这种无序类别,直接用序号编码可能让模型误读出大小关系,需要谨慎。

均值编码:把标签的分组统计填回来

均值编码更进一步:把每个类别替换成”该类别下标签的均值”。它需要用到标签,属于监督式编码,只适合连续值标签或二分类 0/1 标签——多分类标签求均值没有意义。用 pandas 手写就能看穿它的本质:

import pandas as pd
from sklearn.preprocessing import OrdinalEncoder

data = pd.DataFrame({
    'Pclass': [3, 1, 3, 1, 3, 3, 1, 2, 3, 2, 3, 1],
    'Sex': ['male', 'female', 'female', 'male', 'female', 'male',
            'female', 'male', 'female', 'female', 'male', 'male'],
    'Age': [22, 38, 26, 35, 35, 30, 28, 14, 21, 27, 5, 45],
    'Survived': [0, 1, 1, 0, 1, 1, 1, 0, 1, 0, 0, 1],
})

mean_map = data['Survived'].groupby(data['Sex']).mean()
print('按性别分组的存活均值:')
print(mean_map)
data_mean = data.copy()
data_mean['Sex'] = data_mean['Sex'].map(mean_map)
print(data_mean.head(4))
按性别分组的存活均值:
Sex
female    0.833333
male      0.333333
Name: Survived, dtype: float64
   Pclass       Sex  Age  Survived
0       3  0.333333   22         0
1       1  0.833333   38         1
2       3  0.833333   26         1
3       1  0.333333   35         0

样例中 6 位女性存活 5 位(均值 0.833333)、6 位男性存活 2 位(均值 0.333333),性别列被替换成这两个”存活率”。相比独热编码只告诉模型”是不是女性”,均值编码直接告诉模型”这个群体的历史存活率有多高”——后验信息密度更大。代价是它对标签的依赖:编码与标签直接相关,在训练集上”先见标签再算均值”容易造成信息泄漏,工业实践中通常只在训练折上统计映射、再套用到验证与测试数据。

四、目标编码与证据权重编码

目标编码:先验与后验的折中

目标编码(target encoding)在均值编码的基础上引入先验信息做平滑:类别的编码值不再是纯后验均值,而是”先验(全局均值)与后验(类别均值)按样本量加权”的结果。样本量大的类别更靠近后验均值,样本量小的类别被拉回先验,从而缓解小类别均值的剧烈波动。scikit-learn 从 1.3 版本起内置了官方实现:

import numpy as np
import pandas as pd
from sklearn.preprocessing import TargetEncoder

data = pd.DataFrame({
    'Pclass': [3, 1, 3, 1, 3, 3, 1, 2, 3, 2, 3, 1],
    'Sex': ['male', 'female', 'female', 'male', 'female', 'male',
            'female', 'male', 'female', 'female', 'male', 'male'],
    'Survived': [0, 1, 1, 0, 1, 1, 1, 0, 1, 0, 0, 1],
})
y = data['Survived']
good_total, bad_total = int(y.sum()), int((1 - y).sum())
woe_map = {}
for cat in ['female', 'male']:
    mask = data['Sex'] == cat
    p_good = int(y[mask].sum()) / good_total
    p_bad = int((1 - y[mask]).sum()) / bad_total
    woe_map[cat] = round(float(np.log(p_good / p_bad)), 4)
print('WOE 映射:', woe_map)
data_woe = data.copy()
data_woe['Sex'] = data_woe['Sex'].map(woe_map)
print(data_woe.head(4))

enc = TargetEncoder(target_type='binary')
data_te = data.copy()
enc.fit(data_te[['Sex']], y)
data_te['Sex'] = enc.transform(data_te[['Sex']]).ravel()
print('目标编码映射:', dict(zip(['female', 'male'], np.round(enc.encodings_[0].ravel(), 4))))
print(data_te.head(4))
WOE 映射: {'female': 1.273, 'male': -1.0296}
   Pclass     Sex  Survived
0       3 -1.0296         0
1       1  1.2730         1
2       3  1.2730         1
3       1 -1.0296         0
目标编码映射: {'female': np.float64(0.8116), 'male': np.float64(0.3664)}
   Pclass       Sex  Survived
0       3  0.366391         0
1       1  0.811594         1
2       3  0.811594         1
3       1  0.366391         0

对比一下数值就能看到平滑的效果:女性纯后验均值是 0.833333,目标编码后是 0.811594;男性纯后验 0.333333,编码后 0.366391——两者都被往全局先验(7/12 ≈ 0.5833)的方向拉回了一点。补充两个使用要点:

  • 进阶提示:TargetEncoder 的 fit_transform 与 fit + transform 行为不同——fit_transform 内部使用交叉拟合(cross-fitting)产生折外估计来防止标签泄漏,fit 后 transform 则使用全量映射。训练特征建议走 fit_transform,这里为教学演示清晰采用先 fit 后 transform 的形态。
  • 版本说明:目标编码在早期需要第三方库 category_encoders 实现;scikit-learn 1.3 起官方内置 TargetEncoder,本讲义使用官方实现。原课程录制时(约 2020 年)使用的是 category_encoders 库的 TargetEncoder,思想一致。

证据权重编码:风控场景的标配

WOE(Weight of Evidence,证据权重)编码在金融风控里几乎是标配。风控场景的 0/1 标签对应”坏客户/好客户”,WOE 的定义是:

WOEi=ln⁡类别 i 中好样本占比类别 i 中坏样本占比\mathrm{WOE}_i = \ln\frac{\text{类别 } i \text{ 中好样本占比}}{\text{类别 } i \text{ 中坏样本占比}}

分子分母用的都是”类别内好(坏)样本数 ÷ 全体好(坏)样本数”,即对全局好坏分布做归一化后再取对数。上面的代码块里已经用 numpy 手写了这个公式:样例中好样本共 7 人、坏样本共 5 人,女性好 5 坏 1,男性好 2 坏 4,于是:

  • 女性:ln⁡5/71/5=ln⁡257≈1.273\ln\dfrac{5/7}{1/5} = \ln\dfrac{25}{7} \approx 1.273
  • 男性:ln⁡2/74/5=ln⁡514≈−1.0296\ln\dfrac{2/7}{4/5} = \ln\dfrac{5}{14} \approx -1.0296

WOE 为正说明该类别好样本相对更多、为负则风险更高,数值大小还近似等价于对数几率的变化幅度,这让它在评分卡建模中可以与逻辑回归直接衔接。两个工程注意点:某类别内全是好样本或全是坏样本时,比例会出现 0、对数趋于无穷,工业实现会做平滑处理;WOE 同样依赖标签,泄漏防护思路与均值编码一致。原课程的 WOE 编码使用 category_encoders 库的 WOEEncoder 实现,公式即上式,讲义以手写方式呈现机制。

📝 动手练一练

练习 1(概念辨析):均值编码为什么只适合连续值标签或二分类 0/1 标签?如果标签是”0=未婚、1=已婚、2=离异”这样的三分类,直接对类别特征做均值编码会出什么问题?

👉 点击查看参考答案

对”类别均值”求平均,隐含的前提是标签数值本身有”可平均”的语义:连续值标签的均值是平均水平,0/1 标签的均值是事件发生率。而三分类标签的编码值 0、1、2 只是代号,“已婚”和”未婚”的均值 0.5 不对应任何真实类别,某个类别下算出的标签均值(比如 1.3)既不是发生率也没有业务含义,编码后的特征反而引入了噪声。多分类标签应改用”各目标类别的目标编码(多分类版)“或对每个类别单独做 0/1 化后分别编码。

练习 2(动手实算):用本节 12 行样例数据,对 Pclass(舱位)这一列手工做均值编码:分别算出一等舱、二等舱、三等舱的存活率,再把 12 行的 Pclass 替换为对应数值。算完与 data['Survived'].groupby(data['Pclass']).mean() 的结果核对。

👉 点击查看参考答案

逐行清点:一等舱(Pclass=1)共 4 人,存活 3 人,存活率 0.75;二等舱共 2 人,存活 0 人,存活率 0.0;三等舱共 6 人,存活 4 人,存活率 4/6 ≈ 0.666667。groupby 结果为 1 舱 0.75、2 舱 0.000000、3 舱 0.666667,与手算一致。值得注意的是二等舱存活率为 0——样本只有 2 人,纯后验均值极端不稳定,这正是目标编码要引入先验平滑的原因。

本章小结

特征编码解决”类别取值进不了模型”的问题,五条路线各有位置:独热适合无序且类别数适中的特征,注意 drop_first 与 pandas 2.0 的布尔返回;序号编码适合天然有序的等级类别;均值编码把分组标签均值填回特征,信息密度高但只适用于连续或 0/1 标签;目标编码用先验平滑缓解小类别波动,官方实现在 scikit-learn 1.3+;WOE 编码基于好坏比例的对数,是风控评分卡的基石。共同纪律是:依赖标签的编码只在训练数据上拟合映射,避免泄漏。

📋 行动清单

  • 用 pd.get_dummies(..., drop_first=True).astype(int) 对一份含类别列的数据做独热编码,观察展开结果
  • 手写一遍均值编码的 groupby + map,对比它与独热编码给模型的信息差异
  • 挑一个多类别特征(如汽车品牌、服装颜色),分别用序号编码与目标编码处理,思考各自的风险

—— 小象教研组