☰
TensorFlow特征工程:indicator与embedding列的原理与实战
2026/10/5 17:14:53 网站建设 项目流程

1. feature_column处理链路:类型列到稠密列之间的“最后一公里”

做TensorFlow特征工程的人,几乎都会遇到这样一个问题:原始特征明明是类别型数据,模型输入层却要的是数值张量。很多新手第一反应是手动做one-hot,结果类别一多直接炸内存,而且代码又臭又长。我在去年一个推荐项目里就是被这个逼疯了,后来彻底切到feature_column体系,才把这块理顺。

feature_column这层抽象,本质上解决的是“把原始特征描述成模型能吃的样子”这件事。它不是一个单独的API,而是一条处理链。整条链大致可以分成三段:第一段是“categorical_column”系列,负责把字符串或整数映射成类别ID;第二段就是标题里的indicator_column和embedding_column,它们把类别ID继续转换成稠密向量;第三段是DenseFeatures或者序列化导出时的那一层,把多个列拼起来变成模型的输入张量。你去看官方文档,它喜欢把第二段叫“列之间的转换器”,我觉得更准确的说法是“最后一公里”——前面都在处理ID映射,到了这两个列,才开始真正触碰模型的数值表示。

很多教程一上来就告诉你“indicator就是one-hot,embedding就是查表”,这说法没错,但它解释不了为什么有时候明明用indicator效果更好,有时候换embedding提点特别明显。要回答这个问题,得先把categorical列这一段讲清楚,然后你才能真正理解indicator和embedding在这条链上的定位。

1.1 categorical_column系列到底在做什么

categorical_column系列有好几种,最容易混淆的是下面这几个:

  • categorical_column_with_identity:直接把整数ID当类别,适合已经是稠密整数编码的特征,比如年龄分段0、1、2。
  • categorical_column_with_vocabulary_list:给定一个词表,把字符串映射成词表下标。
  • categorical_column_with_vocabulary_file:词表从文件读,适合词表很大的场景。
  • categorical_column_with_hash_bucket:不维护词表,直接对字符串做hash后取模,适合不知道全量词表的场景。
  • bucketized_column:把连续值分箱变成离散类别。

无论用哪种,它们在链路上产出的都是一个SparseTensor或者一个稀疏形式的ID序列。注意,这里说的ID是整数下标,不是one-hot向量。比如“city=北京”被映射成了ID 3,那一天在模型里能看到的只是一个数字。真正把它变成模型能用的稠密向量,就是indicator_column和embedding_column的职责。

这里有个特别容易忽略的点:categorical列本身是不能直接塞给DenseFeatures的。如果你试图写tf.keras.layers.DenseFeatures([categorical_column_with_identity(...)]),TensorFlow会直接报错,因为DenseFeatures只接受dense列。这也是为什么indicator和embedding这两个“转换器”在整条链路里不可跳过。

1.2 为什么需要两种不同的“转换器”

同一个稀疏ID表示,为什么TensorFlow要提供indicator_column和embedding_column两种完全不搭边的转换方式?答案在于它们对类别ID的语义假设完全不同。

indicator_column做的是标准的multi-hot展开:类别ID 3被展开成[0, 0, 0, 1, 0, ...],类别ID 5被展开成[0, 0, 0, 0, 0, 1, ...]。它假设所有类别之间是绝对独立的,类别0和类别1没有任何关联,模型只能通过权重学习到ID之间的区分,无法共享信息。

embedding_column则假设类别之间存在某种隐藏的相似性。它把每个类别ID对应到一张可训练的大表里的一行向量,模型在训练过程中可以按需调整这些向量。最终学习出来的嵌入向量,比如“足球”和“篮球”的向量在空间里的距离就很近,跟“财经”隔得很远。

这两种假设没有绝对的对错。类别基数小、语义清楚的时候,indicator直白高效;类别基数大、语义复杂的时候,embedding的泛化能力就体现出来了。接下来两节我分别把它们的机制和参数抠一遍,顺带分享几个实际跑模型时踩过的坑。

2. indicator_column的核心机制:稀疏ID到multi-hot的转化过程

