1. 从推荐系统的一个经典难题说起如果你做过推荐系统或者点击率预估肯定遇到过这样的场景用户特征比如年龄、性别、城市和物品特征比如商品类别、价格单独来看对预测结果的贡献似乎都有限。一个年轻的男性用户可能会点击电子产品也可能会点击运动服饰这种单一特征的预测能力是模糊的。真正的魔法往往发生在特征的交叉组合里一个“居住在一线城市”、“年龄在25-35岁”、“最近浏览过数码产品”的用户点击“最新款无线耳机”的概率会急剧升高。这就是特征交叉的威力。早期工程师们会手动设计这种交叉特征比如把“城市”和“商品类目”组合成一个新的特征“城市_类目”然后喂给逻辑回归之类的模型。这种方法简单直接但问题也显而易见一是特征组合爆炸维度灾难随之而来二是对于训练数据中从未出现过的组合比如某个偏远城市突然上架了某种小众商品模型完全无法预测泛化能力为零。Factorization MachinesFM算法就是为了优雅地解决这个问题而诞生的。它不再需要你手动进行特征工程而是通过为每个特征学习一个低维的隐向量Latent Vector让模型自动学习任意两个特征之间的交叉关系。其核心公式中除了线性项还有一个关键的交叉项这个交叉项的计算复杂度通过数学优化从O(kn²)降到了O(kn)其中n是特征数k是隐向量的维度通常远小于n。这意味着FM既能捕获高阶特征交互又保持了线性的计算效率。那么为什么今天我们要用Keras来实现FM原因很简单易用性和灵活性。TensorFlow 2.x时代的Keras以其简洁的API和动态图机制让我们能够像搭积木一样构建复杂的模型结构。对于FM这种具有清晰数学定义的模型用Keras的Layer和Model子类化方式来实现不仅代码直观、易于调试还能无缝接入Keras的生态系统——使用其优化器、损失函数、回调函数以及利用model.fit()进行训练。这比从头用NumPy实现或者用一些封装过于严密的第三方库要透明和可控得多。接下来我将手把手带你用Keras从零构建一个FM模型我们会深入其每一层的设计原理并在一个经典的CTR预估数据集上进行实战。你会发现用Keras实现FM远比你想象的要简单和强大。2. 拆解FM算法的数学内核与Keras层设计在动手写代码之前我们必须吃透FM的数学模型。这决定了我们如何用Keras的层Layer来映射这些数学运算。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$用于学习特征自身的重要性。二阶交叉项$\sum_{i1}^{n} \sum_{ji1}^{n} \langle v_i, v_j \rangle x_i x_j$这是FM的灵魂。它为每个特征$i$学习了一个$k$维的隐向量$v_i$。特征$i$和$j$的交叉效应通过它们对应隐向量的点积$\langle v_i, v_j \rangle$来刻画。求和只计算$ji$的部分避免了重复计算。直接按照这个公式实现计算二阶项的时间复杂度是$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) $$这个公式是我们在Keras中实现FM层的核心依据。它的计算过程可以分解为以下几步非常适合用张量操作来实现对每个特征$i$计算$v_i * x_i$。假设输入特征向量x的形状是(batch_size, n)隐向量矩阵V的形状是(n, k)那么这一步的结果是(batch_size, n, k)。对上述结果在特征维度n上求和得到$\sum_{i1}^{n} v_{i,f} x_i$形状为(batch_size, k)。计算这个结果的平方$(\sum v_{i,f} x_i)^2$形状仍为(batch_size, k)。计算$v_{i,f}^2 x_i^2$的和$\sum v_{i,f}^2 x_i^2$形状也是(batch_size, k)。将第3步的结果减去第4步的结果再乘以0.5就得到了最终的二阶交叉项形状为(batch_size, k)。通常我们还会在k维度上求和将其压缩为一个标量或者根据任务需求保留形状变为(batch_size, 1)。基于这个理解我们的Keras模型可以设计为以下几个核心层输入层接收稀疏或稠密的特征向量。FM交叉层核心一个自定义的Keras层负责实现上述优化后的二阶交叉项计算。线性层一个Dense层不含激活函数用于实现一阶线性项$\sum w_i x_i$。偏置项一个独立的可训练变量作为全局偏置$w_0$。输出层将线性项、交叉项和偏置项相加并通过Sigmoid激活函数对于二分类任务输出最终预测概率。接下来我们就进入最关键的环节用代码将这份数学蓝图变为现实。3. 手把手实现Keras自定义FM交叉层理论清晰后实现就变成了按部就班的“翻译”工作。我们将创建一个自定义的Keras层FMLayer这是整个模型的核心。import tensorflow as tf from tensorflow.keras.layers import Layer, Dense, Input from tensorflow.keras.models import Model from tensorflow.keras.regularizers import l2 class FMLayer(Layer): 自定义Factorization Machines二阶交叉层。 实现优化后的O(kn)复杂度计算。 def __init__(self, factor_dim, **kwargs): 初始化层。 Args: factor_dim (int): 隐向量v的维度k。 **kwargs: 其他传递给父类的参数。 super(FMLayer, self).__init__(**kwargs) self.factor_dim factor_dim def build(self, input_shape): 创建层的权重。 这里我们创建隐向量矩阵V其形状为 (输入特征维度, factor_dim)。 # input_shape 是一个元组例如 (batch_size, feature_dim) feature_dim input_shape[-1] # 添加隐向量矩阵V使用随机正态分布初始化 self.V self.add_weight( nameV, shape(feature_dim, self.factor_dim), initializerglorot_normal, regularizerl2(0.01), # 通常对隐向量施加L2正则防止过拟合 trainableTrue ) super(FMLayer, self).build(input_shape) def call(self, inputs): 前向传播逻辑实现优化后的FM二阶交叉项公式。 # 计算 (V_i * x_i) 对于所有i结果形状: (batch_size, feature_dim, factor_dim) # 这里利用广播机制inputs[:, :, None] 将 shape (batch, feat) - (batch, feat, 1) # self.V 的 shape 是 (feat, factor_dim)相乘后得到 (batch, feat, factor_dim) vx inputs[:, :, tf.newaxis] * self.V # 等价于 tf.expand_dims(inputs, -1) * self.V # 计算 sum_square: (∑ V_i * x_i)^2 在特征维度feat上求和 # 先求和: sum_vx tf.reduce_sum(vx, axis1) - shape (batch, factor_dim) # 再平方: sum_vx_square tf.square(sum_vx) - shape (batch, factor_dim) sum_vx tf.reduce_sum(vx, axis1) sum_vx_square tf.square(sum_vx) # 计算 square_sum: ∑ (V_i^2 * x_i^2) # 先计算平方: v_square tf.square(self.V) - shape (feat, factor_dim) # x_square tf.square(inputs) - shape (batch, feat) # 然后计算加权和: square_sum tf.matmul(x_square, v_square) - shape (batch, factor_dim) v_square tf.square(self.V) x_square tf.square(inputs) square_sum tf.matmul(x_square, v_square) # 应用公式: 0.5 * (sum_square - square_sum) # 结果形状: (batch_size, factor_dim) interaction 0.5 * tf.subtract(sum_vx_square, square_sum) # 通常我们将所有factor_dim维度的交叉信息求和得到一个标量输出 # 你也可以选择保留这个维度或者用Dense层进一步处理 output tf.reduce_sum(interaction, axis1, keepdimsTrue) # shape (batch, 1) return output def get_config(self): 用于模型保存和加载的配置 config super().get_config() config.update({factor_dim: self.factor_dim}) return config代码关键点解析与避坑指南buildvs__init__权重的创建必须放在build方法中而不是__init__。因为build方法在首次看到输入数据的形状input_shape后才被调用这样我们才能根据具体的特征维度feature_dim来创建形状正确的权重矩阵V。在__init__中我们只知道隐向量维度k不知道输入特征数n。广播机制Broadcasting在call方法中计算vx inputs[:, :, tf.newaxis] * self.V时我们利用了NumPy/TensorFlow的广播机制。inputs的形状是(batch, feat)增加一个维度变成(batch, feat, 1)self.V的形状是(feat, k)。在乘法操作时(batch, feat, 1)和(feat, k)会自动广播为(batch, feat, k)并逐元素相乘。这是向量化实现的关键避免了低效的循环。正则化Regularization我们在定义权重V时加入了l2(0.01)正则化器。这对于FM模型至关重要。隐向量V学习的是特征间的交互关系如果没有正则化约束在稀疏数据下很容易过拟合学到的交互权重可能非常极端或不稳定。L2正则化能有效平滑模型参数提升泛化能力。这个权重系数0.01是一个超参数可以根据验证集效果调整。计算顺序与数值稳定性我们严格遵循了优化公式的计算顺序。虽然数学上$\sum (v_i x_i)^2$和$\sum v_i^2 x_i^2$是独立的但在代码实现中先计算sum_vx再平方与先平方再计算sum_vx在数学上等价但前者在数值计算上有时更稳定尽管对于本例差异不大。保持与公式一致是最清晰的做法。输出处理最后我们使用tf.reduce_sum(interaction, axis1, keepdimsTrue)对k个隐因子维度进行了求和将形状从(batch, k)压缩为(batch, 1)。这意味着我们把所有隐因子维度学习到的交叉信息汇总成了一个综合的交叉强度信号。你也可以尝试不求和直接输出(batch, k)的交互矩阵后面再接一个全连接层让模型自己学习如何融合这些信息这有时能带来更好的效果但也会增加参数量和过拟合风险。有了这个核心层构建完整的FM模型就水到渠成了。4. 组装完整FM模型与数据准备实战现在我们将FMLayer、线性项和偏置项组合起来构建一个端到端的Keras模型。同时我们需要一个合适的数据集来验证我们的实现。这里我选择Criteo Kaggle Display Advertising Challenge数据集的一个小样本criteo_sample.txt作为示例它是一个经典的CTR预估数据集包含数值型和类别型特征。首先我们构建完整的FM模型def build_fm_model(input_dim, factor_dim10): 构建完整的FM模型。 Args: input_dim (int): 输入特征的维度one-hot等编码后的总维度。 factor_dim (int): FM隐向量的维度k。 Returns: tf.keras.Model: 编译好的FM模型。 # 输入层 inputs Input(shape(input_dim,), nameinput_features) # 一阶线性部分一个没有激活函数的Dense层输出标量 linear_part Dense(1, use_biasFalse, kernel_regularizerl2(0.01), namelinear_layer)(inputs) # 注意这里的偏置我们单独加所以Dense层设置use_biasFalse # 二阶交叉部分使用我们自定义的FMLayer interaction_part FMLayer(factor_dimfactor_dim, namefm_layer)(inputs) # 全局偏置项一个可训练的参数 bias tf.constant_initializer(0.0) # 初始化偏置为0 bias_part tf.keras.layers.Dense(1, use_biasFalse, kernel_initializerbias, trainableTrue, nameglobal_bias)(tf.ones_like(linear_part)) # 技巧通过一个Dense层无激活函数权重固定为1来创建一个可训练的偏置变量。 # 另一种更直接的方式是使用 self.add_weight 在自定义层或模型中添加这里用Dense层是一种取巧。 # 将三部分相加 add_layer tf.keras.layers.Add(nameadd_components)([linear_part, interaction_part, bias_part]) # 对于二分类CTR预估使用Sigmoid激活函数输出概率 outputs tf.keras.layers.Activation(sigmoid, nameprediction)(add_layer) # 构建模型 model Model(inputsinputs, outputsoutputs, nameFM_Model) # 编译模型 model.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), lossbinary_crossentropy, metrics[accuracy, tf.keras.metrics.AUC(nameauc)] # AUC是CTR任务的关键指标 ) return model # 假设我们处理后的特征维度是100 model build_fm_model(input_dim100, factor_dim10) model.summary()模型结构要点线性部分使用一个Dense(1, use_biasFalse)层。use_biasFalse是因为我们将全局偏置单独处理。同样我们对权重也施加了L2正则化。偏置项这里演示了一个创建可训练标量的技巧。更清晰的做法是在一个自定义的FMModel类中的build方法里用self.add_weight添加。为了保持代码模块化我们采用了这个Dense层技巧。加法合并使用Add层将线性项、交叉项和偏置项相加。这是FM公式的直接体现。输出激活使用Sigmoid将加权和映射到(0,1)区间作为点击概率。接下来是数据准备。CTR数据通常是高度稀疏的包含大量类别特征。我们需要进行特征编码。import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import LabelEncoder, MinMaxScaler from tensorflow.keras.preprocessing.sequence import pad_sequences # 假设我们有一个简单的模拟数据生成函数用于演示 def generate_sample_data(num_samples10000, num_sparse_feat10, num_dense_feat5): 生成模拟的CTR数据。 在实际项目中你需要从criteo_sample.txt这样的文件读取并处理。 np.random.seed(2024) # 生成稠密特征数值型模拟归一化后的值 dense_features np.random.randn(num_samples, num_dense_feat) scaler MinMaxScaler() dense_features scaler.fit_transform(dense_features) # 生成稀疏特征类别型用随机整数模拟并假设每个特征有不同数量的类别 sparse_features [] sparse_feat_sizes [50, 30, 20, 15, 10, 8, 6, 5, 4, 3] # 每个稀疏特征的类别数 for i, size in enumerate(sparse_feat_sizes): sparse_features.append(np.random.randint(0, size, sizenum_samples)) sparse_features np.column_stack(sparse_features) # 生成标签 (0/1) # 模拟一个简单的逻辑标签与某些特征组合相关 y (0.3 * dense_features[:,0] 0.5 * (sparse_features[:,0] % 3) / 3.0 0.2 * (sparse_features[:,1] % 2) / 2.0 np.random.randn(num_samples) * 0.1) # 加一些噪声 y (y y.mean()).astype(int) # 转换为二分类标签 return dense_features, sparse_features, y def preprocess_data(dense_feat, sparse_feat, label): 预处理数据对稀疏特征进行LabelEncoding OneHot并与稠密特征拼接。 注意对于大规模数据OneHot会导致维度爆炸通常使用Embedding层。 这里为了演示FM的输入是稠密向量我们使用OneHot。 在实际大规模场景中更推荐在模型内部使用Embedding层处理稀疏特征。 from sklearn.preprocessing import OneHotEncoder # 1. 对每个稀疏特征列进行LabelEncoding如果还不是从0开始的连续整数 # 假设我们的模拟数据已经是这里跳过。实际数据可能需要 # encoders [LabelEncoder().fit(sparse_feat[:,i]) for i in range(sparse_feat.shape[1])] # sparse_feat_encoded np.column_stack([encoders[i].transform(sparse_feat[:,i]) for i in range(sparse_feat.shape[1])]) # 2. 使用OneHotEncoder将稀疏特征转换为one-hot格式 # 注意OneHotEncoder会为每个特征的所有类别创建列总维度会很大。 ohe OneHotEncoder(sparse_outputFalse, handle_unknownignore) # 忽略未见过的类别 sparse_feat_onehot ohe.fit_transform(sparse_feat) print(f原始稀疏特征维度: {sparse_feat.shape}) print(fOne-Hot编码后稀疏特征维度: {sparse_feat_onehot.shape}) # 3. 将稠密特征和one-hot后的稀疏特征在列上拼接 all_features np.hstack([dense_feat, sparse_feat_onehot]) print(f最终特征总维度: {all_features.shape}) # 4. 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split( all_features, label, test_size0.2, random_state42, stratifylabel ) return X_train, X_test, y_train, y_test, all_features.shape[1] # 生成并处理数据 dense, sparse, y generate_sample_data() X_train, X_test, y_train, y_test, input_dim preprocess_data(dense, sparse, y) # 现在 input_dim 就是我们模型的输入维度 print(f模型输入维度 (input_dim): {input_dim})数据处理的实战心得One-Hot的维度灾难上面的代码使用了OneHotEncoder这在特征类别很多时会产生巨大的特征维度比如从几十个原始特征膨胀到几十万维。这对于内存和计算都是挑战。在生产环境中对于FM模型更标准的做法是使用特征哈希Feature Hashing或者让模型的第一层是嵌入层Embedding Layer来处理稀疏ID特征。我们这里为了保持与标准FM公式输入稠密特征向量的一致性使用了OneHot。如果你要实现工业级版本需要修改模型输入为多个稀疏特征字段并为每个字段配置一个Embedding层然后将所有嵌入向量和稠密特征拼接后再送入自定义的FM交叉层进行计算。这涉及到更复杂的模型结构。缺失值与归一化对于稠密特征数值特征FM模型虽然对特征的尺度不敏感因为权重和隐向量会自适应学习但进行归一化如MinMaxScaler或StandardScaler通常能加速优化器的收敛。对于缺失值需要根据业务进行填充如均值、中位数或一个特殊值。标签分布CTR数据通常是高度不平衡的点击样本远少于未点击样本。在划分数据集时使用stratifylabel可以保证训练集和测试集的正负样本比例一致。在训练时可能需要考虑在损失函数中使用类别权重class_weight或使用过采样/欠采样技术。数据准备好后我们就可以开始训练模型了。5. 模型训练、评估与深度调优策略现在我们用处理好的数据来训练和评估我们构建的FM模型。# 构建模型输入维度为预处理后的特征总维度 model build_fm_model(input_diminput_dim, factor_dim8) # 隐向量维度设为8 # 训练模型 history model.fit( X_train, y_train, validation_data(X_test, y_test), epochs20, batch_size256, verbose1 ) # 评估模型 test_loss, test_acc, test_auc model.evaluate(X_test, y_test, verbose0) print(f\n测试集评估结果:) print(f 损失 (Loss): {test_loss:.4f}) print(f 准确率 (Accuracy): {test_acc:.4f}) print(f AUC: {test_auc:.4f}) # 绘制训练过程曲线 import matplotlib.pyplot as plt plt.figure(figsize(12, 4)) plt.subplot(1, 2, 1) plt.plot(history.history[loss], label训练损失) plt.plot(history.history[val_loss], label验证损失) plt.title(模型损失) plt.xlabel(Epoch) plt.ylabel(Loss) plt.legend() plt.grid(True) plt.subplot(1, 2, 2) plt.plot(history.history[auc], label训练AUC) plt.plot(history.history[val_auc], label验证AUC) plt.title(模型AUC) plt.xlabel(Epoch) plt.ylabel(AUC) plt.legend() plt.grid(True) plt.tight_layout() plt.show()训练完成后我们不仅要看最终的指标更要分析训练过程曲线。如果验证损失很早就开始上升而训练损失持续下降这是典型的过拟合信号。此时你需要回过头去调整我们之前提到的一些关键超参数和结构。FM模型的核心调优策略与经验隐向量维度factor_dim(k)这是FM最重要的超参数。它控制了特征交叉的表示能力。值太小如k1或2模型容量不足无法充分学习复杂的特征交互可能导致欠拟合。值太大如k128或256模型容量过高在稀疏数据上极易过拟合学到的隐向量噪声多泛化能力差。经验范围对于大多数CTR预估任务k在4到32之间调整通常能取得不错的效果。可以从8或10开始根据验证集AUC进行调整。一个实用的技巧是如果特征字段很多但每个字段的取值空间vocab size不大k可以设小一些反之如果某些特征维度极高如用户ID可以尝试稍大的k但必须配合强正则化。正则化强度我们在FMLayer的隐向量V和线性层的权重W上都添加了L2正则化kernel_regularizerl2(0.01)。这个0.01是另一个关键超参数。如果模型过拟合训练AUC远高于验证AUC可以增大L2的系数如0.1或者同时减小k。如果模型欠拟合训练AUC和验证AUC都低可以减小L2的系数如0.001或者同时增大k。分域正则化更精细的做法是对不同特征的隐向量施加不同的正则化强度。例如对于出现频率低的特征长尾特征应该施加更强的正则化防止其过拟合。这需要更复杂的自定义层实现。优化器与学习率我们使用了Adam优化器学习率0.001是一个不错的起点。如果训练不稳定损失震荡剧烈或收敛慢可以尝试调整学习率。也可以使用学习率调度器如ReduceLROnPlateau当验证指标停滞时自动降低学习率。针对稀疏数据的工程优化进阶如前所述我们演示的OneHot方式不适合超大规模稀疏特征。工业级实现应使用Embedding层。这时模型输入不再是单个稠密向量而是一个字典或列表包含多个稠密特征字段和多个稀疏特征ID字段。FM层的实现也需要相应修改其输入将是所有特征的嵌入向量对于稠密特征可以看作是其值乘以一个1维的“嵌入”向量拼接后的结果。这能极大减少参数数量从OneHot的百万级降到Embedding的万级并提升效率。特征工程依然重要虽然FM能自动学习特征交叉但好的基础特征仍然是天花板。对于数值特征可以考虑分桶binning将其转化为类别特征让FM学习桶之间的交叉。对于类别特征可以尝试基于业务知识的特征交叉如“用户历史点击品类”和“当前商品品类”作为新的特征字段加入模型有时能带来惊喜。与深度学习模型的结合纯FM模型是浅层模型。一个强大的趋势是将其作为一层与深度神经网络DNN结合形成DeepFM、xDeepFM等模型。我们的FMLayer可以轻松地作为一个组件与Keras的DNN层并联或串联构建更复杂的混合模型同时捕获低阶显式交叉和高阶隐式交叉。通过以上步骤你不仅实现了一个可工作的Keras版FM更掌握了其背后的原理、实现细节和调优方向。这比单纯调用一个库函数要深刻得多。下次当你遇到需要特征交叉的场景时不妨考虑用这个自己亲手搭建的FM模型作为基线它轻量、高效且解释性相对较好常常能带来不俗的效果。