FM(Factorization Machines)的理论与实践

FM(Factorization Machines)的理论与实践

FM的paper地址如下:csie.ntu.edu.tw/~b97053

1. FM背景

在计算广告和推荐系统中,CTR预估(click-through rate)是非常重要的一个环节,判断一个物品是否进行推荐需要根据CTR预估的点击率排序决定。业界常用的方法有人工特征工程 + LR(Logistic Regression)、GBDT(Gradient Boosting Decision Tree) + LR、FM(Factorization Machine)和FFM(Field-aware Factorization Machine)模型。

最近几年出现了很多基于FM改进的方法,如deepFM,FNN,PNN,DCN,xDeepFM等,今后的内容将会分别介绍。

2. FM原理

FM主要是解决稀疏数据下的特征组合问题,并且其预测的复杂度是线性的,对于连续和离散特征有较好的通用性。

假设一个电影评分系统,根据用户和电影的特征,预测用户对电影的评分。

系统记录了用户(U)在特定时间(t)对电影(i)的评分(r),评分为1,2,3,4,5。

给定一个例子:

U=\left\{Alice(A),Bob(B),Charlie(C),... \right\}

I=\left\{Titanic (TI), Notting Hill (NH), Star Wars (SW), Star Trek (ST), . . . \right\}

S=\left\{(A, TI, 2010-1, 5),(A, NH, 2010-2, 3),(A, SW, 2010-4, 1),\\ (B, SW, 2009-5, 4),(B, ST, 2009-8, 5),\\ (C, TI, 2009-9, 1),(C, SW, 2009-12, 5)\right\}

评分是label,用户id、电影id、评分时间是特征。由于用户id和电影id是categorical类型的,需要经过独热编码(One-Hot Encoding)转换成数值型特征。因为是categorical特征,所以经过one-hot编码以后,导致样本数据变得很稀疏。

下图显示了从S构建的例子:

每行表示目标 y^{(i)} 与其对应的特征向量 x^{(i)} ,蓝色区域表示了用户变量,红色区域表示了电影变量,黄色区域表示了其他隐含的变量,进行了归一化,绿色区域表示一个月内的投票时间,棕色区域表示了用户上一个评分的电影,最右边的区域是评分。

2.1 特征交叉

普通的线性模型,我们都是将各个特征独立考虑的,并没有考虑到特征与特征之间的相互关系。但实际上,特征之间可能具有一定的关联。以新闻推荐为例,一般男性用户看军事新闻多,而女性用户喜欢情感类新闻,那么可以看出性别与新闻的频道有一定的关联性,如果能找出这类的特征,是非常有意义的。

为了简单起见,我们只考虑二阶交叉的情况,具体的模型如下:

\tilde{y}(x)=w_{0}+\sum_{i=1}^{n}{w_{i}x_{i}}+\sum_{i=1}^{n}{\sum_{j=i+1}^{n}{w_{ij}x_{i}x_{j}}} \tag{1} \\

其中, n 代表样本的特征数量, x_{i} 是第i个特征的值, w_{0 }w_{i}w_{ij } 是模型参数,只有当 x_{i}x_{j} 都不为0时,交叉才有意义。

在数据稀疏的情况下,满足交叉项不为0的样本将非常少,当训练样本不足时,很容易导致参数 w_{ij} 训练不充分而不准确,最终影响模型的效果。

那么,交叉项参数的训练问题可以用矩阵分解来近似解决,有下面的公式。

\tilde{y}(x)=w_{0}+\sum_{i=1}^{n}{w_{i}x_{i}}+\sum_{i=1}^{n}{\sum_{j=i+1}^{n}{<v_{i},v_{j}>x_{i}x_{j}}} \tag{2}\\

其中模型需要估计的参数是:

w_{0}\in\mathbb{R},\boldsymbol{w}\in\mathbb{R}^n,\boldsymbol{V}\in\mathbb{R}^{n\times k}\\

<·,·> 是两个 k 维向量的内积:

<v_{i},v_{j}>=\sum_{f=1}^{k}{v_{i,f}v_{j,f}} \tag{3}\\

对任意正定矩阵 W ,只要 k 足够大,就存在矩阵 W ,使得 W=VV^T 。然而在数据稀疏的情况下,应该选择较小的k,因为没有足够的数据来估计 w_{ij} 。限制k的大小提高了模型更好的泛化能力。