2.1 输出维度、OOV和数据类型

先看一个最基础的例子。假设有一个标签特征tags,词表里只有五个词:科技、汽车、财经、游戏、体育。

import tensorflow as tf tag_vocab = ['科技', '汽车', '财经', '游戏', '体育'] tags_categorical = tf.feature_column.categorical_column_with_vocabulary_list( key='tags', vocabulary_list=tag_vocab, num_oov_buckets=1 ) tags_indicator = tf.feature_column.indicator_column(tags_categorical)

注意我在这里显式设置了num_oov_buckets=1。很多人第一次用的时候会忽略这个参数,它的含义是“为词表之外的内容预留几个桶”。假如线上来了一个词表里没有的新标签,没有OOV桶就会直接报错,加了OOV桶则会被映射到词表最后面的那些ID上。

这个参数直接影响indicator_column的输出维度。如果词表长度是5,num_oov_buckets=0,输出维度就是5;num_oov_buckets=1,输出维度就是6;num_oov_buckets=3,输出维度就是8。所以你在检查模型输入shape的时候,发现维度比词表多一截,别慌,先想想是不是OOV桶造成的。

另外还有一个数据类型的问题:indicator_column的输出是float32的0.0和1.0,不是整数。这是为了直接喂给Dense层做矩阵乘法。我第一次用它的时候拿tf.cast做了半天类型转换,后来发现纯属多此一举。

2.2 多值稀疏特征与indicator的天然契合

indicator_column真正好用的场景,不在单值类别特征,而在多值特征。比如一篇文章同时有“科技”和“游戏”两个标签,你用VarLenFeature把标签读进来,得到一个SparseTensor,经过indicator_column处理后,输出就是一个multi-hot向量,比如[1.0, 0.0, 0.0, 0.0, 1.0](假设第一个位置的权重属于“科技”,最后一个属于“游戏”)。

这个特性在做内容理解、用户兴趣画像时特别常见。我处理过一个内容推荐模型,用户兴趣标签可能有十几个,有的用户一个标签都没有,有的用户标签列表很长。用indicator_column时,TensorFlow会按SparseTensor里的有效值做展开,没有值的维度自然补0,不需要你手动处理padding,省了很多事。

下面这段从TFRecord读数据的代码,配合indicator_column效果很好:

def parse_fn(example_proto): feature_description = { 'city': tf.io.FixedLenFeature([], tf.string), 'tags': tf.io.VarLenFeature(tf.string), } parsed = tf.io.parse_example(example_proto, feature_description) return parsed dataset = tf.data.TFRecordDataset(['data.tfrecord']) dataset = dataset.map(parse_fn).batch(64)

这里的tags是变长多值特征,VarLenFeature会把它解析成SparseTensor,indicator_column直接在训练过程中把这个SparseTensor转成multi-hot稠密向量。整个过程不需要手动统计标签数量、不需要做序列切分。

2.3 与hash_bucket搭配:控制维度爆炸的唯一手法

indicator_column最大的软肋是维度会随词表线性增长。如果词表有一百万个词,multi-hot向量就有100万维,这谁受得了。

实际工程里我很少直接用categorical_column_with_vocabulary_list去定义超高基数特征,而是改成categorical_column_with_hash_bucket:

tags_hashed = tf.feature_column.categorical_column_with_hash_bucket( key='tags', hash_bucket_size=10000 ) tags_indicator = tf.feature_column.indicator_column(tags_hashed)

这样不管原始字符串有多少种,最终只用10000个桶来表示。相当于hashing trick下的multi-hot,维度完全可控。代价是不同的字符串可能被hash到同一个桶里产生冲突,但只要桶的数量设得足够大,冲突率是能接受的。

这个组合是在线CTR预估场景里最经典的手法之一,日志系统里存的是原始字符串,模型侧不需要维护词表,上线也方便。做特征处理时,遇到超高基数特征第一反应应该是hash_bucket,而不是硬背vocabulary_list。

3. embedding_column的底层逻辑:一张可训练的向量查找表

3.1 它不是全连接,是查表

