协同过滤


协同过滤

概率矩阵分解(PMF),另一种时深度学习方法

为用户推荐产品的一般解决方案:协同过滤,查看当前用户使用过或喜欢过哪些产品,找到使用过或喜欢类似产品的其他用户,然后推荐这些用户使用过或喜欢过的其他产品给当前用户。关键底层思想:潜在特征。

数据集

电影推荐,使用MovieLens,包含千万条电影评分数据。

from fastai.collab import *
from fastai.tabular.all import *
path = untar_data(URLs.ML_100k)

ratings = pd.read_csv(path/'u.data', delimiter='\t', header=None,names=['user','movie','rating','timestamp'])
ratings

数据集

然而以交叉表的形式展示更直观


交叉表

如果我们可以清楚地知道每个用户喜欢每个重要的电影类别(如类型、年龄、最喜爱的导演和演员等)的程度,同时我们也清楚每个电影的这些类别信息,那么一个简单的方法就是将每部电影和每个用户的对应属性的特征值相乘后再求和。例如,如果我们使用-1到+1的数值来表示匹配程度,其中正数表示更强的匹配,负数表示更弱的匹配,并且我们有三个类别(科幻、动作和老电影)如下。

last_skywalker = np.array([0.98,0.9,-0.9])
user1 = np.array([0.9,0.8,-0.6])
(user1*last_skywalker).sum()
# 2.1420000000000003
casablanca = np.array([-0.99,-0.3,0.8])
(user1*casablanca).sum()
# -1.611

点积(内积):将两个向量的各元素分别相乘,然后将其结果进行求和的数学运算。

学习潜在特征

步骤1:随机初始化一些参数。这些参数将是每个用户和电影的一组潜在特征。我们需要决定使用多少个潜在特征。为了说明目的,我们现在先使用5个。因为每个用户和每部电影都会有一组这样的特征,我们可以在交叉表中用户和电影旁边显示这些随机初始化的值,然后在中间填入每对组合的点积。

步骤2:计算预测。可以通过取每部电影与每个用户的点积来实现这一点。例如,如果第一个潜在用户特征代表用户喜欢动作电影的程度,而第一个潜在电影特征代表电影是否包含大量动作场面,那么如果用户喜欢动作电影且电影确实包含大量动作场面,或者用户不喜欢动作电影且电影没有动作场面,这两个特征的乘积将会特别高。另一方面,如果有不匹配的情况(用户喜欢动作电影但电影不是动作片,或者用户不喜欢动作电影但它是一部动作片),乘积将会非常低。

步骤3:计算损失。可以使用任何我们希望的损失函数;选择均方误差,因为这是表示预测准确性的一种合理方式。

有了这些,我们就可以使用随机梯度下降来优化我们的参数(即潜在特征),以最小化损失。在每一步,随机梯度下降优化器将使用点积计算每部电影和每个用户之间的匹配,并将其与每个用户给出的每部电影的实际评分进行比较。然后它将计算这个值的导数,并通过乘以学习率来调整权重。经过多次这样的操作,损失将会越来越小,推荐也会越来越好。

创建DataLoaders

希望展示时看见电影名而不是ID

movies = pd.read_csv(path/'u.item',  delimiter='|', encoding='latin-1',
                     usecols=(0,1), names=('movie','title'), header=None)
# movies.head()
# 合并表格
ratings = ratings.merge(movies)
ratings.head()

电影评分表

根据此表构建一个DataLoaders对象。默认第一列表示用户,第二列表示项目(电影),第三列表示评级。

dls = CollabDataLoaders.from_df(ratings, item_name='title', bs=64)
dls.show_batch()

数据块

在深度学习模型中,我们通常不直接进行索引查找操作,因为这不是模型能够直接处理的。但是,我们可以通过将索引转换为独热编码(one-hot encoding)向量,然后使用矩阵乘法来间接实现这一点。

独热编码是一种将分类变量转换为可以提供给机器学习算法的格式的方法。在独热编码中,每个索引值都被转换为一个全是0的向量,除了代表索引的位置是1。

例如,如果我们有一个向量 $v=\begin{bmatrix}v_1,v_2,v_3,…,v_n\end{bmatrix}$

