推荐系统之FFM模型及python实现


)

FFM模型的基本原理

FFM模型引入

考虑以下数据集:
在这里插入图片描述其中:Publisher、Advertiser、Gender表示特征域(field),EPSN、NBC、NIKE、Adidas、Male、Female表示one-hot编码的特征(feature)
对于第一条数据来说,FM模型的二次项为:
w E P S N ⋅ w N i k e + w E P S N ⋅ w M a l e + w N i k e ⋅ w M a l e w_{EPSN}⋅w_{Nike}+w_{EPSN}⋅w_{Male}+w_{Nike}⋅w_{Male} wEPSNwNike+wEPSNwMale+wNikewMale。(这里只是把上面的v符合改成了w)每个特征只用一个隐向量来学习和其它特征的潜在影响。对于上面的例子中,Nike是广告主,Male是用户的性别,描述(EPSN,Nike)和(EPSN,Male)特征组合,FM模型都用同一个wESPNwESPN,而实际上,ESPN作为广告商,其对广告主和用户性别的潜在影响可能是不同的。
field是什么?简单的说,同一个类别特征进行one-hot编码后生成的数值特征都可以放在同一个field中,比如Male,Famale可以放于同一个field中。如果是数值特征而非类别,可以直接作为一个field。
引入了field后,对于刚才的例子来说,二次项变为:
在这里插入图片描述

  • 对于特征组合(EPSN,Nike)来说,其隐向量采用的是 w E P S N , A w_{EPSN,A} wEPSN,A w N i k e , P w_{Nike,P} wNike,P,对于 w E P S N , A w_{EPSN,A} wEPSN,A这是因为Nike属于广告主(Advertiser)的field,而第二项 w N i k e , P w_{Nike,P} wNike,P则是EPSN是广告商(Publisher)的field。
  • 再举个例子,对于特征组合(EPSN,Male)来说, w E P S N , G w_{EPSN,G} wEPSN,G,G是因为Male是用户性别(Gender)的field,而第二项 w M a l e , P w_{Male,P} wMale,P,P是因为EPSN是广告商(Publisher)的field。

FFM模型数学公式

因此,FFM的数学公式表示为:
y ^ = w 0 + ∑ i = 1 n w i x i + ∑ i = 1 n ∑ j = i + 1 n ( w i , f j , w j , f i ) x i x j \hat{y}=w_{0}+\sum_{i=1}^{n}{w_{i}x_{i}}+\sum_{i=1}^{n}{\sum_{j=i+1}^{n}{(w_{i,f_{j}},w_{j,f_{i}})x_{i}x_{j}}} y^=w0+i=1nwixi+i=1nj=i+1n(wi,fj,wj,fi)xixj
其中 f i f_{i} fi f j f_{j} fj分别代表第 i i i个特征和第 j j j个特征所属的field。若field有 f f f个,隐向量的长度为 k k k,则二次项系数共有 d f k dfk dfk个,远多于FM模型的 d k dk dk个。此外,隐向量和field相关,并不能像FM模型一样将二次项化简,计算的复杂度是 d 2 k d^2k d2k
通常情况下,每个隐向量只需要学习特定field的表示,所以有 k F F M ≪ k F M k_{FFM}≪k_{FM} kFFMkFM

FFM模型学习

为了方便推导,这里省略FFM的一次项和常数项,公式为:
f ( w , x ) = ∑ i = 1 n ∑ j = i + 1 n ( w i , f j , w j , f i ) x i x j f(w,x)=\sum_{i=1}^{n}{\sum_{j=i+1}^{n}{(w_{i,f_{j}},w_{j,f_{i}})x_{i}x_{j}}} f(w,x)=i=1nj=i+1n(wi,fj,wj,fi)xixj

