
1. 项目概述为什么用Keras实现FM是个好主意如果你在推荐系统、点击率预估或者任何涉及高维稀疏特征的数据场景里摸爬滚打过那你肯定对特征组合的威力深有体会。传统的逻辑回归模型简单高效但它是个“线性脑袋”只能看到单个特征的影响看不到特征之间“112”的协同效应。手动构造交叉特征吧工程量大不说还容易陷入维度灾难和过拟合的泥潭。Factorization Machines也就是我们常说的FM算法就是为了优雅地解决这个问题而生的。它通过为每个特征学习一个隐向量用隐向量的内积来建模任意两个特征之间的交互作用不仅计算高效还能自动学习到数据中潜藏的复杂模式。那么为什么我要选择用Keras来实现FM呢这背后有几个很实际的考量。首先Keras的API设计极其人性化它允许我们用构建积木的方式去搭建模型这对于实现FM这种结构清晰线性项交叉项的模型来说几乎就是量身定做。你不需要写一大堆复杂的矩阵运算代码用几个Dense层和自定义的Lambda层就能把核心逻辑表达清楚。其次Keras能无缝对接TensorFlow的后端这意味着你可以轻松地利用GPU加速训练处理大规模稀疏数据时优势明显。最后也是最重要的一点用Keras实现的FM模型可以非常方便地融入更复杂的深度学习架构中比如作为DeepFM、xDeepFM等模型的组件或者与文本、图像特征进行多模态融合扩展性极强。这篇文章我就带你从零开始用Keras快速搭建一个可用的FM模型并分享我在实现和调优过程中踩过的坑和总结的经验。2. FM算法核心思想与Keras实现优势2.1 从公式到直觉FM到底在做什么FM的数学公式看起来可能有点唬人但它的思想非常直观。我们假设有一个经过One-Hot编码后的稀疏特征向量x。一个二阶FM模型的预测公式如下ŷ(x) w₀ Σᵢ wᵢ xᵢ Σᵢ Σ_{ji} vᵢ, vⱼ xᵢ xⱼ我们来分部分解读偏置项 (w₀)一个全局的偏置类似于线性回归中的截距。线性项 (Σᵢ wᵢ xᵢ)这就是一个标准的线性回归部分每个特征xᵢ对应一个权重wᵢ用于建模特征的一阶重要性。交叉项 (Σᵢ Σ_{ji} vᵢ, vⱼ xᵢ xⱼ)这是FM的灵魂。它为每个特征i都学习了一个k维的隐向量vᵢ。特征i和特征j的交互强度不再是一个需要单独学习的参数w_{ij}而是通过它们对应的隐向量vᵢ和vⱼ的内积vᵢ, vⱼ来表示。这样做有什么天大的好处呢在数据高度稀疏的场景下比如推荐系统用户和物品的交互矩阵非常稀疏很多特征组合(i, j)在训练数据中根本没有同时出现过。如果为每个组合都学一个参数w_{ij}那么这个w_{ij}就永远学不到因为没有数据预测时也会是零。但FM通过隐向量打破了这种孤立。即使特征i和j没有在训练数据中直接共现但只要它们各自与其他特征比如k有过交互它们的隐向量vᵢ和vⱼ就能在训练过程中通过vᵢ与v_k、vⱼ与v_k的交互间接地被更新和优化。这使得FM拥有强大的数据稀疏性下的泛化能力。2.2 为什么Keras是FM的“快速实现”利器“快速实现”不仅仅指代码写得快更指从想法到可运行、可调试、可扩展的模型路径短。Keras在这方面的优势体现在三个层面第一声明式模型构建。你不需要关心底层的张量操作细节当然懂更好只需要像搭乐高一样用Input,Embedding,Lambda,Dense这些层把计算图定义出来。FM的线性部分就是一个没有激活函数的Dense层交叉部分可以通过一个自定义的层来计算所有隐向量两两内积的加权和。这种抽象层级非常适合快速原型开发。第二内置的优化器和损失函数。Keras集成了Adam、SGD等主流优化器以及binary_crossentropy用于CTR预估、mse用于回归等常见损失函数。你只需要在model.compile()中指定省去了自己手写反向传播的麻烦让开发者能更专注于模型结构本身。第三无缝的扩展与集成。这是我认为最强大的一点。当你用Keras定义好一个FM层比如一个自定义的FMLayer之后你可以轻易地把它作为一个组件和其他的深度学习层拼接起来。例如想试试DeepFM那就把FM层的输出和一个多层感知机MLP的输出拼接起来再过一个全连接层即可。整个流程在Keras的Functional API下异常清晰和简洁。注意虽然FM的交叉项计算有复杂度为O(kn)的优化公式其中k是隐向量维度n是非零特征数但在用Keras实现时为了代码的清晰性和教学目的我们可能会先使用直观但复杂度稍高的实现方式O(kn²)。在实际处理超大规模数据时再考虑引入优化后的计算方式。对于大多数中小规模数据集直观实现已经足够高效。3. 手把手搭建Keras FM模型从数据到训练3.1 环境准备与数据预处理在开始写模型之前我们需要一个干净的环境。我强烈建议使用conda或venv创建独立的Python环境避免包版本冲突。核心依赖很简单pip install tensorflow2.10.0 # 或更高稳定版本内置Keras pip install pandas scikit-learn numpy这里选择TensorFlow 2.x因为它已经将Keras作为其官方高阶API。数据方面我们以经典的Criteo CTR预估数据集为例。这个数据集包含连续特征和类别特征且经过了脱敏处理非常适合演示。import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import LabelEncoder, MinMaxScaler # 假设我们读取了部分数据 # df pd.read_csv(criteo_sample.csv) # 这里我们用模拟数据说明流程 import numpy as np n_samples 10000 n_sparse_features 20 # 假设有20个稀疏的类别特征 n_dense_features 10 # 10个稠密连续特征 # 模拟稀疏特征类别ID sparse_data np.random.randint(0, 100, size(n_samples, n_sparse_features)) # 模拟稠密特征 dense_data np.random.randn(n_samples, n_dense_features) # 模拟标签 labels np.random.randint(0, 2, size(n_samples,)) # 关键步骤对稀疏特征进行Label Encoding和One-Hot或直接映射到嵌入层 # 方法AOne-Hot适用于特征取值不多的情况 # 这个方法会极大扩展维度通常不直接用于FM这里仅作对比说明。 # from sklearn.preprocessing import OneHotEncoder # encoder OneHotEncoder() # sparse_onehot encoder.fit_transform(sparse_data) # 得到一个稀疏矩阵 # 方法B为每个稀疏特征建立字典将特征值映射到连续的索引推荐 # 这是为嵌入层做准备。FM中每个稀疏特征值都对应一个隐向量。 sparse_inputs [] field_dict [] # 记录每个特征域field的大小 for i in range(n_sparse_features): le LabelEncoder() encoded_feat le.fit_transform(sparse_data[:, i]) sparse_inputs.append(encoded_feat.reshape(-1, 1)) # 保持二维方便后续输入 field_dict.append(len(le.classes_)) # 该特征域有多少个不同的取值 # 处理稠密特征归一化 scaler MinMaxScaler() dense_scaled scaler.fit_transform(dense_data) # 划分训练集和测试集 train_idx, test_idx train_test_split(range(n_samples), test_size0.2, random_state42)预处理的核心思想是将每个稀疏的类别特征如用户ID、商品ID、城市单独视为一个“字段”Field。每个字段内的具体取值如用户123、商品456将被映射为一个整数索引。这个索引就是后续嵌入层Embedding Layer的查找键。field_dict列表记录了每个字段的词汇表大小这是定义嵌入层的关键参数。3.2 构建Keras FM模型层接下来是核心部分用Keras的Functional API构建FM模型。我们将模型拆分为几个逻辑部分。import tensorflow as tf from tensorflow.keras.layers import Input, Embedding, Flatten, Dense, Concatenate, Lambda, Add from tensorflow.keras.models import Model from tensorflow.keras.regularizers import l2 def build_fm_model(sparse_field_sizes, dense_dim, embedding_dim10): 构建FM模型。 参数 sparse_field_sizes: list每个稀疏字段的词汇表大小。 dense_dim: int稠密特征的数量。 embedding_dim: int隐向量的维度k。 # 1. 输入层 sparse_inputs [] for size in sparse_field_sizes: inp Input(shape(1,), dtypeint32, namefsparse_feat_{len(sparse_inputs)}) sparse_inputs.append(inp) dense_input Input(shape(dense_dim,), namedense_features) # 2. 嵌入层为每个稀疏特征学习隐向量 embeddings [] for i, (inp, size) in enumerate(zip(sparse_inputs, sparse_field_sizes)): # 每个稀疏特征输入形状为 (batch, 1)嵌入层输出形状为 (batch, 1, embedding_dim) # 使用l2正则化防止过拟合 emb Embedding(input_dimsize, output_dimembedding_dim, embeddings_regularizerl2(1e-5))(inp) emb Flatten()(emb) # 展平成 (batch, embedding_dim) embeddings.append(emb) # 3. 线性部分一阶项 # 稀疏特征的线性项每个特征一个权重通过一个输出维度为1的Embedding实现 linear_embeddings [] for i, (inp, size) in enumerate(zip(sparse_inputs, sparse_field_sizes)): linear_emb Embedding(input_dimsize, output_dim1, embeddings_regularizerl2(1e-5))(inp) linear_emb Flatten()(linear_emb) # 形状 (batch, 1) linear_embeddings.append(linear_emb) # 稠密特征的线性项一个全连接层无激活函数 dense_linear Dense(1, use_biasFalse, kernel_regularizerl2(1e-5))(dense_input) # 将所有一阶项相加 linear_part Add()(linear_embeddings [dense_linear]) # 形状 (batch, 1) # 4. 交叉部分二阶项 - 这是FM的核心 # 将所有稀疏特征的隐向量拼接起来 # embeddings 是一个列表每个元素形状为 (batch, embedding_dim) # 拼接后形状为 (batch, num_sparse_fields * embedding_dim) # 但为了计算交叉项我们需要保持 (batch, num_sparse_fields, embedding_dim) 的形状 # 因此我们换一种方式先堆叠 stacked_embeddings tf.stack(embeddings, axis1) # 形状 (batch, num_sparse_fields, embedding_dim) # 计算交叉项利用公式 0.5 * sum( (sum V)^2 - sum(V^2) ) # 这个公式将O(n^2)的复杂度降到了O(kn) # sum_square: 先对隐向量求和再平方。形状 (batch, embedding_dim) - (batch, embedding_dim) sum_square tf.square(tf.reduce_sum(stacked_embeddings, axis1)) # square_sum: 先对隐向量平方再求和。形状 (batch, embedding_dim) - (batch, embedding_dim) square_sum tf.reduce_sum(tf.square(stacked_embeddings), axis1) # 交叉项结果0.5 * (sum_square - square_sum) 再在embedding_dim维度求和 cross_term 0.5 * tf.reduce_sum(sum_square - square_sum, axis1, keepdimsTrue) # 形状 (batch, 1) # 5. 组合输出层 # 偏置项可以包含在最后一个Dense层的bias中也可以单独加。这里我们让线性部分的Dense层包含偏置。 # 修改线性部分使其包含偏置 linear_part_with_bias Dense(1, use_biasTrue, kernel_regularizerl2(1e-5))(dense_input) # 注意上面的 dense_linear 被替换了。为了简化我们可以直接用一个偏置项。 # 更清晰的做法偏置项作为一个可训练变量或者让linear_part的Add()包含一个Dense(1, use_biasTrue)层。 # 我们调整一下结构 # 将稀疏特征的线性权重和稠密特征的线性权重相加后通过一个带有偏置的Dense(1)层。 combined_linear Add()(linear_embeddings) # 只加稀疏部分的一阶权重形状 (batch, 1) # 将稀疏一阶权重、稠密特征本身、交叉项拼接起来最后过一个带偏置的Dense层。 concat_all Concatenate(axis1)([combined_linear, dense_input, cross_term]) # 最终输出层。如果是二分类使用sigmoid激活。 output Dense(1, activationsigmoid, use_biasTrue, kernel_regularizerl2(1e-5))(concat_all) # 6. 构建模型 model Model(inputssparse_inputs [dense_input], outputsoutput) return model # 使用示例 model build_fm_model(field_dict, dense_dimn_dense_features, embedding_dim8) model.summary()这段代码有几个关键点需要解释双嵌入层策略我们为每个稀疏特征创建了两个嵌入层。一个用于学习交叉项的隐向量embedding_dim维另一个用于学习线性项的权重1维。这严格对应了FM公式中的v_i和w_i。交叉项的优化计算我们没有直接计算所有特征对的内积和O(n²k)复杂度而是使用了FM论文中经典的优化公式将复杂度降到了O(nk)。这在stacked_embeddings之后的三行代码中实现。这是FM能高效处理稀疏数据的关键务必理解。输入处理模型接受两种输入一个列表每个元素对应一个稀疏特征字段的整数索引输入和一个稠密特征向量。这要求我们在准备数据时也要将稀疏特征按字段分开。正则化我们在嵌入层和全连接层都加入了l2正则化embeddings_regularizer和kernel_regularizer这对于防止过拟合、提升模型泛化能力至关重要尤其是在特征维度很高的情况下。3.3 模型训练、评估与预测模型构建好后训练流程就和标准的Keras模型一模一样了。# 编译模型 model.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), lossbinary_crossentropy, # 适用于CTR二分类 metrics[accuracy, tf.keras.metrics.AUC(nameauc)] # AUC是CTR预估的关键指标 ) # 准备输入数据 # 将训练集的每个稀疏字段数据提取出来组成一个列表 train_sparse [sparse_inputs[i][train_idx] for i in range(n_sparse_features)] train_dense dense_scaled[train_idx] train_y labels[train_idx] test_sparse [sparse_inputs[i][test_idx] for i in range(n_sparse_features)] test_dense dense_scaled[test_idx] test_y labels[test_idx] # 训练模型 history model.fit( train_sparse [train_dense], # 输入数据列表 train_y, validation_data(test_sparse [test_dense], test_y), epochs20, batch_size256, verbose1 ) # 评估 test_loss, test_acc, test_auc model.evaluate(test_sparse [test_dense], test_y, verbose0) print(fTest Loss: {test_loss:.4f}, Test Accuracy: {test_acc:.4f}, Test AUC: {test_auc:.4f}) # 预测 predictions model.predict(test_sparse [test_dense])在训练过程中有几点经验分享学习率Adam优化器的默认学习率0.001在大多数情况下是个不错的起点。如果训练初期loss下降很慢或者震荡可以尝试稍微调大如0.005如果后期loss不稳定可以尝试调小或使用学习率衰减策略。批大小Batch Size对于稀疏数据较大的Batch Size如256、512有助于获得更稳定的梯度估计。但如果GPU内存有限需要适当调小。早停Early Stopping强烈建议在fit中加入tf.keras.callbacks.EarlyStopping(monitorval_auc, patience5, modemax)回调函数。当验证集AUC在连续5个epoch不再提升时自动停止训练避免过拟合并恢复最佳权重。指标监控对于CTR预估AUCArea Under ROC Curve比准确率Accuracy更重要。因为正负样本通常极不平衡AUC更能衡量模型对正负样本的排序能力。4. 实战调优与高级技巧4.1 隐向量维度k的选择与正则化强度隐向量维度k是FM最重要的超参数之一。它控制着特征交互的建模能力。k太小如4或8模型容量低可能无法充分学习复杂的特征交互导致欠拟合。k太大如64或128模型容量高但需要更多的数据来训练否则容易过拟合。同时也会增加计算和存储开销。我的经验是对于大多数推荐和广告点击场景k在8到32之间是一个很好的搜索区间。可以从8开始如果验证集效果持续提升可以尝试16、32。同时必须配合使用正则化。l2正则化的强度代码中的1e-5也需要调整。一个常见的做法是当k增大时适当增强正则化如从1e-5调到1e-4来抑制过拟合风险。你可以使用GridSearchCV或RandomizedSearchCV结合Keras的KerasClassifier包装器进行超参数搜索但更高效的方法是使用诸如optuna或hyperopt等自动化超参数优化库。4.2 处理大规模稀疏特征与工程优化当稀疏特征的基数不同取值的数量非常大时例如百万甚至上亿的用户ID直接使用上面的代码可能会遇到内存问题因为嵌入层矩阵的大小是(vocab_size, embedding_dim)。工程优化技巧1哈希技巧Hashing Trick对于基数极大或不断增长的特征如用户ID可以使用特征哈希Feature Hashing。即设定一个固定的哈希空间大小N比如2^24通过哈希函数将原始特征值映射到[0, N-1]的范围内。这样嵌入层的大小就固定为(N, embedding_dim)不再随词汇表增长而膨胀。代价是可能存在哈希冲突但实践表明在足够大的N下对模型效果影响很小。在Keras中你可以使用tf.keras.layers.Hashing层或者在数据预处理阶段完成哈希。工程优化技巧2使用tf.dataAPI构建高效数据管道如果数据无法一次性装入内存需要使用tf.data.Dataset来构建数据流水线支持从磁盘动态加载和预处理并充分利用CPU进行数据预取让GPU永不空闲。def dataset_generator(sparse_arrays, dense_array, labels, batch_size256): # 假设 sparse_arrays 是列表每个元素是一个特征列的数组 dataset tf.data.Dataset.from_tensor_slices((tuple(sparse_arrays), dense_array, labels)) dataset dataset.shuffle(buffer_size10000).batch(batch_size).prefetch(tf.data.AUTOTUNE) return dataset train_dataset dataset_generator(train_sparse, train_dense, train_y) val_dataset dataset_generator(test_sparse, test_dense, test_y) model.fit(train_dataset, validation_dataval_dataset, epochs20)4.3 从FM到DeepFM深度学习的自然延伸FM的强大之处在于它和深度学习框架的亲和性。DeepFM模型简单来说就是“Wide Deep”架构的一个经典实现其中“Wide”部分由FM负责显式地学习低阶特征交互“Deep”部分是一个多层感知机MLP隐式地学习高阶特征交互。两者共享相同的特征嵌入层最终将它们的输出相加后进行预测。用我们已有的Keras FM模块构建DeepFM易如反掌from tensorflow.keras.layers import Input, Embedding, Flatten, Dense, Concatenate, Add from tensorflow.keras.models import Model def build_deepfm_model(sparse_field_sizes, dense_dim, embedding_dim10, hidden_units[128, 64]): # ... 前面的输入层、嵌入层构建与FM部分相同 ... # 假设我们已经得到了 stacked_embeddings (batch, fields, emb_dim) 和 dense_input # FM部分二阶交叉项 sum_square tf.square(tf.reduce_sum(stacked_embeddings, axis1)) square_sum tf.reduce_sum(tf.square(stacked_embeddings), axis1) fm_cross_term 0.5 * tf.reduce_sum(sum_square - square_sum, axis1, keepdimsTrue) # FM部分一阶线性项简化处理 # 将每个字段的1维嵌入权重相加 fm_linear_terms Add()(linear_embeddings) # 假设linear_embeddings是之前计算的每个字段的1维权重 fm_output Add()([fm_linear_terms, fm_cross_term]) # FM部分的最终输出 # Deep部分 # 将所有的嵌入向量展平后与稠密特征拼接 flattened_embeddings Flatten()(stacked_embeddings) # 形状 (batch, fields * emb_dim) deep_input Concatenate(axis1)([flattened_embeddings, dense_input]) x deep_input for units in hidden_units: x Dense(units, activationrelu)(x) deep_output Dense(1, use_biasFalse)(x) # Deep部分的输出 # 结合FM和Deep combined_output Add()([fm_output, deep_output]) final_output Dense(1, activationsigmoid, use_biasTrue)(combined_output) model Model(inputssparse_inputs [dense_input], outputsfinal_output) return model通过这种方式你可以轻松地探索更复杂的模型架构而无需重写底层的数据处理和特征嵌入逻辑。5. 常见问题排查与经验心得5.1 训练过程中的典型问题与解决思路问题1Loss损失不下降或下降非常慢。检查学习率学习率可能设置得太小。尝试逐步增大如0.001 - 0.005 - 0.01观察loss初期变化。检查数据预处理确保稀疏特征的索引是从0开始的连续整数没有出现超出嵌入层input_dim的索引值。检查稠密特征是否做了合理的归一化如MinMaxScaler或StandardScaler过大或过小的数值会影响梯度稳定性。检查模型初始化虽然Adam等优化器对初始化不那么敏感但糟糕的初始化仍会拖慢训练。Keras嵌入层默认使用均匀分布初始化通常是合理的。可以尝试改为glorot_normal看看。检查梯度在复杂自定义层中可以使用tf.GradientTape来手动检查梯度是否存在或是否为NaN。问题2验证集AUC早期上升后很快下降即过拟合。增强正则化这是首要手段。增加嵌入层和全连接层的l2正则化系数如从1e-5增加到1e-4或1e-3。降低模型复杂度减小隐向量维度k。使用Dropout可以在Deep部分的Dense层之间加入Dropout层随机丢弃一部分神经元。使用早停Early Stopping这是防止过拟合最有效的工程手段务必使用。问题3训练速度很慢。利用GPU确保你的TensorFlow版本支持GPU并且CUDA/cuDNN已正确安装。训练时观察GPU利用率例如使用nvidia-smi命令。优化数据管道使用tf.dataAPI并启用.prefetch和.cache操作确保数据加载不成为瓶颈。调整批大小在GPU内存允许的范围内适当增加批大小可以提升计算吞吐量。5.2 关于稀疏特征处理的特别注意事项缺失值处理对于类别型特征通常将缺失视为一个单独的类别赋予其一个特殊的索引如0或最大值1。对于稠密特征可以用均值、中位数或一个特殊值如-1填充但要注意归一化处理。低频特征过滤对于出现次数极少的类别长尾特征其对应的嵌入向量训练不充分可能会引入噪声。常见的做法是设定一个最小出现频次如5次或10次将低于此频次的类别统一归为“未知”或“其他”类别。嵌入层权重冻结如果你有预训练好的嵌入向量例如从Word2Vec或Graph Embedding获得可以在模型初期冻结嵌入层的权重只训练模型的其他部分待其他部分稳定后再进行微调fine-tuning。5.3 我的个人实操心得从简开始逐步复杂不要一开始就追求DeepFM这样的复杂模型。先用一个简单的FM模型甚至只有交叉项跑通整个数据 pipeline确保数据输入、模型构建、训练评估的流程是正确的。得到一个基线AUC后再逐步增加复杂度如加入线性项、调整隐向量维度、加入Deep部分。监控AUC而非仅仅Accuracy在类别不平衡的CTR任务中AUC是黄金标准。一个Accuracy很高但AUC很低的模型很可能只是学会了总是预测负例。可视化嵌入对于重要的稀疏特征如用户ID、商品类别训练完成后可以将它们的隐向量v_i通过t-SNE或PCA降维后可视化。观察同一类别下的点是否聚在一起这可以直观地检验模型是否学到了有意义的特征表示。线上服务的考虑如果模型需要部署上线提供服务需要考虑模型导出和推理速度。可以使用tf.saved_model.save保存模型并使用TensorFlow Serving或集成到推理引擎中。FM模型的计算相对轻量但也要注意对稀疏特征进行实时哈希和查找嵌入向量的开销。用Keras实现FM就像拥有了一套高灵活度的机器学习乐高。它隐藏了繁琐的微分和矩阵运算让你能更专注于模型结构的设计和业务逻辑的验证。希望这篇详细的指南能帮你快速上手并在你的推荐、广告或任何需要特征交叉的任务中发挥出FM模型的强大威力。