,并且我们想要通过独热编码来选择第三个元素,我们可以创建一个独热编码向量 $e_3=\begin{bmatrix}0,0,1,0,…,0\end{bmatrix}$ 。然后,我们可以通过矩阵乘法来获取 $v$ 的第三个元素:

$v\times e_3^T=\begin{bmatrix}v_1,v_2,v_3,…,v_n\end{bmatrix}\times\begin{bmatrix}0\0\1\0\\vdots\0\end{bmatrix}=v_3$

这样我们就可以执行矩阵乘法。结果是一个只包含 $v_3$ 的向量。

n_users  = len(dls.classes['user'])
n_movies = len(dls.classes['title'])
n_factors = 5

user_factors = torch.randn(n_users, n_factors)
movie_factors = torch.randn(n_movies, n_factors)

one_hot_3 = one_hot(3, n_users).float()
user_factors.t() @ one_hot_3
# tensor([-0.4586, -0.9915, -0.4052, -0.3621, -0.5908])
user_factors[3]
# tensor([-0.4586, -0.9915, -0.4052, -0.3621, -0.5908])

嵌入(embedding)。在深度学习中,嵌入层是一种有效的方式来处理类别数据,特别是当类别数量很大时。

当我们使用独热编码来表示索引时,我们确实会得到一个由独热编码向量组成的矩阵,这样的操作本质上是一个矩阵乘法。这种方法在理论上是可行的,但它会消耗更多的内存和计算时间。这是因为独热编码向量大部分元素都是0,只有一个位置是1,这导致了大量的存储和计算浪费。

因此,深度学习库,如PyTorch,提供了一种特殊的层——嵌入层。嵌入层允许我们直接使用整数索引来查找数组中的元素,而不需要存储完整的独热编码向量。这样做的好处是显而易见的:

  • 减少内存使用:不需要存储大量的独热编码向量。
  • 提高效率:直接索引比搜索整个独热编码向量要快得多。
  • 保持梯度计算的一致性:尽管使用整数索引,但嵌入层的梯度计算方式与使用独热编码进行矩阵乘法的方式相同。

当我们用独热编码矩阵乘以嵌入矩阵时,实际上我们在做的是选择嵌入矩阵中对应的行。

在计算机视觉中,我们通过RGB值来获取像素的所有信息,这是一个直观的过程:每个彩色图像中的像素由三个数字表示,分别对应红色、绿色和蓝色的强度。这三个数值足以让我们的模型后续进行工作。

然而,对于用户或电影这样的实体,我们没有同样简单的方式来表征它们。可能存在与电影类型相关的关系:如果某个用户喜欢浪漫类型的电影,他们可能会给浪漫电影更高的评分。其他因素可能包括电影是更倾向于动作还是对话重,或者用户可能特别喜欢的某个特定演员的出现。

我们如何确定用来表征这些特征的数值呢?答案是,我们不需要自己确定。我们将让模型自己学习它们。通过分析用户和电影之间现有的关系,我们的模型可以自行发现哪些特征看起来重要或不重要。

这就是嵌入(embeddings)的含义。我们将为我们的每个用户和每部电影分配一个随机向量(这里的长度为n_factors=5),并将这些向量作为可学习的参数。这意味着在每一步,当我们通过比较我们的预测和目标来计算损失时,我们将计算损失相对于这些嵌入向量的梯度,并使用随机梯度下降(SGD)或其他优化器的规则来更新它们。

在训练开始时,这些数字没有任何意义,因为我们是随机选择的,但在训练结束时,它们将具有意义。通过在没有任何其他信息的情况下,学习关于用户和电影之间现有数据的关系,我们将看到它们仍然能够获取一些重要的特征,并能够区分大片和独立电影,动作电影和浪漫电影等等。

从头开始进行协同过滤

在PyTorch中创建一个新的模块确实需要从Module类继承。当我们创建一个新的类时,我们可以通过继承来复用和扩展PyTorch的Module类的基础功能。在PyTorch中,Module类是所有神经网络模块的基类,它提供了一些基本的结构和方法,例如参数管理、模型保存和加载、设备转移(CPU/GPU)、钩子函数等。当我们定义自己的模块时,我们通过继承Module类来获得这些功能。