很多人第一次看embedding_column的时候,以为它底层是one-hot向量乘一个稠密矩阵,也就是一个全连接层。理论上等价,但实际实现完全不是这回事。

TensorFlow底层用的是类似tf.nn.embedding_lookup的操作:拿到稀疏ID之后,直接去嵌入矩阵里按行索引,把对应向量“取”出来,根本没有做one-hot展开那个大稀疏矩阵乘法。这两种实现计算结果虽然一样,但性能差很多。100万维的one-hot乘矩阵,需要100万次乘加运算,而embedding_lookup只需要按索引拿一行,时间复杂度只跟嵌入维度有关。

这个差异在工业场景里是非常实在的。我有一个模型里嵌入了3000万用户ID,如果按one-hot展开,光是这一列就能把显存撑爆;用embedding_column,内存开销就是3000万乘以嵌入维度,比如64维,再乘4字节,大约7.6GB,加上训练时需要的梯度,靠分片还能吃得下。

embedding_column的完整写法如下:

user_id_column = tf.feature_column.categorical_column_with_hash_bucket( key='user_id', hash_bucket_size=30000000 ) user_embedding = tf.feature_column.embedding_column( categorical_column=user_id_column, dimension=64, combiner='sqrtn', initializer=tf.keras.initializers.truncated_normal(stddev=0.01) )

3.2 维度怎么选,有没有硬性经验

embedding维度是模型里最敏感的超参之一。维度太低,表达不了类别间的复杂关系;维度太高,不仅增加参数量,还会带来过拟合。

业界流传最广的经验是“嵌入维度约为类别总数的开四次方”。比如vocab size是10000,开四次方约等于10;vocab size是1000,开四次方约等于5.6。这个经验来自谷歌发表在DLRS workshop上的一篇论文,虽然简单粗暴,但在很多场景下确实是个不错的起点。

我自己的工程经验是:先按四次方根估算一个下限,然后在这个下限和32之间做选择。绝大多数推荐排序模型的embedding维度落在8到16之间就够了,很少需要超过32。你可以在模型里让不同特征共享embedding维度,然后做一个小的网格搜索对比AUC,而不是一上来就把维度调到64、128。

另外别忽略initializer这个参数。默认是truncated_normal,但如果你在冷启动场景下想给某些ID更明确的初始向量,可以用预训练好的embedding结果来初始化。embedding_column支持ckpt_to_load_from和tensor_name_in_ckpt两个参数,可以从旧模型checkpoint里直接恢复嵌入表。

3.3 combiner参数:多值特征里的“隐式池化”

embedding_column处理多值稀疏特征时,一个关键参数是combiner。因为多值特征有多个ID,查表会查到多行向量,需要把这多行向量合并成一个定长向量,才能喂给后续Dense层。

combiner有三个可选值:

  • sum:直接求和。对ID个数敏感,ID多的一行数值天然偏大。
  • mean:求平均。对ID个数不敏感,但会稀释低频ID的贡献。
  • sqrtn:先求和,再除以ID个数的平方根。介于sum和mean之间,对长度做了折中。

从效果上讲,我最常用的是sqrtn,尤其是用户兴趣标签这种长度分布差异很大的特征。sum会把标签多的用户和标签少的用户直接拉到不同尺度,模型不得不额外去学一个长度归一化;mean的问题在于标签很少的用户,嵌入向量会被严重稀释,比如只有一个“体育”标签,mean的结果就是体育的嵌入本身,而实际上这个用户的兴趣不应该这么极端。sqrtn在大多数情况下表现得最均衡。

这个参数我建议你当成和维度一样重要的超参来调,不要用默认的sum就完事。同样一个多值特征,combiner从sum换到sqrtn,AUC变化0.003到0.005是很常见的。

3.4 shared_embeddings:让多个特征共享一张查找表

embedding_column还有一个少有人提、但特别好用的变体:shared_embeddings。

比如一个电商场景,用户有点击过的商品ID,也有收藏过的商品ID。这两个特征虽然语义不同,但ID空间是同一个——都是商品库。如果你分别做两个embedding_column,等于给点击和收藏各学了一套商品嵌入,不仅参数量翻倍,而且两套向量之间还没有天然的一致关系。

