实体嵌入实战:高基数类别特征的低维稠密表示与PyTorch实现

发布时间:2026/10/17 8:07:05
实体嵌入实战:高基数类别特征的低维稠密表示与PyTorch实现 1. 实体嵌入的核心逻辑与方案选型1.1 从独热编码的困境说起做过结构化数据建模的人大概率都经历过这样的场景一张用户行为表里城市字段有三百多个取值商品类目有上千个SKU直接扔进梯度提升树还能凑合跑但一旦换成神经网络独热编码的维度直接爆炸。更麻烦的是独热编码假设所有类别之间彼此独立上海和苏州的距离跟上海和乌鲁木齐的距离一模一样——这显然不符合常识。实体嵌入要解决的就是这个问题。它的核心思路很朴素把每一个离散的类别值映射成一个低维稠密向量让语义相近的类别在向量空间里也靠得近。这个思路最早在自然语言处理领域被验证有效后来有人把它迁移到表格数据上效果出奇地好。某次竞赛中一位参赛者把高基数类别特征做了嵌入处理模型AUC直接涨了两个千分点从此这套方法就在结构化数据圈子里传开了。适合读这篇内容的人我大致分三类一是做推荐系统、风控模型、广告排序的算法工程师手里有大量类别特征需要处理二是数据科学竞赛的参与者想找一个稳定提分的特征工程手段三是对嵌入技术感兴趣、想从词向量扩展到通用类别特征的学习者。不管你用PyTorch还是TensorFlow核心逻辑是通的。1.2 为什么嵌入比独热更适合高基数类别独热编码的本质是把类别空间映射到标准正交基上维度等于类别数量。当类别数达到几千甚至几万时参数量会急剧膨胀而且每个类别只有一位是1其余全是0梯度更新极其稀疏模型很难学到类别之间的关联。实体嵌入则把维度压到远小于类别数的水平通常取类别数的四次方根到平方根之间。举个例子一千个类别嵌入维度取10到30就够用了。这样做有三个直接好处第一参数量大幅下降训练更快更稳第二稠密向量让梯度更新更充分每个类别都能从每次迭代中获得信息第三嵌入空间自带相似度度量你可以直接算两个城市的向量余弦相似度这在独热编码下是做不到的。还有一个容易被忽略的点嵌入层本质上是一个可学习的查找表。它和后续的全连接层一起训练梯度会反向传播到嵌入矩阵让每个类别的向量朝着对任务有利的方向调整。这意味着嵌入不是静态的而是任务驱动的——同样的城市字段在销量预测任务和用户流失预测任务中学到的嵌入可能完全不同。1.3 嵌入维度怎么定经验公式与实测校准维度选择是实体嵌入最常被问到的问题。业界流传最广的经验公式是min(50, (n_categories 1) // 2)但这个公式在高基数场景下会给出过大的维度。我自己的做法是分三步走第一步先用round(n_categories ** 0.25)作为初始值。比如一千个类别四次方根约等于5.6取6一万个类别四次方根等于10取10。这个值偏保守但训练稳定。第二步观察验证集损失曲线。如果嵌入维度太小模型欠拟合训练损失和验证损失都居高不下如果维度太大验证损失会先降后升出现过拟合。我一般会在初始值附近取三档做对比实验比如初始值是10就试8、10、15。第三步结合下游任务复杂度微调。如果后续网络层很浅嵌入维度可以适当大一点让嵌入本身承载更多信息如果后续是深层网络嵌入维度小一点也没关系网络有能力从中提取特征。下面这张表是我在几个模拟项目里总结的参考值实际使用时建议还是跑一轮小实验确认类别数量级推荐嵌入维度参数量估算10以内2-4可忽略100左右4-8几百1000左右8-15一万五以内10000左右12-25二十五万以内100000以上20-50五百万以内注意嵌入层参数量等于类别数乘以嵌入维度。十万类别、维度50就是五百万参数这已经接近一个小型模型的规模了。如果显存吃紧优先降维度而不是降类别数因为类别数砍掉会丢信息维度砍掉只是压缩表示。2. 实体嵌入的代码实现与关键细节2.1 PyTorch下的嵌入层搭建PyTorch提供了nn.Embedding模块用起来非常直接。假设我们有一个类别特征列已经做了标签编码取值从0到n_categories-1那么嵌入层可以这样定义import torch import torch.nn as nn class EntityEmbeddingModel(nn.Module): def __init__(self, n_categories, embedding_dim, n_numeric, hidden_dim): super().__init__() self.embedding nn.Embedding( num_embeddingsn_categories, embedding_dimembedding_dim ) self.fc1 nn.Linear(embedding_dim n_numeric, hidden_dim) self.relu nn.ReLU() self.fc2 nn.Linear(hidden_dim, 1) self.sigmoid nn.Sigmoid() def forward(self, cat_input, num_input): emb self.embedding(cat_input) x torch.cat([emb, num_input], dim1) x self.fc1(x) x self.relu(x) x self.fc2(x) return self.sigmoid(x)这段代码里有个细节值得展开nn.Embedding的输入必须是LongTensor而且取值必须在[0, num_embeddings-1]范围内。如果标签编码时用了-1表示缺失值直接传进去会报索引越界。我的处理方式是把缺失值单独编码成一个类别比如让缺失值对应索引0实际类别从1开始编号。这样既避免了越界又让模型能学到“缺失”本身的含义。另一个细节是嵌入层的初始化。PyTorch默认用标准正态分布初始化这在大多数场景下没问题。但如果类别数很大、维度很小我建议用均匀分布初始化范围取[-1/sqrt(dim), 1/sqrt(dim)]这样初始向量的方差更可控训练初期更稳定。2.2 多类别特征的嵌入拼接与维度对齐实际项目中很少只有一个类别特征。假设我们有城市、商品类目、用户等级三个类别特征类别数分别是300、50、5嵌入维度分别取10、8、3。那么每个样本经过嵌入层后会得到一个108321维的向量再和数值特征拼接送入后续网络。这里有个容易踩的坑不同类别特征的嵌入维度差异很大如果直接拼接维度大的特征会主导后续层的输入分布。我的做法是在拼接前对每个嵌入向量做一次层归一化让每个特征的嵌入输出均值为0、方差为1这样后续层能更公平地对待每个特征。class MultiEmbeddingModel(nn.Module): def __init__(self, cat_dims, emb_dims, n_numeric, hidden_dim): super().__init__() self.embeddings nn.ModuleList([ nn.Embedding(n_cat, emb_dim) for n_cat, emb_dim in zip(cat_dims, emb_dims) ]) self.norms nn.ModuleList([ nn.LayerNorm(emb_dim) for emb_dim in emb_dims ]) total_emb_dim sum(emb_dims) self.fc1 nn.Linear(total_emb_dim n_numeric, hidden_dim) self.relu nn.ReLU() self.fc2 nn.Linear(hidden_dim, 1) def forward(self, cat_inputs, num_input): embs [] for i, (emb_layer, norm) in enumerate(zip(self.embeddings, self.norms)): e emb_layer(cat_inputs[:, i]) e norm(e) embs.append(e) x torch.cat(embs [num_input], dim1) x self.relu(self.fc1(x)) return self.fc2(x)这段代码里cat_inputs是一个形状为(batch_size, n_cat_features)的张量每一列对应一个类别特征。LayerNorm对每个嵌入向量独立归一化不跨样本、不跨特征这样不会破坏嵌入空间本身的几何结构。2.3 训练技巧学习率、正则与嵌入冻结嵌入层的训练和普通全连接层有些不同。因为嵌入矩阵的每一行只对应一个类别某些类别在训练集中出现次数很少它们的嵌入向量更新次数也少容易欠拟合。针对这个问题我总结了几个实用技巧。第一给嵌入层设置更大的学习率。嵌入层需要从随机初始化开始学习每个类别的表示而后续层是在嵌入基础上做组合所以嵌入层通常需要更快的学习。我一般用分层学习率嵌入层的学习率是后续层的2到5倍。在PyTorch里可以通过参数组实现optimizer torch.optim.Adam([ {params: model.embeddings.parameters(), lr: 1e-3}, {params: model.fc1.parameters(), lr: 5e-4}, {params: model.fc2.parameters(), lr: 5e-4} ])第二对嵌入矩阵加权重衰减。嵌入向量如果过大会导致后续层输入饱和模型泛化变差。我一般给嵌入层加weight_decay1e-5到1e-4比后续层的正则稍强一些。第三低频类别的嵌入可以冻结或共享。如果某个类别在训练集中只出现几次它的嵌入向量基本学不到有效信息反而可能引入噪声。我的做法是统计每个类别的出现频次低于阈值的类别统一映射到一个“其他”类别共享同一个嵌入向量。这样既减少了参数量又避免了低频类别的过拟合。提示嵌入层的梯度是稀疏的因为每个batch里只有部分类别被激活。PyTorch的nn.Embedding默认支持稀疏梯度设置sparseTrue可以节省显存和计算但只有部分优化器支持稀疏梯度比如SparseAdam。如果用了sparseTrue又用了Adam需要换成SparseAdam否则会报错。3. 完整实操流程从数据到嵌入可视化3.1 数据准备与标签编码假设我们有一个模拟的电商用户行为数据集包含用户ID、商品类目、用户城市、用户年龄、历史购买金额五个字段目标是预测用户是否会复购。其中用户ID、商品类目、用户城市是类别特征年龄和购买金额是数值特征。第一步是标签编码。用sklearn的LabelEncoder把每个类别特征转成从0开始的整数。注意要保存编码器推理时需要用同样的映射。from sklearn.preprocessing import LabelEncoder import pandas as pd df pd.read_csv(simulated_data.csv) cat_cols [user_id, category, city] encoders {} for col in cat_cols: le LabelEncoder() df[col] le.fit_transform(df[col].astype(str)) encoders[col] le这里有个细节如果训练集和测试集是分开的一定要用训练集拟合编码器然后transform测试集。测试集里出现训练集没有的类别时LabelEncoder会报错需要提前处理成“未知”类别。第二步是统计每个类别特征的类别数用于定义嵌入层大小。cat_dims [df[col].nunique() for col in cat_cols] print(cat_dims) # 例如 [5000, 50, 300]第三步是划分训练集和验证集并把数值特征做标准化。from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler num_cols [age, purchase_amount] scaler StandardScaler() df[num_cols] scaler.fit_transform(df[num_cols]) train_df, val_df train_test_split(df, test_size0.2, random_state42)3.2 嵌入维度计算与模型初始化根据前面说的经验公式对每个类别特征计算嵌入维度import math emb_dims [min(50, round(dim ** 0.25) 2) for dim in cat_dims] print(emb_dims) # 例如 [11, 5, 6]这里我加了一个2的偏移量因为纯四次方根在类别数较少时给出的维度太小比如50个类别四次方根约等于2.7取整后是3但实际用5左右效果更好。这个偏移量可以根据验证集表现调整。模型初始化时把cat_dims和emb_dims传进去同时指定数值特征数量和隐藏层维度。隐藏层维度我一般取嵌入总维度的2到4倍比如嵌入总维度是22隐藏层取64或128。model MultiEmbeddingModel( cat_dimscat_dims, emb_dimsemb_dims, n_numericlen(num_cols), hidden_dim64 )3.3 训练循环与嵌入可视化训练循环和普通神经网络没有本质区别但要注意输入数据的组织方式。类别特征和数值特征要分开传入模型。import torch from torch.utils.data import DataLoader, TensorDataset def make_dataloader(df, batch_size256): cat_tensor torch.LongTensor(df[cat_cols].values) num_tensor torch.FloatTensor(df[num_cols].values) label_tensor torch.FloatTensor(df[label].values).unsqueeze(1) dataset TensorDataset(cat_tensor, num_tensor, label_tensor) return DataLoader(dataset, batch_sizebatch_size, shuffleTrue) train_loader make_dataloader(train_df) val_loader make_dataloader(val_df, batch_size1024) criterion nn.BCEWithLogitsLoss() optimizer torch.optim.Adam([ {params: model.embeddings.parameters(), lr: 1e-3}, {params: model.fc1.parameters(), lr: 5e-4}, {params: model.fc2.parameters(), lr: 5e-4} ]) for epoch in range(20): model.train() for cat_x, num_x, y in train_loader: optimizer.zero_grad() pred model(cat_x, num_x) loss criterion(pred, y) loss.backward() optimizer.step() # 验证集评估略训练完成后嵌入矩阵就是我们最关心的产物。以城市特征为例取出它的嵌入矩阵用t-SNE降到二维画散点图可以直观看到哪些城市被模型认为相似。这个图在向业务方解释模型时特别有用——你可以指着图说“模型自己学到了华东城市和华南城市是两拨”。from sklearn.manifold import TSNE import matplotlib.pyplot as plt city_emb model.embeddings[2].weight.detach().numpy() tsne TSNE(n_components2, random_state42) city_2d tsne.fit_transform(city_emb) plt.figure(figsize(10, 8)) plt.scatter(city_2d[:, 0], city_2d[:, 1], s5, alpha0.6) plt.title(City Embedding Visualization) plt.show()注意t-SNE的随机种子会影响布局但不会改变相对距离关系。如果类别数超过一万t-SNE计算会很慢可以先用PCA降到50维再跑t-SNE速度会快很多。4. 常见问题与排查技巧实录4.1 嵌入层训练不收敛的排查思路嵌入层不收敛通常表现为损失震荡或居高不下。我遇到过几次排查下来原因集中在三个方面。第一学习率太大。嵌入层的梯度比全连接层更稀疏但单次更新的幅度可能很大尤其是低频类别。如果学习率设成1e-2嵌入向量会剧烈跳动损失根本降不下来。解决办法是把嵌入层学习率降到1e-3甚至5e-4或者用梯度裁剪把梯度范数限制在1以内。第二类别编码有误。如果标签编码时出现了负数或超出类别数的值nn.Embedding会直接报错但如果用了sparseTrue且索引越界有时不会立刻报错而是产生奇怪的梯度。我的习惯是在数据加载后加一行断言assert cat_tensor.min() 0 and cat_tensor.max() n_categories。第三嵌入维度太小。如果类别数很多但维度只有2或3嵌入向量无法承载足够的区分信息模型会欠拟合。这时候训练损失和验证损失都会很高且下降缓慢。解决办法是适当增大维度或者检查是否把多个类别特征拼成了一个嵌入层——每个类别特征应该有自己的嵌入层不能混在一起。4.2 低频类别导致过拟合的处理方案低频类别是实体嵌入最头疼的问题。某个类别在训练集中只出现10次它的嵌入向量基本只被更新了10次很可能过拟合到这10个样本上。验证集里如果出现这个类别预测结果会很不稳定。我的处理方案分三层。第一层是频次阈值过滤出现次数低于某个阈值比如50次的类别统一归为“其他”。这个阈值不是固定的取决于数据集大小一般取总样本数的0.1%到1%。第二层是嵌入向量共享把低频类别的嵌入向量初始化为所有类别嵌入的均值训练时冻结不更新。第三层是嵌入Dropout在训练时随机把某些类别的嵌入向量置零强迫模型不依赖单一类别特征。PyTorch没有内置的嵌入Dropout但可以手动实现def embedding_dropout(emb, p0.1): if not self.training: return emb mask torch.bernoulli(torch.ones(emb.size(0), 1) * (1 - p)).to(emb.device) return emb * mask / (1 - p)这个操作在batch维度上做每个样本有一定概率把整个嵌入向量置零。实测下来p取0.1到0.2之间效果最好太大反而欠拟合。4.3 嵌入维度与模型容量的平衡表下面这张表整理了我在不同数据规模下的参数配置可以作为起点参考。实际使用时建议先用小规模实验确认方向再放大到全量数据。训练样本数类别数量级嵌入维度隐藏层维度学习率正则强度1万以内1004-6321e-31e-510万10008-12641e-31e-5100万500012-201285e-41e-41000万5000020-402565e-41e-41亿以上10000030-505122e-41e-4这张表里的隐藏层维度指的是嵌入拼接后的第一层全连接。如果后续还有多层第一层可以宽一些后面逐层收窄。学习率是嵌入层的学习率后续层可以取它的二分之一到三分之一。4.4 嵌入向量复用与迁移训练好的嵌入向量可以保存下来迁移到其他任务。比如你在复购预测任务上训练了城市嵌入现在要做用户流失预测城市嵌入可以直接加载作为初始化然后在新任务上微调。这样做的好处是新任务不需要从零开始学习城市表示收敛更快效果也通常更好。保存和加载嵌入向量的代码很简单# 保存 torch.save(model.embeddings.state_dict(), embeddings.pth) # 加载 model.embeddings.load_state_dict(torch.load(embeddings.pth))但要注意迁移时类别编码必须一致。如果新任务的城市编码和旧任务不同嵌入向量就对不上了。我的做法是把编码器也一起保存用joblib存LabelEncoder对象迁移时先加载编码器确保映射一致。提示如果新旧任务的类别集合不完全重合可以用strictFalse加载只加载重合部分的嵌入向量新类别保持随机初始化。PyTorch的load_state_dict支持strictFalse但需要确保形状匹配。5. 嵌入效果的评估与业务解释5.1 离线指标之外嵌入空间的业务校验模型AUC涨了不代表嵌入学得合理。我习惯在离线评估之外额外做一轮嵌入空间的业务校验。具体做法是从嵌入矩阵中取出几个业务上已知相似的类别算它们的余弦相似度看是否显著高于随机类别对。比如城市特征中同省份的城市相似度应该高于跨省份的城市。如果业务校验不通过说明嵌入虽然对当前任务有效但学到的表示不符合业务直觉。这时候可以考虑加一个辅助损失让相似类别的嵌入向量在训练中互相靠近。辅助损失的权重一般取主损失的0.1到0.3太大反而会干扰主任务。5.2 嵌入向量的维度含义与降维展示嵌入向量的每一维没有明确的业务含义但整体向量可以反映类别的潜在属性。比如城市嵌入的某一维可能对应“城市规模”另一维对应“消费水平”但这些维度是模型自己学出来的不是人为定义的。向业务方解释时我一般用降维图加案例的方式。先展示t-SNE散点图让业务方看到类别聚簇然后挑几个典型类别展示它们最近的邻居。比如“上海”的最近邻是“北京”“深圳”“广州”业务方一看就明白模型学到了什么。如果业务方追问某一维的含义可以做一个简单的相关性分析把嵌入向量的每一维和已知的业务指标如城市GDP、人口算相关系数找出相关性最高的维度给它一个近似的业务标签。但这个方法只能作为参考不能过度解读。5.3 嵌入层参数量与推理速度的权衡嵌入层参数量直接影响模型大小和推理速度。在线上服务中如果嵌入矩阵有百万级参数加载和查询都会变慢。我的优化手段有三个一是用半精度存储嵌入矩阵显存占用减半推理速度基本不变二是对嵌入矩阵做量化把float32压成int8精度损失通常在可接受范围内三是把嵌入查询从GPU移到CPU用内存换显存适合嵌入层很大但后续网络很小的场景。实测下来半精度是最稳妥的方案几乎不需要改代码model.half()一行搞定。量化需要额外工具而且对某些类别可能精度损失较大建议先在小流量上验证。5.4 从嵌入到特征下游模型的使用方式实体嵌入不一定非要和神经网络绑定。你也可以把训练好的嵌入向量当作特征喂给梯度提升树或线性模型。具体做法是用神经网络训练嵌入层训练完成后取出嵌入矩阵把每个样本的类别特征替换成对应的嵌入向量然后把这些向量作为数值特征输入到LightGBM或XGBoost。这样做的好处是结合了嵌入的表示能力和树模型的鲁棒性。缺点是嵌入层和下游模型分开训练嵌入可能不是对下游任务最优的。如果追求极致效果还是端到端训练更好如果追求工程简单和可解释性两阶段方案更实用。我在几个模拟项目里对比过两种方案端到端训练的AUC平均高0.5到1个千分点但两阶段方案的训练时间少一半而且树模型的特征重要性可以直接看业务方更容易接受。具体选哪个看你对效果和工程复杂度的权衡。最后分享一个小技巧嵌入矩阵训练完成后可以算一下每个类别的嵌入向量范数。范数特别大的类别往往是模型认为特别重要或特别的类别范数接近零的类别可能是低频或噪声类别。这个范数可以作为类别重要性的一个粗略指标在特征筛选时参考。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询