此外,当我们的模块被调用时,PyTorch会自动调用一个名为forward的方法。这个forward方法定义了模块的前向传播逻辑,即当我们对模块进行调用(例如model(x))时,实际上是在调用model.forward(x)。在forward方法中,我们定义了输入数据如何通过模型流动并返回输出。

下面是一个定义点积模型的类的示例,它展示了如何从Module继承并实现forward方法:

class DotProduct(Module):
    def __init__(self, n_users, n_movies, n_factors):
        self.user_factors = Embedding(n_users, n_factors)
        self.movie_factors = Embedding(n_movies, n_factors)
        
    def forward(self, x):
        users = self.user_factors(x[:,0])
        movies = self.movie_factors(x[:,1])
        return (users * movies).sum(dim=1)

对于模型的输入,我们通常使用一个形状为batch_size x 2的张量,其中:

  • 第一列(x[:, 0])包含用户ID。
  • 第二列(x[:, 1])包含电影ID。

这里的x是一个批次的输入数据,batch_size是批次中的样本数量。每一行代表一个用户-电影对,模型将为这些对生成预测评分。

嵌入层(embedding layers)用于表示用户和电影的潜在特征矩阵。

x,y = dls.one_batch()
x.shape
# torch.Size([64, 2])

这里不使用已经定义好的Learner,而是从头开始定义。

model = DotProduct(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3)

拟合一下

为了让模型更好,将预测值设置在0到5之间。根据经验最好让范围超过5一点点,设为(0,5.5)区间。

在构建模型时,确保预测值位于合理范围内(例如,0到5分的评分系统)是很重要的。使用sigmoid_range函数可以帮助我们将模型的输出限制在这个范围内。sigmoid_range函数通过应用Sigmoid函数来压缩输出,然后将其缩放到指定的范围。

将范围设置得稍微超过5(例如,使用(0, 5.5))的原因是基于经验的发现。这样做有几个潜在的好处:

  1. 避免边界值问题:如果模型预测的值非常接近范围的上限或下限,使用稍微扩展的范围可以减少预测值被截断的情况。这意味着模型有更多的空间来表示那些接近极端评分的情况。
  2. 提高学习效率:在训练过程中,如果预测值被限制在一个非常严格的范围内,模型可能会在训练早期就遇到梯度消失的问题,因为Sigmoid函数的梯度在其输入值非常大或非常小的时候会变得非常小。扩展范围可以在一定程度上缓解这个问题。
  3. 更好的梯度流动:在优化过程中,扩展的范围可以提供更稳定的梯度,因为它避免了Sigmoid函数在极值附近的平坦区域,这有助于模型更有效地学习。
  4. 灵活性和鲁棒性:在实际应用中,评分可能会受到多种因素的影响,包括噪声和偏差。允许模型预测略微超出实际评分范围的值可以提供额外的灵活性,使模型能够更好地适应这些因素。

总的来说,将输出范围设置为略微超过实际评分的最大值,可以帮助模型在训练和预测时更加稳定和准确。这是一种基于实践的调整,旨在提高模型的整体性能。

class DotProduct(Module):
    def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
        self.user_factors = Embedding(n_users, n_factors)
        self.movie_factors = Embedding(n_movies, n_factors)
        self.y_range = y_range
       
	def forward(self, x):
        users = self.user_factors(x[:,0])
        movies = self.movie_factors(x[:,1])
        return sigmoid_range((users * movies).sum(dim=1), *self.y_range)
    
model = DotProduct(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3)

重新拟合一下

😧

在目前的点积模型中,我们只考虑了用户和电影的潜在特征,这些特征通过点积来预测评分。然而,这种方法没有考虑到一些用户可能天生就更倾向于给出正面或负面的评价,或者某些电影可能普遍被认为是好或坏。

为了解决这个问题,我们可以为每个用户和每部电影添加一个偏置项。这样,我们的模型就不仅仅是学习用户和电影之间的相互作用,还能学习到它们各自的特性。具体来说:

  • 用户偏置(User Bias):每个用户都有一个偏置值,它代表了该用户评分的整体倾向性。例如,一些用户可能普遍给出较高的评分,而另一些用户可能普遍给出较低的评分。
  • 电影偏置(Movie Bias):每部电影也有一个偏置值,它代表了电影的普遍受欢迎程度。例如,一些电影可能普遍受到好评,而另一些电影则可能不那么受欢迎。