用shared_embeddings可以解决这个问题:

clicked_col = tf.feature_column.categorical_column_with_hash_bucket( key='clicked_items', hash_bucket_size=1000000) favorited_col = tf.feature_column.categorical_column_with_hash_bucket( key='favorited_items', hash_bucket_size=1000000) clicked_emb, favorited_emb = tf.feature_column.shared_embeddings( [clicked_col, favorited_col], dimension=16 )

这样两个特征共享同一张商品嵌入表,点击和收藏行为在同一个向量空间里做交互,语义一致性更强,参数也更省。我有一次做用户长短期兴趣融合,短期点击序列和长期收藏序列就是靠shared_embeddings把两个序列拉到了同一个语义空间,离线评估的提升比单独调大维度来得更明显。

4. 实战决策:同一个特征该用indicator还是embedding

4.1 从三个维度给特征做体检

到了真正建模的时候,最频繁的问题就是:这个特征我到底用indicator还是embedding?我自己的做法是先给特征做三个维度的“体检”:基数、语义、稀疏度。

基数指的是这个特征一共有多少种取值。语义指的是类别之间是否存在可学习的相似关系。稀疏度指的是训练样本里这个特征取值的覆盖情况。这三个维度一张表可以看得很清楚:

特征类型典型基数语义关系推荐转换原因
设备类型个位数弱,类别间独立性较强indicator维度低,输出可解释
城市几十到几百弱到中,城市间有经济圈相似性基数小用indicator,基数大用embedding视词表覆盖和样本量
商品ID百万级强,商品间有品类/价格带相似性embedding维度压缩、泛化能力强
用户ID千万级强,用户行为模式有聚类性embeddingone-hot完全不可行
文章标签几百到几千中,标签间存在语义相似度embedding的话还能顺带做向量召回多值特征配合combiner

注意,这里的“语义关系”不是玄学,它指的是类别ID与模型目标之间是否存在迁移性。设备类型里的“iPhone 14”和“iPhone 15”对模型目标的影响可能差不多,但用indicator学习时,模型要从0开始单独估计它们各自的权重,无法互相借用信息。embedding则可以通过训练把相近的语义聚到一起,稀疏情况下泛化更好。

4.2 我的三条决策规则,供你直接抄

第一条:类别基数小于50,且训练样本足够大,优先用indicator。这时候维度低,训练快,也没有太多可调的超参。比如操作系统版本、设备类型、支付方式这类,我一般直接indicator。

第二条:类别基数超过500,或者类别之间存在明显可推断的相似性,优先用embedding。比如内容ID、商品ID、用户ID,还有“关注作者ID”,都走embedding。用embedding还能得到一个额外收益:训练完成后可以用嵌入向量做近邻召回,直接复用到检索阶段。

第三条:多值特征优先看combiner能不能救。多值标签特征如果你发现indicator输出的multi-hot维度还在可控范围内,且词表不大,可以先用indicator跑一版baseline;如果词表太大,再改成embedding_column,设combiner='sqrtn'。先跑通,再换着对比,是模型实验最稳的节奏。

4.3 混合使用:同一个模型里两个转换器怎么共存

一个模型里完全可以混着用。我最近一个内容推荐模型的feature_column是这样组织的:

feature_columns = [] # 低基数的类别特征,走 indicator feature_columns.append(tf.feature_column.indicator_column( tf.feature_column.categorical_column_with_vocabulary_list( 'platform', ['ios', 'android', 'web'], num_oov_buckets=1))) # 中高基数的标签特征,走 embedding feature_columns.append(tf.feature_column.embedding_column( tf.feature_column.categorical_column_with_hash_bucket( 'tags', hash_bucket_size=50000), dimension=16, combiner='sqrtn')) # 超高基数行为特征,走 shared_embeddings feature_columns.extend(tf.feature_column.shared_embeddings( [ tf.feature_column.categorical_column_with_hash_bucket( 'clicked_items', hash_bucket_size=3000000), tf.feature_column.categorical_column_with_hash_bucket( 'favorited_items', hash_bucket_size=3000000), ], dimension=16 ))

