推荐系统实践 0x0c FM系列

逻辑回归(LR)

在介绍FM系列之前,我想首先简单介绍一下逻辑回归。通常来说,逻辑回归模型能够综合利用更多的信息,如用户、物品、上下文等多种不同的特征,生成更为全面的结果。另外,逻辑回归将推荐问题看成一个分类问题。通过预测正样本的概率对物品进行排序,这里的正样本可以是用户观看了某个视频,也可以是用户点击了某个商品,或者用户播放了某个音乐等等。逻辑回归模型将推荐问题转换成了CTR(click throught rate)预估的问题。

步骤

一般来说,逻辑回归模型的推荐过程分成以下几步:

  1. 将用户年龄、性别等信息,商品名称、属性等信息,以及上下文等信息转换成数值型特征向量。
  2. 将逻辑回归作为优化目标,利用样本数据对逻辑回归模型进行训练,调整模型内部参数。
  3. 在模型服务阶段,将特征向量的输入到模型当中,得到用户“点击”等正反馈的概率。
  4. 按照正反馈的概率对物品进行排序,得到推荐列表。

这里的逻辑回归也使用了梯度下降的算法。这里我推荐一篇文章专门介绍逻辑回归的数学原理,感兴趣的读者可以继续阅读。另外特别要说明的事是,逻辑回归是分类模型,不是回归模型。

优点

  • 有着具体的数学含义作为支撑。由于CTR模型符合伯努利分布,所以使用逻辑回归作为CTR模型符合逻辑规律。
  • 可解释性强,能够通过权重对各个因素进行定位,给出结果的可解释性原因。
  • 实际工程需要。由于易于并行化、模型简单以及训练开销小等特点,逻辑回归受到了广泛认可。

局限

  • 表达能力不强,无法进行特征交叉、特征筛选等操作等

POLY2

POLY2是最简单的特征交叉的算法,直接对特征进行暴力组合,看看它的数学形式就能知道

\[\mathrm{POLY2}(w,x)=\sum_{j_1=1}^{n-1}\sum_{j_2=j_1+1}^{n}w_{h(j_1,j_2)}x_{j_1}x_{j_2}
\]

直接对特征进行两两交叉,并对交叉后的特征组合赋予权重。POLY2仍然是线性模型,训练方法与逻辑回归模型并无区别。

局限

  1. 对于很多互联网数据,通常使用的是one-hot编码,无选择的特征交叉使得特征向量更加稀疏,对于权重缺乏有效训练,甚至无法收敛。
  2. 权重参数直接上升了一个数量级,计算量难以接受

Factorization Machines(FM)

为了解决POLY2的局限,FM模型使用了两个向量内积取代了单一的权重系数。FM模型为每个特征学习了一个隐权重向量,在做特征交叉时使用两个特征隐向量的内积作为交叉特征的权重。如以下公式:

\[\mathrm{FM}(w,x)=\sum_{j_1=1}^{n-1}\sum_{j_2=j_1+1}^{n}(w_{j_1}w_{j_2})x_{j_1}x_{j_2}
\]

FM引入特征隐向量与矩阵分解中的隐向量有异曲同工之妙。通过引入特征隐向量的方式,把POLY2当中\(n^2\)级别的权重参数降低到了\(nk\),极大地降低了训练开销。

另外,由于特征隐向量的存在,使得模型具备了计算特征组合权重的能力,如家具,蔬菜两种特征中的一个训练样本,(桌子,西红柿),就不需要同时出现桌子和西红柿才能学习这种特征组合。另外,当出现新的样本事也能通过计算过的特征隐向量进行在线服务。

同样的,FM也可以使用梯度下降法进行学习,不失实时性和灵活性。我们看一下PyTorch版本的FM是如何实现的吧。

import torch as torch
import torch.nn as nn
import numpy as np
import torch.nn.functional as F


class FeaturesLinear(nn.Module):

    def __init__(self, field_dims, output_dim=1):
        super(FeaturesLinear, self).__init__()
        print("field_dims: ", field_dims)
        self.fc = nn.Embedding(sum(field_dims), output_dim)
        self.bias = nn.Parameter(torch.zeros((output_dim,)))
        # accumulation add function to sparse the categories like:[1,3,4,7]==>[1,4,8,15]
        self.offsets = np.array((0, *np.cumsum(field_dims)[:-1]), dtype=np.long)

    def forward(self, x):
        """
          to change the category Serial number to ordered number
          like we got x = [2, 4] means category_1's id is 2, and category_2's id is 4
          assume field_dims like [3, 8], category_1 has 3 ids, category_2 has 8 ids. ==> offsets=[0, 3]
          x = [0 + 2, 4 + 3] ==> [2, 7]
        """
        x = x + x.new_tensor(self.offsets).unsqueeze(0)
        return torch.sum(self.fc(x), dim=1)+self.bias