为什么说可以提高模型的泛化能力呢?

以上述电影评分系统为例,假设我们要计算用户 A 与电影 ST 的交叉项,很显然,训练数据里没有这种情况,这样会导致 w_{A,ST}=0 ,但是我们可以近似计算出 <V_{A},V_{ST}> 。首先,用户 BC 有相似的向量 V_{B}V_{C} ,因为他们对 SW 的预测评分比较相似, 所以<V_{B},V_{SW}><V_{C},V_{SW}> 是相似的。用户 AC 有不同的向量,因为对 TISW 的预测评分完全不同。接下来, STSW 的向量可能相似,因为用户 B 对这两部电影的评分也相似。最后可以看出, <V_{A},V_{ST}><V_{A},V_{SW}> 是相似的。

直接计算公式(2)的时间复杂度是 O(kn^2) ,因为所有的交叉特征都需要计算。但是通过公式变换,可以减少到线性复杂度,方法如下:

\begin{align} &\sum_{i=1}^{n}{\sum_{j=i+1}^{n}{<v_{i},v_{j}>x_{i}x_{j}}} \\ &=\frac{1}{2}\sum_{i=1}^{n}{\sum_{j=1}^{n}{<v_{i},v_{j}>x_{i}x_{j}}}-\frac{1}{2}\sum_{i=1}^{n}{<v_{i},v_{i}>x_{i}x_{i}} \\ &=\frac{1}{2}(\sum_{i=1}^{n}{\sum_{j=1}^{n}{\sum_{f=1}^{k}{v_{i,f}v_{j,f}x_{i}x_{j}}}}-\sum_{i=1}^{n}{\sum_{f=1}^{k}{v_{i,f}v_{i,f}x_{i}x_{i}}}) \\ &=\frac{1}{2}\sum_{f=1}^{k}{((\sum_{i=1}^{n}{v_{i,f}x_{i}})(\sum_{i=1}^{n}{v_{j,f}x_{j}})-\sum_{i=1}^{n}{v_{i,f}^2x_{i}^2})} \\ &=\frac{1}{2}\sum_{f=1}^{k}{((\sum_{i=1}^{n}{v_{i,f}x_{i}})^2-\sum_{i=1}^{n}{v_{i,f}^2x_{i}^2})} \end{align} \tag{4}

可以看到这时的时间复杂度为 O(kn)

2.2 预测

FM算法可以应用在多种的预测任务中,包括:

  • Regression\hat{y}(x) 可以直接用作预测,并且最小平方误差来优化。
  • Binary classification\hat{y}(x) 作为目标函数并且使用hinge loss或者logit loss来优化。
  • Ranking:向量 x 通过 \hat{y}(x) 的分数排序,并且通过pairwise的分类损失来优化成对的样本(x^{(a)},x^{(b)})

对以上的任务中,正则化项参数一般加入目标函数中以防止过拟合。

2.3 参数学习

从上面的描述可以知道FM可以在线性的时间内进行预测。因此模型的参数可以通过梯度下降的方法(例如随机梯度下降)来学习,对于各种的损失函数。FM模型的梯度是:

\begin{equation} \frac{\partial}{\partial{\theta}}\hat{y}(x)=\left\{ \begin{aligned} &1 , & if \ \theta \ is \ w_{0}\\ &x_{i}   , & if \ \theta \ is \ w_{i} \\ &x_{i}\sum_{j=1}^{n}{v_{j,f}x_{j}}-v_{i,f}x_{i}^2 ,  & if \ \theta \ is \ v_{i,f} \end{aligned} \right. \end{equation} \tag{5}\\

由于 \sum_{j=1}^{n}{v_{j,f}x_{j}} 只与 f 有关,与 i 是独立的,可以提前计算出来,并且每次梯度更新可以在常数时间复杂度内完成,因此FM参数训练的复杂度也是 O(kn) 。综上可知,FM可以在线性时间训练和预测,是一种非常高效的模型。

2.4 多阶FM

2阶FM可以很容易泛化到高阶:

\hat{y}(x)=w_{0}+\sum_{i=1}^{n}{w_{i}x_{i}} + \sum_{l=2}^{d}{\sum_{i_{1}=1}^{n}{...\sum_{i_{l}=i_{l-1}+1}^{n}({\prod_{j=1}^{l}x_{i_{j}})(\sum_{f=1}^{k_{l}}{\sum_{j=1}^{l}{v_{i_{j},f}^{(l)}}})}}} \tag{6}\\

其中对第 l 个交互参数是由PARAFAC模型的参数因子分解得到:

V^{(l)}\in\mathbb{R}^{n\times k_{l}}, k_{l}\in\mathbb{N_{0}^+} \\

直接计算公式 (6) 的时间复杂度是 O(k_{d}n^d) 。通过调整也可以在线性时间内运行。

2.5 Factorization Machines With FTRL

FTRL是Google在2013年放出这个优化方法,该方法有较好的稀疏性和收敛性。FTRL是一个在线学习的框架,论文中用于求解LR,具体求解方法如下图:

我们只需要把论文中的伪代码进行修改,即可用于FM的参数求解。伪代码如下:

2.6 总结

FM模型有两个优势:

  1. 在高度稀疏的情况下特征之间的交叉仍然能够估计,而且可以泛化到未被观察的交叉
  2. 参数的学习和模型的预测的时间复杂度是线性的

FM模型的优化点:

1.特征为全交叉,耗费资源,通常user与user,item与item内部的交叉的作用要小于user与item的交叉

2.使用矩阵计算,而不是for循环计算

3.高阶交叉特征的构造

3. FM实践

代码是用python3.5写的,tensorflow的版本为1.10.1,其他低版本可能不兼容。完整代码可参考我的github地址:

LLSean/data-mining

本文使用的数据是movielens-100k,数据包括u.item,u.user,ua.base及ua.test,u.item包括的数据格式为:

movie id | movie title | release date | video release date |
IMDb URL | unknown | Action | Adventure | Animation |
Children's | Comedy | Crime | Documentary | Drama | Fantasy |
Film-Noir | Horror | Musical | Mystery | Romance | Sci-Fi |
Thriller | War | Western |

u.user包括的数据格式为:

user id | age | gender | occupation | zip code

ua.base和ua.test的数据格式为:

user id | item id | rating | timestamp

本文将评分等于5分的评分数据作为用户的点击数据,评分小于5分的数据作为用户的未点击数据,构造为一个二分类问题。

数据输入

要使用FM模型,首先要将数据处理成一个矩阵,本文使用了pandas对数据进行处理,生成输入的矩阵,并且对label做onehot编码处理。

def onehot_encoder(labels, NUM_CLASSES):
    enc = LabelEncoder()
    labels = enc.fit_transform(labels)
    labels = labels.astype(np.int32)
    batch_size = tf.size(labels)
    labels = tf.expand_dims(labels, 1)
    indices = tf.expand_dims(tf.range(0, batch_size,1), 1)
    concated = tf.concat([indices, labels] , 1)
    onehot_labels = tf.sparse_to_dense(concated, tf.stack([batch_size, NUM_CLASSES]), 1.0, 0.0) 
    with tf.Session() as sess:
        return sess.run(onehot_labels)

def load_dataset():
    header = ['user_id', 'age', 'gender', 'occupation', 'zip_code']
    df_user = pd.read_csv('data/u.user', sep='|', names=header)
    header = ['item_id', 'title', 'release_date', 'video_release_date', 'IMDb_URL', 'unknown', 'Action', 'Adventure', 'Animation', 'Children',
            'Comedy', 'Crime', 'Documentary', 'Drama', 'Fantasy', 'Film-Noir', 'Horror', 'Musical', 'Mystery', 'Romance', 'Sci-Fi', 
            'Thriller', 'War', 'Western']
    df_item = pd.read_csv('data/u.item', sep='|', names=header, encoding = "ISO-8859-1")
    df_item = df_item.drop(columns=['title', 'release_date', 'video_release_date', 'IMDb_URL', 'unknown'])
    
    df_user['age'] = pd.cut(df_user['age'], [0,10,20,30,40,50,60,70,80,90,100], labels=['0-10','10-20','20-30','30-40','40-50','50-60','60-70','70-80','80-90','90-100'])
    df_user = pd.get_dummies(df_user, columns=['gender', 'occupation', 'age'])
    df_user = df_user.drop(columns=['zip_code'])
    
    user_features = df_user.columns.values.tolist()
    movie_features = df_item.columns.values.tolist()
    cols = user_features + movie_features
    
    header = ['user_id', 'item_id', 'rating', 'timestamp']
    df_train = pd.read_csv('data/ua.base', sep='\t', names=header)
    df_train['rating'] = df_train.rating.apply(lambda x: 1 if int(x) == 5 else 0)
    df_train = df_train.merge(df_user, on='user_id', how='left') 
    df_train = df_train.merge(df_item, on='item_id', how='left')
    
    df_test = pd.read_csv('data/ua.test', sep='\t', names=header)
    df_test['rating'] = df_test.rating.apply(lambda x: 1 if int(x) == 5 else 0)
    df_test = df_test.merge(df_user, on='user_id', how='left') 
    df_test = df_test.merge(df_item, on='item_id', how='left')
    train_labels = onehot_encoder(df_train['rating'].astype(np.int32), 2)
    test_labels = onehot_encoder(df_test['rating'].astype(np.int32), 2)
    return df_train[cols].values, train_labels, df_test[cols].values, test_labels

模型设计

得到输入之后,我们使用tensorflow来设计我们的模型,目标函数包括两部分,线性以及交叉特征的部分,交叉特征直接使用我们最后推导的形式即可。

#输入
def add_input(self):
    self.X = tf.placeholder('float32', [None, self.p])
    self.y = tf.placeholder('float32', [None, self.num_classes])
    self.keep_prob = tf.placeholder('float32')

#forward过程
def inference(self):
    with tf.variable_scope('linear_layer'):
        w0 = tf.get_variable('w0', shape=[self.num_classes],
                            initializer=tf.zeros_initializer())
        self.w = tf.get_variable('w', shape=[self.p, num_classes],
                             initializer=tf.truncated_normal_initializer(mean=0,stddev=0.01))
        self.linear_terms = tf.add(tf.matmul(self.X, self.w), w0) 

    with tf.variable_scope('interaction_layer'):
        self.v = tf.get_variable('v', shape=[self.p, self.k],
                            initializer=tf.truncated_normal_initializer(mean=0, stddev=0.01))
        self.interaction_terms = tf.multiply(0.5,
                                             tf.reduce_mean(
                                                 tf.subtract(
                                                     tf.pow(tf.matmul(self.X, self.v), 2),
                                                     tf.matmul(self.X, tf.pow(self.v, 2))),
                                                 1, keep_dims=True))
    self.y_out = tf.add(self.linear_terms, self.interaction_terms)
    if self.num_classes == 2:
        self.y_out_prob = tf.nn.sigmoid(self.y_out)
    elif self.num_classes > 2:
        self.y_out_prob = tf.nn.softmax(self.y_out)

#loss
def add_loss(self):
    if self.num_classes == 2:
        cross_entropy = tf.nn.sigmoid_cross_entropy_with_logits(labels=self.y, logits=self.y_out)
    elif self.num_classes > 2:
        cross_entropy = tf.nn.softmax_cross_entropy_with_logits(labels=self.y, logits=self.y_out)
    mean_loss = tf.reduce_mean(cross_entropy)
    self.loss = mean_loss
    tf.summary.scalar('loss', self.loss)

#计算accuracy
def add_accuracy(self):
    # accuracy
    self.correct_prediction = tf.equal(tf.cast(tf.argmax(self.y_out,1), tf.float32), tf.cast(tf.argmax(self.y,1), tf.float32))
    self.accuracy = tf.reduce_mean(tf.cast(self.correct_prediction, tf.float32))
    # add summary to accuracy
    tf.summary.scalar('accuracy', self.accuracy)

#训练
def train(self):
    self.global_step = tf.Variable(0, trainable=False)
    optimizer = tf.train.FtrlOptimizer(self.lr, l1_regularization_strength=self.reg_l1,
                                       l2_regularization_strength=self.reg_l2)
    extra_update_ops = tf.get_collection(tf.GraphKeys.UPDATE_OPS)
    with tf.control_dependencies(extra_update_ops):
        self.train_op = optimizer.minimize(self.loss, global_step=self.global_step)

#构建图
def build_graph(self):
    self.add_input()
    self.inference()
    self.add_loss()
    self.add_accuracy()
    self.train()


本文如有错误的地方,请私信或者留言指出。


参考资料:

csie.ntu.edu.tw/~b97053

static.googleusercontent.com

wnzhang.net/share/rtb-p

推荐系统遇上深度学习(一)--FM模型理论和实践 - 云+社区 - 腾讯云

FM算法论文 Factorization Machines 阅读笔记

深入FFM原理与实践

编辑于 2018-11-20

文章被以下专栏收录