4. 类别特征编码:标签编码、独热编码、目标编码、证据权重(WOE)编码

类别特征,说白了就是那些不是数字的东西——城市名、用户等级、商品品类。你想想看,模型只认数字,不认文字。所以我们得把文字“翻译”成数字。怎么翻?这里头学问不小。我这些年踩过的坑,80%都出在类别编码上。今天咱们把四种主流方法掰开揉碎聊透。

4.1 标签编码(Label Encoding)

最简单粗暴的方法。给每个类别分配一个整数,比如北京=0、上海=1、广州=2。代码实现就一行:

from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
labels = le.fit_transform(['北京','上海','广州','上海','北京'])

输出就是 [0, 1, 2, 1, 0]。看着挺利索,但我得提醒你:千万别用在有序不明确的类别上。为什么?因为模型会以为2 > 1 > 0,默认有大小关系。你想想,「北京大于上海」这合理吗?

我曾经在一个风控项目里这么干过,结果特征重要性一排序,这个城市编码排第一。后来一分析,模型把城市当成了连续变量,拟合出了一条毫无意义的曲线。嗯,当时被业务方骂得挺惨。

适用场景:只能用于有序类别,比如学历「高中=0、本科=1、硕士=2」。或者树模型对数值大小不敏感的场景(随机森林、XGBoost),也可以用一用。

4.2 独热编码(One-Hot Encoding)

最传统的办法。每个类别变成一个0/1列。类别有多少种,就生成多少列。比如城市有3个值:北京、上海、广州,编码后:

from sklearn.preprocessing import OneHotEncoder
import pandas as pd
data = pd.DataFrame({'城市':['北京','上海','广州','上海']})
encoder = OneHotEncoder(sparse=False)
encoded = encoder.fit_transform(data[['城市']])
columns = encoder.get_feature_names_out(['城市'])

结果长这样:

城市_北京城市_上海城市_广州
100
010
001
010

优点:没有大小关系,模型能公平对待每个类别。缺点也明显——维度爆炸。如果有1000个城市,就得加1000列,GPU显存直接警告。

我的习惯:当类别数 < 10 时,我会首选独热编码。超过10个,尤其到了几百上千类,就得换方法了。

我记得有个电商项目,商品品类编码用了独热,结果生成了800多列。模型训练慢不说,调参都卡。后来我改成目标编码,一天的工作量缩到半小时。

4.3 目标编码(Target Encoding)

用目标变量的统计值替换类别。最常见的做法是:用该类别下目标变量的均值来编码。比如预测用户是否付费,算一下「北京」用户的付费率,拿这个率代替「北京」。实现方式:

import pandas as pd
# 假设df有列 '城市' 和目标 'label'
mean_encoding = df.groupby('城市')['label'].mean()
df['城市_target'] = df['城市'].map(mean_encoding)

这里有个大坑——数据泄露。你用全体数据算均值,训练时就把未来信息泄露出去了。我建议用交叉验证的方式做:

from sklearn.model_selection import KFold
kf = KFold(n_splits=5, shuffle=True)
df['城市_target'] = 0
for train_idx, val_idx in kf.split(df):
    train, val = df.iloc[train_idx], df.iloc[val_idx]
    mean_map = train.groupby('城市')['label'].mean()
    df.loc[val_idx, '城市_target'] = val['城市'].map(mean_map)

我曾经没做交叉验证直接全量编码,模型在测试集上表现超好,一上线就崩。后来定位到是目标编码泄露了标签信息。记住:所有统计类编码都必须在训练集内计算,再映射到验证/测试集。

目标编码的优点:不增加维度,适合高基数类别(比如用户ID、商品ID)。缺点是对异常值敏感——某个类别只有1个样本,均值就是它本身,过拟合风险极大。解决办法:加入平滑项或使用贝叶斯均值。

4.4 证据权重(WOE)编码

WOE 最初用在信用评分卡里。公式是:

WOE = ln( (好客户占比) / (坏客户占比) )

实际计算时,给分子分母各加一个很小的数(比如0.5),防止除零。WOE 有个好处:它天然处理了缺失值——把缺失值单独作为一个类别计算WOE。而且WOE与逻辑回归的线性假设特别搭,因为WOE值本身是单调的。

实现示例:

def calc_woe(df, feature, target):
    groups = df.groupby(feature)[target]
    total_bad = df[target].sum()
    total_good = len(df) - total_bad
    woe_dict = {}
    for name, group in groups:
        bad = group.sum()
        good = len(group) - bad
        # 加0.5平滑
        woe = np.log((good + 0.5) / (bad + 0.5))
        woe_dict[name] = woe
    return woe_dict

WOE 编码后,你通常会配合IV(信息价值)来筛选特征。IV超过0.02就算有预测力了。我个人习惯:先用WOE编码,再算IV,扔掉IV < 0.02的类别特征,剩下的进模型。

四种编码对比总结

方法维度是否有序适合模型典型场景
标签编码1列有顺序树模型有序类别、高基数(树模型)
独热编码N列无顺序线性模型、神经网络低基数类别(<10)
目标编码1列无顺序树模型、线性模型高基数类别
WOE编码1列有序(通常)逻辑回归、线性模型风控、评分卡

看到这儿你可能要问:到底选哪个?我的建议很简单——先看类别基数。基数低(<10)可以直接独热;基数高(>100)用目标编码或WOE;中间值(10~100)可以考虑目标编码。迭代项目里先用标签编码跑通基线,再回来换。

GPU优化小贴士:独热编码带来的稀疏矩阵会让GPU计算效率下降,因为大量0浪费带宽。如果你必须用独热,可以试试Embedding层(PyTorch的nn.Embedding),把类别索引映射成稠密向量,既保留独热的无偏性,又避免维度爆炸。这个技巧我在广告CTR模型里用了很多次,显存占用直接砍掉70%。

类别特征编码选择流程图 类别特征 类别基数 < 10? 是 独热编码 否 目标编码 / WOE 结合嵌入层或交叉验证 高基数类别建议优先尝试目标编码,配合交叉验证防止泄露

最后强调一点:没有银弹。我在一个项目里试了四种编码,结果最管用的是最简单的标签编码(因为树模型对数值顺序不敏感)。所以别嫌麻烦,交叉验证跑一圈,哪个编码效果好就上哪个。嗯,这就是我这么多年积累的经验——先试,再信。