class FeaturesEmbedding(nn.Module):

    def __init__(self, field_dims, embed_dim):
        super(FeaturesEmbedding, self).__init__()
        self.embedding = nn.Embedding(sum(field_dims), embed_dim)
        self.offsets = np.array((0, *np.cumsum(field_dims)[:-1]), dtype=np.long)
        nn.init.xavier_uniform_(self.embedding.weight.data)

    def forward(self, x):
        x = x + x.new_tensor(self.offsets).unsqueeze(0)
        return self.embedding(x)

class FactorizationMachine(nn.Module):
    def __init__(self, reduce_sum=True):
        super(FactorizationMachine, self).__init__()
        self.reduce_sum = reduce_sum

    def forward(self, x):
        """
             $\frac{1}{2}\sum_{k=1}^{K}[(\sum_{i=1}^{n}v_{ik}x_i)^2-\sum_{i=1}^{n}v_{ik}^2x_i^2]$
        :param x: float tensor of size (batch_size, num_fields, embed_dim)
        :return:
        """
        square_of_sum = torch.sum(x, dim=1) ** 2
        sum_of_square = torch.sum(x ** 2, dim=1)
        ix = square_of_sum - sum_of_square
        if self.reduce_sum:
            ix = torch.sum(ix, dim=1, keepdim=True)
        return 0.5 * ix
import torch.nn.functional as F
from base import BaseModel
import torch as torch
import torch.nn as nn

from model.layers import *


class FM(BaseModel):

    def __init__(self, field_dims=None, embed_dim=None):
        super().__init__()
        self.linear = FeaturesLinear(field_dims)
        self.embedding = FeaturesEmbedding(field_dims, embed_dim)
        self.fm = FactorizationMachine(reduce_sum=True)

    def forward(self, x):
        x = self.linear(x) + self.fm(self.embedding(x))
        x = torch.sigmoid(x.squeeze(1))
        return x

Field-aware Factorization Machine(FFM)

还是为了解决数据特征系数的问题,FFM在FM的基础上进一步改进,在模型中引入域的概念,即field。将同一个域的特征单独进行one-hot,因此在FFM中,每一维特征都会针对其他特征的每个域,分别学习一个隐变量,该隐变量不仅与特征相关,也与域相关。

\[\mathrm{FFM}(w,x)=\sum_{j_1=1}^{n-1}\sum_{j_2=j_1+1}^{n}(w_{j_1,f_2}w_{j_2,f_1})x_{j_1}x_{j_2}
\]

按照我的理解,引入特征域的概念实际上是希望每种特征都能够针对性对其他特征有更合适的权重,也就是学习域与域之间的权重分布,作为特征隐变量。但是与此同时,计算复杂度从\(nk\)上升到了\(n^2k\),在实际应用中需要在效果和工程投入进行权衡。

我们看一下相关代码:

class FieldAwareFactorizationMachine(nn.Module):
    def __init__(self, field_dims, embed_dim):
        super().__init__()
        self.num_fields = len(field_dims)
        self.embeddings = nn.ModuleList([
            nn.Embedding(sum(field_dims), embed_dim) for _ in range(self.num_fields)
        ])
        self.offsets = np.array((0, *np.cumsum(field_dims)[:-1]), dtype=np.long)
        for embedding in self.embeddings:
            nn.init.xavier_uniform_(embedding.weight.data)

    def forward(self, x):
        x = x + x.new_tensor(self.offsets).unsqueeze(0)
        xs = [self.embeddings[i](x) for i in range(self.num_fields)]
        ix = list()
        for i in range(self.num_fields-1):
            for j in range(i+1, self.num_fields):
                ix.append(xs[j][:, j] * xs[i][:, j])
        ix = torch.stack(ix, dim=1)
        return ix
from model.layers import *


class FFM(nn.Module):

    def __init__(self, field_dims, embed_dim):
        super().__init__()
        self.linear = FeaturesLinear(field_dims)
        self.ffm = FieldAwareFactorizationMachine(field_dims, embed_dim)

    def forward(self, x):
        ffm_term = torch.sum(torch.sum(self.ffm(x), dim=1), dim=1, keepdim=True)
        x = self.linear(x) + ffm_term
        return x.squeeze(1)

参考

【机器学习】逻辑回归(非常详细)
Github:ottsion/deeplite