1. 从推荐系统的“记忆与泛化”困局说起
做推荐算法或者广告点击率预估的朋友,大概率都经历过这样的场景:模型离线AUC涨了两个千分点,上线后CTR却纹丝不动,甚至偶尔还往下掉。排查一圈发现,问题往往出在模型对特征交叉的捕捉能力上——要么只记住了历史共现过的组合,要么泛化到没见过的组合时直接“摆烂”。DeepFM这个结构,就是冲着这个痛点来的。
它的核心思路其实不复杂:用FM部分负责低阶特征交叉,用Deep部分负责高阶非线性交叉,两者共享同一套Embedding输入。这样一来,模型既能像逻辑回归那样“记住”高频共现模式,又能像深度网络那样“泛化”到稀疏甚至未出现过的组合。TensorFlow 2.0 的Keras API把这件事的实现门槛拉低了不少,但真动手写的时候,从特征编码、Embedding维度选择、到训练时的数值稳定性,坑一个都不少。
这篇内容适合两类人:一是刚接触CTR预估、想找一个完整可跑通Demo的算法工程师;二是已经用过DeepFM但对其内部数据流和调参细节还有些模糊的从业者。我会把整个实现拆成可复现的步骤,代码直接给,数据用公开的Criteo采样版或者自己构造的模拟数据都行。重点不是“跑通”,而是“跑明白”——每个参数为什么这么设,每个层为什么这么接,训练过程中哪些信号说明模型在正常工作。
提示:本文代码基于TensorFlow 2.x + Keras函数式API,不依赖TF 1.x的Session机制。如果你还在用
tf.compat.v1,建议先切到2.x的写法,后续维护成本会低很多。
2. DeepFM的结构拆解:FM与Deep到底怎么共享Embedding
2.1 FM部分的数学本质与实现映射
FM(Factorization Machine)处理二阶交叉的公式大家应该都见过:
[ y_{FM} = w_0 + \sum_{i=1}^{n} w_i x_i + \sum_{i=1}^{n}\sum_{j=i+1}^{n} \langle v_i, v_j \rangle x_i x_j ]
其中(v_i)是第i个特征的隐向量。关键点在于,二阶项可以通过代数变换在(O(nk))时间内算完,不需要显式构造(n^2)个交叉项。在代码里,这件事被拆成三步:先对Embedding矩阵按特征维度求和再平方,再对平方后的Embedding求和,两者相减除以2。
我见过不少实现直接把Embedding层输出拿去做tf.reduce_sum,但忘了区分“特征维度”和“Embedding维度”。假设batch_size=1024,特征数=39,Embedding维度=8,那么Embedding层输出形状是(1024, 39, 8)。求和要在axis=1上做,得到(1024, 8),而不是在axis=2上。这个细节搞错,FM项的输出形状就完全不对了。
# FM二阶项的核心计算 sum_square = tf.square(tf.reduce_sum(embeddings, axis=1)) # (batch, k) square_sum = tf.reduce_sum(tf.square(embeddings), axis=1) # (batch, k) fm_second_order = 0.5 * tf.reduce_sum(sum_square - square_sum, axis=1, keepdims=True)这段代码看起来简单,但sum_square - square_sum在数值上可能因为浮点误差出现微小负值,虽然对最终结果影响不大,但在调试阶段如果打印出来看到负数,不要慌,这是正常的。
2.2 Deep部分的输入为什么不能直接用原始特征
Deep部分接收的是所有特征的Embedding拼接后的向量。这里有一个常见误解:有人觉得Deep部分应该单独学一套Embedding,和FM部分分开。但DeepFM论文明确说了,两部分共享同一套Embedding。共享的好处是:FM部分的低阶交叉信号会通过梯度回传到Embedding,帮助Deep部分学到更好的特征表示;反过来,Deep部分的高阶信号也会影响Embedding,让FM的二阶项不是孤立训练的。
实现上,你只需要定义一个Embedding层,然后FM和Deep都从它取输出。Deep部分把(batch, field_num, embedding_dim)展平成(batch, field_num * embedding_dim),后面接全连接。展平前要不要做Flatten还是Reshape?Keras的Flatten默认从最后一个维度开始展平,对于(batch, 39, 8)会得到(batch, 312),顺序是field0的8维、field1的8维……这个顺序无所谓,只要全连接层能学到对应权重就行。
2.3 输出层的合并方式与损失函数选择
FM部分输出一个标量,Deep部分输出一个标量,两者相加再过sigmoid得到最终CTR预估值。这里有人会问:能不能用加权求和?比如alpha * fm + beta * deep?理论上可以,但实践中直接相加就够了,因为网络自己会学到合适的尺度。加权重反而引入额外超参,增加调参负担。
损失函数用binary_crossentropy,优化器我习惯用Adam,学习率从1e-3开始试。如果数据量很大(千万级以上),可以考虑Adagrad或者带衰减的Adam,但小规模数据上Adam收敛更快。评估指标除了AUC,建议加上binary_accuracy和logloss,AUC看排序能力,logloss看概率校准,两者结合才能判断模型是否真的可用。
3. 特征工程:从原始字段到模型可吃的输入
3.1 数值特征与类别特征的分治策略
CTR数据通常包含两类特征:数值型(如年龄、价格、点击次数)和类别型(如性别、城市、广告位ID)。DeepFM对两类特征的处理方式不同:数值特征可以直接作为一维输入,也可以离散化后当类别特征用;类别特征必须经过LabelEncoder或者Hash映射成整数索引,再送进Embedding层。
我的经验是:数值特征如果分布比较均匀,直接归一化后作为单独的一列输入;如果分布长尾严重(比如用户历史点击次数,大部分是0,少数是几千),最好做分桶离散化,然后当类别特征处理。分桶的边界可以用等频分桶或者基于业务经验设定。比如点击次数可以分成[0, 1, 2, 5, 10, 50, 100, +inf],每个桶一个ID。
# 数值特征分桶示例 def bucketize(value, boundaries): for i, b in enumerate(boundaries): if value < b: return i return len(boundaries)3.2 类别特征的编码陷阱:LabelEncoder不是万能的
LabelEncoder把字符串映射成0到N-1的整数,看起来很方便,但有两个坑。第一,如果训练集里没出现过的类别在验证集或线上出现,LabelEncoder会直接报错。解决办法是用handle_unknown='ignore'的OrdinalEncoder,或者自己维护一个字典,未知类别统一映射到0。第二,类别数很多的时候(比如城市有几百个),Embedding层的参数量会爆炸。这时候要么做频率过滤(出现次数少于阈值的类别归为“其他”),要么用Hash Trick把高基数特征映射到固定维度。
我一般会统计每个类别特征的出现频率,把频率低于10的类别合并成__UNK__,这样既能控制Embedding规模,又不会丢失太多信息。对于广告ID这种基数可能上百万的特征,直接Hash到10万维,冲突带来的噪声在CTR场景下通常可以接受。
3.3 特征拼接与输入层的组织方式
Keras函数式API里,每个特征对应一个Input层,然后所有Input拼成一个列表送进模型。这里有个细节:数值特征和类别特征的Input形状不同。数值特征通常是(1,),类别特征是(1,)的整数索引。Embedding层只接受整数输入,所以类别特征的Input要指定dtype='int32'。
from tensorflow.keras.layers import Input, Embedding, Flatten, Concatenate, Dense # 假设有3个数值特征和5个类别特征 num_inputs = [Input(shape=(1,), name=f'num_{i}') for i in range(3)] cat_inputs = [Input(shape=(1,), dtype='int32', name=f'cat_{i}') for i in range(5)] # 类别特征Embedding embeddings = [Embedding(input_dim=vocab_sizes[i], output_dim=8)(cat_inputs[i]) for i in range(5)]注意Embedding层的input_dim必须大于等于实际最大索引+1,否则会报越界错误。我习惯在编码后打印一下max_index,确保input_dim设得比它大。
4. 用Keras函数式API搭一个能跑的DeepFM
4.1 模型定义:从Input到Output的完整数据流
下面是一个完整的DeepFM实现,假设你已经把特征处理成了X_num(数值特征矩阵)和X_cat(类别特征矩阵)。代码里我加了注释说明每一步的形状变化。
import tensorflow as tf from tensorflow.keras.layers import Input, Embedding, Dense, Flatten, Concatenate, Dropout from tensorflow.keras.models import Model def build_deepfm(num_feature_num, cat_feature_num, vocab_sizes, embedding_dim=8, deep_layers=[64, 32]): # 输入层 num_inputs = [Input(shape=(1,), name=f'num_{i}') for i in range(num_feature_num)] cat_inputs = [Input(shape=(1,), dtype='int32', name=f'cat_{i}') for i in range(cat_feature_num)] # Embedding层:所有类别特征共享一个Embedding矩阵(也可以每个特征独立) # 这里为了简化,每个特征独立Embedding cat_embeddings = [] for i in range(cat_feature_num): emb = Embedding(input_dim=vocab_sizes[i], output_dim=embedding_dim, name=f'emb_{i}')(cat_inputs[i]) cat_embeddings.append(emb) # 形状: (batch, 1, embedding_dim) # 拼接所有Embedding: (batch, cat_feature_num, embedding_dim) concat_emb = Concatenate(axis=1)(cat_embeddings) # FM部分 sum_square = tf.square(tf.reduce_sum(concat_emb, axis=1)) # (batch, embedding_dim) square_sum = tf.reduce_sum(tf.square(concat_emb), axis=1) # (batch, embedding_dim) fm_second = 0.5 * tf.reduce_sum(sum_square - square_sum, axis=1, keepdims=True) # (batch, 1) # 一阶项:数值特征直接加,类别特征用Embedding的sum first_order_num = Concatenate()(num_inputs) # (batch, num_feature_num) first_order_cat = Concatenate()([Embedding(input_dim=vocab_sizes[i], output_dim=1)(cat_inputs[i]) for i in range(cat_feature_num)]) first_order = Concatenate()([first_order_num, Flatten()(first_order_cat)]) first_order_out = Dense(1, use_bias=False)(first_order) # (batch, 1) # Deep部分 deep_input = Flatten()(concat_emb) # (batch, cat_feature_num * embedding_dim) deep_input = Concatenate()([deep_input] + num_inputs) # 拼上数值特征 x = deep_input for units in deep_layers: x = Dense(units, activation='relu')(x) x = Dropout(0.2)(x) deep_out = Dense(1)(x) # 合并 fm_out = first_order_out + fm_second final_out = tf.keras.layers.Add()([fm_out, deep_out]) final_out = tf.keras.layers.Activation('sigmoid')(final_out) model = Model(inputs=num_inputs + cat_inputs, outputs=final_out) return model这段代码里,first_order_cat用了单独的Embedding(output_dim=1),其实可以复用前面的Embedding然后取sum,但为了逻辑清晰,单独定义也没问题。Dropout加在Deep部分,FM部分不加,因为FM本身参数量少,加Dropout反而影响低阶信号的传递。
4.2 编译与训练:优化器、损失和回调的配置
编译模型时,Adam的默认学习率是1e-3,对DeepFM来说通常够用。如果训练loss震荡厉害,可以降到5e-4。损失函数用binary_crossentropy,注意from_logits=False,因为最后一层已经过了sigmoid。
model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss='binary_crossentropy', metrics=['AUC', 'binary_accuracy'] ) callbacks = [ tf.keras.callbacks.EarlyStopping(patience=3, restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=2) ] history = model.fit( x=[X_num[:, i] for i in range(num_feature_num)] + [X_cat[:, i] for i in range(cat_feature_num)], y=y_train, validation_split=0.2, batch_size=1024, epochs=20, callbacks=callbacks )EarlyStopping的patience设3,ReduceLROnPlateau的patience设2,这两个配合使用能省不少调参时间。注意restore_best_weights=True,否则早停后模型权重是最后一轮的,可能已经过拟合了。
4.3 训练过程中的数值稳定性问题
DeepFM训练时最常见的问题是loss变成NaN。原因通常有三个:一是学习率太大,二是Embedding层初始化不当,三是输入数据里有异常值。排查顺序建议从数据入手:先检查数值特征有没有inf或者超大值,做一下clip;然后看类别索引有没有负数或者超出input_dim;最后再调学习率。
如果loss在几百步后突然NaN,大概率是梯度爆炸。可以在Adam里加clipnorm=1.0,或者把学习率降到1e-4。另外,Embedding层的初始化用默认的uniform就行,不要用zeros,否则所有特征初始表示相同,梯度对称,学不出差异。
5. 调参实战:Embedding维度、Deep层数和Dropout的取舍
5.1 Embedding维度不是越大越好
Embedding维度k的选择有个经验公式:(k \approx \sqrt[4]{N}),其中N是类别总数。但实际调参时,我一般从8开始试,然后试16、32。维度太小,模型容量不够,AUC上不去;维度太大,参数量爆炸,训练慢且容易过拟合。对于类别数在几百到几千的特征,8到16通常够用;对于上百万基数的特征,32到64可能更合适,但要注意内存。
有一个判断方法:训练完后看Embedding矩阵的奇异值分布。如果大部分奇异值都很小,说明维度冗余,可以降;如果奇异值衰减很慢,说明维度可能不够。
5.2 Deep部分层数与宽度的平衡
Deep部分的层数和每层神经元数,我试过的组合里,[64, 32]和[128, 64, 32]最常见。层数太多容易过拟合,尤其当训练数据少于百万级时。宽度方面,第一层可以宽一点(64或128),后面逐层减半。激活函数用relu,不要用tanh或sigmoid,后者在深层网络里梯度消失严重。
如果验证集AUC比训练集低很多(比如差5个千分点以上),说明过拟合了。这时候要么减层数,要么加Dropout,要么加L2正则。我一般先加Dropout(0.2到0.5),效果不明显再加L2。
5.3 学习率与Batch Size的联动调整
学习率和batch size是联动的。经验规则是:batch size翻倍,学习率也翻倍(线性缩放)。但DeepFM里Embedding层的梯度稀疏,线性缩放不一定最优。我通常固定batch size=1024,然后调学习率。如果GPU内存够,batch size可以到4096,学习率相应调到4e-3左右。
另外,Adam的epsilon默认是1e-7,如果Embedding维度很大,可以调到1e-6或1e-5,避免分母过小导致梯度异常。
6. 踩坑记录:从数据准备到线上部署的五个真实问题
6.1 类别特征索引越界:一个低级但致命的错误
第一次跑DeepFM时,训练到第3个epoch突然报InvalidArgumentError: indices[5,0] = 12345 is not in [0, 10000)。原因是某个类别特征的input_dim设成了10000,但编码后的最大索引是12345。这种错误在数据预处理阶段不容易发现,因为LabelEncoder不会告诉你最大索引是多少。解决办法是在编码后加一行print(X_cat.max(axis=0)),然后根据最大值设input_dim。
6.2 验证集AUC不升反降:过拟合的早期信号
训练到第5个epoch时,训练AUC还在涨,验证AUC开始跌。这是典型的过拟合。我当时的处理是加Dropout和EarlyStopping,但效果一般。后来发现根本原因是训练集和验证集的分布不一致——验证集里有一些新出现的类别,Embedding层没见过,输出随机。解决办法是把验证集里出现次数少于阈值的类别也归到__UNK__,保证训练和验证的类别空间一致。
6.3 模型保存与加载:自定义层的序列化问题
用model.save('deepfm.h5')保存后,加载时报Unknown layer: tf.square之类的错误。原因是模型里用了tf.square、tf.reduce_sum这些TensorFlow原生操作,Keras保存时无法序列化。解决办法是用tf.keras.layers.Lambda包装这些操作,或者改用Keras自带的层。更稳妥的方式是保存权重model.save_weights('deepfm_weights.h5'),加载时先重建模型结构再load_weights。
6.4 线上推理性能:Embedding查表是瓶颈
离线训练时感觉模型很快,但上线后发现QPS上不去。排查发现瓶颈在Embedding查表——每个请求要查几十个Embedding向量,如果Embedding矩阵很大(比如几百万乘64),内存带宽吃紧。优化方法有两个:一是把Embedding矩阵转成float16,内存减半,精度损失通常可接受;二是对低频特征做降维或者Hash,减少查表次数。
6.5 特征版本管理:训练和线上不一致的隐形杀手
最隐蔽的坑是训练时用的特征编码和线上不一致。比如训练时用LabelEncoder把城市映射成0到300的整数,线上服务用的是另一套映射,导致Embedding查出来的向量完全不对。解决办法是维护一个统一的特征字典文件,训练和线上都从同一个文件加载映射关系。每次特征更新时,版本号要同步。
7. 从离线AUC到线上CTR:模型评估的完整链路
7.1 离线评估指标的选择与解读
AUC是CTR模型最常用的离线指标,但它只反映排序能力,不反映概率校准。如果业务方需要准确的CTR预估值(比如用于出价),还要看logloss和calibration curve。我一般会同时看AUC、logloss和平均预估CTR与实际CTR的比值。如果AUC高但logloss也高,说明模型排序对但概率偏了,可能需要做 Platt Scaling 或者 Isotonic Regression 校准。
7.2 线上AB实验的设计要点
离线AUC涨了不代表线上CTR一定涨。上线前一定要做AB实验,实验组和对照组的流量要随机分配,实验周期至少覆盖一个完整的用户行为周期(通常7天)。观察指标除了CTR,还要看人均曝光、人均点击、以及长期留存。有时候CTR涨了但人均曝光跌了,整体收益可能是负的。
7.3 模型迭代的节奏与回滚机制
CTR模型不是一劳永逸的,数据分布会漂移,特征会失效。我一般每周重新训练一次,每天监控线上指标。如果发现CTR连续两天下降超过阈值,自动回滚到上一个版本。回滚机制要在上线前就准备好,不要等出问题了再临时处理。
8. 一些让DeepFM更稳的工程技巧
8.1 用tf.data构建输入管道
当数据量很大时,用tf.data.Dataset比直接传numpy数组更高效。可以把数值特征和类别特征分别做成Dataset,然后zip起来,再用batch和prefetch。这样GPU利用率会高很多,训练速度能提升30%以上。
def make_dataset(X_num, X_cat, y, batch_size=1024): num_ds = tf.data.Dataset.from_tensor_slices(X_num) cat_ds = tf.data.Dataset.from_tensor_slices(X_cat) y_ds = tf.data.Dataset.from_tensor_slices(y) ds = tf.data.Dataset.zip((num_ds, cat_ds, y_ds)) ds = ds.shuffle(10000).batch(batch_size).prefetch(tf.data.AUTOTUNE) return ds8.2 混合精度训练:省显存但不一定省时间
TensorFlow支持mixed_float16,理论上能省一半显存,训练速度也能提升。但DeepFM里Embedding层对精度敏感,用float16可能导致梯度下溢。我的建议是:Embedding层保持float32,全连接层用float16。实现上用tf.keras.mixed_precision.set_global_policy('mixed_float16'),然后对Embedding层单独指定dtype='float32'。
8.3 模型剪枝与量化:为线上推理瘦身
如果线上推理资源紧张,可以对DeepFM做剪枝:把Embedding矩阵里接近零的行去掉,对应的类别映射到__UNK__。量化方面,把float32转成int8,模型大小能压缩到四分之一,推理速度提升2到3倍。但量化后AUC可能掉1到2个千分点,需要评估是否值得。
8.4 监控Embedding的更新幅度
训练过程中,可以定期打印Embedding矩阵的L2范数。如果范数增长过快,说明学习率太大或者正则不够;如果范数几乎不变,说明Embedding没学到东西,可能是梯度被截断或者学习率太小。这个监控指标比loss更直观,能提前发现训练异常。
9. 我个人在实际操作中的几点体会
DeepFM这个结构从2017年提出到现在,工业界用了很多年,依然是个很稳的baseline。它的优势不在于多复杂,而在于结构清晰、容易实现、效果可预期。我试过用更复杂的模型(比如xDeepFM、AutoInt)去替代它,离线AUC确实能涨一点,但工程复杂度和维护成本高不少,最后往往还是回到DeepFM。
调参方面,我的经验是:Embedding维度和Deep层数对效果的影响最大,学习率和Dropout次之,batch size影响最小。如果时间有限,优先调前两个。另外,特征工程的质量比模型结构重要得多——同样的DeepFM,特征做得好坏能差5个千分点以上的AUC。
最后分享一个小技巧:训练时把验证集的AUC按epoch打印出来,画成曲线。如果曲线是“先升后降”,说明过拟合,早停点选在最高点;如果曲线一直平,说明模型容量不够或者特征没信息量,加层数或加特征;如果曲线震荡厉害,说明学习率太大或者batch size太小。这个曲线比任何指标都直观,建议每次训练都看一眼。