FM(Factorization Machines,因子分解机)早在2010年提出,作为逻辑回归模型的改进版,拟解决在稀疏数据的场景下模型参数难以训练的问题。并且考虑了特征的二阶交叉,弥补了逻辑回归表达能力差的缺陷。
《原始论文:Fast context-aware recommendations with factorization machines》
Steffen Rendle et al. “Factorization Machines.” in ICDM 2010.
GitHub参考代码:Recommend-System-tf2.0/FM/
FM 作为推荐算法广泛应用于推荐系统及计算广告领域,通常用于预测点击率 CTR(click-through rate)和转化率 CVR(conversion rate)。
FM、FFM模型只具备二阶特征交叉的能力。Deep Crossing模型可以通过调整神经网络的深度进行特征之间的“深度交叉”,这也 是 Deep Crossing名称的由来。
一、概述
1、辛普森悖论
在对样本集合进行分组研究时,在分组比较中都占优势的一方,在总评中 有时反而是失势的一方,这种有悖常理的现象,被称为“辛普森悖论”。下面用一个视频推荐的例子进一步说明什么是“辛普森悖论”。
假设表2-1和表2-2所示为某视频应用中男性用户和女性用户点击视频的 数据。
从以上数据中可以看出,无论男性用户还是女性用户,对视频B 的点击率 都高于视频A ,显然推荐系统应该优先考虑向用户推荐视频B。
那么,如果忽略性别这个维度,将数据汇总(如表2・ 3所示)会得出什么 结论呢?
在汇总结果中,视频A 的点击率居然比视频B 高。如果据此进行推荐, 将得出与之前的结果完全相反的结论,这就是所谓的“辛普森悖论”。
在 “辛普森悖论”的例子中,分组实验相当于使用“性别” + “视 频 id” 的 组合特征计算点击率,而汇总实验则使用“视频id” 这一单一特征计算点击率。 汇总实验对高维特征进行了合并,损失了大量的有效信息,因此无法正确刻画数 据模式。
2、LR(逻辑回归)的缺陷
逻辑回归模型表达能力不强的问题,会不可避免地造成有效信息的损失。
在 仅利用单一特征而非交叉特征进行判断的情况下,有时不仅是信息损失的问题, 甚至会得出错误的结论。
著名的“辛普森悖论”用一个非常简单的例子,说明了 进行多维度特征交叉的重要性。
逻辑回归只对单一特征做简单加权,不具备进行特征交叉生成高维组合特征的能力,因此表达能力很弱,甚至可能得出像“辛普森悖论”那样的错误结论。
因此,通过改造逻辑回归模型,使其具备特征交叉的能力是必要和迫切的。
3、POLY2模型一特征交叉的开始
针对特征交叉的问题,算法工程师经常采用先手动组合特征,再通过各种分析手段筛选特征的方法,但该方法无疑是低效的。
更遗憾的是,人类的经验往往 有局限性,程序员的时间和精力也无法支撑其找到最优的特征组合。因此,采用 P0LY2模型进行特征的“暴力”组合成了可行的选择。
POLY2模型的数学形式如下图所示。
可以看到,该模型对所有特征进行了两两交叉(特征
x
j
1
x_{j_1}
xj1和
x
j
2
x_{j_2}
xj2), 并对所有的 特征组合赋予权重
w
h
(
j
1
,
j
2
)
w_{h(j_1,j_2)}
wh(j1,j2)。POLY2通过暴力组合特征的方式,在一定程度上解 决了特征组合的问题。POLY2模型本质上仍是线性模型,其训练方法与逻辑回归 并无区别,因此便于工程上的兼容。
但 POLY2模型存在两个较大的缺陷。
- ( 1 ) 在处理互联网数据时,经常采用one-hot编码的方法处理类别型数据, 致使特征向量极度稀疏,POLY2进行无选择的特征交叉一原本就非常稀疏的特 征向量更加稀疏,导致大部分交叉特征的权重缺乏有效的数据进行训练,无法收 敛。
- ( 2 ) 权重参数的数量由 n n n 直接上升到 n 2 n^2 n2 ,极大地增加了训练复杂度。
二、因子分解机FM
为了解决POLY2模型的缺陷,2010年,Rendle提出了 FM模型。
因子分解机(Factorization Machines) 是由 Steffen Rendle于2010年提出一种因子分解模型,其目的是解决传统的因子分解模型的一些缺点:
- 首先,传统的因子模型,每遇到一种新问题,都需要在矩阵分解的基础上建立一个新模型(例如上一节的SVD),推导出新的参数学习算法,并在学习参数过程中调节各种参数。以至于这些因子分解模型对于那些对因子分解模型的使用不是很熟悉的人来说是费事、耗力、易错的。
- 其次,传统的因子分解模型不能很好地利用特征工程法( feature engineering)来完成学习任务。在实际的机器学习任务中,常用的方法是首先用特征向量来表示数据,然后用一些开源工具 LibSVM或Weka等工具进行学习,方便地完成分类或决策任务。
FM的优势在于它能够通过特征向量去模拟因子分解模型它既结合了特征工程法的普遍性和适用性,又能够利用因子分解模型对不同类别的变量之间的交互作用(interaction)进行建模估计,借助开源实现工具 libFM,能够快速地完成学习任务,取得很好的精度。将这一模型命名为因子分解机,作者正是希望该模型能像支撑向量机那样简单、易用、高精度。
下图是 FM二阶部分的数学形式,与 POLY2相比,其主要区别是:
- 用两 个向量的内积 ( w j 1 ⋅ w j 2 ) (w_{j_1}·w_{j_2}) (wj1⋅wj2)取代了POLY2模型中单一的权重系数 w h ( j 1 , j 2 ) w_{h(j_1,j_2)} wh(j1,j2)。
- 具体地说,FM为每个特 征学习了一个隐权重向量(latent vector )。 在特征交叉时,使用两个特征隐向量 的内积作为交叉特征的权重。
本质上,FM 引入隐向量的做法,与矩阵分解用隐向量代表用户和物品的做 法异曲同工。可以说,FM是将矩阵分解隐向量的思想进行了进一步扩展,从单 纯的用户、物品隐向量扩展到了所有特征上。
FM通过引入特征隐向量的方式,直接把P0LY2模型 n 2 n^2 n2 级别的权重参数数 量减少到了 n k nk nk ( k k k为隐向量维度, n > > k n>>k n>>k )。 在使用梯度下降法进行FM训练的过 程中,FM的训练复杂度同样可被降低到成 n k nk nk 级别,极大地降低了训练开销。
隐向量的引入使FM能更好地解决数据稀疏性的问题。举例来说,在某商品 推荐的场景下,样本有两个特征,分别是频道(channel)和品牌(brand),某训 练样本的特征组合是(ESPN, Adidas)。在 P0LY2中,只有当ESPN和 Adidas同时 出现在一个训练样本中时,模型才能学到这个组合特征对应的权重;而在FM中, ESPN的隐向量也可以通过(ESPN, Gucci)样本进行更新,Adidas的隐向量也可以 通过(NBC, Adidas)样本进行更新,这大幅降低了模型对数据稀疏性的要求。甚至 对于一个从未出现过的特征组合(NBC, Gucci), 由于模型之前已经分别学习过 NBC和 Gucci的隐向量,具备了计算该特征组合权重的能力,这是POLY2无法 实现的。相比P0LY2, FM虽然丢失了某些具体特征组合的精确记忆能力,但是 泛化能力大大提高。
在工程方面,FM 同样可以用梯度下降法进行学习,使其不失实时性和灵活 性。相比之后深度学习模型复杂的网络结构导致难以部署和线上服务,FM较容 易实现的模型结构使其线上推断的过程相对简单,也更容易进行线上部署和服 务。
因此, FM在 2012—2014年前后,成为业界主流的推荐模型之一。
三、代码
FM 层代码:将 FM 封装成 Layer,随后在搭建 Model 时直接调用即可。
import tensorflow as tf
import tensorflow.keras.backend as K
class FM_layer(tf.keras.layers.Layer):
def __init__(self, k, w_reg, v_reg):
super(FM_layer, self).__init__()
self.k = k # 隐向量vi的维度
self.w_reg = w_reg # 权重w的正则项系数
self.v_reg = v_reg # 权重v的正则项系数
def build(self, input_shape): # 需要根据input来定义shape的变量,可在build里定义)
self.w0 = self.add_weight(name='w0', shape=(1,), # shape:(1,)
initializer=tf.zeros_initializer(),
trainable=True,)
self.w = self.add_weight(name='w', shape=(input_shape[-1], 1), # shape:(n, 1)
initializer=tf.random_normal_initializer(), # 初始化方法
trainable=True, # 参数可训练
regularizer=tf.keras.regularizers.l2(self.w_reg)) # 正则化方法
self.v = self.add_weight(name='v', shape=(input_shape[-1], self.k), # shape:(n, k)
initializer=tf.random_normal_initializer(),
trainable=True,
regularizer=tf.keras.regularizers.l2(self.v_reg))
def call(self, inputs, **kwargs):
# inputs维度判断,不符合则抛出异常
if K.ndim(inputs) != 2:
raise ValueError("Unexpected inputs dimensions %d, expect to be 2 dimensions" % (K.ndim(inputs)))
# 线性部分,相当于逻辑回归
linear_part = tf.matmul(inputs, self.w) + self.w0 #shape:(batchsize, 1)
# 交叉部分——第一项
inter_part1 = tf.pow(tf.matmul(inputs, self.v), 2) #shape:(batchsize, self.k)
# 交叉部分——第二项
inter_part2 = tf.matmul(tf.pow(inputs, 2), tf.pow(self.v, 2)) #shape:(batchsize, k)
# 交叉结果
inter_part = 0.5*tf.reduce_sum(inter_part1 - inter_part2, axis=-1, keepdims=True) #shape:(batchsize, 1)
# 最终结果
output = linear_part + inter_part
return tf.nn.sigmoid(output) #shape:(batchsize, 1)
定义好了 FM 层,模型搭建就简单了,Model 代码如下:
class FM(tf.keras.Model):
def __init__(self, k, w_reg=1e-4, v_reg=1e-4):
super(FM, self).__init__()
self.fm = FM_layer(k, w_reg, v_reg) # 调用写好的FM_layer
def call(self, inputs, training=None, mask=None):
output = self.fm(inputs) # 输入FM_layer得到输出
return output
数据处理代码:
import pandas as pd
from sklearn.preprocessing import MinMaxScaler
from sklearn.model_selection import train_test_split
def create_criteo_dataset(file_path, test_size=0.3):
data = pd.read_csv(file_path)
dense_features = ['I' + str(i) for i in range(1, 14)] # 数值特征
sparse_features = ['C' + str(i) for i in range(1, 27)] # 类别特征
# 缺失值填充
data[dense_features] = data[dense_features].fillna(0)
data[sparse_features] = data[sparse_features].fillna('-1')
# 归一化(数值特征)
data[dense_features] = MinMaxScaler().fit_transform(data[dense_features])
# onehot编码(类别特征)
data = pd.get_dummies(data)
#数据集划分
X = data.drop(['label'], axis=1).values
y = data['label']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=test_size)
return (X_train, y_train), (X_test, y_test)
模型训练代码:
from model import FM
from utils import create_criteo_dataset
import tensorflow as tf
from tensorflow.keras import optimizers, losses, metrics
from sklearn.metrics import accuracy_score
if __name__ == '__main__':
file_path = 'data\train.txt' # 修改为自己的路径
(X_train, y_train), (X_test, y_test) = create_criteo_dataset(file_path, test_size=0.2)
k = 8
w_reg = 1e-5
v_reg = 1e-5
model = FM(k, w_reg, v_reg)
optimizer = optimizers.SGD(0.01)
summary_writer = tf.summary.create_file_writer('.\tensorboard') # tensorboard可视化文件路径
for epoch in range(100):
with tf.GradientTape() as tape:
y_pre = model(X_train) # 前馈得到预测值
loss = tf.reduce_mean(losses.binary_crossentropy(y_true=y_train, y_pred=y_pre)) # 与真实值计算loss值
print('epoch: {} loss: {}'.format(epoch, loss.numpy()))
grad = tape.gradient(loss, model.variables) # 根据loss计算模型参数的梯度
optimizer.apply_gradients(grads_and_vars=zip(grad, model.variables)) # 将梯度应用到对应参数上进行更新
# 需要tensorboard记录的变量(不需要可视化可将该模块注释掉)
with summary_writer.as_default():
tf.summary.scalar("loss", loss, step=epoch)
#评估
pre = model(X_test)
pre = [1 if x>0.5 else 0 for x in pre]
print("ACC: ", accuracy_score(y_test, pre)) # ACC: 0.772
tensorboard的可视化结果如下: (可继续加大 epoch 数)
参考资料:
推荐算法(一)——FM因式分解(原理+代码)
推荐系统入门(四)|FM模型(三)
神经网络中的几个FM家族模型(FNN、NFM、AFM、DeepFM)
CTR预估算法之FM, FFM, DeepFM及实践
Python实现FM (附代码与数据)