FFM模型使用logistic loss作为损失函数,并加上L2正则项:
L o s s = ∑ i = 1 M l o g ( 1 + e ( − y i f ( w , x i ) ) + λ 2 ∣ ∣ w ∣ ∣ 2 Loss=\sum_{i=1}^{M}{log(1+e^{(-y_{i}f(w,x_{i}))}}+\frac{\lambda}{2}||w||^2 Loss=i=1Mlog(1+e(yif(w,xi))+2λw2
采用随机梯度下降来(SGD)来优化损失函数,因此,损失函数只采用单个样本的损失:
L o s s = l o g ( 1 + e ( − y i f ( w , x i ) ) + λ 2 ∣ ∣ w ∣ ∣ 2 Loss={log(1+e^{(-y_{i}f(w,x_{i}))}}+\frac{\lambda}{2}||w||^2 Loss=log(1+e(yif(w,xi))+2λw2
对于每次迭代,选取一条数据 ( x , y ) (x,y) (x,y)
,然后让 L o s s Loss Loss w i , f j w_{i,f_{j}} wi,fj
w j , f i w_{j,f_{i}} wj,fi求偏导(注意,采用SGD上面的求和项就去掉了,只采用单个样本的损失),得:
g i , f j = ∂ L o s s ∂ w i , f j = k ∗ w j , f i ∗ x i x j + λ ∗ w i , f j g_{i,f_{j}}=\frac{\partial Loss}{\partial w_{i,f_{j}}}=k *w_{j,f_{i}}*x_{i}x_{j}+\lambda*w_{i,f_{j}} gi,fj=wi,fjLoss=kwj,fixixj+λwi,fj
g j , f i = ∂ L o s s ∂ w j , f i = k ∗ w i , f j ∗ x i x j + λ ∗ w j , f i g_{j,f_{i}}=\frac{\partial Loss}{\partial w_{j,f_{i}}}=k *w_{i,f_{j}}*x_{i}x_{j}+\lambda*w_{j,f_{i}} gj,fi=wj,fiLoss=kwi,fjxixj+λwj,fi
其中: k = − y 1 + e y f ( w , x ) k=\frac{-y}{1+e^{yf(w,x)}} k=1+eyf(w,x)y

Adagrad算法更新学习率

Adagrad算法能够在训练中自动的调整学习率,对于稀疏的参数增加学习率,而稠密的参数则降低学习率。因此,Adagrad非常适合处理稀疏数据。
g t , j g_{t,j} gt,j为第t轮第j个参数的梯度,则SGD和采用Adagrad的参数更新公式分别如下:
S G D : w t + 1 , j = w t , j − α ∗ g t , j SGD:w_{t+1,j}=w_{t,j} - \alpha*g_{t,j} SGD:wt+1,j=wt,jαgt,j
A d a g r a d : w t + 1 , j = w t , j − α G t , j j + ϵ ∗ g t , j Adagrad:w_{t+1,j}=w_{t,j} - \frac{\alpha}{\sqrt{G_{t,jj}+\epsilon}}*g_{t,j} Adagrad:wt+1,j=wt,jGt,jj+ϵ αgt,j
可以看出,Adagrad在学习率ηη上还除以一项 G t , j j + ϵ \sqrt{G_{t,jj}+\epsilon} Gt,jj+ϵ ,这是什么意思呢? ϵ ϵ ϵ为平滑项,防止分母为0, G t , j j = ∑ j = 1 t g t , j j 2 G_{t,jj}=\sum_{j=1}^{t}{g_{t,jj}^2} Gt,jj=j=1tgt,jj2 G t , j j G_{t,jj} Gt,jj为对角矩阵,每个对角线位置的值 j , j j,j j,j为参数 w j w_{j} wj每一轮的平方和,可以看出,随着迭代的进行,每个参数的历史梯度累加到一起,使得每个参数的学习率逐渐减小。
因此,计算完梯度后,下一步就是更新分母的对角矩阵。
G i , f j = G i , f j + ( g i , f j ) 2 G_{i,f_{j}} = G_{i,f_{j}} +(g_{i,f_{j}})^2 Gi,fj=Gi,fj+(gi,fj)2
G j , f i = G j , f i + ( g j , f i ) 2 G_{j,f_{i}} = G_{j,f_{i}} +(g_{j,f_{i}})^2 Gj,fi=Gj,fi+(gj,fi)2
最后,更新模型参数:
w i , f j = w i , f j − α G i , f j + ϵ ∗ g i , f j w_{i,f_{j}} = w_{i,f_{j}} - \frac{\alpha}{\sqrt{G_{i,f_{j}}+\epsilon}}*g_{i,f_{j}} wi,fj=wi,fjGi,fj+ϵ αgi,fj
w j , f i = w j , f i − α G j , f i + ϵ ∗ g j , f i w_{j,f_{i}} = w_{j,f_{i}} - \frac{\alpha}{\sqrt{G_{j,f_{i}}+\epsilon}}*g_{j,f_{i}} wj,fi=wj,fiGj,fi+ϵ αgj,fi

适用范围和使用技巧

在FFM原论文中,作者指出,FFM模型对于one-hot后类别特征十分有效,但是如果数据不够稀疏,可能相比其它模型提升没有稀疏的时候那么大,此外,对于数值型的数据效果不是特别的好。
使用FFM模型,特征需要转化为“field_id:feature_id:value”格式,相比LibSVM的格式多了field_id,即特征所属的field的编号,feature_id是特征编号,value为特征的值。

此外,美团点评的文章中,提到了训练FFM时的一些注意事项:
第一,样本归一化。FFM默认是进行样本数据的归一化的 。若不进行归一化,很容易造成数据inf溢出,进而引起梯度计算的nan错误。因此,样本层面的数据是推荐进行归一化的。
第二,特征归一化。CTR/CVR模型采用了多种类型的源特征,包括数值型和categorical类型等。但是,categorical类编码后的特征取值只有0或1,较大的数值型特征会造成样本归一化后categorical类生成特征的值非常小,没有区分性。例如,一条用户-商品记录,用户为“男”性,商品的销量是5000个(假设其它特征的值为零),那么归一化后特征“sex=male”(性别为男)的值略小于0.0002,而“volume”(销量)的值近似为1。特征“sex=male”在这个样本中的作用几乎可以忽略不计,这是相当不合理的。因此,将源数值型特征的值归一化到[0,1]是非常必要的。
第三,省略零值特征。从FFM模型的表达式(3-1)可以看出,零值特征对模型完全没有贡献。包含零值特征的一次项和组合项均为零,对于训练模型参数或者目标值预估是没有作用的。因此,可以省去零值特征,提高FFM模型训练和预测的速度,这也是稀疏样本采用FFM的显著优势。

FFM模型python代码实现

import numpy as np
import math
import time

# 将预测值映射到0~1区间,解决指数函数的溢出问题
def sigmoid(x):
    if x >= 0:
        return 1 / (1 + np.exp(-x))
    else:
        return np.exp(x) / (1 + np.exp(x))


# 计算logit损失函数
def logit(y, y_hat):
    z = y * y_hat
    if z >= 0:
        return np.log(1 + np.exp(-z))
    else:
        return np.log(1 + np.exp(z)) - z


# 计算logit损失函数的外层偏导数(不对y_hat本身求导)
def df_logit(y, y_hat):
    return sigmoid(-y * y_hat) * (-y)


# 该类表示一个样本的一个特征
class FFM_Node(object):
    '''
    通常x是高维稀疏向量,所以用链表来表示一个x,链表上的每个节点是个3元组(j,f,v),表示一个样本x的一个非0特征
    '''
    # 按元组(而不是字典)的方式来存储类的成员属性
    __slots__ = ['j', 'f', 'v']

    def __init__(self, j, f, v):
        '''
        :param j: Feature index (0 to n-1)
        :param f: Field index (0 to m-1)
        :param v: value
        '''
        self.j = j
        self.f = f
        self.v = v


class FFM(object):
    def __init__(self, m, n, k, alpha, Lambda):
        # m:域个数 n:特征个数 k:隐向量维度 alpha:学习率 Lambda:正则化权重系数
        self.m = m
        self.n = n
        self.k = k
        # 超参数
        self.alpha = alpha
        self.Lambda = Lambda
        # 初始化三维权重矩阵w~U(0,1/sqrt(k))
        self.w = np.random.rand(n, m, k) / math.sqrt(k)
        # 初始化累积梯度平方和为,AdaGrad时要用到,防止除0异常
        self.G = np.ones(shape=(n, m, k), dtype=np.float64)

    # 特征组合式的线性加权求和
    def phi(self, node_list):
        # node_list: 一个样本,用链表存储x中的非0值
        z = 0.0
        for i in range(len(node_list)):
            node_i = node_list[i]
            j_i = node_i.j
            f_i = node_i.f
            v_i = node_i.v
            for d in range(i+1, len(node_list)):
                node_d = node_list[d]
                j_d = node_d.j
                f_d = node_d.f
                v_d = node_d.v
                w_i = self.w[j_i, f_d]
                w_d = self.w[j_d, f_i]
                z += np.dot(w_i, w_d) * v_i * v_d
        return z

    # 输出x, 预测y的值
    def predict(self, node_list):
        '''
        :param node_list:  用链表存储x中的非0值。
        :return: 预测值y
        '''
        z = self.phi(node_list)
        pre_y = sigmoid(z)
        return pre_y

    # 根据一个样本来更新模型参数、
    def SGD_FFM(self, node_list, y):
        '''
        :param node_list: 用链表存储x中的非0值。
        :param y: 正样本1, 负样本-1
        '''
        y_hat = self.phi(node_list)
        loss = logit(y, y_hat)
        df_loss = df_logit(y, y_hat)
        for i in range(len(node_list)):
            node_i = node_list[i]
            j_i = node_i.j
            f_i = node_i.f
            v_i = node_i.v
            for d in range(i + 1, len(node_list)):
                node_d = node_list[d]
                j_d = node_d.j
                f_d = node_d.f
                v_d = node_d.v
                c = df_loss * v_i * v_d
                # 计算对w[j_i, f_d],w[j_d, f_i]的梯度
                # self.w[j_i, f_d]和self.w[j_d, f_i]均是k维向量,故得到的g_ji_fd, g_jd_fi均是向量
                g_ji_fd = c * self.w[j_i, f_d] + self.Lambda * self.w[j_i, f_d]
                g_jd_fi = c * self.w[j_d, f_i] + self.Lambda * self.w[j_d, f_i]
                # 计算各位维度上的梯度累积平方和
                # s所有的G肯定是大于0的正数,因为初始化时G都为1
                self.G[j_i, f_d] += g_ji_fd ** 2
                self.G[j_d, f_i] += g_jd_fi ** 2
                # Adagrad
                # np.sqrt(G)作为分母,所以G必须是大于0的正数
                self.w[j_i, f_d] -= self.alpha / np.sqrt(self.G[j_i, f_d]) * g_ji_fd
                # math.sqrt()只能接收一个数字作为参数,而np.sqrt()可以接收一个array作为参数,表示对array中每个元素分别开方
                self.w[j_d, f_i] -= self.alpha / np.sqrt(self.G(j_d, f_i)) * g_jd_fi
        return loss

    def train(self, sample_generator, max_echo):
        '''
        :param sample_generator: 样本产生器,每次yeild(node_list, y), node_list中储存的是x的非0值。通常x要事先做好一次归一化,即模长为1, 这样精度会略高一些
        :param max_echo: 最大迭代次数
        :param max_r2:
        :return:
        '''
        # 训练结束的标识
        flag = 1
        # 前一次迭代的总损失
        loss_total_old = 0
        # SGD开始时间
        st = time.time()
        for step in range(max_echo):
            # 本次迭代的总损失
            loss_total_new = 0
            for node_list, y in sample_generator:
                loss_total_new += self.SGD_FFM(node_list, y)
            # SGD结束条件2:损失值过小,跳出
            if loss_total_new < 1e-2:
                flag = 2
                print("the total step:%d\n the loss is:%.6f" % ((step + 1), loss_total_new))
                break
            # 第一次迭代,不计算前后损失值之差
            if step == 0:
                loss_total_old = loss_total_new
                continue
            # SGD结束条件3:前后损失值之差过小,跳出
            if (loss_total_old - loss_total_new) < 1e-5:
                flag = 3
                print("the total step:%d\n the loss is:%.6f" % ((step + 1), loss_total_new))
                break
            else:
                loss_total_old = loss_total_new
            if step % 10 == 0:
                print("the step is :%d\t the loss is:%.6f" % ((step + 1), loss_total_new))
        # SGD结束时间
        et = time.time()
        print("the total time:%.4f\nthe type of jump out:%d" % ((et - st), flag))

参考

1.『我爱机器学习』FM、FFM与DeepFM
2.CTR/CVR中的FM、FFM算法

猜你喜欢

转载自blog.csdn.net/shiaiao/article/details/109156533