资讯动态

Keras实现因子分解机:从数学原理到工程实践

发布时间:2026/8/27 6:23:08 来源:尧图企业网站定制
1. 项目概述为什么用Keras实现FM是个好主意如果你正在处理推荐系统、点击率预测或者任何涉及高维稀疏特征比如用户ID、商品ID、电影类型标签的机器学习任务那你大概率听说过或者被“特征组合”这个问题困扰过。传统的逻辑回归模型简单高效但它只能学习特征的线性关系模型表达能力有限。为了捕捉特征之间的交互作用比如“年轻男性用户”与“科幻动作片”之间的强关联我们通常需要手动构造交叉特征。这不仅需要深厚的领域知识而且特征维度会爆炸式增长带来巨大的计算和存储开销这就是所谓的“维数灾难”。Factorization MachinesFM因子分解机算法由Steffen Rendle在2010年提出优雅地解决了这个问题。它的核心思想是为每个特征学习一个隐向量latent vector通过隐向量的内积来建模任意两个特征之间的交互作用。这种方法的好处是即使某个特征组合在训练数据中从未出现过FM也能通过其对应的隐向量进行预测具备了强大的泛化能力。FM可以看作是线性模型、矩阵分解和SVM使用多项式核的统一框架在推荐系统、广告点击率预估等领域取得了巨大成功。那么为什么我要选择用Keras来实现它呢原因很简单降低实现门槛聚焦模型本质。网上能找到的FM实现很多是基于NumPy或传统机器学习库如libfm的。用NumPy实现你需要从头推导梯度并手动编写训练循环对数学和工程能力要求较高而libfm等专用库虽然高效但定制化和与现代深度学习工作流集成不够灵活。Keras作为一款高阶神经网络API以其极简、模块化的设计哲学著称。用Keras实现FM我们可以像搭积木一样用Layer和Model来定义模型结构自动处理繁琐的反向传播和优化器更新还能无缝接入TensorFlow的生态系统方便地进行GPU加速、模型导出和部署。这篇内容就是带你从零开始用Keras快速、清晰地搭建一个FM模型。我会详细拆解FM的数学原理并将其转化为直观的Keras层。无论你是刚接触推荐算法的学生还是想将FM快速集成到现有深度学习项目中的工程师这篇内容都能给你一份可直接“抄作业”的代码和透彻的原理讲解。我们不止步于“跑通代码”更要弄明白每一行代码背后的“为什么”。2. FM算法核心原理与Keras层设计思路在动手写代码之前我们必须吃透FM的数学模型。只有理解了公式背后的物理意义才能正确地用Keras的组件去表达它。2.1 FM模型公式拆解FM模型的预测公式通常如下所示$$ \hat{y}(x) w_0 \sum_{i1}^{n} w_i x_i \sum_{i1}^{n} \sum_{ji1}^{n} \langle v_i, v_j \rangle x_i x_j $$这个公式包含三部分我们逐一拆解全局偏置项$w_0$一个标量代表模型的整体偏差。一阶线性项$\sum_{i1}^{n} w_i x_i$这部分就是传统的线性模型。为每个特征$x_i$分配一个权重$w_i$用于学习该特征单独的贡献。例如在电影推荐中用户是会员这个特征可能有一个较高的正权重$w_i$。二阶交叉项$\sum_{i1}^{n} \sum_{ji1}^{n} \langle v_i, v_j \rangle x_i x_j$这是FM的精华所在。它为每个特征$x_i$学习一个$k$维的隐向量$v_i$$k$是超参数表示隐向量的长度。特征$x_i$和$x_j$的交互作用通过它们对应隐向量$v_i$和$v_j$的内积 $\langle v_i, v_j \rangle$ 来建模再乘以特征值$x_i x_j$。这个设计的巧妙之处在于参数共享。假设我们有100万个特征如果直接学习两两交叉的权重需要大约$10^{12}$个参数根本不可行。而FM为每个特征只学习一个$k$维向量比如$k10$总参数量是$100万 * 10 1000万$并且通过内积任意两个特征的交互权重被表示为$\langle v_i, v_j \rangle$这使得模型即使对于未出现过的特征组合$(i, j)$也能做出合理的预测。2.2 交叉项的计算优化直接按照公式计算二阶项复杂度是$O(kn^2)$这在特征数$n$很大时是无法接受的。FM论文中给出了一个化简公式将复杂度降至$O(kn)$$$ \sum_{i1}^{n} \sum_{ji1}^{n} \langle v_i, v_j \rangle x_i x_j \frac{1}{2} \sum_{f1}^{k} \left[ \left(\sum_{i1}^{n} v_{i,f} x_i \right)^2 - \sum_{i1}^{n} v_{i,f}^2 x_i^2 \right] $$这个化简是FM高效的关键也是我们用Keras实现时的核心。它的推导基于一个简单的数学等式$(ab)^2 a^2 b^2 2ab$。我们不需要深究推导过程但必须理解其计算意义我们不再需要两两计算内积而是先对每个隐维度$f$计算所有特征在该维度上的加权和$\sum_i v_{i,f} x_i$然后利用平方和与和的平方之间的关系一次性算出所有交互作用。这在Keras中可以通过向量化操作高效完成。2.3 Keras实现蓝图基于以上理解我们可以规划Keras实现方案输入层接受高维稀疏特征。通常我们会使用Embedding层来处理类别型特征将ID映射为稠密向量但对于FM的隐向量我们需要单独处理。一阶项这部分等价于一个没有激活函数的Dense层偏置项由全局偏置$w_0$承担或者直接用Embedding层查找权重$w_i$后与输入相乘再求和。二阶项核心我们需要实现上述的优化计算公式。这可以封装成一个自定义的KerasLayer。这个层接收两个输入特征值$x$和对应的特征隐向量矩阵$V$。在call方法中按照优化公式实现向量化计算。输出层将全局偏置、一阶项求和结果、二阶项计算结果相加通过一个Activation层如sigmoid用于二分类linear用于回归得到最终预测。我们将采用函数式API来构建模型因为它比Sequential模型更灵活便于处理多输入和自定义计算层。3. 手把手实现Keras版Factorization Machines理论清晰了现在进入实战环节。我们将从数据准备开始一步步构建出完整的FM模型。3.1 环境准备与数据模拟首先确保你的环境安装了TensorFlow和Keras。这里我们使用TensorFlow 2.x它内置了Keras。pip install tensorflow为了演示我们不依赖外部数据集而是模拟一个典型的CTR预测场景。假设我们有num_samples个样本每个样本有num_features个特征。特征都是稀疏的我们用一个(num_samples, num_features)的二维数组表示其中大部分元素为0非零值表示特征存在比如是1或者是经过归一化的数值。import numpy as np import tensorflow as tf from tensorflow import keras from tensorflow.keras import layers, Model # 模拟参数 num_samples 10000 num_features 1000 # 生成稀疏特征矩阵稀疏度约1% feature_data np.random.randn(num_samples, num_features) * (np.random.rand(num_samples, num_features) 0.99) # 模拟二分类标签 labels np.random.randint(0, 2, size(num_samples, 1)) print(f特征矩阵形状: {feature_data.shape}) print(f标签形状: {labels.shape}) print(f特征稀疏度: {np.mean(feature_data ! 0):.2%})3.2 构建自定义的FM交叉项层这是整个模型的核心。我们将实现前面提到的优化计算公式。class FMCrossLayer(layers.Layer): 实现FM二阶交叉项的自定义层 def __init__(self, **kwargs): super(FMCrossLayer, self).__init__(**kwargs) def build(self, input_shape): # input_shape 应该是一个列表包含两个tensor的形状: [ (batch, num_features), (batch, num_features, k) ] # 分别对应特征值x和特征隐向量v。 # 这个层没有需要训练的参数参数都在传入的隐向量v里了。 super(FMCrossLayer, self).build(input_shape) def call(self, inputs): 计算FM二阶交叉项。 公式: 0.5 * sum_over_k( (sum_i v_i * x_i)^2 - sum_i (v_i^2 * x_i^2) ) Args: inputs: 包含两个元素的列表 [x, v] - x: 特征值形状 (batch_size, num_features) - v: 特征隐向量形状 (batch_size, num_features, latent_dim) Returns: 二阶交叉项结果形状 (batch_size, 1) x, v inputs # 计算 sum_i v_i * x_i需要扩展x的维度以便广播 # x: (batch, num_features) - (batch, num_features, 1) # v: (batch, num_features, latent_dim) # 相乘后得到 (batch, num_features, latent_dim)再在特征维度上求和 sum_vx tf.reduce_sum(v * tf.expand_dims(x, axis-1), axis1) # 形状 (batch, latent_dim) # 计算 sum_i v_i^2 * x_i^2 sum_v2x2 tf.reduce_sum(tf.square(v) * tf.expand_dims(tf.square(x), axis-1), axis1) # 形状 (batch, latent_dim) # 应用公式 # (sum_vx)^2 - sum_v2x2然后对隐维度latent_dim求和最后乘以0.5 cross_term 0.5 * tf.reduce_sum(tf.square(sum_vx) - sum_v2x2, axis1, keepdimsTrue) # 形状 (batch, 1) return cross_term def compute_output_shape(self, input_shape): # 输出形状是 (batch_size, 1) return (input_shape[0][0], 1)注意这里有一个非常重要的实现细节。在标准的FM公式中隐向量矩阵$V$是一个全局参数形状为(num_features, latent_dim)。但在我们的实现中FMCrossLayer的输入v的形状是(batch, num_features, latent_dim)。这是因为在实际训练时我们通常不会一次性将整个$V$矩阵传入而是通过一个Embedding层来动态查找每个样本所涉及的特征对应的隐向量。我们会在模型构建部分看到具体做法。这种设计使得模型能够处理稀疏输入只计算非零特征对应的隐向量。3.3 组装完整的FM模型现在我们将一阶项、二阶项和偏置项组合起来。def build_fm_model(num_features, latent_dim10): 构建FM模型。 Args: num_features: 特征总数。 latent_dim: 隐向量的维度k。 Returns: 一个编译好的Keras Model实例。 # 1. 输入层 input_layer layers.Input(shape(num_features,), nameinput_features, dtypefloat32) # 2. 一阶项部分 # 为每个特征学习一个一阶权重 w_i linear_embedding layers.Embedding(input_dimnum_features, output_dim1, namelinear_embedding)(tf.range(num_features)) # 将Embedding权重展平形状 (num_features, 1) - (num_features,) linear_weights tf.squeeze(linear_embedding, axis-1) # 计算一阶项: sum(w_i * x_i) # 这里使用tf.gather获取权重然后与输入逐元素相乘再求和 # 更高效的做法是直接使用一个Dense(1, use_biasFalse)层但这里为了清晰展示FM结构我们拆解出来。 linear_term tf.reduce_sum(input_layer * linear_weights, axis1, keepdimsTrue) # 形状 (batch, 1) # 3. 二阶项部分 # 为每个特征学习一个隐向量 v_i latent_embedding layers.Embedding(input_dimnum_features, output_dimlatent_dim, namelatent_embedding)(tf.range(num_features)) # latent_embedding 形状: (num_features, latent_dim) # 我们需要为每个样本复制这个矩阵形成 (batch, num_features, latent_dim) # 这里使用一个技巧通过一个Lambda层将隐向量矩阵“广播”到每个样本 latent_vectors layers.Lambda(lambda x: tf.expand_dims(latent_embedding, axis0) * tf.ones((tf.shape(x)[0], 1, 1)), namebroadcast_latent)(input_layer) # 现在 latent_vectors 形状为 (batch, num_features, latent_dim) # 调用自定义的FM交叉层 cross_layer FMCrossLayer(namefm_cross) cross_term cross_layer([input_layer, latent_vectors]) # 形状 (batch, 1) # 4. 全局偏置项 bias tf.Variable(initial_value0.0, trainableTrue, nameglobal_bias) # 5. 合并所有部分 # 将偏置项广播到每个样本 bias_term bias * tf.ones_like(linear_term) output layers.Add(nameadd_all_terms)([bias_term, linear_term, cross_term]) # 6. 输出层 (例如用于二分类的sigmoid) prediction layers.Activation(sigmoid, nameprediction)(output) # 构建模型 model Model(inputsinput_layer, outputsprediction, nameKeras_FM) return model # 实例化模型 latent_dim 8 model build_fm_model(num_featuresnum_features, latent_dimlatent_dim) # 查看模型结构 model.summary()运行model.summary()你会看到一个清晰的模型结构图包含了我们定义的所有层。这里的关键是我们使用了两个Embedding层linear_embedding学习一阶权重$w_i$latent_embedding学习二阶隐向量$v_i$。FMCrossLayer则负责高效计算交叉项。3.4 模型编译、训练与评估模型构建完成后剩下的就是标准的Keras流程了。# 编译模型 model.compile( optimizerkeras.optimizers.Adam(learning_rate0.001), lossbinary_crossentropy, # 对于二分类任务 metrics[accuracy, keras.metrics.AUC(nameauc)] ) # 划分训练集和验证集 from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val train_test_split(feature_data, labels, test_size0.2, random_state42) # 训练模型 history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs10, batch_size256, verbose1 ) # 评估模型 test_loss, test_acc, test_auc model.evaluate(X_val, y_val, verbose0) print(f\n验证集评估结果) print(f 损失(Loss): {test_loss:.4f}) print(f 准确率(Accuracy): {test_acc:.4f}) print(f AUC: {test_auc:.4f})4. 关键细节、优化与避坑指南把模型跑起来只是第一步。在实际项目中有几个关键的细节和优化点决定了模型的成败。4.1 输入特征的处理稀疏性与Embedding我们上面的模拟数据是稠密矩阵这在实际中很少见。真实场景下输入是稀疏的。Keras的Embedding层天然适合处理稀疏的类别ID输入。我们需要调整输入格式将特征表示为索引列表对于每个样本我们不传递一个长度为num_features的向量其中大部分是0而是传递一个列表包含该样本中非零特征的索引和对应的值。使用tf.sparse.SparseTensor或预处理一种更高效的方式是使用tf.sparse.SparseTensor作为输入。但为了简化常见的做法是预先将数据处理成(sample_indices, feature_indices, values)的格式然后在模型内部使用tf.nn.embedding_lookup_sparse。不过在Keras中直接支持稀疏张量作为Embedding层的输入可能会有些棘手。一个更实用的工程化做法是对原始特征进行哈希或编码生成固定长度的稠密向量作为FM的输入。例如对所有类别特征进行One-Hot编码后通过一个全连接层Dense进行降维这个降维后的向量就可以作为我们上面实现的FM层的输入x。同时这个Dense层的权重矩阵的转置就可以视作是特征到隐向量的映射矩阵$V$的一部分。这种变体有时被称为“Field-aware FM”或深度FM的浅层部分它更适合与深度学习架构结合。4.2 隐向量维度k的选择latent_dim即$k$值是一个关键的超参数。$k$值越大模型的容量越大能学习到更复杂的特征交互模式但同时也更容易过拟合训练速度更慢。$k$值越小模型更简单泛化能力可能更强但可能无法充分捕捉重要的交互关系。如何选择经验值在推荐系统中$k$通常在4到64之间。可以从8或16开始尝试。网格搜索/随机搜索在验证集上尝试不同的$k$值选择AUC或LogLoss最好的那个。观察训练曲线如果训练集损失远低于验证集损失可能是过拟合尝试减小$k$或增加正则化。4.3 正则化防止过拟合的利器FM模型同样需要正则化尤其是在特征维度很高、数据稀疏的情况下。主要有两种正则化方式L2正则化权重衰减这是最常用的。可以直接在Embedding层和Dense层中添加。# 在构建Embedding层时加入正则化 from tensorflow.keras import regularizers linear_embedding layers.Embedding( input_dimnum_features, output_dim1, embeddings_regularizerregularizers.l2(0.01), # L2正则化系数 namelinear_embedding )(tf.range(num_features))对latent_embedding也做同样的处理。L2正则化会惩罚大的权重使模型参数趋向于较小的值提高泛化能力。Dropout可以在特征输入后或隐向量计算后加入Dropout层随机“关闭”一部分特征或隐向量维度也是一种有效的正则化手段。但对于FM这类浅层模型Dropout的效果有时不如L2稳定需要谨慎尝试。4.4 与深度学习的结合DeepFM与NFM纯粹的FM是一个浅层模型。为了学习更高阶、更非线性的特征交互业界提出了很多将FM与深度神经网络结合的模型例如DeepFM将FM层和Deep部分全连接网络并行共享相同的输入特征Embedding。FM部分负责低阶特征交互Deep部分负责高阶非线性交互最后将两者输出相加。用我们已有的代码可以很容易地扩展在build_fm_model函数中除了计算linear_term和cross_term再将输入特征或经过一个变换后的特征输入到一个Dense网络中最后将三者的输出相加。NFM (Neural Factorization Machines)将FM的二阶交叉项隐向量内积的结果作为输入送入一个深度神经网络以学习更复杂的交叉函数而不仅仅是内积。我们的Keras实现为这些扩展提供了良好的基础。你可以将FMCrossLayer的输出作为特征连接到任意的DNN网络上。5. 实战常见问题与排查技巧在实际编码和调试过程中你可能会遇到以下问题问题1模型不收敛Loss为NaN或非常大。可能原因学习率过高。FM模型通常比较敏感。排查将学习率调低一个数量级例如从0.001调到0.0001。使用Adam优化器通常比SGD更稳定。可能原因输入特征值范围过大。如果$x_i$的值很大比如成千上万在计算交叉项时可能导致数值溢出。排查对输入特征进行标准化或归一化。对于CTR任务特征值通常为0/1或归一化到[0,1]区间。问题2训练速度很慢。可能原因我们之前的实现中latent_vectors通过Lambda层进行广播这可能在计算图和内存上不是最优的。优化考虑更高效的计算方式。可以直接在FMCrossLayer的call方法中利用tf.gather或tf.nn.embedding_lookup从全局的latent_embedding矩阵中查找非零特征对应的隐向量而不是为每个样本生成完整的(num_features, latent_dim)矩阵。这能极大减少计算量尤其是当特征非常稀疏时。可能原因batch_size太小。排查在GPU内存允许的范围内适当增大batch_size如256512可以提高GPU利用率加速训练。问题3过拟合严重训练集AUC很高验证集AUC很低。可能原因模型复杂度过高latent_dim太大或训练数据不足。排查增加L2正则化的强度增大l2系数。减小latent_dim。在输入层或FMCrossLayer后添加Dropout层。如果可能收集更多训练数据。问题4如何保存和加载自定义层模型保存使用model.save(fm_model.h5)或model.save(fm_model)SavedModel格式即可。Keras会自动处理自定义层。加载在加载时需要提供自定义层类给加载函数。from tensorflow.keras.models import load_model # 需要将自定义层放入custom_objects字典中 loaded_model load_model(fm_model.h5, custom_objects{FMCrossLayer: FMCrossLayer})一个重要的调试技巧可视化EmbeddingFM的核心是学习特征的隐向量。你可以将训练好的latent_embedding层的权重取出使用降维技术如PCA t-SNE可视化到二维平面。观察相似的特征例如同品类的商品是否在隐向量空间中也距离较近。这能直观地验证模型是否学到了有意义的特征表示。# 获取训练好的隐向量矩阵 latent_embeddings model.get_layer(latent_embedding).get_weights()[0] print(f隐向量矩阵形状: {latent_embeddings.shape}) # 应该是 (num_features, latent_dim) # 然后可以使用sklearn的PCA进行可视化 from sklearn.decomposition import PCA import matplotlib.pyplot as plt pca PCA(n_components2) embeddings_2d pca.fit_transform(latent_embeddings[:50]) # 可视化前50个特征 plt.scatter(embeddings_2d[:, 0], embeddings_2d[:, 1]) plt.title(FM Feature Embeddings (PCA)) plt.show()通过这篇内容你不仅获得了一个可以运行的Keras版FM模型更重要的是理解了从数学公式到TensorFlow/Keras计算图的映射过程以及在实际应用中需要考虑的种种细节。记住好的模型实现是“活”的你需要根据具体的数据和任务灵活调整特征处理方式、模型结构和超参数。希望这份“作业”能成为你探索推荐算法和深度学习结合的一个坚实起点。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价