ARTICLE DETAIL

资讯详情

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

TensorFlow中indicator_column与embedding_column:类别特征处理详解

TensorFlow中indicator_column与embedding_column:类别特征处理详解 在TensorFlow特征工程这块feature_column一直是绕不开的基础组件。很多人一开始接触时会被一堆名词劝退尤其是indicator_column指示列和embedding_column嵌入列这两个光看名字就有点绕。但说穿了它们都是处理类别特征的工具只是走的路子不一样。这篇文章我就结合自己的实际项目经验把这两个column掰开揉碎讲清楚它们各自解决什么问题、内部是怎么工作的、什么场景该用哪个、维度怎么定、有哪些容易踩的坑。不管你是刚开始学TensorFlow还是已经在做推荐系统、CTR预估这类任务这篇内容应该都能给你一些实在的参考。1. 内容整体设计与思路拆解1.1 特征工程里类别特征为什么这么难搞在讲indicator_column和embedding_column之前得先想清楚一个问题为什么类别特征在深度学习模型里这么特殊假如你有一个数值特征比如“用户年龄”28岁、35岁这本身就是数字喂给模型直接参与计算没问题。但类别特征不一样比如“用户所在城市”它是一堆离散的字符串北京、上海、广州、深圳。模型是数学运算器它不认识“北京”这两个字它只认数字。所以必须有个环节把这些离散的、非数值的类别转换成模型能吃的数值形态。这就引出了特征工程里最经典的一句话类别特征的处理的本质是“离散化后的向量化”。那怎么向量化最朴素的想法就是one-hot。比如全国有30个省级行政区你把“用户所在省份”这个特征展开成30个维度的向量用户在北京北京那一位是1其余29位是0。这个思路没错indicator_column干的就是这件事。它把一个类别特征变成一个稀疏的0/1指示向量所以叫“指示列”。但问题来了如果类别数量不是30而是30万呢比如商品ID电商平台有几千万个SKU你把商品ID做one-hot一个特征就有几千万维。且不说内存爆炸模型参数量也会大得离谱训练根本跑不动。而且one-hot向量绝大多数位置都是0这玩意儿又稀疏又高维神经网络在这么高的维度上很难学到有效的模式。这时候就得换个思路与其把类别映射到一个巨大的稀疏向量上不如把它映射到一个低维的稠密向量上让模型自己去学这个类别的“表示”。这就是embedding_column干的事。所以你看这两个column解决的问题其实是同一个——类别特征的向量化——但策略完全不同indicator_column追求“精确、简单、可解释”代价是维度爆炸embedding_column追求“低维、稠密、可学习”代价是需要训练数据去学。理解了这一层后面所有的细节都好办了。1.2 indicator和embedding本质上是两种不同的映射哲学如果只记住一句话那就是indicator_column是one-hot的封装embedding_column是可学习查表。indicator_column听着高级但剥开看就是one-hot。你给它一个类别特征它内部做两件事第一把类别映射成整数ID比如北京0、上海1、广州2第二把这个整数ID转成一个向量向量长度等于类别总数ID对应的那个位置是1其余是0。embedding_column则是另一套逻辑。它也先把类别映射成整数ID但这个ID不会用来做one-hot而是拿去做查表。它维护一个形状为类别总数embedding维度的矩阵每一行就是一个类别对应的稠密向量。模型训练的时候这个ID对应的那一行向量会被取出来喂给后面的网络。同时这个矩阵里的数值是可训练的模型会通过反向传播不断调整它。训练完之后每个类别就拥有了一个能表达自身特征的稠密向量。用一个生活化的类比来解释indicator_column像一个门牌号系统每户人家一个编号你报编号邮递员就知道送哪家精确但编号体系很占资源embedding_column像一个“熟人推荐”你跟他说你认识谁他脑子里浮现出这个人的特征画像然后根据画像去推荐不精确但很灵活而且这个画像会越处越准。这个区别直接决定了它们的适用场景。类别数量少几百以内、业务上需要解释性强的用indicator_column类别数量大几万、几十万甚至更多、且类别之间存在语义关联的用embedding_column。这个判断标准后面我会详细展开。2. 核心细节解析与实操要点2.1 indicator_column底层在做什么我们先从TensorFlow的源码层面看看indicator_column到底是怎么实现的。知道了底层逻辑你才能准确地预判它的行为。indicator_column在TensorFlow中接收一个categorical_column作为输入最常见的是categorical_column_with_vocabulary_list。它的核心流程是这样的输入一个原始值比如字符串“北京”。通过一个查找表LookupTable把“北京”映射成一个整数ID假设ID0。根据类别总数构造一个one-hot向量在第0位上置1其余位置置0。这里有一个很关键的细节这个one-hot向量是稀疏表示的。也就是说它在内存里不会真的存一个几万维的数组而是只存非零位置的索引和值。这个设计对性能至关重要不然类别一多内存直接爆炸。indicator_column的API长这样import tensorflow as tf # 第一步定义类别特征列 province_column tf.feature_column.categorical_column_with_vocabulary_list( keyprovince, vocabulary_list[北京, 上海, 广州, 深圳, 杭州] ) # 第二步包装成指示列 province_indicator tf.feature_column.indicator_column(province_column)这里有个初学者经常会犯的错拿原始字符串特征直接去构造模型。比如你的数据集里“province”这一列是字符串你不能直接把这一列丢给DenseFeatures你必须先经过一个categorical_column做字符串到ID的映射再丢给indicator_column做one-hot。原因很简单模型只认数值不认字符串。再说一个实际项目里的经验。indicator_column虽然底层是稀疏的但一旦你把它接入DenseFeatures它会转换成稠密张量。比如你有1000个类别那每个样本在模型里就会变成一个1000维的稠密向量其中只有1个位置是1其余999个是0。这会导致一个问题如果这1000维后面直接接全连接层那这一层的参数就是1000×隐藏单元数计算量和内存消耗都很大。所以在用indicator_column的时候类别总数必须控制在一个合理的范围。我个人的经验是2000以内问题不大超过5000就要掂量掂量了。当然这跟你的机器配置、模型复杂度都有关但方向是明确的indicator_column只适合“类别少、维度可控”的场景。2.2 embedding_column的查表机制和训练方式embedding_column的实现要比indicator_column复杂一些。它的核心是一个Embedding层或者说一个可训练的查找表。它的流程是输入原始字符串比如“华为手机”。通过查找表映射成整数ID假设ID888。在一个形状为类别总数embedding_size的矩阵里取出第888行得到一个长度为embedding_size的稠密向量。这个向量作为特征进入后续的网络层。API长这样import tensorflow as tf # 第一步定义类别特征列这里的num_buckets表示类别总数 product_column tf.feature_column.categorical_column_with_hash_bucket( keyproduct, hash_bucket_size100000 ) # 第二步包装成嵌入列 product_embedding tf.feature_column.embedding_column( categorical_columnproduct_column, dimension16 )这里有一个核心思想要理解embedding矩阵的每一行并不是预先定义好的而是模型训练过程中学出来的。初始的时候这些向量是随机初始化的模型通过损失函数的梯度不断调整这些向量让相似的类别在向量空间里距离更近不相似的类别距离更远。举个实际的例子。做电商推荐的时候商品类别是成千上万的但“手机”和“耳机”这两个类别在用户的点击行为上是有相关性的模型在训练过程中会逐渐把这两个类别的embedding向量学得比较接近。这个“接近”不是我们手动定义的而是数据驱动学出来的。再说说embedding维度怎么选。这是很多人最纠结的问题官方文档给了一个经验法则embedding维度取类别数的4次方根。比如你有10000个类别10000的4次方根是10那embedding维度就取10。你有100万个类别4次方根是31.6那取32左右。这个经验公式我在实际项目中验证过虽然不是绝对最优但作为起点非常靠谱。维度太小表达能力不够模型欠拟合维度太大不仅参数多还容易过拟合而且训练速度明显变慢。我自己做CTR预估的时候类目特征差不多几万个用16维效果就很不错了。还有一个重要的技巧embedding_column支持设置多个共享embedding。比如你有两个特征一个叫“商品品牌”一个叫“商品品类”它们本身是两个独立的类别特征但你可以让它们共享同一个embedding矩阵只要它们的ID空间是一致的。这在迁移学习和多任务场景下特别好用。2.3 两种column的对比什么时候用哪个我把这两个column放到一张表里对比方便你直观地看差异对比维度indicator_columnembedding_column输出形态高维稀疏0/1向量低维稠密向量是否可学习否固定映射是随训练更新内存占用随类别数线性增长容易爆炸相对可控解释性强每一维都有明确含义弱向量维度没有明确含义适用类别数建议几千以内几万到几百万都可以训练速度快但后续层计算量大较慢但整体效率高典型场景小类别特征、需要强解释大规模类别特征、推荐系统这表看完你会发现一个有意思的现象indicator_column看着简单但它真正适合的场景其实很窄。只有当类别数少、且你明确知道每一维的含义很重要的时候才应该用它。最常见的就是一些二值特征比如性别、是否新用户、是否会员这种类别数只有几个用indicator_column完全没问题。embedding_column则是工程上更常用的方案。尤其是推荐系统、广告点击率预估、搜索排序这些场景特征动不动就是几万几百万的ID这种场景下embedding_column几乎是唯一的选择。不过我得提醒一句如果真的只有几个类别你别用embedding_column。比如性别就2个取值你embedding成8维向量不仅增加参数效果也未必比indicator好。杀鸡不用牛刀这是特征工程里的基本修养。3. 实操过程与核心环节实现3.1 完整示例从原始数据到模型训练光讲理论不实操等于白讲。我带大家走一个完整的例子从构造数据开始到训练一个模型把indicator_column和embedding_column都用上。假设我们要做一个简单的电影推荐模型。特征有三个用户ID、电影ID、电影类型。标签是用户是否喜欢这部电影。前两个是大基数的类别特征第三个是小基数的类别特征。首先构造数据import pandas as pd import tensorflow as tf # 模拟数据 data { user_id: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] * 100, movie_id: [101, 102, 103, 104, 105, 106, 107, 108, 109, 110] * 100, genre: [动作, 喜剧, 科幻, 爱情, 动作, 科幻, 喜剧, 爱情, 动作, 科幻] * 100, label: [1, 0, 1, 0, 1, 1, 0, 0, 1, 0] * 100 } df pd.DataFrame(data)接着构造feature_column。用户ID和电影ID基数大用embedding_column电影类型基数小用indicator_column# 用户ID特征做embedding user_id_col tf.feature_column.categorical_column_with_hash_bucket( keyuser_id, hash_bucket_size1000 ) user_emb tf.feature_column.embedding_column( categorical_columnuser_id_col, dimension8 ) # 电影ID特征做embedding movie_id_col tf.feature_column.categorical_column_with_hash_bucket( keymovie_id, hash_bucket_size1000 ) movie_emb tf.feature_column.embedding_column( categorical_columnmovie_id_col, dimension8 ) # 电影类型特征类别少用indicator genre_col tf.feature_column.categorical_column_with_vocabulary_list( keygenre, vocabulary_list[动作, 喜剧, 科幻, 爱情] ) genre_ind tf.feature_column.indicator_column(genre_col) feature_columns [user_emb, movie_emb, genre_ind]注意到这里有个细节用户ID和电影ID在原始数据里是整数但我们还是用categorical_column_with_hash_bucket来处理它们。因为ID虽然是个数字但它本质上是离散的类别不是有大小关系的数值。你不能说用户ID2比用户ID1“大”这个“大”没有任何数学意义。所以类别特征的处理方式不管输入是字符串还是整数逻辑是一样的。然后构造输入函数转成Dataset格式def make_input_fn(data_df, num_epochs, shuffleTrue, batch_size32): def input_fn(): dataset tf.data.Dataset.from_tensor_slices( (dict(data_df), data_df[label]) ) if shuffle: dataset dataset.shuffle(10000) dataset dataset.repeat(num_epochs).batch(batch_size) return dataset return input_fn train_input_fn make_input_fn(df, num_epochs10, shuffleTrue)最后构建模型。用DenseFeatures把feature_column统一处理然后接几个全连接层# 构建模型 model tf.keras.Sequential([ tf.keras.layers.DenseFeatures(feature_columns), tf.keras.layers.Dense(64, activationrelu), tf.keras.layers.Dense(32, activationrelu), tf.keras.layers.Dense(1, activationsigmoid) ]) model.compile( optimizeradam, lossbinary_crossentropy, metrics[accuracy] ) # 训练 model.fit(train_input_fn, epochs10, steps_per_epoch100)这里DenseFeatures是一个关键组件。它接收feature_column列表在模型内部自动完成特征的解析、变换、拼接。indicator_column会在这里变成one-hot向量embedding_column会在这里做查表并输出稠密向量最后它们会被拼接成一个大的特征向量作为后面全连接层的输入。3.2 DenseFeatures的原理以及它和feature_column的关系上面例子里的DenseFeatures你可能会好奇它到底干了什么为什么把feature_column传给一个层前面那一堆转换逻辑就全部搞定了这其实是TensorFlow 2.x一个非常优雅的设计。DenseFeatures本质上是一个预处理层它在内部维护了一张图每个feature_column都对应图上的一个操作节点。数据流进来的时候根据key从输入字典里取出对应的原始特征。对类别特征执行“字符串/数字到ID”的映射。根据column类型执行one-hotindicator_column或查表embedding_column。把所有column的输出拼接成一个大特征向量。这个拼接逻辑要理解一下。假设你有3个特征用户ID embedding后是8维电影ID embedding后是8维电影类型indicator后是4维那最终DenseFeatures输出的向量就是20维。后面的全连接层接收的就是这20维输入。这个设计好在哪里好在你不需要手动去写一堆数据预处理代码。以前没有feature_column的时候你得自己在数据管道里把字符串转成one-hot、把ID转成embedding索引然后再手动拼特征又麻烦又容易出错。现在只要在模型定义里声明feature_columnDenseFeatures帮你把预处理和模型训练无缝衔接起来。不过要注意DenseFeatures结合feature_column的方案在TensorFlow 2.x里已经算是一种“经典但逐渐过时”的用法。TensorFlow官方现在更推荐用Keras的预处理层如tf.keras.layers.CategoryEncoding、tf.keras.layers.Embedding。但理解feature_column依然很有价值因为大量存量项目、生产系统还在用它而且它的设计思路和Keras预处理层一脉相承学会了feature_column再学Keras预处理层就是降维打击。3.3 在真实项目中embedding维度到底怎么定维度选择在真实项目里不是一个纯理论问题它受数据量、模型复杂度、训练资源等多重因素约束。我把自己的实操经验分享出来第一从经验公式出发。类别总数开4次方根得到一个初始值。比如你有10万类别4次方根大约17.8取16或者20都行。这个初始值可以作为第一次实验的基准。第二根据模型表现调整。如果训练集loss下降明显快但验证集效果差出现过拟合迹象说明embedding维度偏大可以按2的倍数缩小试试。反过来如果训练集loss都降不下去欠拟合明显说明维度偏小可以增大。第三考虑后续网络结构。embedding维度会直接影响DenseFeatures输出的总维度进而影响第一层全连接的参数数量。如果embedding维度翻倍第一层全连接的参数也会翻倍训练和推理都会变慢。所以维度不是越大越好够用就行。第四小技巧做消融实验。同一个特征分别用4、8、16、32维训练四个模型对比验证集效果。这是最朴素但最有效的方法。我第一次做的时候40万类别的特征8维和16维效果差不多那我当然选8维训练速度快一倍。顺带说一句embedding列还有个常见配置项叫combiner。默认是sqrtn还支持sum和mean。这个配置在处理多值类别特征时很重要。比如一个用户看过10部电影这10部电影的embedding向量需要合并成一个向量combiner就决定了怎么合并。sum是直接求和mean是取平均sqrtn是求和后除以向量维度的平方根。不用纠结太多默认sqrtn在多数场景下表现稳定如果发现特征贡献度不合理再试试mean。4. 常见问题与排查技巧实录4.1 categorical_column的几个子类型到底选哪个用了feature_column一段时间后你会发现categorical_column有好几个变体经常把人搞晕categorical_column_with_vocabulary_list、categorical_column_with_vocabulary_file、categorical_column_with_hash_bucket、categorical_column_with_identity。我一个个说清楚。categorical_column_with_vocabulary_list你手动把所有类别值列出来。适合类别值已知且固定的情况但缺点是vocabulary_list要自己维护类别多了不现实。categorical_column_with_vocabulary_file跟上面一样但类别值从文件读取。适合类别特别多、不方便写在代码里的情况。categorical_column_with_hash_bucket不维护具体的类别表而是把输入做一个哈希映射到0到hash_bucket_size-1之间。好处是省内存坏处是存在哈希冲突两个不同的类别可能映射到同一个ID上造成信息混淆。categorical_column_with_identity输入本身就是整数ID不需要额外映射。适合那种已经是ID的类别特征比如用户ID、商品ID。实际项目里怎么选我个人的策略是如果类别集合可控用vocabulary_list如果类别集合会动态增长用hash_bucket如果特征是整数ID直接用identity。注意hash_bucket_size必须设置得比实际类别数大一些留出余量减少哈希冲突。比如你有5万类别设成10万或者20万问题不大。4.2 踩过的坑类别特征没经过categorical_column直接喂给模型这个坑我见过太多次了包括我自己早期也犯过。直接拿原始字符串特征去构造DenseFeatures代码一跑直接报错说找不到对应的处理逻辑。核心原因DenseFeatures只会处理你在feature_columns里定义的列。如果你定义了一个embedding_column使用的是user_id这个key那输入数据里必须有user_id这个字段。但如果你定义的是数值列numeric_column输入数据里也必须有对应的key。但如果某个key你没有定义任何feature_column那DenseFeatures不会理会它。报错通常出现在类型不匹配上。比如你的输入是字符串但feature_column定义的是numeric_column那字符串转float直接失败。反过来input是整数但你用了categorical_column_with_vocabulary_list且vocabulary_list是字符串也会报错。解决方式很简单但也容易忽略在你构造Dataset的时候确保输入数据的类型和你定义的feature_column匹配。字符串特征就交给categorical_column_with_vocabulary_list或hash_bucket数值特征就交给numeric_column。不要混。还有一个细节经验DenseFeatures的输入必须是一个字典dictkey对应特征名。很多新手在这里卡壳写着写着就把字典变成了list或者Tensor直接报错。记住这个规则输入给DenseFeatures的永远是一个特征名到值的字典。4.3 哈希冲突的影响以及怎么缓解categorical_column_with_hash_bucket用起来方便但哈希冲突是个绕不开的问题。假设你设了hash_bucket_size1000但实际类别有5000个那平均每个bucket会被5个类别占用。这5个完全不同的类别会被当成同一个ID处理模型无法区分它们。哈希冲突的直接影响是特征表达能力的下降。比如“苹果”水果和“苹果”手机品牌如果冲突了模型就分不清用户提到的是哪个苹果特征信号就糊了。缓解哈希冲突有这么几个手段第一把hash_bucket_size设大减少冲突概率。这个最直接有效。经验值是设成实际类别数的2到4倍。第二对输入值先做归一化或标准化。比如商品ID有大小写之分统一转成小写再去哈希能减少无效的类别数量。第三如果冲突造成了明显的模型效果问题考虑用vocabulary_list方案精确映射不哈希。代价是内存占用偏高。第四用多个哈希函数做bag of tricks。把特征哈希到两个不同的bucket集合里拼接embedding结果能在一定程度上对冲冲突的影响。这个技巧在工业界广告系统里很常见。我自己的经验是哈希冲突并没有想象中那么可怕。原因在于embedding本身是学习出来的如果几个类别在数据中出现的模式相似模型学出来的embedding也会相近倒不一定是坏事。只有当冲突的类别行为模式差异很大时才会有明显影响。所以先做实验发现模型效果确实受影响再考虑方案调整。4.4 训练速度慢、内存爆了怎么办embedding_column用多了遇到的另一个典型问题是训练速度慢、内存占用高。原因很直接embedding矩阵的大小是类别数乘以维度类别数一多矩阵就大训练时反向传播还要更新这整块矩阵慢是正常的。针对内存问题有几个实际操作手段一是使用分片sharding。在大规模分布式训练里embedding矩阵可以按照ID范围切分到不同的机器上每台机器只负责一部分ID的查表和更新。这个在TensorFlow里可以通过嵌入分区配置实现。二是降低维度。这个前面说过了做消融实验选出够用的最小维度。三是过滤低频类别。类别特征往往存在严重的“长尾现象”绝大多数类别只出现一两次。这些低频类别不仅占用存储还会引入噪声。建议在预处理阶段过滤掉出现次数少于阈值的类别统一映射到一个单独的“未知”类别。这个操作对训练速度和模型效果的提升都很明显。我做过一个案例商品ID特征有200万类别其中90%的出现次数不超过5次。过滤之后类别数锐减到20万embedding矩阵直接缩小10倍训练速度上去了模型效果还提升了。因为那些只出现一两次的类别本来就不足以学到可靠的embedding留着只会增加噪声。4.5 indicator_column遇到OOV词表外值怎么办indicator_column用的是精确映射但这也意味着它处理不了词表外的值。如果你的vocabulary_list里只列了1000个词结果线上来了一个新的词不在词表里直接报错或者返回全零向量。这个问题的解决方案是在vocabulary_list里预留一个“未知”标记。比如在列表最后加上一个 然后在数据预处理阶段所有不在词表里的值都替换成这个标记。这样indicator_column始终能处理所有输入不会因为新类别而崩溃。另外categorical_column_with_vocabulary_list有一个参数叫num_oov_buckets专门用来设置OOV桶的数量。比如你设num_oov_buckets1那所有词表外的词都会映射到同一个额外的桶里。这个参数配合词汇表可以更优雅地处理OOV问题。不过说实话如果类别经常变动、新类别层出不穷indicator_column也许本来就不是最合适的方案。这种场景下用hash_bucket方式更稳妥因为它天然具备处理新类别的能力。这是我在实际项目里反复验证过的结论。最后聊点个人的实操体会做了这么多年特征工程我最大的感受是feature_column这套东西看起来只是API层面的封装但它背后的设计思想特别值钱。你把indicator_column和embedding_column搞透了其实就搞懂了“离散特征怎么做向量化”这个深度学习的核心问题。如果说有什么实操上的建议我会说第一别迷信经验公式embedding维度一定要用消融实验去验证第二处理大基数类别特征时先做低频过滤收益立竿见影第三不要一条道走到黑类别少就用indicator类别多用embedding别反着来。后面我自己还在折腾的一个方向是把feature_column和tf.data的数据管道深度结合做流式特征工程。在超大规模推荐系统里特征的实时更新是一个非常大的挑战embedding的增量训练、低频特征的上线下架都有很多细节可以打磨。这些内容如果大家感兴趣我后面再单独写文章分享。
返回列表