在模型中引入偏置项后,预测评分的计算将变为用户和电影潜在特征的点积加上相应的用户偏置和电影偏置。数学上,这可以表示为:

预测评分=(用户特征⋅电影特征)+用户偏置+电影偏置

这样,模型就能更准确地反映出用户和电影的个性化特征,从而提高推荐系统的准确性和个性化程度。

class DotProductBias(Module):
    def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
        self.user_factors = Embedding(n_users, n_factors)
        self.user_bias = Embedding(n_users, 1)
        self.movie_factors = Embedding(n_movies, n_factors)
        self.movie_bias = Embedding(n_movies, 1)
        self.y_range = y_range
        
    def forward(self, x):
        users = self.user_factors(x[:,0])
        movies = self.movie_factors(x[:,1])
        res = (users * movies).sum(dim=1, keepdim=True)
        res += self.user_bias(x[:,0]) + self.movie_bias(x[:,1])
        return sigmoid_range(res, *self.y_range)

重新拟合一下

坏事了!

可以看到两次训练,验证集上的损失在中途停止改善。仿佛过拟合了。

对此,使用正则化技术——权重衰减(weight decay)。数据增强不适合。

权重衰减

权重衰减(Weight Decay),也称为L2正则化,是在损失函数中加入所有权重的平方和。为什么要这样做呢?因为当我们计算梯度时,它会增加一个促使权重尽可能小的贡献。

为什么它能防止过拟合呢?这个想法是,系数越大,损失函数中的峡谷就会越尖锐。如果我们以抛物线为基本例子,$y=a⋅x^2$ ,a越大,抛物线就越窄。

在数学上,L2正则化可以表示为在损失函数 $L$ 中加入一个正则项 $\lambda\sum_iw_i^2$ ,其中 $\text{λ}$ 是正则化参数, $w_{i}$ 是模型权重。整个损失函数变为:

$L_{\mathrm{total}}=L_{\mathrm{original}}+\lambda\sum_iw_i^2$

这个正则项会惩罚大的权重值,因为当权重值增大时,正则项也会增大,从而增加总损失。在训练过程中,模型会尝试最小化这个总损失,这自然会导致权重值尽量小。

权重衰减有助于防止过拟合,因为它限制了模型的复杂度。较小的权重值意味着模型的预测将不会对输入数据中的小波动过于敏感,这有助于提高模型在未见数据上的泛化能力。

因此,让模型学习高参数据可能导致过拟合,模型用一个变化非常剧烈且过于复杂的函数来你和训练集中的所有数据点,最终导致过拟合。

过多限制权重增长,将会减慢模型的训练速度,当会形成一种泛化更好的状态。

loss_with_wd = loss + wd * (parameters**2).sum()
# 为损失函数添加平方和,假设parameters是所有参数的张量

权重衰减是我们选择的一个参数。通常,我们会将其设置为一个较大的值,这样我们甚至不需要在方程中包含乘以2的部分。在fastai库中使用权重衰减非常简单,您只需要在调用fitfit_one_cycle函数时传递wd参数即可。

model = DotProductBias(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3, wd=0.1)

重新训练

舒服了😀

创建自己的嵌入模块

尽管上面用到了Embedding,但是没有考虑实际工作原理,下面构造自己的DotProductBias这个类。

在PyTorch中,如果我们只是将一个张量作为属性添加到Module中,它不会自动包含在模型的参数中。为了让Module知道我们想将一个张量视为参数,我们需要将它包装在nn.Parameter类中。这个类实际上并没有添加任何功能(除了自动为我们调用requires_grad_),它仅仅用作一个“标记”,以显示哪些张量应该包含在参数中。

class T(Module):
    def __init__(self): self.a = nn.Parameter(torch.ones(3))

L(T().parameters())

当我们调用T().parameters()时,它会返回一个包含所有参数的列表。在这个例子中,一个包含三个元素的张量,并且每个元素都设置为1,并且具有梯度。

所有的PyTorch模块都使用nn.Parameter来定义可训练的参数,这就是为什么到目前为止我们没有需要显式使用这个包装器的原因。当我们定义自己的模型时,我们可以通过这种方式来创建可训练的参数。

