在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,假设ID=0。
- 根据类别总数,构造一个one-hot向量,在第0位上置1,其余位置置0。
这里有一个很关键的细节:这个one-hot向量是稀疏表示的。也就是说,它在内存里不会真的存一个几万维的数组,而是只存非零位置的索引和值。这个设计对性能至关重要,不然类别一多,内存直接爆炸。
indicator_column的API长这样:
import tensorflow as tf # 第一步:定义类别特征列 province_column = tf.feature_column.categorical_column_with_vocabulary_list( key='province', 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,假设ID=888。
- 在一个形状为(类别总数,embedding_size)的矩阵里,取出第888行,得到一个长度为embedding_size的稠密向量。
- 这个向量作为特征进入后续的网络层。
API长这样:
import tensorflow as tf # 第一步:定义类别特征列(这里的num_buckets表示类别总数) product_column = tf.feature_column.categorical_column_with_hash_bucket( key='product', hash_bucket_size=100000 ) # 第二步:包装成嵌入列 product_embedding = tf.feature_column.embedding_column( categorical_column=product_column, dimension=16 )这里有一个核心思想要理解: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_column | embedding_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( key='user_id', hash_bucket_size=1000 ) user_emb = tf.feature_column.embedding_column( categorical_column=user_id_col, dimension=8 ) # 电影ID特征,做embedding movie_id_col = tf.feature_column.categorical_column_with_hash_bucket( key='movie_id', hash_bucket_size=1000 ) movie_emb = tf.feature_column.embedding_column( categorical_column=movie_id_col, dimension=8 ) # 电影类型特征,类别少,用indicator genre_col = tf.feature_column.categorical_column_with_vocabulary_list( key='genre', 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虽然是个数字,但它本质上是离散的类别,不是有大小关系的数值。你不能说用户ID=2比用户ID=1“大”,这个“大”没有任何数学意义。所以类别特征的处理方式,不管输入是字符串还是整数,逻辑是一样的。
然后构造输入函数,转成Dataset格式:
def make_input_fn(data_df, num_epochs, shuffle=True, batch_size=32): 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_epochs=10, shuffle=True)最后构建模型。用DenseFeatures把feature_column统一处理,然后接几个全连接层:
# 构建模型 model = tf.keras.Sequential([ tf.keras.layers.DenseFeatures(feature_columns), tf.keras.layers.Dense(64, activation='relu'), tf.keras.layers.Dense(32, activation='relu'), tf.keras.layers.Dense(1, activation='sigmoid') ]) model.compile( optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'] ) # 训练 model.fit(train_input_fn, epochs=10, steps_per_epoch=100)这里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-hot(indicator_column)或查表(embedding_column)。
- 把所有column的输出拼接成一个大特征向量。
这个拼接逻辑要理解一下。假设你有3个特征:用户ID embedding后是8维,电影ID embedding后是8维,电影类型indicator后是4维,那最终DenseFeatures输出的向量就是20维。后面的全连接层接收的就是这20维输入。
这个设计好在哪里?好在你不需要手动去写一堆数据预处理代码。以前没有feature_column的时候,你得自己在数据管道里把字符串转成one-hot、把ID转成embedding索引,然后再手动拼特征,又麻烦又容易出错。现在只要在模型定义里声明feature_column,DenseFeatures帮你把预处理和模型训练无缝衔接起来。
不过要注意,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的输入必须是一个字典(dict),key对应特征名。很多新手在这里卡壳,写着写着就把字典变成了list或者Tensor,直接报错。记住这个规则:输入给DenseFeatures的,永远是一个特征名到值的字典。
4.3 哈希冲突的影响,以及怎么缓解
categorical_column_with_hash_bucket用起来方便,但哈希冲突是个绕不开的问题。假设你设了hash_bucket_size=1000,但实际类别有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_buckets=1,那所有词表外的词都会映射到同一个额外的桶里。这个参数配合词汇表,可以更优雅地处理OOV问题。
不过说实话,如果类别经常变动、新类别层出不穷,indicator_column也许本来就不是最合适的方案。这种场景下,用hash_bucket方式更稳妥,因为它天然具备处理新类别的能力。这是我在实际项目里反复验证过的结论。
最后聊点个人的实操体会
做了这么多年特征工程,我最大的感受是:feature_column这套东西,看起来只是API层面的封装,但它背后的设计思想特别值钱。你把indicator_column和embedding_column搞透了,其实就搞懂了“离散特征怎么做向量化”这个深度学习的核心问题。
如果说有什么实操上的建议,我会说:第一,别迷信经验公式,embedding维度一定要用消融实验去验证;第二,处理大基数类别特征时,先做低频过滤,收益立竿见影;第三,不要一条道走到黑,类别少就用indicator,类别多用embedding,别反着来。
后面我自己还在折腾的一个方向,是把feature_column和tf.data的数据管道深度结合,做流式特征工程。在超大规模推荐系统里,特征的实时更新是一个非常大的挑战,embedding的增量训练、低频特征的上线下架,都有很多细节可以打磨。这些内容如果大家感兴趣,我后面再单独写文章分享。