1. 从手工特征到端到端建模:DeepFM到底解决了什么问题
推荐系统这个领域,做过几年的人都有一个共同感受:模型结构换了一茬又一茬,但真正卡住效果的,往往不是DNN那几层全连接,而是特征交叉这一环。早年做CTR预估,LR加人工特征工程是标配,一个团队里最值钱的往往不是算法工程师,而是那个能把特征组合玩出花的特征工程老手。后来FM出来了,用隐向量内积自动捕捉二阶交叉,省了一部分手工活;再后来DeepFM把FM和DNN拼在一起,让低阶交叉和高阶交叉同时端到端学习,这才算把“特征交叉”这件事从手工时代推进到了模型时代。
DeepFM这个结构,全称是Deep Factorization Machine,最早是在一篇工业界论文里提出的,核心动机很直接:Wide&Deep虽然把记忆能力和泛化能力结合了,但Wide那部分还是得靠人工设计交叉特征,而DeepFM把Wide部分换成了FM,让二阶交叉也变成自动学习的。这样一来,输入层到输出层之间,低阶部分由FM负责,高阶部分由DNN负责,两边共享同一份embedding输入,整个网络可以联合训练。
它适合谁看?如果你正在做CTR预估、推荐排序、广告点击率预测这类任务,手里有一堆稀疏的类别特征,又不想把大量精力耗在手工交叉上,那DeepFM是一个性价比很高的baseline。它不算新,但足够稳,工业界落地案例多,调参经验也相对成熟。下面我会从设计思路、核心细节、实操实现、问题排查几个角度,把我在实际项目里踩过的坑和总结的经验完整拆一遍。
2. 整体设计思路拆解:为什么是FM加DNN,而不是别的组合
2.1 从LR、FM到DeepFM的演进逻辑
要理解DeepFM为什么长这样,得先看清楚它前面几代模型各自卡在哪。
LR的问题最明显:它本质上是一个线性模型,特征之间没有交互。你想让它捕捉“用户是男性且年龄在20到25岁且喜欢篮球”这种组合信号,只能手动构造交叉特征。特征少的时候还行,特征一多,组合爆炸,人工根本枚举不过来。
FM的改进在于,它给每个特征分配一个隐向量,二阶交叉项用两个隐向量的内积表示。这样一来,即使两个特征在训练数据里从没共现过,只要它们各自和其他特征有过交互,隐向量就能学到一定意义,交叉权重依然可以估计。这是FM相比LR最大的优势:泛化能力强,能处理稀疏数据。
但FM只做到二阶。现实中的特征交互往往更高阶、更复杂。比如“用户年龄段 × 商品类目 × 时间段 × 价格区间”这种四阶组合,FM表达不了。于是有了DNN路线,用多层非线性变换去拟合高阶交叉。但纯DNN有个问题:它对低阶交叉的捕捉并不显式,尤其是二阶这种在推荐里极其重要的交互,DNN往往学得不够好,需要靠FM来补。
Wide&Deep的思路是Wide部分记低阶、Deep部分记高阶,但Wide部分需要人工设计交叉特征,又回到了手工时代。DeepFM的聪明之处就在于:用FM替换Wide,既保留了低阶交叉的自动学习,又和DNN共享embedding,训练效率高,结构也干净。
2.2 共享embedding这个设计到底妙在哪
DeepFM结构里有一个容易被忽略但非常关键的细节:FM部分和DNN部分共享同一份embedding。
这件事的意义不只是省参数。你想,如果FM和DNN各用一套embedding,那FM学到的隐向量和DNN学到的稠密表示就是两套东西,低阶交叉和高阶交叉之间没有信息流通。共享之后,embedding同时受到FM损失和DNN损失的梯度更新,相当于一个表示要同时服务于低阶和高阶两个目标,学出来的向量更全面。
从工程角度看,共享embedding也意味着参数量减少,训练时显存占用更低,推理时只需要查一次表。对于特征维度动辄上百万甚至上千万的推荐场景,这个优化很实在。
我在实际项目里对比过共享和不共享两个版本,共享版本在AUC上通常能高出0.2到0.5个百分点,训练收敛也更快。当然这不是绝对的,具体还要看数据分布和特征质量,但共享embedding作为默认选择,基本不会错。
2.3 和Wide&Deep、NFM、xDeepFM的横向对比
选模型的时候,光看论文不够,得知道各自适合什么场景。我整理了一个对比表,方便你快速判断。
| 模型 | 低阶交叉 | 高阶交叉 | 是否需手工特征 | 参数量 | 适用场景 |
|---|---|---|---|---|---|
| LR | 无 | 无 | 需要 | 少 | 特征极少、可解释性优先 |
| FM | 二阶 | 无 | 不需要 | 中 | 稀疏特征、二阶为主 |
| Wide&Deep | 人工设计 | DNN | Wide需手工 | 中 | 有明确交叉经验 |
| DeepFM | 二阶自动 | DNN | 不需要 | 中 | 通用CTR baseline |
| NFM | 二阶 | DNN(带交互池化) | 不需要 | 中 | 高阶交叉更重要 |
| xDeepFM | 二阶 | CIN显式高阶 | 不需要 | 大 | 数据充足、追求极致 |
从落地角度看,DeepFM是那个“不知道选什么就选它”的选项。xDeepFM理论上更强,但CIN部分计算量大,调参也更敏感,数据量不够的时候反而容易过拟合。NFM在二阶之后加了一个交互池化层,对高阶交叉的建模更直接,但结构上不如DeepFM通用。所以如果你刚开始搭推荐排序模型,DeepFM是很好的起点。
3. 核心细节解析与实操要点
3.1 输入特征的处理:稀疏类别特征怎么喂进去
DeepFM的输入是典型的推荐系统特征:大量高基数类别特征,比如用户ID、商品ID、类目ID、城市、设备型号等等。这些特征不能直接扔进网络,得先做处理。
标准流程是这样的:每个类别特征先做Label Encoding,映射成整数索引,然后通过embedding层转成稠密向量。数值特征可以直接归一化后拼接到DNN部分,但FM部分通常只处理类别特征,因为FM的隐向量是针对离散特征的。
这里有个细节要注意:高基数特征(比如用户ID有几百万个取值)的embedding维度不能设太大,否则参数量爆炸。经验值是embedding维度取k = log2(基数)或者直接固定为8到16。我一般先用16试,如果模型欠拟合再往上加,过拟合就往下减。
另一个坑是低频特征的过滤。用户ID里可能有大量只出现一两次的长尾值,这些值的embedding学不好,反而引入噪声。常见做法是设一个最小出现次数阈值,比如10次,低于这个值的统一归到“其他”类别。这个操作在工业界几乎是标配,能明显提升模型稳定性。
3.2 FM部分的二阶交叉计算细节
FM的二阶项公式看起来简单,但实现的时候有几个点容易出错。
标准FM二阶项是:
sum_{i<j} <v_i, v_j> * x_i * x_j直接双重循环计算复杂度是O(n^2),n是特征数。实际实现要用化简公式:
0.5 * ((sum_i v_i * x_i)^2 - sum_i (v_i * x_i)^2)这个化简把复杂度降到O(n*k),k是embedding维度。在代码里,就是先算embedding加权和,再算embedding平方的加权和,两者相减乘0.5。
注意:这个化简只在x_i取0或1的时候严格成立,也就是特征必须是one-hot或者multi-hot形式。如果你的特征做了归一化变成连续值,这个公式就不对了,得用原始的双重循环或者别的处理方式。
我在早期实现的时候踩过这个坑,当时把数值特征也塞进了FM部分,结果loss一直不降,排查了半天才发现是公式用错了。后来把FM部分严格限制在类别特征上,问题就消失了。
3.3 DNN部分的层数、激活和Dropout选择
DNN部分相对标准,但也不是随便堆几层就行。
层数方面,推荐场景下一般2到3层隐藏层就够了。太深容易过拟合,而且训练慢。我常用的结构是[400, 400, 400]或者[256, 128, 64],前者适合特征维度大的场景,后者适合特征相对少的。每层之间用ReLU激活,这是最稳的选择,不用折腾。
Dropout是必须加的,推荐数据噪声大,不加Dropout很容易过拟合。Dropout率一般设0.5左右,如果发现训练集AUC远高于验证集,可以加到0.6或0.7。但也不要太高,太高会导致欠拟合,模型学不动。
还有一个细节是Batch Normalization。DeepFM原论文里没提BN,但实际用下来,在embedding层之后、DNN第一层之前加一个BN,对训练稳定性帮助很大,尤其是特征尺度差异大的时候。不过BN会引入额外计算,如果线上推理延迟敏感,可以权衡一下。
3.4 损失函数与优化器的选择
CTR预估是二分类问题,损失函数用Binary Cross Entropy,这个没悬念。
优化器方面,Adam是默认选择,学习率一般设1e-3到1e-4。我习惯用1e-3起步,配合学习率衰减,比如每多少步衰减0.1。如果训练不稳定,可以换成AdamW,对权重衰减的处理更合理。
Batch size的话,推荐场景下通常设256到1024。太小训练慢,太大梯度估计不准。我一般用512,显存够就上1024。
实操心得:DeepFM对学习率比较敏感,尤其是embedding层和DNN层如果共用同一个学习率,有时候embedding学得太快、DNN跟不上,或者反过来。一个技巧是给embedding层设置更小的学习率,比如DNN用1e-3,embedding用1e-4,这样两边收敛更同步。这个在TensorFlow和PyTorch里都可以通过参数组实现。
4. 实操过程与核心环节实现
4.1 数据准备与特征工程流水线
假设你手里有一份推荐日志数据,每行是一条曝光记录,包含用户特征、物品特征、上下文特征,以及标签(是否点击)。第一步是把它整理成模型能吃的格式。
我一般会写一个特征配置文件,把每个特征的类型、基数、embedding维度、是否参与FM、是否参与DNN都定义清楚。这样后面换数据或者加特征的时候,改配置就行,不用动代码。
# 特征配置示例 feature_config = { "user_id": {"type": "categorical", "vocab_size": 500000, "emb_dim": 16, "use_fm": True, "use_dnn": True}, "item_id": {"type": "categorical", "vocab_size": 200000, "emb_dim": 16, "use_fm": True, "use_dnn": True}, "category_id": {"type": "categorical", "vocab_size": 5000, "emb_dim": 8, "use_fm": True, "use_dnn": True}, "price": {"type": "numeric", "use_fm": False, "use_dnn": True}, "hour": {"type": "categorical", "vocab_size": 24, "emb_dim": 4, "use_fm": True, "use_dnn": True}, }数值特征比如价格,做分桶离散化之后也可以参与FM,但要看具体场景。如果价格和点击的关系是非线性的,分桶后进FM可能有帮助;如果关系比较平滑,直接归一化进DNN就够了。
数据划分上,按时间切分比随机切分更合理。推荐系统有时间效应,随机切分会导致未来信息泄露,验证集AUC虚高。我一般用最近7天做验证,之前的数据做训练。
4.2 模型搭建:PyTorch实现关键代码
下面是一个简化版的DeepFM实现,核心部分都保留了。
import torch import torch.nn as nn class DeepFM(nn.Module): def __init__(self, feature_config, dnn_dims=[400, 400, 400], dropout=0.5): super().__init__() self.feature_config = feature_config self.embeddings = nn.ModuleDict() fm_dim = 0 dnn_input_dim = 0 for name, cfg in feature_config.items(): if cfg["type"] == "categorical": emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"]) self.embeddings[name] = emb if cfg["use_fm"]: fm_dim += cfg["emb_dim"] if cfg["use_dnn"]: dnn_input_dim += cfg["emb_dim"] else: if cfg["use_dnn"]: dnn_input_dim += 1 self.fm_linear = nn.Linear(fm_dim, 1) dnn_layers = [] prev_dim = dnn_input_dim for dim in dnn_dims: dnn_layers.append(nn.Linear(prev_dim, dim)) dnn_layers.append(nn.ReLU()) dnn_layers.append(nn.Dropout(dropout)) prev_dim = dim dnn_layers.append(nn.Linear(prev_dim, 1)) self.dnn = nn.Sequential(*dnn_layers) self.sigmoid = nn.Sigmoid() def forward(self, cat_inputs, num_inputs): fm_embs = [] dnn_embs = [] for name, cfg in self.feature_config.items(): if cfg["type"] == "categorical": emb = self.embeddings[name](cat_inputs[name]) if cfg["use_fm"]: fm_embs.append(emb) if cfg["use_dnn"]: dnn_embs.append(emb) # FM部分 fm_stack = torch.stack(fm_embs, dim=1) # [B, F, K] sum_square = fm_stack.sum(dim=1) ** 2 square_sum = (fm_stack ** 2).sum(dim=1) fm_second = 0.5 * (sum_square - square_sum).sum(dim=1, keepdim=True) fm_first = self.fm_linear(fm_stack.view(fm_stack.size(0), -1)) fm_out = fm_first + fm_second # DNN部分 dnn_input = torch.cat(dnn_embs + [num_inputs], dim=1) dnn_out = self.dnn(dnn_input) logit = fm_out + dnn_out return self.sigmoid(logit)这段代码里,FM部分的化简公式和前面说的一致。注意fm_stack的维度是[batch, field_num, emb_dim],sum(dim=1)是对field维度求和,得到[batch, emb_dim],然后平方、求和,最后得到[batch, 1]。
DNN部分的输入是所有参与DNN的embedding拼接,再加上数值特征。这里我把数值特征直接拼在最后,实际用的时候要注意数值特征的归一化,否则尺度差异大会影响训练。
4.3 训练循环与验证策略
训练循环本身不复杂,但有几个细节值得说。
model = DeepFM(feature_config).cuda() optimizer = torch.optim.Adam([ {"params": model.embeddings.parameters(), "lr": 1e-4}, {"params": model.fm_linear.parameters(), "lr": 1e-3}, {"params": model.dnn.parameters(), "lr": 1e-3}, ]) criterion = nn.BCELoss() for epoch in range(num_epochs): model.train() for batch in train_loader: cat_inputs, num_inputs, labels = batch preds = model(cat_inputs, num_inputs) loss = criterion(preds.squeeze(), labels.float()) optimizer.zero_grad() loss.backward() optimizer.step() # 验证 model.eval() val_auc = evaluate(model, val_loader) print(f"Epoch {epoch}, Val AUC: {val_auc}")这里我用了参数组,embedding层学习率设小一点,DNN和FM线性层用正常学习率。这个技巧在前面提过,实测对收敛稳定性有帮助。
验证指标用AUC,这是CTR预估的标准指标。但AUC有时候不够敏感,我还会看LogLoss和校准曲线。如果AUC涨了但LogLoss没降,可能是模型排序能力提升但概率估计不准,这时候要考虑是否需要做概率校准。
早停策略也很重要。我一般设patience为3,验证AUC连续3个epoch不提升就停,保存验证集上最好的模型。这样能避免过拟合,也省训练时间。
4.4 线上推理的性能优化
模型训练好只是第一步,线上推理的性能同样关键。推荐系统对延迟要求高,通常要在几十毫秒内返回结果。
DeepFM的推理瓶颈主要在embedding查表和DNN前向。优化手段有几个:
第一,embedding表可以量化。把float32的embedding转成float16甚至int8,显存占用和带宽都能降下来,精度损失通常很小。这个在TensorRT或者ONNX Runtime里都有现成支持。
第二,DNN部分可以做算子融合。把Linear+ReLU+Dropout融合成一个算子,减少kernel launch开销。推理阶段Dropout要关掉,这个别忘了。
第三,batch推理。线上请求虽然单条来,但可以攒一小批一起推理,提高GPU利用率。当然攒批会增加延迟,需要根据SLA权衡。
第四,如果延迟实在压不下来,可以考虑把DNN部分砍浅一点,或者用知识蒸馏把DeepFM蒸馏到一个更小的模型上。这个在工业界很常见,大模型离线训好,小模型线上跑。
实操心得:线上推理和离线训练的特征处理逻辑一定要保持一致,这是最容易出bug的地方。我见过好几次离线AUC很好、线上效果拉胯的情况,最后排查发现是线上某个特征的默认值处理跟离线不一样。建议把特征处理逻辑封装成公共模块,离线和线上共用同一份代码。
5. 常见问题与排查技巧实录
5.1 训练不收敛或Loss震荡怎么办
这是最常见的问题,原因可能有好几个。
先看学习率。学习率太大是最常见的原因,表现是loss剧烈震荡甚至发散。把学习率降一个数量级试试,比如从1e-3降到1e-4。如果降了之后收敛太慢,再慢慢往上调。
再看特征尺度。数值特征没归一化,或者embedding初始化方差太大,都会导致训练不稳定。数值特征做标准化,embedding用xavier或者正态分布初始化,方差设小一点。
还有可能是batch size太小。梯度估计噪声大,loss自然震荡。把batch size调大,或者用梯度累积模拟大batch。
如果以上都试了还不行,检查一下数据里有没有异常值。比如某个数值特征出现了极大值,或者某个类别特征的索引越界了。这些低级错误在实际项目里并不少见。
5.2 过拟合的识别与缓解
过拟合的典型表现是训练集AUC持续上升,验证集AUC早早见顶然后下降。DeepFM因为参数量大,过拟合风险不低。
缓解手段按优先级排:第一,加Dropout,0.5起步,不够加到0.7;第二,加L2正则,权重衰减设1e-5到1e-4;第三,减少embedding维度,尤其是高基数特征;第四,减少DNN层数或每层神经元数;第五,增加训练数据,这个最根本但往往最难。
还有一个容易被忽略的点:低频特征。前面提过,低频特征的embedding学不好,容易记住噪声。过滤低频特征或者做特征哈希,能明显缓解过拟合。
5.3 特征重要性分析与模型可解释性
DeepFM是个黑盒,但业务方往往想知道哪些特征重要。有几个实用方法。
最直接的是看embedding的范数。范数大的特征,说明模型认为它重要。但这个方法有局限,因为embedding是联合训练的,范数大不一定代表对最终预测贡献大。
更靠谱的是做消融实验。把某个特征去掉重新训练,看AUC掉多少。掉得多说明重要。这个方法准确但费时间,适合对少数关键特征做分析。
还有一种方法是看FM部分的交叉权重。虽然FM的交叉是隐式的,但可以计算特定特征对之间的内积,内积绝对值大的特征对,说明模型认为它们交互强。这个对业务理解有帮助,比如发现“年龄”和“类目”交互强,可能提示需要针对不同年龄段做类目运营。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方法 |
|---|---|---|---|
| Loss不降 | 学习率过大、特征未归一化 | 打印梯度范数、检查特征分布 | 降学习率、标准化数值特征 |
| Loss震荡 | batch太小、学习率大 | 看loss曲线周期 | 增大batch、降学习率 |
| 训练AUC高验证低 | 过拟合 | 对比训练验证曲线 | 加Dropout、L2、减参 |
| 验证AUC不涨 | 欠拟合、特征不足 | 看训练AUC是否也低 | 加特征、加层、升学习率 |
| 线上效果差 | 特征处理不一致 | 对比离线线上特征 | 统一特征处理模块 |
| 推理延迟高 | 模型太大、未优化 | profile各层耗时 | 量化、算子融合、蒸馏 |
5.5 几个容易踩的坑
第一个坑是embedding的padding_idx。如果你的类别特征里有padding,比如序列特征补齐用的0,那embedding的padding_idx要设对,否则padding的embedding也会被更新,引入噪声。
第二个坑是FM部分的特征选择。不是所有类别特征都适合进FM。比如用户ID这种超高基数特征,进FM之后二阶交叉的方差很大,可能反而有害。我一般会让高基数特征只进DNN,不进FM,或者用更小的embedding维度。
第三个坑是训练数据的样本权重。推荐系统里不同位置的曝光点击率差异很大,如果不做位置偏差校正,模型会偏向高位置。常见做法是给不同位置样本不同的权重,或者把位置作为特征喂进去。这个要看具体业务,不是所有场景都需要。
第四个坑是模型更新频率。推荐系统数据分布变化快,模型需要定期更新。全量训练太慢,可以用增量训练或者在线学习。但增量训练要注意灾难性遗忘,新数据学到的可能会覆盖旧知识。我一般会保留一部分旧数据混着训,或者用EWC之类的持续学习方法。
6. 从DeepFM出发的扩展思路
DeepFM本身是个baseline,实际业务里往往需要根据场景做调整。
如果特征里有序列行为,比如用户历史点击序列,可以在DeepFM基础上加一个序列建模模块,用GRU或者Transformer编码序列,然后把序列表示拼到DNN输入里。这个就是DeepFM+序列的常见做法。
如果多任务,比如同时预测点击和转化,可以把DeepFM的共享底层保留,上面接多个塔,每个任务一个塔。这就是MMoE或者PLE的思路,DeepFM作为底层特征提取器。
如果对延迟极度敏感,可以把DeepFM蒸馏到一个小模型上。用DeepFM的输出作为软标签,训练一个浅层网络,精度损失通常可控,但推理速度快很多。
如果数据量特别大,单机训不动,可以用分布式训练。embedding表分片到不同参数服务器,DNN部分做数据并行。这个工程复杂度高,但工业界有成熟方案。
我在实际项目里的体会是,DeepFM的价值不在于它本身有多强,而在于它提供了一个干净、可扩展的框架。你可以在它基础上加序列、加多任务、加蒸馏,它都能兼容。这种可扩展性,比单纯刷高零点几个百分点的AUC更有意义。
最后分享一个小技巧:调DeepFM的时候,先把FM部分和DNN部分分开训,各自调到不错的效果,再联合训练。这样能快速定位问题出在哪一部分。如果FM单独训就不行,那可能是特征或者embedding维度的问题;如果DNN单独训不行,那可能是层数或者学习率的问题。分开调比一上来就联合调效率高很多。