
1. feature_column处理链路类型列到稠密列之间的“最后一公里”做TensorFlow特征工程的人几乎都会遇到这样一个问题原始特征明明是类别型数据模型输入层却要的是数值张量。很多新手第一反应是手动做one-hot结果类别一多直接炸内存而且代码又臭又长。我在去年一个推荐项目里就是被这个逼疯了后来彻底切到feature_column体系才把这块理顺。feature_column这层抽象本质上解决的是“把原始特征描述成模型能吃的样子”这件事。它不是一个单独的API而是一条处理链。整条链大致可以分成三段第一段是“categorical_column”系列负责把字符串或整数映射成类别ID第二段就是标题里的indicator_column和embedding_column它们把类别ID继续转换成稠密向量第三段是DenseFeatures或者序列化导出时的那一层把多个列拼起来变成模型的输入张量。你去看官方文档它喜欢把第二段叫“列之间的转换器”我觉得更准确的说法是“最后一公里”——前面都在处理ID映射到了这两个列才开始真正触碰模型的数值表示。很多教程一上来就告诉你“indicator就是one-hotembedding就是查表”这说法没错但它解释不了为什么有时候明明用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( keytags, vocabulary_listtag_vocab, num_oov_buckets1 ) tags_indicator tf.feature_column.indicator_column(tags_categorical)注意我在这里显式设置了num_oov_buckets1。很多人第一次用的时候会忽略这个参数它的含义是“为词表之外的内容预留几个桶”。假如线上来了一个词表里没有的新标签没有OOV桶就会直接报错加了OOV桶则会被映射到词表最后面的那些ID上。这个参数直接影响indicator_column的输出维度。如果词表长度是5num_oov_buckets0输出维度就是5num_oov_buckets1输出维度就是6num_oov_buckets3输出维度就是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会把它解析成SparseTensorindicator_column直接在训练过程中把这个SparseTensor转成multi-hot稠密向量。整个过程不需要手动统计标签数量、不需要做序列切分。2.3 与hash_bucket搭配控制维度爆炸的唯一手法indicator_column最大的软肋是维度会随词表线性增长。如果词表有一百万个词multi-hot向量就有100万维这谁受得了。实际工程里我很少直接用categorical_column_with_vocabulary_list去定义超高基数特征而是改成categorical_column_with_hash_buckettags_hashed tf.feature_column.categorical_column_with_hash_bucket( keytags, hash_bucket_size10000 ) 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( keyuser_id, hash_bucket_size30000000 ) user_embedding tf.feature_column.embedding_column( categorical_columnuser_id_column, dimension64, combinersqrtn, initializertf.keras.initializers.truncated_normal(stddev0.01) )3.2 维度怎么选有没有硬性经验embedding维度是模型里最敏感的超参之一。维度太低表达不了类别间的复杂关系维度太高不仅增加参数量还会带来过拟合。业界流传最广的经验是“嵌入维度约为类别总数的开四次方”。比如vocab size是10000开四次方约等于10vocab 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换到sqrtnAUC变化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( keyclicked_items, hash_bucket_size1000000) favorited_col tf.feature_column.categorical_column_with_hash_bucket( keyfavorited_items, hash_bucket_size1000000) clicked_emb, favorited_emb tf.feature_column.shared_embeddings( [clicked_col, favorited_col], dimension16 )这样两个特征共享同一张商品嵌入表点击和收藏行为在同一个向量空间里做交互语义一致性更强参数也更省。我有一次做用户长短期兴趣融合短期点击序列和长期收藏序列就是靠shared_embeddings把两个序列拉到了同一个语义空间离线评估的提升比单独调大维度来得更明显。4. 实战决策同一个特征该用indicator还是embedding4.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设combinersqrtn。先跑通再换着对比是模型实验最稳的节奏。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_buckets1))) # 中高基数的标签特征走 embedding feature_columns.append(tf.feature_column.embedding_column( tf.feature_column.categorical_column_with_hash_bucket( tags, hash_bucket_size50000), dimension16, combinersqrtn)) # 超高基数行为特征走 shared_embeddings feature_columns.extend(tf.feature_column.shared_embeddings( [ tf.feature_column.categorical_column_with_hash_bucket( clicked_items, hash_bucket_size3000000), tf.feature_column.categorical_column_with_hash_bucket( favorited_items, hash_bucket_size3000000), ], dimension16 ))这种写法的好处是每个特征按自己的特性选择了合适的转换方式最后所有的稠密输出会被DenseFeatures拼接成一个大的稠密向量模型前几层Dense不需要关心特征到底是indicator来的还是embedding来的。但要注意一个问题indicator输出高维稀疏embedding输出低维稠密拼接后的向量里不同特征对后续Dense层的“尺度贡献”是不一样的。indicator列的0/1值数值范围小embedding列的值受initializer影响可能集中在正负0.01附近也可能在正负1附近。如果直接拼接模型的第一层Dense会做很多无谓的尺度调整。我的做法是让embedding的初始方差不要开得太大truncated_normal(stddev0.01)这样整个拼接向量一开始的尺度比较均衡。5. 接入Keras模型DenseFeatures、多值输入与可变长特征的处理5.1 DenseFeatures是把所有列拼成模型输入的唯一入口feature_column定义好后下一步就是放进DenseFeatures层。这个层的作用是把之前定义的所有column在同一个样本上执行一遍输出一个拼接好的稠密张量。inputs { platform: tf.keras.Input(shape(), nameplatform, dtypetf.string), tags: tf.keras.Input(shape(), nametags, dtypetf.string), clicked_items: tf.keras.Input(shape(), nameclicked_items, dtypetf.string), favorited_items: tf.keras.Input(shape(), namefavorited_items, dtypetf.string), } preprocessed tf.keras.layers.DenseFeatures(feature_columns)(inputs) x tf.keras.layers.Dense(64, activationrelu)(preprocessed) x tf.keras.layers.Dense(32, activationrelu)(x) outputs tf.keras.layers.Dense(1, activationsigmoid)(x) model tf.keras.Model(inputs, outputs)在Keras的Functional API里DenseFeatures对input dict的处理是按name匹配的也就是说你必须确保column的key和keras.Input的name完全一致。很多报错都出在这个地方column的key叫tagsInput的name手滑写成了tagDenseFeatures找不到特征直接抛KeyError。5.2 可变长多值特征在Input里的声明方式前面提到tags是多值特征从TFRecord读进来是SparseTensor。但Keras的Input层默认处理的是DenseTensor所以直接写tf.keras.Input(shape(), nametags, dtypetf.string)在遇到SparseTensor输入时可能会出问题。正确做法是给Input层加上sparseTrue告诉Keras这个输入是稀疏的inputs { platform: tf.keras.Input(shape(), nameplatform, dtypetf.string), tags: tf.keras.Input(shape(None,), nametags, dtypetf.string, sparseTrue), }如果你用tf.data构建训练pipeline那么对于可变长特征用map(parse_fn).batch(64)把数据喂给模型时parse出来的SparseTensor会自动被Keras按照Input层的sparse标记识别。实际跑的时候DenseFeatures对稀疏输入的处理很直观indicator_column直接把有效的ID位置置1embedding_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的语义了combinersqrtn的“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映射和聚合逻辑选型其实不会太难。