这种写法的好处是,每个特征按自己的特性选择了合适的转换方式,最后所有的稠密输出会被DenseFeatures拼接成一个大的稠密向量,模型前几层Dense不需要关心特征到底是indicator来的还是embedding来的。

但要注意一个问题:indicator输出高维稀疏,embedding输出低维稠密,拼接后的向量里,不同特征对后续Dense层的“尺度贡献”是不一样的。indicator列的0/1值,数值范围小;embedding列的值受initializer影响,可能集中在正负0.01附近,也可能在正负1附近。如果直接拼接,模型的第一层Dense会做很多无谓的尺度调整。我的做法是让embedding的初始方差不要开得太大,truncated_normal(stddev=0.01),这样整个拼接向量一开始的尺度比较均衡。

5. 接入Keras模型:DenseFeatures、多值输入与可变长特征的处理

5.1 DenseFeatures是把所有列拼成模型输入的唯一入口

feature_column定义好后,下一步就是放进DenseFeatures层。这个层的作用是把之前定义的所有column在同一个样本上执行一遍,输出一个拼接好的稠密张量。

inputs = { 'platform': tf.keras.Input(shape=(), name='platform', dtype=tf.string), 'tags': tf.keras.Input(shape=(), name='tags', dtype=tf.string), 'clicked_items': tf.keras.Input(shape=(), name='clicked_items', dtype=tf.string), 'favorited_items': tf.keras.Input(shape=(), name='favorited_items', dtype=tf.string), } preprocessed = tf.keras.layers.DenseFeatures(feature_columns)(inputs) x = tf.keras.layers.Dense(64, activation='relu')(preprocessed) x = tf.keras.layers.Dense(32, activation='relu')(x) outputs = tf.keras.layers.Dense(1, activation='sigmoid')(x) model = tf.keras.Model(inputs, outputs)

在Keras的Functional API里,DenseFeatures对input dict的处理是按name匹配的,也就是说你必须确保column的key和keras.Input的name完全一致。很多报错都出在这个地方:column的key叫tags,Input的name手滑写成了tag,DenseFeatures找不到特征,直接抛KeyError。

5.2 可变长多值特征在Input里的声明方式

前面提到tags是多值特征,从TFRecord读进来是SparseTensor。但Keras的Input层默认处理的是DenseTensor,所以直接写tf.keras.Input(shape=(), name='tags', dtype=tf.string)在遇到SparseTensor输入时可能会出问题。

正确做法是给Input层加上sparse=True,告诉Keras这个输入是稀疏的:

inputs = { 'platform': tf.keras.Input(shape=(), name='platform', dtype=tf.string), 'tags': tf.keras.Input(shape=(None,), name='tags', dtype=tf.string, sparse=True), }

如果你用tf.data构建训练pipeline,那么对于可变长特征,用map(parse_fn).batch(64)把数据喂给模型时,parse出来的SparseTensor会自动被Keras按照Input层的sparse标记识别。实际跑的时候DenseFeatures对稀疏输入的处理很直观:indicator_column直接把有效的ID位置置1,embedding_column把多个ID查出来的向量交给combiner归并。

5.3 用RaggedTensor处理定长序列特征的补充方案

有些特征虽然是多值,但语义上是“序列”,比如用户最近点击的50个商品ID。这时候把ID当成一个有序序列比当成无序集合更合理,序列的前后顺序本身携带信息。DenseFeatures里的embedding_column配合combiner会把多值ID直接汇总成一个向量,这个操作会丢掉顺序信息,对序列特征并不友好。

碰到这类特征,我的做法是绕开feature_column,直接在模型里用tf.keras.layers.Embedding加Masking加LSTM/Transformer。这算是feature_column体系和自定义神经网络层的边界:feature_column擅长的是无序类别特征的经典处理,而序列建模需要保留顺序,更适合用Keras原生层手动搭。

实际工程里这两种方式可以共存。一个模型的输入层分成两条支路:一条走DenseFeatures处理无序类别特征,另一条走Embedding+序列模型处理点击序列,最后把两个支路的输出在hidden层concat起来。这个思路在很多推荐排序模型里都非常常见。

