推荐系统(九)Factor Machines

背景

有时系统中用户的行为比较稀少,采集到的样本很稀疏,这样直接导致常用的拟合方法学到的模型存在严重的过拟合问题,即特征之间存在严重的依赖和隔离关系,使得模型无法进一步学习到精准的内在规律。为了解决这一问题,FM模型应运而生,其基本原理是学到特征与特征之间的关系,从而达到更加精准的预测的目的。

递进

引用论文原文的图示,图中的一条样本描述了当前用户的id特征、当前物品的id特征、当前用户对其他物品的打分、时间、当前用户上次对物品的打分,这条样本的label是当前用户对当前物品的打分。

可以看出,整个矩阵比较稀疏,通常意义下LR模型都会为每一维特征分配一个权重,公式如下所示:
y ^ ( x ) = w 0 + ∑ i = 1 n w i x i \hat{y}(x)=w_0+\sum_{i=1}^n w_ix_i y^(x)=w0+i=1nwixi

FM 模型与上述公式不同的地方在于其添加了一个 V ( n × k ) V(n\times k) V(n×k)矩阵, V V V的每一行代表的是 x x x的某个特征本身的"特征",具体公式如下所示:
y ^ ( x ) = w 0 + ∑ i = 1 n w i x i + ∑ i = 1 n ∑ j = i + 1 n ⟨ v i , v j ⟩ x i x j \hat{y}(x)=w_0+\sum_{i=1}^n w_ix_i + {\color{Red} \sum_{i=1}^n\sum_{j=i+1}^n\left \langle v_i,v_j \right \rangle x_i x_j} y^(x)=w0+i=1nwixi+i=1nj=i+1nvi,vjxixj

示意图如下所示:

这里 ⟨ v i , v j ⟩ = ∑ f = 1 k v i , f v j , f \left \langle v_i,v_j \right \rangle=\sum_{f=1}^kv_{i,f}v_{j,f} vi,vj=f=1kvi,fvj,f,代表的是 x i x_i xi x j x_j xj之间的相互关系。而上述红色公式这样设定的原因我个人理解是因为其描述的是两个不同特征之间的关系,同一个特征之间的关系没有学习的意义,即学习的关系如下所示(蓝色为需要学习的领域,白色为不需要学习的领域):

可以看出上述公式的时间复杂度为 O ( k n 2 ) O(kn^2) O(kn2),但是这个时间复杂度可以优化到 O ( k n ) O(kn) O(kn),公式推导如下所示:

上述公式的梯度下降计算公式如下:
∂ y ^ ( x ) ∂ θ = { 1      θ = w 0 x i      θ = w i x i ∑ j = 1 n v j , f x j − v i , f x i 2     θ = v i , f \frac{\partial \hat{y}(x)}{\partial \theta} = \left\{\begin{matrix} 1 \ \ \ \ \theta =w_0\\ x_i \ \ \ \ \theta =w_i\\ x_i\sum_{j=1}^n v_{j,f}x_j-v_{i,f}x_i^2 \ \ \ \theta=v_{i,f} \end{matrix}\right. θy^(x)=1    θ=w0xi    θ=wixij=1nvj,fxjvi,fxi2   θ=vi,f

乍一看模型反向传播的计算时间复杂度为 O ( n 2 ) O(n^2) O(n2),但后来发现针对 v i , f v_{i,f} vi,f的梯度计算, ∑ j = 1 n v j , f x j \sum_{j=1}^n v_{j,f}x_j j=1nvj,fxj已经在前向传播中计算过一遍,只需要把之前的结果保存下来就行,因而这里只需要计算 v i , f x i v_{i,f}x_i vi,fxi,因而 V V V的反向传播计算时间复杂度几乎是常量的。

可以通过反向传播的公式看出,只要 x i x_i xi不为0,则 v i , f v_{i,f} vi,f是通过其他维所有特征以及 V V V的其他维度完成梯度更新,因而他学习到东西更加全面,相当于学习到了二阶交叉特征,所以FM要比LR的效果好。

代码实现

用最简单的方式来阐述FM前向推导的整体过程:

import tensorflow as tf

X = tf.constant([[1, 2, 3, 4]], dtype=tf.float16)

w_0 = tf.constant([0.5], dtype=tf.float16)
W = tf.constant([[5], [6], [7], [8]], dtype=tf.float16)
V = tf.constant([[1, 2, 3, 4],[1, 2, 3, 4]], dtype=tf.float16)

linear_output = tf.add(w_0, tf.matmul(X, W))

# 公式[1] \sum((X * V^T)^2 - (X^2 * (V^T)^2)) * 0.5
complex_output = tf.multiply(tf.reduce_sum(tf.subtract(tf.pow(tf.matmul(X, tf.transpose(V)), 2), \
                                tf.matmul(tf.pow(X, 2), tf.pow(tf.transpose(V), 2))), \
                                           axis=1, keep_dims=True), 0.5)
final_output= tf.add(linear_output, complex_output)

init = tf.global_variables_initializer()

with tf.Session() as sess:
    sess.run(init)
    print(complex_output.shape, complex_output.eval())
>>> (1, 1) [[546.]]

在写这段代码之前,确实有些疑惑,代码的实现方式和论文中的公式有所不同,代码中的实现方式有些难以理解,但是当我们仔细展开公式,发现二者确实是一样的,初始化 X X X V V V如下所示:
X = [ x 1 , x 2 , x 3 , x 4 ] X = [x_1, x_2, x_3, x_4] X=[x1,x2,x3,x4]

V = [ v 11 v 12 v 13 v 14 v 21 v 22 v 23 v 24 ] V=\begin{bmatrix} v_{11} & v_{12} & v_{13} & v_{14}\\ v_{21} & v_{22} & v_{23} & v_{24} \end{bmatrix} V=[v11v21v12v22v13v23v14v24]

上述FM核心代码的公式[1]的推导如下所示(格式有些乱,但是思路比较清晰):
c o m p l e x _ o u t = 1 2 ∑ f = 1 2 ( ( ∑ i = 1 4 v i , f x i ) 2 − ∑ i = 1 4 v i , f 2 x i 2 ) = 1 2 [ ( v 11 x 1 + v 21 x 2 + v 31 x 3 + v 41 x 4 ) 2 − ( v 11 2 x 1 2 + v 21 2 x 2 2 + v 31 2 x 3 2 + v 41 2 x 4 2 ) ] + 1 2 [ ( v 12 x 1 + v 22 x 2 + v 32 x 3 + v 42 x 4 ) 2 − ( v 12 2 x 1 2 + v 22 2 x 2 2 + v 32 2 x 3 2 + v 42 2 x 4 2 ) ] = 1 2 [ ( v 11 x 1 + v 21 x 2 + v 31 x 3 + v 41 x 4 , v 12 x 1 + v 22 x 2 + v 32 x 3 + v 42 x 4 ) 2 − ( v 11 2 x 1 2 + v 21 2 x 2 2 + v 31 2 x 3 2 + v 41 2 x 4 2 , v 12 2 x 1 2 + v 22 2 x 2 2 + v 32 2 x 3 2 + v 42 2 x 4 2 ) ] = r e d u c e _ s u m ( 1 2 [ ( X ∗ V T ) 2 − ( X 2 ∗ ( V T ) 2 ) ] ) \begin{aligned} complex\_out = \frac{1}{2}\sum_{f=1}^2((\sum_{i=1}^4v_{i,f}x_i)^2-\sum_{i=1}^4v_{i,f}^2x^2_i) \\ =\frac{1}{2}[(v_{11} x_1+v_{21} x_2+v_{31} x_3+v_{41} x_4)^2 \\ -(v_{11}^2x_1^2+v_{21}^2x_2^2+v_{31}^2x_3^2+v_{41}^2x_4^2)] \\ +\frac{1}{2}[(v_{12} x_1+v_{22} x_2+v_{32} x_3+v_{42} x_4)^2- \\ (v_{12}^2x_1^2+v_{22}^2x_2^2+v_{32}^2x_3^2+v_{42}^2x_4^2)] \\ =\frac{1}{2}[(v_{11} x_1+v_{21} x_2+v_{31} x_3+v_{41} x_4, \\ v_{12} x_1+v_{22} x_2+v_{32} x_3+v_{42} x_4)^2 \\ -(v_{11}^2x_1^2+v_{21}^2x_2^2+v_{31}^2x_3^2+v_{41}^2x_4^2, \\ v_{12}^2x_1^2+v_{22}^2x_2^2+v_{32}^2x_3^2+v_{42}^2x_4^2)] \\ = reduce\_sum(\frac{1}{2}[(X*V^T)^2-(X^2*(V^T)^2)]) \end{aligned} complex_out=21f=12((i=14vi,fxi)2i=14vi,f2xi2)=21[(v11x1+v21x2+v31x3+v41x4)2(v112x12+v212x22+v312x32+v412x42)]+21[(v12x1+v22x2+v32x3+v42x4)2(v122x12+v222x22+v322x32+v422x42)]=21[(v11x1+v21x2+v31x3+v41x4,v12x1+v22x2+v32x3+v42x4)2(v112x12+v212x22+v312x32+v412x42,v122x12+v222x22+v322x32+v422x42)]=reduce_sum(21[(XVT)2(X2(VT)2)])

性能优化

正则化

对于正则化,组内大佬说每次只计算 x i x_i xi不为0对应的 v i ⃗ \vec{v_i} vi 的正则化项,效果会更好,代码如下所示:

import tensorflow as tf

x = tf.Variable([[0, 1, 0, 1]])
v = tf.Variable([[6, 7, 8, 9],
				 [10, 11, 12, 13],
				 [20, 21, 22, 23]])

one = tf.ones_like(x)
zero = tf.zeros_like(x)
idx_val_tensor = tf.where(tf.math.not_equal(x, tf.constant(0)), x=one, y=zero)
not_zero_v = v * idx_val_tensor

sess = tf.Session()
sess.run(tf.global_variables_initializer())
output, idx = sess.run([not_zero_v, idx_val_tensor])
print("output is:")
print(output)

output is:
[[ 0  7  0  9]
 [ 0 11  0 13]
 [ 0 21  0 23]]

可以发现,如果 x x x的某一维为0,则对应到的 v ⃗ \vec{v} v 的一整列都为0,这时再去计算正则化项,之后得到最终结果。

节省计算

serve阶段inference的过程中,如果 x x x的某一维为0,则不管 v ⃗ \vec{v} v x x x在那一维如何计算,得到的结果都是0,因而可以跳过 x x x为0的维度,这样能够大大节省计算(节省约50ms),

import tensorflow as tf
in_tensor = tf.Variable([[0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]], dtype=tf.float32)
feature_num = in_tensor.shape[1]
factor_num = 3
factor_mat = tf.get_variable(name='interaction_factors',
    shape=[feature_num, factor_num],
    initializer=tf.ones_initializer(),
    trainable=True) # shaped (feature_num, factor_num)

def func(elem):
    """
    compute single item interaction
    """
    new_elem = tf.boolean_mask(elem, tf.not_equal(elem, tf.constant(0.0)))
    elem_factor_mat = tf.boolean_mask(factor_mat, tf.not_equal(elem, tf.constant(0.0)))
    not_zero_elem = tf.expand_dims(new_elem, -1)
    print("shape of not_zero_elem is {}".format(not_zero_elem))
    print("shape of elem_factor_mat is {}".format(elem_factor_mat))
    vx_sum_squared = tf.math.pow(
            tf.math.reduce_sum(tf.math.multiply(elem_factor_mat, not_zero_elem), axis=0), 2) 
    vx_squared_sum = tf.math.reduce_sum(
            tf.math.multiply(tf.math.pow(elem_factor_mat, 2), tf.math.pow(not_zero_elem, 2)), axis=0)
    out = 0.5 * tf.math.subtract(vx_sum_squared, vx_squared_sum)
    out = tf.math.reduce_sum(out, axis=0)
    return out

out_tensor = tf.map_fn(func, in_tensor, parallel_iterations=1, infer_shape=False)

sess = tf.Session()
sess.run(tf.global_variables_initializer())

out_tensor_op, factor_mat_op = sess.run([out_tensor, factor_mat])
print("out_tensor_op is {}".format(out_tensor_op))
print("factor_mat_op is {}".format(factor_mat_op))

out_tensor_op is [3. 3.]
factor_mat_op is [[1. 1. 1.]
 [1. 1. 1.]
 [1. 1. 1.]
 [1. 1. 1.]]

知识分析

这段时间在看张俊林老师的知乎博客,因而在这里总结下我能够理解的知识点。印象较深的有两点:1. FM模型作为召回模型如何在工程中使用,2. 统一召回和多路召回的优缺点,因而我也抄录部分内容作为自己知识点的扩充。

FM召回模型

  • 只考虑User和Item各自的特征

    将单个User每一维User相关特征的embedding叠加起来,作为 U U U,并将所有的User的 U U U放入redis中,将单个Item每一维Item相关特征的embedding叠加起来,作为 I I I,并将所有的Item的 I I I放入faiss中,如下图所示:

    当一个User请求打过来时,通过这个User对应的 U U U去faiss选出与这个 U U U topK相关的 I I I,具体计算方式即为 U U U I I I的点积,而能够这样做的原因恰恰是FM的原理,即在只考虑User和Item各自独立特征时,可以看出如下公式的等价关系(借用上面公式),
    ∑ i = 1 n ∑ j = i + 1 n ⟨ v i , v j ⟩ x i x j = ∑ i = 1 n ∑ j = i + 1 n ⟨ x i ∗ v i , x j ∗ v j ⟩ = ⟨ ∑ i = 1 n x i ∗ v i , ∑ j = 1 , j ≠ i n x j ∗ v j ⟩ \sum_{i=1}^n\sum_{j=i+1}^n \left \langle v_i,v_j \right \rangle x_i x_j=\sum_{i=1}^n\sum_{j=i+1}^n \left \langle x_i * v_i, x_j * v_j \right \rangle=\left \langle \sum_{i=1}^nx_i*v_i,\sum_{j=1,j\neq i}^nx_j*v_j \right \rangle i=1nj=i+1nvi,vjxixj=i=1nj=i+1nxivi,xjvj=i=1nxivi,j=1,j=inxjvj
    而如果在这里只考虑User和Item各自的特征,则上述公式可以转化为:
    ⟨ ∑ i = 1 n x i ∗ v i , ∑ j = 1 , j ≠ i n x j ∗ v j ⟩ = ⟨ ∑ i U i , ∑ j I j ⟩ \left \langle \sum_{i=1}^nx_i*v_i,\sum_{j=1,j\neq i}^nx_j*v_j \right \rangle=\left \langle \sum_iU_i,\sum_jI_j \right \rangle i=1nxivi,j=1,j=inxjvj=iUi,jIj

  • 添加context信息

    context信息只能够通过线上实时获取,比如用户当时的播放行为等等,这里设context信息为 C C C,相应的做法也很简单,就是将User和Context的向量叠加后得到 U + C U+C U+C,而后去faiss通过内积的方式取出topK相关的 I I I,如下图所示:

统一召回 VS 多路召回

  • 统一召回的优点
  1. 多路召回的召回得分不可比,统一召回的得分是有意义的。但是在实际操作中,其实往往不去看多路召回的得分,只去看特征,比如这个资源是不是音乐,播放时长是多少。
  2. 如果采用多路召回,每一路召回多少个Item这个超参确实比较难调(可以从现有的系统中调研下)
  3. 多路召回会存在ranking和recall存在割裂感,比如增加了一路召回,但是ranking却没有添加相应的特征在模型中,使得这一路召回的Item没办法排上来
  • 多路召回的优点
  1. 上线比较灵活,新增一种召回方式对线上整体的召回系统影响较小,系统的稳定性较好

本质分析

参考文献

  1. FM paper
  2. 张俊林 推荐系统召回四模型之:全能的FM模型
  3. FM代码实现
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值