FFM代码实现
上一篇我们讲了《FFM原理及公式推导》,现在来编码实现一下。
把写代码所需要所有公式都列出来,各符号与原文《Field-aware Factorization Machines for CTR Prediction》中的保持一致。
符号约定:
$n$:特征的维数
$m$:域的个数
$k$:隐向量的维度
$j$:在特征中的下标
$f$:在域中的下标
$d$:在隐向量中的下标
$l$:样本的总数
粗体字母表示向量或矩阵
特征组合
最基本的线性加权
$$\phi_{LM}(\textbf{w},\textbf{x})=\sum_{i=1}^n{w_ix_i}$$
任意特征两两组合
$$\phi_{poly2}(\textbf{w},\textbf{x})=\sum_{j1=1}^n{\sum_{j2=j1+1}^n{w_{j1,j2}x_{j1}x_{j2}}}$$
$\textbf{w}$是一个对称方阵,即$w_{j1,j2}=w_{j2,j1}$,可以用矩阵分解法来拟合$\textbf{w}$。
$$w_{j1,j2}=\textbf{v}_{j1}\cdot\textbf{v}_{j2}=\textbf{v}_{j2}\cdot\textbf{v}_{j1}=w_{j2,j1}$$
矩阵$\textbf{w}$的规模是$n\times n$,矩阵$\textbf{v}$的规模是$n\times k$,$k\ll n$。实际上我们已经推导出了因子分解法。
因子分解法FM
$$\phi_{FM}(\textbf{w},\textbf{x})=\sum_{j1=1}^n{\sum_{j2=j1+1}^n{\textbf{w}_{j1}\cdot \textbf{w}_{j2}x_{j1}x_{j2}}}$$
这里的$\textbf{w}_j$相当于上面的$\textbf{v}_j$。
域感知的因子分解法FFM
$$\phi_{FFM}(\textbf{w},\textbf{x})=\sum_{j1=1}^n{\sum_{j2=j1+1}^n{\textbf{w}_{j1,f2}\cdot \textbf{w}_{j2,f1}x_{j1}x_{j2}}}$$
在FM中$\textbf{w}$是规模为$n\times k_{FM}$的二维矩阵,而在FFM中$\textbf{w}$是规模为$n\times m \times k_{FFM}$的三维矩阵,$k_{FFM} \ll k_{FM}$。
逻辑回归二分类
决策函数
$$\hat{y}=\frac{1}{1+exp(\phi_{FFM}(\textbf{w},\textbf{x}))}$$
带L2正则的目标函数
$$\min_{\textbf{w}}\;\;\frac{\lambda}{2}\parallel w\parallel_2^2+\sum_{i=1}^llog(1+exp(-y_i\phi_{FFM}(\textbf{w},\textbf{x}_i)))$$
其中$y_i \in \{-1,1\}$
在SGD中每次只需要考虑一个样本的损失,此时目标函数为
$$\min_{\textbf{w}}\;\;\frac{\lambda}{2}\parallel w\parallel_2^2+log(1+exp(-y\phi_{FFM}(\textbf{w},\textbf{x})))$$
梯度
$$\textbf{g}_{j1,f2}=\lambda\cdot\textbf{w}_{j1,f2}+\kappa\cdot\textbf{w}_{j2,f1}x_{j1}x_{j2}$$
$$\textbf{g}_{j2,f1}=\lambda\cdot\textbf{w}_{j2,f1}+\kappa\cdot\textbf{w}_{j1,f2}x_{j1}x_{j2}$$
梯度之所会这么简单,依赖一个很重要的前提:同一个域下的各个特征只有一个是非0值。
其中
$$\kappa=\frac{\partial log(1+exp(-y\phi_{FFM}(\textbf{w},\textbf{x})))}{\partial\phi_{FFM}(\textbf{w},\textbf{x})}=\frac{-y}{1+exp(y\phi_{FFM}(\textbf{w},\textbf{x}))}$$
AdaGrad更新w
至于为什么要用AdaGrad替代传统的梯度下降法,请参见我之前写的《优化方法》。
$$(G_{j1,f2})_d\gets(G_{j1,f2})_d+(g_{j1,f2})_d^2$$
$$(G_{j2,f1})_d\gets(G_{j2,f1})_d+(g_{j2,f1})_d^2$$
$$(w_{j1,f2})_d\gets(w_{j1,f2})_d-\frac{\eta}{\sqrt{(G_{j1,f2})_d}}(g_{j1,f2})_d$$
$$(w_{j2,f1})_d\gets(w_{j2,f1})_d-\frac{\eta}{\sqrt{(G_{j2,f1})_d}}(g_{j2,f1})_d$$
初始化$G_d=1$,这样在计算$\frac{\eta}{\sqrt{G_d}}$时既可以防止分母为0,又可以避免该项太大或太小。
$\eta$是学习率,通常可取0.01。
初始的$\textbf{w}$可以从均匀分布中抽样$\textbf{w}\sim U(0,\frac{1}{\sqrt{k}})$
实现发现将每个$\textbf{x}$归一化,即模长为1,在测试集得到的准确率会稍微好一点且对参数不太敏感。
代码实现
只要把公式推导搞明白了,写代码就非常容易了,直接把公式直译成代码即可。
# -*- coding: utf-8 -*- # @Date : 3/2/18 # @Author : zhangchaoyang import numpy as np np.random.seed(0) import math from logistic import Logistic class FFM_Node(object): ''' 通常x是高维稀疏向量,所以用链表来表示一个x,链表上的每个节点是个3元组(j,f,v) ''' __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, eta, lambd): ''' :param m: Number of fields :param n: Number of features :param k: Number of latent factors :param eta: learning rate :param lambd: regularization coefficient ''' self.m = m self.n = n self.k = k # 超参数 self.eta = eta self.lambd = lambd # 初始化三维权重矩阵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) self.log = Logistic() def phi(self, node_list): ''' 特征组合式的线性加权求和 :param node_list: 用链表存储x中的非0值 :return: ''' z = 0.0 for a in xrange(len(node_list)): node1 = node_list[a] j1 = node1.j f1 = node1.f v1 = node1.v for b in xrange(a + 1, len(node_list)): node2 = node_list[b] j2 = node2.j f2 = node2.f v2 = node2.v w1 = self.w[j1, f2] w2 = self.w[j2, f1] z += np.dot(w1, w2) * v1 * v2 return z def predict(self, node_list): ''' 输入x,预测y的值 :param node_list: 用链表存储x中的非0值 :return: ''' z = self.phi(node_list) y = self.log.decide_by_tanh(z) return y def sgd(self, node_list, y): ''' 根据一个样本来更新模型参数 :param node_list: 用链表存储x中的非0值 :param y: 正样本1,负样本-1 :return: ''' kappa = -y / (1 + math.exp(y * self.phi(node_list))) for a in xrange(len(node_list)): node1 = node_list[a] j1 = node1.j f1 = node1.f v1 = node1.v for b in xrange(a + 1, len(node_list)): node2 = node_list[b] j2 = node2.j f2 = node2.f v2 = node2.v c = kappa * v1 * v2 # self.w[j1,f2]和self.w[j2,f1]是向量,导致g_j1_f2和g_j2_f1也是向量 g_j1_f2 = self.lambd * self.w[j1, f2] + c * self.w[j2, f1] g_j2_f1 = self.lambd * self.w[j2, f1] + c * self.w[j1, f2] # 计算各个维度上的梯度累积平方和 self.G[j1, f2] += g_j1_f2 ** 2 # 所有G肯定是大于0的正数,因为初始化时G都为1 self.G[j2, f1] += g_j2_f1 ** 2 # AdaGrad self.w[j1, f2] -= self.eta / np.sqrt(self.G[j1, f2]) * g_j1_f2 # sqrt(G)作为分母,所以G必须是大于0的正数 self.w[j2, f1] -= self.eta / np.sqrt( self.G[j2, f1]) * g_j2_f1 # math.sqrt()只能接收一个数字作为参数,而numpy.sqrt()可以接收一个array作为参数,表示对array中的每个元素分别开方 def train(self, sample_generator, max_echo, max_r2): ''' 根据一堆样本训练模型 :param sample_generator: 样本生成器,每次yield (node_list, y),node_list中存储的是x的非0值。通常x要事先做好归一化,即模长为1,这样精度会略微高一点 :param max_echo: 最大迭代次数 :param max_r2: 拟合系数r2达到阈值时即可终止学习 :return: ''' for itr in xrange(max_echo): print "echo", itr y_sum = 0.0 y_square_sum = 0.0 err_square_sum = 0.0 # 误差平方和 population = 0 # 样本总数 for node_list, y in sample_generator: y = 0.0 if y == -1 else y # 真实的y取值为{-1,1},而预测的y位于(0,1),计算拟合效果时需要进行统一 self.sgd(node_list, y) y_hat = self.predict(node_list) y_sum += y y_square_sum += y ** 2 err_square_sum += (y - y_hat) ** 2 population += 1 var_y = y_square_sum - y_sum * y_sum / population # y的方差 r2 = 1 - err_square_sum / var_y print "r2=",r2 if r2 > max_r2: # r2值越大说明拟合得越好 print 'r2 have reach', r2 break def save_model(self, outfile): ''' 序列化模型 :param outfile: :return: ''' np.save(outfile, self.w) def load_model(self, infile): ''' 加载模型 :param infile: :return: ''' self.w = np.load(infile)
完整代码见 https://github.com/Orisun/ffm
本文来自博客园,作者:高性能golang,转载请注明原文链接:https://www.cnblogs.com/zhangchaoyang/articles/8410719.html