6. 工程化落地中容易踩的坑:从训练到serving的全链路一致性

6.1 vocabulary文件版本与serving一致性

用categorical_column_with_vocabulary_file的时候,最容易踩的坑是训练和serving读到的vocabulary文件版本不一致。训练时词表里有“游戏”,serving时词表文件被人更新过,把“游戏”删了、加了个新词,“游戏”这个词映射到的ID就和训练时完全错位。模型在serving端看到的是另一组向量的组合,效果直接崩。

这个问题在ID类特征里极其隐蔽,因为模型不会报错,它只会默默地把“旧游戏”的嵌入向量当成“新游戏”的向量来用。我在生产环境里加过一个很简单的防护:把vocabulary文件的MD5值打点进训练日志,同时记录在SavedModel的asset路径里。上线时检查serving进程加载的vocabulary文件和训练时记录的是否一致,不一致立刻告警。

6.2 SavedModel导出时的feature_column行为

TF2的Keras模型用model.save('saved_model_dir')导出后,feature_column会跟着模型一起被序列化进去。这意味着vocabulary_list这种以Python对象存在的词表,会被固化在SavedModel里,上线时不需要额外传词表文件。

但如果你用的是categorical_column_with_vocabulary_file,词表是外部文件,SavedModel会把它作为asset打包到saved_model_dir/assets目录。上线时要保证这个assets目录一并发布,不能只拷一个saved_model.pb。不少团队上线上出过这种问题,本地预测好好的,线上报找不到词表文件。

要检查导出内容,可以用model.save后查看assets目录:

ls -R saved_model_dir/assets

如果词表文件在这个目录下,才算真的打进去了。

6.3 combiner和OOV对预测结果的影响:一个亲测案例

有一次我排查线上和离线AUC不一致的问题,离线评估AUC是0.781,线上实时预测只有0.769。特征、模型、样本都对了一遍,最后发现是线上serving代码里对多值标签做了截断:只保留前5个标签,而离线训练时用的是全量标签,平均每行有9个。

这就涉及combiner的语义了:combiner='sqrtn'的“n”是有效标签数。截断后,分母从sqrt(9)变成了sqrt(5),即使分子也少了一部分,向量整体尺度和方向都变了。后来把serving端改成了和离线一致的逻辑,AUC又对齐了。

这个案例给我的教训是:combiner依赖于特征的长度分布,一旦serving端做了截断、过滤、去重之类的操作,特征长度分布变了,embedding聚合出来的向量也会变,模型效果流失会超出预期。所以要保证serving特征处理和离线训练时的预处理逻辑完全一致,任何“线上少做一步”的偷懒,最后都会体现在指标上。

6.4 推荐在模型验收阶段做的最小回归测试

feature_column链路牵扯到的细节太多,我最后分享一个每次模型验收都会做的回归测试清单。

第一,检查输入shape。DenseFeatures(columns)拿一个batch数据前向跑一遍,打印输出shape,确认indicator列和embedding列的维度跟预期一致。

第二,核对OOV行为。构造一个词表之外的字符串,输入模型,确保不crash。你可以在input_fn里临时加一条脏数据,跑几个step验证。

第三,验证多值特征对齐。把原始特征里两个不同样本的标签数量分别故意调成极多和极少,看模型输出值是否平稳。如果输出波动特别大,大概率combiner设置不合理。

这个清单看起来简单,但每一个都对应着真实上线事故。我建议把它做成一个自动化的冒烟测试脚本,每次改特征都能跑一遍。

讲到这里,feature_column里indicator和embedding这两个转换器,从原理、参数、取舍到工程坑,基本都覆盖了。如果你现在正在接手一个时间紧的排序模型项目,建议直接按第四条里的决策规则过一遍所有类别特征,先跑通一版,再逐个试combiner和维度,效果会比你纠结“用哪个更好”来得更快。我在实际项目中最大的体会是:这两个列都不是银弹,但只要你理解了它们背后的ID映射和聚合逻辑,选型其实不会太难。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询