ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TensorFlow feature_column深度解析:indicator_column与embedding_column对比与选型

TensorFlow feature_column深度解析:indicator_column与embedding_column对比与选型 做搜索、推荐、广告这类稀疏场景的对 TensorFlow 的 feature_column 应该都不陌生。用户ID、商品ID、城市、性别、小时段这些类别特征没办法直接塞给 Dense 层总得先转成某种数值表达。我在 feature_column 上花过不少时间印象最深的就是两个 API 老被拿来对比tf.feature_column.indicator_column指示列和tf.feature_column.embedding_column嵌入列。名字好懂一个指示、一个嵌入但真到了选型的时候文档翻完还是一头雾水。这篇文章就是把这两者彻底讲清楚。我会从底层的 categorical_column 讲起对比 one-hot 式的指示列和低维查找表式的嵌入列给出适用场景、参数设置、可运行的示例还有我这几年的实践经验。适合正在用 TensorFlow 做结构化数据建模、推荐系统或者 CTR 预估的读者尤其是刚接触 feature_column 不久、对这两个 API 差异感到困惑的人。1. 先搞清楚 feature_column 在模型里的真实位置1.1 特征处理管线里的“最后一公里”在动手之前得先把 feature_column 放在整个训练链路里的位置弄清楚。很多教程上来就讲 API 用法忽略了这个背景导致后面一出错就不知道查哪里。我自己的理解是特征处理通常分成两段。前一段是数据清洗、缺失值填充、归一化这些操作发生在 DataFrame 或 tf.data.Dataset 里本质上是原始的取数与加工。后一段才是模型直接消费的结构化输入也就是把离散字符串、整数 ID 这类语义信息转换成数值张量。feature_column 属于后者它不算特征工程工具而是模型输入适配层。举一个最简单的例子。训练数据里有一列叫 gender取值是字符串 male / female。你不能把这个字符串直接喂给神经网络。传统做法是先做 LabelEncoder 变成 0/1再在模型里做 Embedding 或 One-Hot。feature_column 做的事情和这个过程高度相似只不过它把转换过程变成了声明式 API。你只需要声明“这列是类别特征离散取值有哪些”剩下的转换交给框架完成。这一步“最后一公里”往往是性能的关键。特征列一旦声明错了后面模型结构再漂亮学到的也是错误信号。再加上特征列会参与模型导出和线上 Serving 的输入解析如果这里埋了雷排查成本非常高。我在实际项目中见过不少案例模型离线指标一直上不去最后发现是特征列类型选错了该用 embedding 的地方用了 indicator或者把数值特征误当成类别特征处理。1.2 indicator_column 和 embedding_column 不是“平级”关系这是我最想强调的一点。光看名字总觉得 indicator_column 和 embedding_column 是并列的两种特征列其实不是。在 TensorFlow 的实现里它俩都必须包裹在一个 categorical_column 之上。也就是说它们共用的底座是“类别列”。你得先定义好一个类别列指示列和嵌入列才有东西可包。类别列负责把原始输入字符串、整数映射成稀疏的类别 ID。比如categorical_column_with_vocabulary_list就是给一份词表把 male 映射成 0、female 映射成 1。而 indicator_column 负责把稀疏类别 ID 变成 multi-hot 的稠密向量embedding_column 负责把类别 ID 通过查表变成低维稠密向量。这一点务必先建立起来否则后面所有参数都容易理解错。所以这篇文章真正的讨论对象是“从类别 ID 到模型输入张量”的两种转换策略。一个偏向传统的 one-hot / multi-hot一个偏向学习的分布式表示。理解了这层关系再看两者参数就清楚多了。很多人纠结 indicator_column 和 embedding_column 能不能互换从数学上当然可以——都是把类别 ID 变成数值向量——但效率和效果差别很大具体差别下文展开。2. indicator_column最接近 one-hot 的落地姿势2.1 指示列到底在算什么indicator_column 的行为本质上就是 one-hot 的“多值版”。如果类别特征是单选它输出 one-hot如果是多选比如用户的兴趣标签列表它输出 multi-hot。在多值情况下输出向量的长度等于词表大小出现过的类别对应位置为 1其他为 0。从实现角度看输入是一个 SparseTensor每个样本对应若干整数 ID。indicator_column 做的就是根据词表大小把每个 ID 散到一个定长的稠密向量里。可以拿 numpy 类比去理解batch_size 3、词表大小 6三个样本分别包含 {1, 3}、{0, 2, 5}、{4}输出就是三行 6 列的 0/1 矩阵。这比看十页文档都直观。正因为输出是 0/1 的稠密向量它天然不引入可学习的参数梯度不会反传到特征表示上。这个特性在特征维度可控时是优点——简单、稳定、可解释在词表很大时就是灾难——输出维度会爆炸模型参数数量直接跟着涨。2.2 一套代码看穿 indicator_column 的输出直接跑一段代码观察输出比任何解释都直观。我用两个特征列做例子一个是 vocabulary_list 词表列一个是 identity 整数 ID 列。import tensorflow as tf # 词表列字符串映射成 ID color tf.feature_column.categorical_column_with_vocabulary_list( keycolor, vocabulary_list[red, blue, green] ) color_indicator tf.feature_column.indicator_column(color) # identity 列整数 ID 直接使用0-23 表示 24 个小时 user_hour tf.feature_column.categorical_column_with_identity( keyuser_hour, num_buckets24 ) hour_indicator tf.feature_column.indicator_column(user_hour) # 用 DenseFeatures 把转换列变成稠密张量 feature_layer tf.keras.layers.DenseFeatures([color_indicator, hour_indicator]) inputs { color: tf.constant([[red], [blue], [green]]), user_hour: tf.constant([[8], [14], [23]]) } result feature_layer(inputs) print(result.shape) # (3, 27)输出 shape 是 (3, 27)其中 color 贡献 3 维user_hour 贡献 24 维DenseFeatures 会把这俩拼接成一个整体向量。color 那一段是标准的 one-hotuser_hour 那一段也是 one-hot整个向量的含义非常清晰。这里有个容易被忽略的细节DenseFeatures 拼接时列的顺序就是你传入列表的顺序。调试的时候可以根据这个顺序去对应结果向量的不同分段定位哪一段特征出了问题。比如你在排查 user_hour 维度直接看 result.numpy() 的后 24 位就行了。2.3 什么场景选 indicator_column什么时候毫不犹豫地用 indicator_column我的经验是当类别取值数量小且稳定时。几个典型例子星期几取值 0-6性别男/女/未知常用城市Top50 以内商品类目几百个以内用户历史行为标签数量可控且需要强可解释性一般词表大小在几百以内indicator_column 都是划算的。原因很简单one-hot 输出维度不大模型参数增加有限而且每个类别独立学习权重在样本量充足时往往更直接有效。还有一个隐形的优势是调试友好。你看到一个样本的向量一眼就能看出哪些特征位置被激活了这在排查特征链路问题是很有用的。边界也很明显。词表上万时输出维度过大全连接层接上来参数量是词表大小乘隐层维度。10 万词表接一个 128 维隐层光这一层就是 1280 万参数训练和推理成本都明显上升。另外对于取值不断增长的类别比如新用户 ID 不断出现indicator_column 的 vocabulary 方式也会失效需要配合 hash bucket 或者直接换 embedding。真到了这一步就该切到第三章的内容了。3. embedding_column把高维稀疏压成低维稠密3.1 为什么需要 embedding从维度爆炸说起embedding 这个概念大家都不陌生NLP 里到处是 word embedding。本质上是把稀疏的高维 one-hot 表示映射到一个低维稠密的连续向量空间让语义相近的类别在向量空间里距离也近。feature_column 里的 embedding_column 做的就是这件事。它和模型里自己写一个tf.keras.layers.Embedding有什么区别这是个很实际的问题。从结果来看几乎是一回事——embedding_column 底层维护一个形状为 (vocab_size, dimension) 的查找表根据输入的类别 ID 去取对应的行向量。区别在于你不需要手动管理 ID 映射和查表逻辑而且它和 DenseFeatures 配合时能自动处理多值特征的合并方便很多。这里有个很重要的认知embedding 向量不是天生语义化而是通过训练任务学出来的。同一个类别特征在 CTR 二分类任务里学到的向量和在多分类任务里学到的向量表达的含义不一样。所以先别指望预训练大多数业务场景下随机初始化、跟随主任务训练反而更直接。3.2 参数详解dimension、combiner、trainableembedding_column 的签名长这样tf.feature_column.embedding_column( categorical_column, dimension, combinermean, initializerNone, trainableTrue )逐一来说。categorical_column 是要包裹的底层类别列这个前面已经强调过。dimension 是嵌入向量的维度最影响模型容量的参数。combiner 是多值特征的合并策略很多人会忽略它。当一个输入样本对应多个类别 ID 时查找表取出来的多行向量需要合并成一个固定长度的向量。mean 是取平均sum 是求和sqrtn 是按 L2 范数归一化后求和。trainable 是控制 embedding 是否随训练更新的开关。冻结时它相当于一个随机初始化且固定的特征映射一般很少用但在某些增量训练或多任务场景下会用到。initializer 决定初始化方式默认用随机均匀分布一般不需要动除非你有预训练向量要加载。看一个基础示例user_id tf.feature_column.categorical_column_with_hash_bucket( keyuser_id, hash_bucket_size10000 ) user_emb tf.feature_column.embedding_column( categorical_columnuser_id, dimension16, combinermean ) feature_layer tf.keras.layers.DenseFeatures([user_emb]) inputs {user_id: tf.constant([[u123], [u456], [u789]])} result feature_layer(inputs) print(result.shape) # (3, 16)输出 shape 是 (3, 16)每个 user_id 被映射成一个 16 维的稠密向量。这 16 维到底代表什么没人能直接解释但通过训练它会把行为模式相近的用户放到向量空间中相近的位置。3.3 embedding 维度到底怎么定这是所有新人最爱问的问题。先说经验结论维度通常在 4 到 64 之间具体取决于样本量和类别实际复杂程度。常见做法之一是把词表大小的 4 次方根作为参考起点。词表 100004 次方根大约是 10词表 100 万4 次方根是 31 左右。这个经验公式不算严谨但作为起点比我当年拍脑袋定 128 要好用得多。还要考虑业务信号本身能不能支撑高维表达。如果这个特征和预测目标关系很弱你给它 64 维学出来的很可能就是一堆噪声参数。数据量大、特征重要维度可以往 32 或 64 走数据量小8 或 16 反而更稳。我自己见过的推荐模型里user_id、item_id 这类核心特征用 32-64 维其他辅助特征 8-16 维是相对合理的配置。实践里我会用多组维度做小规模对比。同一个模型分别试 [8, 16, 32, 64]看验证集指标变化。维度越大训练越慢、越容易过拟合收益到一定程度就平了这时候就该收敛到平台期的维度上。这个对比实验的成本不高但能避免拍脑袋带来的无效算力浪费。3.4 和手写 Embedding 层相比feature_column 的 embedding 好在哪有些同学已经习惯了自己搭 Embedding 层觉得 feature_column 只是换了个写法。实际工程里feature_column 的价值主要在两点。第一是自动处理多值特征。手写时你得自己维护一个 SparseTensor 再套 embedding然后做池化embedding_column 配合 DenseFeatures直接把这些逻辑封装好了代码量少很多。第二是训练与线上推理的一致性。模型导出为 SavedModel 后原始字符串输入进来特征列会自动完成映射和查表。这就意味着线上不需要再额外部署一套 ID 映射服务。手写 Embedding 层的话你需要自己保证训练时的 ID 映射和线上请求时的映射完全一致稍有不慎就出数据偏差。不过这也不意味着 embedding_column 永远优于手写层。如果你的模型结构特别定制比如要做双塔或者序列特征拼接手写反而更灵活。feature_column 的 API 相对封闭自定义行为不如直接写层来得直接。我的习惯是能用 feature_column 就用 feature_column遇到它搞不定的结构再局部手写。4. 从 categorical_column 到 indicator/embedding 的完整实操4.1 底层类别列的选型identity、vocabulary、hash_bucket在组装 indicator_column 或 embedding_column 之前必须先定义 categorical_column。TensorFlow 提供了几种常用类别列选错会影响整体效果。底层类别列输入类型是否维护词表适用场景categorical_column_with_identity整数否取值范围已知星期几、小时、年龄段categorical_column_with_vocabulary_list字符串是代码内维护性别、城市等稳定枚举categorical_column_with_vocabulary_file字符串是文件维护大词表如商品 ID 全集categorical_column_with_hash_bucket字符串或整数否哈希映射用户 ID、搜索词等开放集合选型原则其实就一句话能枚举的用 vocabulary不能枚举的用 hash bucket。identity 适合输入本身就是连续整数编号的场景。hash bucket 会引入碰撞bucket 大小一般设为预期取值数量的 2-4 倍能有效降低碰撞概率。比如线上用户量约 500 万bucket 可以取 1000 万到 2000 万。4.2 多特征组装DenseFeatures 的输出怎么拼接实际模型不会只用一两个特征得把多个转换列合成一个大的输入向量。DenseFeatures 承担的就是这个职责。组合示例import tensorflow as tf gender tf.feature_column.categorical_column_with_vocabulary_list( keygender, vocabulary_list[male, female, unknown] ) item_id tf.feature_column.categorical_column_with_hash_bucket( keyitem_id, hash_bucket_size5000 ) weekday tf.feature_column.categorical_column_with_identity( keyweekday, num_buckets7 ) gender_ind tf.feature_column.indicator_column(gender) device_ind tf.feature_column.indicator_column(weekday) item_emb tf.feature_column.embedding_column(item_id, dimension12) feature_layer tf.keras.layers.DenseFeatures([gender_ind, device_ind, item_emb]) inputs { gender: tf.constant([[male], [female], [unknown]]), weekday: tf.constant([[1], [5], [0]]), item_id: tf.constant([[i_1001], [i_2002], [i_3003]]) } out feature_layer(inputs) print(out.shape) # (3, 22)这里 3 维来自 gender 的 one-hot7 维来自 weekday 的 one-hot12 维来自 item_id 的 embedding拼接成 22 维输出。用 DenseFeatures 的好处是不同类型的列可以混搭拼接顺序由你传入的列表控制。调试时要知道每个分段的起止这样才能定位是哪一段特征的值出现了异常。4.3 可运行的 CTR 模型 demo下面给一个能直接跑的完整示例。模拟一个二分类场景特征里有字符串类别、整数类别和高基数类别分别用 indicator 和 embedding 处理。import numpy as np import tensorflow as tf # 1. 特征列定义 gender tf.feature_column.categorical_column_with_vocabulary_list( keygender, vocabulary_list[male, female] ) hour tf.feature_column.categorical_column_with_identity( keyhour, num_buckets24 ) city tf.feature_column.categorical_column_with_hash_bucket( keycity, hash_bucket_size500 ) item_id tf.feature_column.categorical_column_with_hash_bucket( keyitem_id, hash_bucket_size10000 ) features [ tf.feature_column.indicator_column(gender), tf.feature_column.indicator_column(hour), tf.feature_column.embedding_column(city, dimension8), tf.feature_column.embedding_column(item_id, dimension16) ] feature_layer tf.keras.layers.DenseFeatures(features) # 2. 构造模拟数据 N 1024 data { gender: np.random.choice([male, female], sizeN), hour: np.random.randint(0, 24, sizeN), city: np.array([fc_{i % 300} for i in range(N)]), item_id: np.array([fitem_{i % 9000} for i in range(N)]), } labels np.random.randint(0, 2, sizeN).astype(np.float32) ds tf.data.Dataset.from_tensor_slices((data, labels)).batch(64).shuffle(N) # 3. 模型 inputs { gender: tf.keras.Input(shape(1,), namegender, dtypetf.string), hour: tf.keras.Input(shape(1,), namehour, dtypetf.int64), city: tf.keras.Input(shape(1,), namecity, dtypetf.string), item_id: tf.keras.Input(shape(1,), nameitem_id, dtypetf.string), } x feature_layer(inputs) x tf.keras.layers.Dense(64, activationrelu)(x) x tf.keras.layers.Dropout(0.3)(x) out tf.keras.layers.Dense(1, activationsigmoid)(x) model tf.keras.Model(inputs, out) model.compile(optimizeradam, lossbinary_crossentropy, metrics[accuracy]) model.fit(ds, epochs3)这段代码可以直接跑。注意tf.data.Dataset.from_tensor_slices传入的是 dictkey 必须和特征列的 key 完全一致。训练时 DenseFeatures 会自动从 dict 里取对应字段做转换。实际数据里你会遇到稀疏取值不均匀的问题随机生成的 demo 数据只是演示链路不代表真实分布。4.4 训练与导出时最容易忽略的问题训练只是第一步。模型要上线经常需要导出 SavedModel 并写 serving 输入签名。feature_column 的优势在于只要保存模型时使用的特征列和训练时一致导出后的模型在推理时也能自动完成特征转换。用户传原始字符串进来模型内部先做映射再预测。这让线上特征处理逻辑和训练保持了一致省去了单独部署特征服务的麻烦。不过坑也在这里。线上请求如果出现训练时没见过的取值行为会分几种情况。vocabulary_list 类的列未登录词会被映射到默认的 OOV 位置hash_bucket 会照常 hash只是可能碰撞identity 列如果取值超过 num_buckets 范围会直接报错。所以线上特征校验特别重要。我的习惯是在入口对取值做合法性校验尤其是 identity 列越界值提前拦截避免推理时抛异常。5. 常见问题与排查技巧实录5.1 DenseFeatures 输出形状对不上最常见的现象feature_layer 输出的维度和自己手算的对不上。原因多半在于 categorical_column 定义和实际输入不匹配。比如 identity 列设置 num_buckets24但输入数据里出现了 25 以上的值框架可能直接抛 OutOfRange 错误。另一种可能是 DenseFeatures 没有把所有转换列传进去漏掉列是最容易犯的错。排查方法很固定先打印 feature_layer 输出 shape手工算一遍每个列贡献的维度加起来对一下。如果对不上逐个列单独用 DenseFeatures 包起来输出观察。这种二分定位法绝大多数形状问题十分钟内能解决。5.2 词表太大用 indicator 导致内存和训练成本飙升这个我踩过。早期有个实验把用户历史浏览的商品子类目做了 indicator_column类目大约 2 万个每个用户平均有 30 个类目标签。one-hot 输出维度直接 2 万第一层全连接参数量到了千万级。训练倒没崩但显存和耗时明显上涨指标提升非常有限。后来换成 embedding_column16 维参数量、训练速度、离线指标全面改善。从那以后超过几千的取值我基本只用 embedding。这个阈值不一定通用但至少给你一个参考线。特征维度小、需要可解释性indicator特征维度大、需要泛化性embedding。5.3 hash bucket 碰撞与 bucket size 的选择hash_bucket 的好处是不需要维护词表坏处是碰撞。两个不同的字符串如果 hash 到同一个桶模型看到的就是同一个特征。碰撞率做不到 0但可以控制。假设真实取值数量是 Vbucket 大小 B碰撞率大致和 V/B 正相关。经验上 B 取预计取值数量的 2-4 倍比较稳妥。具体到代码如果你统计出训练集有 800 万不同的 item_idhash_bucket_size 就可以往 2000 万以上设。太小了碰撞严重太大浪费内存。另外要注意hash 函数在不同进程中通常是稳定的但换了 TensorFlow 版本之后内部 hash 行为可能有差异做版本升级时记得回归验证特征效果。5.4 多值特征 combiner 怎么选一句话总结大多数情况下先试 mean。它对不定长的多值输入有归一化效果数值范围稳定。sum 在特征强度带权重的场景下有优势比如“用户对某类目点击了 5 次”这类计数信息sum 能保留数量差异。sqrtn 介于两者之间用 L2 归一化避免长列表把数值撑得过大。我在实际项目里踩过 combiner 的坑。某个模型里用户历史兴趣标签是多值特征用 sum 时 embedding 输出的数值直接翻了好几倍后续 Dense 层输入分布被拉得很大模型不容易收敛。换成 mean 之后指标立刻回归正常。所以如果特征后面还拼接其他维度的特征优先 mean避免数量级忽高忽低影响后续层的稳定性。5.5 TF2 下 feature_column 的几个隐藏坑在 TF2 的 Keras 模型里用 feature_column有几个点很容易踩。第一DenseFeatures 层在 model.fit 时可以自动从 Dataset 的 dict 里取数但如果你手动构造了 Keras Input必须保证 dict 的 key 和特征列的 key 完全一致大小写、空格都不能错否则报错信息还很隐晦。第二feature_column 定义在函数内部时每次调用会重新创建变量容易出现“变量已存在”的报错。尽量把特征列定义成模块级常量或类属性在初始化阶段就创建好。第三embedding_column 的初始化随机种子默认不固定复现实验时要设置全局种子否则两次训练的初始化结果不同在小数据集上很容易得出错误的对比结论。第四和 tf.keras.layers.DenseFeatures 一起用时特征列列表的顺序会影响输出拼接顺序也影响模型输入层的顺序导出模型时接口参数顺序必须和训练时保持一致。这类问题在文档里都不显眼但遇到时非常花时间。记录一下能省不少调试的功夫。我在自己的项目里已经形成了一套固定习惯能枚举且数量小的用 vocabulary_list 加 indicator_column保留可解释性开放集合的大词表用 hash_bucket 加 embedding_column稳定且省内存多值特征一律配 combinermean。这套组合在多个 CTR 实验里都表现稳定。当然没有放之四海而皆准的配置最重要的还是理解 indicator_column 和 embedding_column 各自在做什么——一个把 ID 铺平成稀疏向量一个把 ID 压进低维空间。把这一步吃透了后面的调参、排错、上线都会顺很多。
返回列表