# 创建一个张量作为参数,并随机初始化
def create_params(size):
    return nn.Parameter(torch.zeros(*size).normal_(0, 0.01))

用张量创建DotProductBias类,而不使用Embedding:

class DotProductBias(Module):
    def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
        self.user_factors = create_params([n_users, n_factors])
        self.user_bias = create_params([n_users])
        self.movie_factors = create_params([n_movies, n_factors])
        self.movie_bias = create_params([n_movies])
        self.y_range = y_range
        
    def forward(self, x):
        users = self.user_factors[x[:,0]]
        movies = self.movie_factors[x[:,1]]
        res = (users*movies).sum(dim=1)
        res += self.user_bias[x[:,0]] + self.movie_bias[x[:,1]]
        return sigmoid_range(res, *self.y_range)
    
model = DotProductBias(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3, wd=0.1)

重新训练一下

嵌入和偏差

直接解释嵌入矩阵并不容易,因为有太多的因素需要考虑。但有一种技术可以提取出这样一个矩阵中最重要的基本方向,称为主成分分析(PCA)。

计算线性代数

https://github.com/fastai/numerical-linear-algebra

使用fastai.collab

# 协同过滤模型
learn = collab_learner(dls, n_factors=50, y_range=(0, 5.5))
learn.fit_one_cycle(5, 5e-3, wd=0.1)

fastai的模型训练

查看模型的所有层

learn.model
#EmbeddingDotBias(
#  (u_weight): Embedding(944, 50)
#  (i_weight): Embedding(1665, 50)
#  (u_bias): Embedding(944, 1)
#  (i_bias): Embedding(1665, 1)
#)

嵌入距离

在多维空间中,这种距离被称为欧几里得距离。通过计算电影嵌入向量之间的欧几里得距离,我们可以量化电影之间的相似性。如果两部电影的嵌入向量之间的距离很小,这表明它们在用户偏好的多维空间中是相似的。这种方法可以帮助我们发现与特定电影相似的其他电影,从而为用户提供个性化的推荐。

movie_factors = learn.model.i_weight.weight
idx = dls.classes['title'].o2i['Silence of the Lambs, The (1991)']
distances = nn.CosineSimilarity(dim=1)(movie_factors, movie_factors[idx][None])
idx = distances.argsort(descending=True)[1]
dls.classes['title'][idx]

为用户做推荐

自助取样启动问题

最极端的情况是没有用户,即不能从历史信息中学习,那向第一个用户推荐什么产品?

用户注册时,问一些喜好之类的问题。

深度学习方法用于协同过滤

在将架构转换为深度学习模型时,首先要做的是取嵌入查找的结果,并将这些激活值拼接在一起。这样我们就得到了一个矩阵,然后可以像通常那样通过线性层和非线性函数进行处理。

由于我们将要拼接嵌入,而不是取它们的点积,两个嵌入矩阵可以有不同的大小(即不同数量的潜在因子)。fastai 提供了一个名为 get_emb_sz 的函数,该函数根据 fast.ai 发现在实践中往往效果不错的启发式规则,返回数据的嵌入矩阵推荐大小:

embs = get_emb_sz(dls)
embs
# [(944, 74), (1665, 102)]

实现该类:

class CollabNN(Module):
    def __init__(self, user_sz, item_sz, y_range=(0,5.5), n_act=100):
        self.user_factors = Embedding(*user_sz)
        self.item_factors = Embedding(*item_sz)
        self.layers = nn.Sequential(
            nn.Linear(user_sz[1]+item_sz[1], n_act),
            nn.ReLU(),
            nn.Linear(n_act, 1))
        self.y_range = y_range
        
    def forward(self, x):
        embs = self.user_factors(x[:,0]),self.item_factors(x[:,1])
        x = self.layers(torch.cat(embs, dim=1))
        return sigmoid_range(x, *self.y_range)

# 创建模型    
model = CollabNN(*embs)

learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3, wd=0.01)

# 使用use_nn,创建了2个隐藏层,大小分别是100,50
learn = collab_learner(dls, use_nn=True, y_range=(0, 5.5), layers=[100,50])
learn.fit_one_cycle(5, 5e-3, wd=0.1)

文章作者: nusqx
文章链接: https://nusqx.top
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 nusqx !
评论
  目录