面向程序员的 FastAI 和 PyTorch 深度学习(五)
原文:Deep Learning for Coders With Fastai and Pytorch
译者:飞龙
第七章:训练一个最先进的模型
本章介绍了更高级的技术,用于训练图像分类模型并获得最先进的结果。如果您想了解更多关于深度学习的其他应用,并稍后回来,您可以跳过它——后续章节不会假设您已掌握这些材料。
我们将看一下什么是归一化,一种强大的数据增强技术叫做 Mixup,渐进式调整大小方法,以及测试时间增强。为了展示所有这些,我们将从头开始训练一个模型(不使用迁移学习),使用一个名为 Imagenette 的 ImageNet 子集。它包含了原始 ImageNet 数据集中 10 个非常不同的类别的子集,使得在我们想要进行实验时训练更快。
这将比我们之前的数据集更难做得好,因为我们使用全尺寸、全彩色的图像,这些图像是不同大小、不同方向、不同光照等对象的照片。因此,在本章中,我们将介绍一些重要的技术,以便充分利用您的数据集,特别是当您从头开始训练,或者使用迁移学习在一个与预训练模型使用的非常不同类型的数据集上训练模型时。
Imagenette
当 fast.ai 刚开始时,人们主要使用三个主要数据集来构建和测试计算机视觉模型:
ImageNet
1.3 百万张各种尺寸的图像,大约 500 像素宽,分为 1,000 个类别,需要几天时间来训练
MNIST
50,000 个 28×28 像素的灰度手写数字
CIFAR10
60,000 个 32×32 像素的彩色图像,分为 10 类
问题在于较小的数据集无法有效地泛化到大型 ImageNet 数据集。在 ImageNet 上表现良好的方法通常必须在 ImageNet 上开发和训练。这导致许多人认为,只有拥有巨大计算资源的研究人员才能有效地为发展图像分类算法做出贡献。
我们认为这似乎是不太可能成立的。我们从未见过一项研究表明 ImageNet 恰好是正确的大小,其他数据集无法提供有用的见解。因此,我们希望创建一个新的数据集,研究人员可以快速、廉价地测试他们的算法,但也能提供可能在完整的 ImageNet 数据集上起作用的见解。
大约三个小时后,我们创建了 Imagenette。我们从完整的 ImageNet 中选择了 10 个看起来非常不同的类别。正如我们所希望的那样,我们能够快速、廉价地创建一个能够识别这些类别的分类器。然后我们尝试了一些算法调整,看它们如何影响 Imagenette。我们发现一些效果不错的,并在 ImageNet 上进行了测试,我们很高兴地发现我们的调整在 ImageNet 上也效果很好!
这里有一个重要的信息:您得到的数据集不一定是您想要的数据集。特别是不太可能是您想要进行开发和原型设计的数据集。您应该力求迭代速度不超过几分钟——也就是说,当您想尝试一个新想法时,您应该能够在几分钟内训练一个模型并查看其效果。如果做一个实验花费的时间更长,考虑如何减少数据集的规模,或简化模型,以提高实验速度。您做的实验越多,效果就越好!
让我们从这个数据集开始:
from fastai.vision.all import *
path = untar_data(URLs.IMAGENETTE)
首先,我们将使用在第五章中介绍的预调整技巧将我们的数据集放入DataLoaders
对象中:
dblock = DataBlock(blocks=(ImageBlock(), CategoryBlock()),
get_items=get_image_files,
get_y=parent_label,
item_tfms=Resize(460),
batch_tfms=aug_transforms(size=224, min_scale=0.75))
dls = dblock.dataloaders(path, bs=64)
然后我们将进行一个作为基线的训练运行:
model = xresnet50()
learn = Learner(dls, model, loss_func=CrossEntropyLossFlat(), metrics=accuracy)
learn.fit_one_cycle(5, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.583403 | 2.064317 | 0.401792 | 01:03 |
1 | 1.208877 | 1.260106 | 0.601568 | 01:02 |
2 | 0.925265 | 1.036154 | 0.664302 | 01:03 |
3 | 0.730190 | 0.700906 | 0.777819 | 01:03 |
4 | 0.585707 | 0.541810 | 0.825243 | 01:03 |
这是一个很好的基准,因为我们没有使用预训练模型,但我们可以做得更好。当使用从头开始训练的模型,或者对与预训练使用的数据集非常不同的数据集进行微调时,一些额外的技术就变得非常重要。在本章的其余部分,我们将考虑一些您需要熟悉的关键方法。第一个方法是归一化您的数据。
归一化
在训练模型时,如果您的输入数据是归一化的,那将会有所帮助——也就是说,具有平均值为 0 和标准差为 1。但大多数图像和计算机视觉库使用像素值在 0 到 255 之间,或者在 0 到 1 之间;在任何一种情况下,您的数据都不会具有平均值为 0 和标准差为 1。
让我们获取一批数据并查看这些值,通过对除了通道轴之外的所有轴进行平均,通道轴是轴 1:
x,y = dls.one_batch()
x.mean(dim=[0,2,3]),x.std(dim=[0,2,3])
(TensorImage([0.4842, 0.4711, 0.4511], device='cuda:5'),
TensorImage([0.2873, 0.2893, 0.3110], device='cuda:5'))
正如我们预期的那样,平均值和标准差与期望值不太接近。幸运的是,在 fastai 中对数据进行归一化很容易,只需添加Normalize
转换。这会一次作用于整个小批量数据,因此您可以将其添加到数据块的batch_tfms
部分。您需要传递给此转换您想要使用的平均值和标准差;fastai 已经定义了标准的 ImageNet 平均值和标准差。(如果您没有向Normalize
转换传递任何统计数据,fastai 将自动从您的数据的一个批次中计算出它们。)
让我们添加这个转换(使用imagenet_stats
,因为 Imagenette 是 ImageNet 的一个子集),现在看一下一个批次:
def get_dls(bs, size):
dblock = DataBlock(blocks=(ImageBlock, CategoryBlock),
get_items=get_image_files,
get_y=parent_label,
item_tfms=Resize(460),
batch_tfms=[*aug_transforms(size=size, min_scale=0.75),
Normalize.from_stats(*imagenet_stats)])
return dblock.dataloaders(path, bs=bs)
dls = get_dls(64, 224)
x,y = dls.one_batch()
x.mean(dim=[0,2,3]),x.std(dim=[0,2,3])
(TensorImage([-0.0787, 0.0525, 0.2136], device='cuda:5'),
TensorImage([1.2330, 1.2112, 1.3031], device='cuda:5'))
让我们来看看这对训练我们的模型有什么影响:
model = xresnet50()
learn = Learner(dls, model, loss_func=CrossEntropyLossFlat(), metrics=accuracy)
learn.fit_one_cycle(5, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.632865 | 2.250024 | 0.391337 | 01:02 |
1 | 1.294041 | 1.579932 | 0.517177 | 01:02 |
2 | 0.960535 | 1.069164 | 0.657207 | 01:04 |
3 | 0.730220 | 0.767433 | 0.771845 | 01:05 |
4 | 0.577889 | 0.550673 | 0.824496 | 01:06 |
尽管在这里只有一点帮助,但在使用预训练模型时,归一化变得尤为重要。预训练模型只知道如何处理之前见过的数据类型。如果训练数据的平均像素值为 0,但您的数据的像素最小可能值为 0,那么模型将看到与预期完全不同的东西!
这意味着当您分发模型时,您需要同时分发用于归一化的统计数据,因为任何使用它进行推断或迁移学习的人都需要使用相同的统计数据。同样,如果您使用别人训练过的模型,请确保您了解他们使用的归一化统计数据,并进行匹配。
在之前的章节中,我们不必处理归一化,因为通过cnn_learner
使用预训练模型时,fastai 库会自动添加适当的Normalize
转换;模型已经使用Normalize
中的某些统计数据进行了预训练(通常来自 ImageNet 数据集),因此库可以为您填充这些数据。请注意,这仅适用于预训练模型,这就是为什么在从头开始训练时需要在这里手动添加这些信息的原因。
到目前为止,我们所有的训练都是在尺寸为 224 的情况下进行的。我们本可以在那之前从较小的尺寸开始训练。这被称为渐进调整。
渐进调整
当 fast.ai 及其学生团队在 2018 年赢得 DAWNBench 比赛时,其中最重要的创新之一是非常简单的事情:使用小图像开始训练,然后使用大图像结束训练。在大部分时期使用小图像进行训练有助于训练完成得更快。使用大图像完成训练使最终准确率更高。我们称这种方法为渐进式调整大小。
术语:渐进式调整大小
在训练过程中逐渐使用越来越大的图像。
正如我们所看到的,卷积神经网络学习的特征类型与图像的大小无关——早期层发现边缘和梯度等内容,而后期层可能发现鼻子和日落等内容。因此,当我们在训练中途更改图像大小时,并不意味着我们必须为我们的模型找到完全不同的参数。
但显然小图像和大图像之间存在一些差异,因此我们不应该期望我们的模型继续完全不变地工作得很好。这让你想起了什么吗?当我们开发这个想法时,它让我们想起了迁移学习!我们试图让我们的模型学会做一些与以前学会的有点不同的事情。因此,在调整图像大小后,我们应该能够使用fine_tune
方法。
渐进式调整大小还有一个额外的好处:它是另一种数据增强形式。因此,您应该期望看到使用渐进式调整大小训练的模型具有更好的泛化能力。
要实现渐进式调整大小,最方便的方法是首先创建一个get_dls
函数,该函数接受图像大小和批量大小,就像我们在前一节中所做的那样,并返回您的DataLoaders
。
现在,您可以使用小尺寸创建您的DataLoaders
,并以通常的方式使用fit_one_cycle
,训练的时期比您可能以其他方式做的要少:
dls = get_dls(128, 128)
learn = Learner(dls, xresnet50(), loss_func=CrossEntropyLossFlat(),
metrics=accuracy)
learn.fit_one_cycle(4, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.902943 | 2.447006 | 0.401419 | 00:30 |
1 | 1.315203 | 1.572992 | 0.525765 | 00:30 |
2 | 1.001199 | 0.767886 | 0.759149 | 00:30 |
3 | 0.765864 | 0.665562 | 0.797984 | 00:30 |
然后,您可以在Learner
内部替换DataLoaders
,并进行微调:
learn.dls = get_dls(64, 224)
learn.fine_tune(5, 1e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 0.985213 | 1.654063 | 0.565721 | 01:06 |
epoch | train_loss | valid_loss | accuracy | time |
--- | --- | --- | --- | --- |
0 | 0.706869 | 0.689622 | 0.784541 | 01:07 |
1 | 0.739217 | 0.928541 | 0.712472 | 01:07 |
2 | 0.629462 | 0.788906 | 0.764003 | 01:07 |
3 | 0.491912 | 0.502622 | 0.836445 | 01:06 |
4 | 0.414880 | 0.431332 | 0.863331 | 01:06 |
正如您所看到的,我们的性能要好得多,而在每个时期的小图像上的初始训练速度要快得多。
您可以根据需要重复增加大小并训练更多时期的过程,为您希望的图像大小——但当然,如果使用大于磁盘上图像大小的图像大小,您将不会获得任何好处。
请注意,对于迁移学习,渐进式调整大小实际上可能会损害性能。如果您的预训练模型与您的迁移学习任务和数据集非常相似,并且是在类似大小的图像上训练的,那么权重不需要进行太多更改。在这种情况下,使用较小的图像进行训练可能会损坏预训练权重。
另一方面,如果迁移学习任务将使用与预训练任务中使用的图像大小、形状或风格不同的图像,渐进式调整大小可能会有所帮助。像往常一样,“它会有帮助吗?”的答案是“试试看!”
我们还可以尝试将数据增强应用于验证集。到目前为止,我们只在训练集上应用了数据增强;验证集始终获得相同的图像。但也许我们可以尝试为验证集的几个增强版本进行预测并取平均值。我们将在下一步考虑这种方法。
测试时间增强
我们一直在使用随机裁剪作为一种获取一些有用数据增强的方法,这导致更好的泛化,并且需要更少的训练数据。当我们使用随机裁剪时,fastai 将自动为验证集使用中心裁剪——也就是说,它将选择图像中心的最大正方形区域,而不会超出图像的边缘。
这通常会带来问题。例如,在多标签数据集中,有时图像边缘会有小物体;这些物体可能会被中心裁剪完全裁剪掉。即使对于像我们的宠物品种分类示例这样的问题,也有可能关键特征,例如鼻子的颜色,可能会被裁剪掉。
解决这个问题的一个方法是完全避免随机裁剪。相反,我们可以简单地压缩或拉伸矩形图像以适应正方形空间。但是这样我们会错过一个非常有用的数据增强,并且还会使图像识别对我们的模型更加困难,因为它必须学会识别被压缩和拉伸的图像,而不仅仅是正确比例的图像。
另一个解决方案是在验证时不进行中心裁剪,而是从原始矩形图像中选择若干区域进行裁剪,将每个区域通过我们的模型,然后取预测的最大值或平均值。事实上,我们不仅可以对不同裁剪进行此操作,还可以对所有测试时间增强参数的不同值进行操作。这被称为测试时间增强(TTA)。
术语:测试时间增强(TTA)
在推断或验证期间,使用数据增强创建每个图像的多个版本,然后取每个增强版本的预测的平均值或最大值。
根据数据集的不同,测试时间增强可以显著提高准确性。它不会改变训练所需的时间,但会增加验证或推断所需的时间,数量取决于请求的测试时间增强图像数量。默认情况下,fastai 将使用未增强的中心裁剪图像加上四个随机增强的图像。
您可以将任何DataLoader
传递给 fastai 的tta
方法;默认情况下,它将使用您的验证集:
preds,targs = learn.tta()
accuracy(preds, targs).item()
0.8737863898277283
正如我们所看到的,使用 TTA 可以显著提高性能,而无需额外的训练。但是,它会使推断变慢——如果你对 TTA 平均了五张图像,推断将变慢五倍。
我们已经看到了一些数据增强如何帮助训练更好的模型。现在让我们专注于一种名为混合的新数据增强技术。
混合
混合(Mixup)是在 2017 年张宏毅等人的论文《混合:超越经验风险最小化》中引入的一种强大的数据增强技术,可以提供极高的准确性,特别是当你没有太多数据,也没有经过预训练的模型,该模型是在与你的数据集相似的数据上训练的。该论文解释道:“虽然数据增强始终会导致改进的泛化,但该过程取决于数据集,并因此需要专业知识的使用。”例如,将图像翻转作为数据增强的一部分是很常见的,但是你应该只水平翻转还是同时垂直翻转呢?答案是取决于你的数据集。此外,如果(例如)翻转对你来说提供的数据增强不够,你不能“多翻转”。有助于拥有数据增强技术,可以“调高”或“调低”变化的程度,以找到最适合你的方法。
对于每个图像,Mixup 的工作方式如下:
-
随机从数据集中选择另一个图像。
-
随机选择一个权重。
-
使用步骤 2 中的权重对所选图像和您的图像进行加权平均;这将是您的自变量。
-
将这个图像的标签与您的图像的标签进行加权平均(使用相同的权重);这将是您的因变量。
在伪代码中,我们这样做(其中t
是我们加权平均值的权重):
image2,target2 = dataset[randint(0,len(dataset)]
t = random_float(0.5,1.0)
new_image = t * image1 + (1-t) * image2
new_target = t * target1 + (1-t) * target2
为了使其正常工作,我们的目标需要进行独热编码。该论文使用图 7-1 中的方程式描述了这一点(其中与我们伪代码中的t
相同)。
图 7-1。Mixup 论文摘录
图 7-2 展示了在 Mixup 中进行图像线性组合的样子。
图 7-2。混合教堂和加油站
第三个图像是通过将第一个图像的 0.3 倍和第二个图像的 0.7 倍相加而构建的。在这个例子中,模型应该预测“教堂”还是“加油站”?正确答案是 30%的教堂和 70%的加油站,因为如果我们采用独热编码目标的线性组合,那就是我们将得到的结果。例如,假设我们有 10 个类别,“教堂”由索引 2 表示,“加油站”由索引 7 表示。独热编码表示如下:
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0] and [0, 0, 0, 0, 0, 0, 0, 1, 0, 0]
这是我们的最终目标:
[0, 0, 0.3, 0, 0, 0, 0, 0.7, 0, 0]
fastai 通过向我们的Learner
添加一个callback来完成所有这些操作。Callback
是 fastai 中用于在训练循环中注入自定义行为的内容(如学习率调度或混合精度训练)。您将在第十六章中学习有关回调的所有内容,包括如何制作自己的回调。目前,您只需要知道使用cbs
参数将回调传递给Learner
。
这是我们如何使用 Mixup 训练模型的方式:
model = xresnet50()
learn = Learner(dls, model, loss_func=CrossEntropyLossFlat(),
metrics=accuracy, cbs=Mixup)
learn.fit_one_cycle(5, 3e-3)
当我们用这种方式“混合”的数据训练模型时会发生什么?显然,训练会更加困难,因为很难看清每个图像中的内容。模型必须为每个图像预测两个标签,而不仅仅是一个,并且还必须弄清楚每个标签的权重。然而,过拟合似乎不太可能成为问题,因为我们不会在每个时代中显示相同的图像,而是显示两个图像的随机组合。
与我们看到的其他增强方法相比,Mixup 需要更多的时代来训练以获得更好的准确性。您可以尝试使用fastai repo中的examples/train_imagenette.py脚本来训练 Imagenette,使用 Mixup 和不使用 Mixup。在撰写本文时,Imagenette repo中的排行榜显示,Mixup 用于训练超过 80 个时代的所有领先结果,而对于更少的时代,不使用 Mixup。这与我们使用 Mixup 的经验一致。
Mixup 如此令人兴奋的原因之一是它可以应用于除照片之外的数据类型。事实上,有些人甚至已经展示了通过在模型内部的激活上使用 Mixup 而获得良好结果,而不仅仅是在输入上使用 Mixup——这使得 Mixup 也可以用于 NLP 和其他数据类型。
Mixup 为我们处理的另一个微妙问题是,我们之前看到的模型实际上永远无法完美。问题在于我们的标签是 1 和 0,但 softmax 和 sigmoid 的输出永远无法等于 1 或 0。这意味着训练我们的模型会使我们的激活值越来越接近这些值,这样我们做的时代越多,我们的激活值就会变得越极端。
使用 Mixup,我们不再有这个问题,因为我们的标签只有在我们碰巧与同一类别的另一幅图像“混合”时才会完全是 1 或 0。其余时间,我们的标签将是一个线性组合,比如我们在之前的教堂和加油站示例中得到的 0.7 和 0.3。
然而,这种方法的一个问题是 Mixup“意外地”使标签大于 0 或小于 1。也就是说,我们并没有明确告诉我们的模型我们想以这种方式改变标签。因此,如果我们想要使标签更接近或远离 0 和 1,我们必须改变 Mixup 的数量,这也会改变数据增强的数量,这可能不是我们想要的。然而,有一种更直接处理的方法,那就是使用标签平滑。
标签平滑
在损失的理论表达中,在分类问题中,我们的目标是独热编码的(在实践中,我们倾向于避免这样做以节省内存,但我们计算的损失与使用独热编码时相同)。这意味着模型被训练为对所有类别返回 0,只有一个类别返回 1。即使是 0.999 也不是“足够好”;模型将获得梯度并学会以更高的信心预测激活。这会鼓励过拟合,并在推理时给出一个不会给出有意义概率的模型:即使不太确定,它总是会为预测的类别说 1,只是因为它是这样训练的。
如果您的数据不完全标记,这可能会变得非常有害。在我们在第二章中研究的熊分类器中,我们看到一些图像被错误标记,或包含两种不同种类的熊。一般来说,您的数据永远不会是完美的。即使标签是人工制作的,也可能出现错误,或者在难以标记的图像上存在不同意见。
相反,我们可以用一个比 1 稍微小一点的数字替换所有的 1,用一个比 0 稍微大一点的数字替换所有的 0,然后进行训练。这就是标签平滑。通过鼓励模型变得不那么自信,标签平滑将使您的训练更加健壮,即使存在错误标记的数据。结果将是一个在推理时更好泛化的模型。
这就是标签平滑在实践中的工作方式:我们从独热编码的标签开始,然后用(这是希腊字母epsilon,在介绍标签平滑的论文和 fastai 代码中使用)替换所有的 0,其中是类别数,是一个参数(通常为 0.1,这意味着我们对标签有 10%的不确定性)。由于我们希望标签总和为 1,我们还用替换 1。这样,我们不会鼓励模型过于自信地预测。在我们的 Imagenette 示例中有 10 个类别,目标变成了这样(这里是对应于索引 3 的目标):
[0.01, 0.01, 0.01, 0.91, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01]
在实践中,我们不想对标签进行独热编码,幸运的是我们也不需要(独热编码只是用来解释标签平滑和可视化的)。
在实践中使用这个方法,我们只需要在调用Learner
时改变损失函数:
model = xresnet50()
learn = Learner(dls, model, loss_func=LabelSmoothingCrossEntropy(),
metrics=accuracy)
learn.fit_one_cycle(5, 3e-3)
与 Mixup 一样,您通常在训练更多时期后才会看到标签平滑带来的显著改进。自己尝试一下:在标签平滑显示改进之前,您需要训练多少个时期?
结论
您现在已经看到了训练计算机视觉中最先进模型所需的一切,无论是从头开始还是使用迁移学习。现在您只需要在自己的问题上进行实验!看看使用 Mixup 和/或标签平滑进行更长时间的训练是否可以避免过拟合并给出更好的结果。尝试渐进式调整大小和测试时间增强。
最重要的是,记住,如果您的数据集很大,那么在整个数据集上进行原型设计是没有意义的。找到一个代表整体的小子集,就像我们在 Imagenette 上所做的那样,并在其上进行实验。
在接下来的三章中,我们将看到 fastai 直接支持的其他应用程序:协同过滤、表格建模和处理文本。在本书的下一部分中,我们将回到计算机视觉,深入研究卷积神经网络在第十三章中。
问卷
-
ImageNet 和 Imagenette 之间有什么区别?在什么情况下最好在其中一个上进行实验而不是另一个?
-
什么是归一化?
-
为什么在使用预训练模型时我们不需要关心归一化?
-
什么是渐进式调整大小?
-
在自己的项目中实现渐进式调整大小。有帮助吗?
-
什么是测试时间增强?如何在 fastai 中使用它?
-
在推理中使用 TTA 比常规推理更慢还是更快?为什么?
-
什么是 Mixup?如何在 fastai 中使用它?
-
为什么 Mixup 可以防止模型过于自信?
-
为什么使用 Mixup 进行五个时期的训练最终比不使用 Mixup 训练更糟糕?
-
标签平滑背后的理念是什么?
-
您的数据中有哪些问题可以通过标签平滑来解决?
-
在使用五个类别的标签平滑时,与索引 1 相关联的目标是什么?
-
当您想在新数据集上快速进行原型实验时,应该采取的第一步是什么?
进一步研究
-
使用 fastai 文档构建一个函数,将图像裁剪为每个角落的正方形;然后实现一种 TTA 方法,该方法对中心裁剪和这四个裁剪的预测进行平均。有帮助吗?比 fastai 的 TTA 方法更好吗?
-
在 arXiv 上找到 Mixup 论文并阅读。选择一两篇介绍 Mixup 变体的较新文章并阅读它们;然后尝试在您的问题上实现它们。
-
找到使用 Mixup 训练 Imagenette 的脚本,并将其用作在自己项目上进行长时间训练的示例。执行它并查看是否有帮助。
-
阅读侧边栏["标签平滑,论文"](#label_smoothing);然后查看原始论文的相关部分,看看您是否能够理解。不要害怕寻求帮助!
第八章:协同过滤深入探讨
解决的一个常见问题是有一定数量的用户和产品,您想推荐哪些产品最有可能对哪些用户有用。存在许多变体:例如,推荐电影(如 Netflix 上),确定在主页上为用户突出显示什么,决定在社交媒体动态中显示什么故事等。解决这个问题的一般方法称为协同过滤,工作原理如下:查看当前用户使用或喜欢的产品,找到其他使用或喜欢类似产品的用户,然后推荐那些用户使用或喜欢的其他产品。
例如,在 Netflix 上,您可能观看了很多科幻、充满动作并且是上世纪 70 年代制作的电影。Netflix 可能不知道您观看的这些电影的特定属性,但它将能够看到观看了与您观看相同电影的其他人也倾向于观看其他科幻、充满动作并且是上世纪 70 年代制作的电影。换句话说,要使用这种方法,我们不一定需要了解电影的任何信息,只需要知道谁喜欢观看它们。
这种方法可以解决更一般的一类问题,不一定涉及用户和产品。实际上,在协同过滤中,我们更常用项目这个术语,而不是产品。项目可以是人们点击的链接、为患者选择的诊断等。
关键的基础概念是潜在因素。在 Netflix 的例子中,我们假设您喜欢老式、充满动作的科幻电影。但您从未告诉 Netflix 您喜欢这类电影。Netflix 也不需要在其电影表中添加列,说明哪些电影属于这些类型。尽管如此,必须存在一些关于科幻、动作和电影年龄的潜在概念,这些概念对于至少一些人的电影观看决策是相关的。
在本章中,我们将解决这个电影推荐问题。我们将从获取适合协同过滤模型的一些数据开始。
数据初探
我们无法访问 Netflix 的完整电影观看历史数据集,但有一个很好的数据集可供我们使用,称为MovieLens。该数据集包含数千万部电影排名(电影 ID、用户 ID 和数字评分的组合),尽管我们只会使用其中的 10 万部作为示例。如果您感兴趣,可以尝试在完整的 2500 万推荐数据集上复制这种方法,您可以从他们的网站上获取。
该数据集可通过通常的 fastai 函数获得:
from fastai.collab import *
from fastai.tabular.all import *
path = untar_data(URLs.ML_100k)
根据README,主表位于文件u.data中。它是以制表符分隔的,列分别是用户、电影、评分和时间戳。由于这些名称没有编码,我们需要在使用 Pandas 读取文件时指定它们。以下是打开此表并查看的方法:
ratings = pd.read_csv(path/'u.data', delimiter='\t', header=None,
names=['user','movie','rating','timestamp'])
ratings.head()
用户 | 电影 | 评分 | 时间戳 | |
---|---|---|---|---|
0 | 196 | 242 | 3 | 881250949 |
1 | 186 | 302 | 3 | 891717742 |
2 | 22 | 377 | 1 | 878887116 |
3 | 244 | 51 | 2 | 880606923 |
4 | 166 | 346 | 1 | 886397596 |
尽管这包含了我们需要的所有信息,但这并不是人类查看这些数据的特别有用的方式。图 8-1 将相同数据交叉制表成了一个人类友好的表格。
图 8-1. 电影和用户的交叉表
我们只选择了一些最受欢迎的电影和观看电影最多的用户,作为这个交叉表示例。这个表格中的空单元格是我们希望我们的模型学会填充的内容。这些是用户尚未评论电影的地方,可能是因为他们还没有观看。对于每个用户,我们希望找出他们最有可能喜欢哪些电影。
如果我们知道每个用户对电影可能属于的每个重要类别的喜好程度,比如流派、年龄、喜欢的导演和演员等,以及我们对每部电影的相同信息,那么填写这个表格的一个简单方法是将这些信息相乘,然后使用组合。例如,假设这些因子的范围在-1 到+1 之间,正数表示更强的匹配,负数表示更弱的匹配,类别是科幻、动作和老电影,那么我们可以表示电影《最后的绝地武士》如下:
last_skywalker = np.array([0.98,0.9,-0.9])
在这里,例如,我们将非常科幻评分为 0.98,非常不老评分为-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
由于我们不知道潜在因子是什么,也不知道如何为每个用户和电影评分,我们应该学习它们。
学习潜在因子
在指定模型的结构和学习模型之间,实际上几乎没有什么区别,因为我们可以使用我们的一般梯度下降方法。
这种方法的第一步是随机初始化一些参数。这些参数将是每个用户和电影的一组潜在因子。我们将不得不决定要使用多少个。我们将很快讨论如何选择这些,但为了说明,让我们现在使用 5 个。因为每个用户将有一组这些因子,每部电影也将有一组这些因子,我们可以在交叉表中的用户和电影旁边显示这些随机初始化的值,然后我们可以填写这些组合的点积。例如,图 8-2 显示了在 Microsoft Excel 中的样子,顶部左侧的单元格公式显示为示例。
这种方法的第二步是计算我们的预测。正如我们讨论过的,我们可以通过简单地将每部电影与每个用户进行点积来实现这一点。例如,如果第一个潜在用户因子代表用户喜欢动作电影的程度,第一个潜在电影因子代表电影是否有很多动作,那么如果用户喜欢动作电影并且电影中有很多动作,或者用户不喜欢动作电影并且电影中没有任何动作,这两者的乘积将特别高。另一方面,如果存在不匹配(用户喜欢动作电影但电影不是动作片,或者用户不喜欢动作电影但电影是动作片),乘积将非常低。
图 8-2. 交叉表中的潜在因子
第三步是计算我们的损失。我们可以使用任何损失函数,让我们现在选择均方误差,因为这是一种合理的表示预测准确性的方法。
这就是我们需要的全部内容。有了这个,我们可以使用随机梯度下降来优化我们的参数(潜在因素),以最小化损失。在每一步中,随机梯度下降优化器将使用点积计算每部电影与每个用户之间的匹配,并将其与每个用户给出的每部电影的实际评分进行比较。然后它将计算这个值的导数,并通过学习率乘以这个值来调整权重。经过多次这样的操作,损失会变得越来越好,推荐也会变得越来越好。
要使用通常的Learner.fit
函数,我们需要将我们的数据放入DataLoaders
中,所以让我们现在专注于这一点。
创建 DataLoaders
在展示数据时,我们宁愿看到电影标题而不是它们的 ID。表u.item
包含 ID 与标题的对应关系:
movies = pd.read_csv(path/'u.item', delimiter='|', encoding='latin-1',
usecols=(0,1), names=('movie','title'), header=None)
movies.head()
电影 | 标题 | |
---|---|---|
0 | 1 | 玩具总动员(1995) |
1 | 2 | 黄金眼(1995) |
2 | 3 | 四个房间(1995) |
3 | 4 | 短小(1995) |
4 | 5 | 复制猫(1995) |
我们可以将这个表与我们的ratings
表合并,以获得按标题分类的用户评分:
ratings = ratings.merge(movies)
ratings.head()
用户 | 电影 | 评分 | 时间戳 | 标题 | |
---|---|---|---|---|---|
0 | 196 | 242 | 3 | 881250949 | 科洛亚(1996) |
1 | 63 | 242 | 3 | 875747190 | 科洛亚(1996) |
2 | 226 | 242 | 5 | 883888671 | 科洛亚(1996) |
3 | 154 | 242 | 3 | 879138235 | 科洛亚(1996) |
4 | 306 | 242 | 5 | 876503793 | 科洛亚(1996) |
然后我们可以从这个表构建一个DataLoaders
对象。默认情况下,它将使用第一列作为用户,第二列作为项目(这里是我们的电影),第三列作为评分。在我们的情况下,我们需要更改item_name
的值,以使用标题而不是 ID:
dls = CollabDataLoaders.from_df(ratings, item_name='title', bs=64)
dls.show_batch()
用户 | 标题 | 评分 | |
---|---|---|---|
0 | 207 | 四个婚礼和一个葬礼(1994) | 3 |
1 | 565 | 日残余(1993) | 5 |
2 | 506 | 小孩(1995) | 1 |
3 | 845 | 追求艾米(1997) | 3 |
4 | 798 | 人类(1993) | 2 |
5 | 500 | 低俗法则(1986) | 4 |
6 | 409 | 无事生非(1993) | 3 |
7 | 721 | 勇敢的心(1995) | 5 |
8 | 316 | 精神病患者(1960) | 2 |
9 | 883 | 判决之夜(1993) | 5 |
为了在 PyTorch 中表示协同过滤,我们不能直接使用交叉表表示,特别是如果我们希望它适应我们的深度学习框架。我们可以将我们的电影和用户潜在因素表表示为简单的矩阵:
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)
要计算特定电影和用户组合的结果,我们必须查找电影在我们的电影潜在因素矩阵中的索引,以及用户在我们的用户潜在因素矩阵中的索引;然后我们可以在两个潜在因素向量之间进行点积。但查找索引不是我们的深度学习模型知道如何执行的操作。它们知道如何执行矩阵乘积和激活函数。
幸运的是,我们可以将查找索引表示为矩阵乘积。技巧是用单热编码向量替换我们的索引。这是一个例子,展示了如果我们将一个向量乘以一个表示索引 3 的单热编码向量会发生什么:
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])
它给我们的结果与矩阵中索引 3 处的向量相同:
user_factors[3]
tensor([-0.4586, -0.9915, -0.4052, -0.3621, -0.5908])
如果我们一次为几个索引这样做,我们将得到一个独热编码向量的矩阵,这个操作将是一个矩阵乘法!这将是使用这种架构构建模型的一种完全可接受的方式,只是它会比必要的使用更多的内存和时间。我们知道没有真正的基础原因来存储独热编码向量,或者通过搜索找到数字 1 的出现 - 我们应该能够直接使用整数索引到数组中。因此,大多数深度学习库,包括 PyTorch,都包括一个特殊的层,它就是这样做的;它使用整数索引到一个向量中,但其导数的计算方式使其与使用独热编码向量进行矩阵乘法时完全相同。这被称为嵌入。
术语:嵌入
通过一个独热编码矩阵相乘,使用计算快捷方式,可以通过直接索引来实现。这是一个非常简单概念的相当花哨的词。您将独热编码矩阵相乘的东西(或者使用计算快捷方式,直接索引)称为嵌入矩阵。
在计算机视觉中,我们有一种非常简单的方法通过其 RGB 值获取像素的所有信息:彩色图像中的每个像素由三个数字表示。这三个数字给我们红色、绿色和蓝色,这足以让我们的模型在之后工作。
对于手头的问题,我们没有同样简单的方法来描述用户或电影。可能与流派有关:如果给定用户喜欢爱情片,他们可能会给爱情片更高的评分。其他因素可能是电影是更注重动作还是对话,或者是否有一个特定的演员,用户可能特别喜欢。
我们如何确定用来描述这些数字的数字?答案是,我们不确定。我们将让我们的模型学习它们。通过分析用户和电影之间的现有关系,我们的模型可以自己找出看起来重要或不重要的特征。
这就是嵌入。我们将为我们的每个用户和每个电影分配一个特定长度的随机向量(这里,n_factors=5
),并将使它们成为可学习的参数。这意味着在每一步,当我们通过比较我们的预测和目标来计算损失时,我们将计算损失相对于这些嵌入向量的梯度,并根据 SGD(或其他优化器)的规则更新它们。
一开始,这些数字没有任何意义,因为我们是随机选择的,但在训练结束时,它们将有意义。通过学习关于用户和电影之间关系的现有数据,没有任何其他信息,我们将看到它们仍然获得一些重要特征,并且可以将大片与独立电影、动作片与爱情片等区分开来。
我们现在有能力从头开始创建我们的整个模型。
从头开始协同过滤
在我们可以用 PyTorch 编写模型之前,我们首先需要学习面向对象编程和 Python 的基础知识。如果您以前没有进行过面向对象编程,我们将在这里为您进行快速介绍,但我们建议您在继续之前查阅教程并进行一些练习。
面向对象编程中的关键思想是类。我们在本书中一直在使用类,比如DataLoader
、String
和Learner
。Python 还让我们很容易地创建新类。这是一个简单类的示例:
class Example:
def __init__(self, a): self.a = a
def say(self,x): return f'Hello {self.a}, {x}.'
这其中最重要的部分是一个特殊的方法叫做__init__
(发音为dunder init)。在 Python 中,任何像这样用双下划线包围的方法都被认为是特殊的。它表示与这个方法名称相关联一些额外的行为。对于__init__
,这是 Python 在创建新对象时将调用的方法。因此,这是你可以在对象创建时设置任何需要初始化的状态的地方。当用户构造类的实例时包含的任何参数都将作为参数传递给__init__
方法。请注意,在类内定义的任何方法的第一个参数是self
,因此你可以使用它来设置和获取任何你需要的属性:
ex = Example('Sylvain')
ex.say('nice to meet you')
'Hello Sylvain, nice to meet you.'
还要注意,创建一个新的 PyTorch 模块需要继承自Module
。继承是一个重要的面向对象的概念,在这里我们不会详细讨论——简而言之,它意味着我们可以向现有类添加额外的行为。PyTorch 已经提供了一个Module
类,它提供了一些我们想要构建的基本基础。因此,我们在定义类的名称后面添加这个超类的名称,如下面的示例所示。
你需要知道创建一个新的 PyTorch 模块的最后一件事是,当调用你的模块时,PyTorch 将调用你的类中的一个名为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,y = dls.one_batch()
x.shape
torch.Size([64, 2])
现在我们已经定义了我们的架构并创建了参数矩阵,我们需要创建一个Learner
来优化我们的模型。在过去,我们使用了特殊函数,比如cnn_learner
,为特定应用程序为我们设置了一切。由于我们在这里从头开始做事情,我们将使用普通的Learner
类:
model = DotProduct(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
现在我们准备拟合我们的模型:
learn.fit_one_cycle(5, 5e-3)
epoch | train_loss | valid_loss | time |
---|---|---|---|
0 | 1.326261 | 1.295701 | 00:12 |
1 | 1.091352 | 1.091475 | 00:11 |
2 | 0.961574 | 0.977690 | 00:11 |
3 | 0.829995 | 0.893122 | 00:11 |
4 | 0.781661 | 0.876511 | 00:12 |
我们可以做的第一件事是让这个模型更好一点,强制这些预测值在 0 到 5 之间。为此,我们只需要使用sigmoid_range
,就像第六章中那样。我们经验性地发现,最好让范围略微超过 5,所以我们使用(0, 5.5)
:
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)
epoch | train_loss | valid_loss | time |
---|---|---|---|
0 | 0.976380 | 1.001455 | 00:12 |
1 | 0.875964 | 0.919960 | 00:12 |
2 | 0.685377 | 0.870664 | 00:12 |
3 | 0.483701 | 0.874071 | 00:12 |
4 | 0.385249 | 0.878055 | 00:12 |
这是一个合理的开始,但我们可以做得更好。一个明显缺失的部分是,有些用户在推荐中只是更积极或更消极,有些电影只是比其他电影更好或更差。但在我们的点积表示中,我们没有任何方法来编码这两件事。如果你只能说一部电影,例如,它非常科幻,非常动作导向,非常不老旧,那么你实际上没有办法说大多数人是否喜欢它。
这是因为在这一点上我们只有权重;我们没有偏差。如果我们为每个用户有一个可以添加到我们的分数中的单个数字,对于每部电影也是如此,那么这将非常好地处理这个缺失的部分。因此,首先让我们调整我们的模型架构:
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)
让我们尝试训练这个模型,看看效果如何:
model = DotProductBias(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3)
epoch | train_loss | valid_loss | time |
---|---|---|---|
0 | 0.929161 | 0.936303 | 00:13 |
1 | 0.820444 | 0.861306 | 00:13 |
2 | 0.621612 | 0.865306 | 00:14 |
3 | 0.404648 | 0.886448 | 00:13 |
4 | 0.292948 | 0.892580 | 00:13 |
但是,结果并不比之前更好(至少在训练结束时)。为什么呢?如果我们仔细观察这两次训练,我们会发现验证损失在中间停止改善并开始变差。正如我们所见,这是过拟合的明显迹象。在这种情况下,没有办法使用数据增强,所以我们将不得不使用另一种正则化技术。一个有帮助的方法是权重衰减。
Weight Decay
权重衰减,或L2 正则化,包括将所有权重的平方和添加到损失函数中。为什么这样做?因为当我们计算梯度时,它会为梯度增加一个贡献,鼓励权重尽可能小。
为什么它可以防止过拟合?这个想法是,系数越大,损失函数中的峡谷就会越尖锐。如果我们以抛物线的基本例子y = a * (x**2)
为例,a
越大,抛物线就越狭窄:
因此,让我们的模型学习高参数可能导致它用一个过于复杂、具有非常尖锐变化的函数拟合训练集中的所有数据点,这将导致过拟合。
限制我们的权重过大会阻碍模型的训练,但会产生一个更好泛化的状态。回顾一下理论,权重衰减(或wd
)是一个控制我们在损失中添加的平方和的参数(假设parameters
是所有参数的张量):
loss_with_wd = loss + wd * (parameters**2).sum()
然而,在实践中,计算那个大和并将其添加到损失中将非常低效(也许在数值上不稳定)。如果你还记得一点高中数学,你可能会记得p**2
关于p
的导数是2*p
,所以将那个大和添加到我们的损失中,实际上等同于这样做:
parameters.grad += wd * 2 * parameters
实际上,由于wd
是我们选择的一个参数,我们可以使它变为两倍大,所以在这个方程中我们甚至不需要*2
。要在 fastai 中使用权重衰减,在调用fit
或fit_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)
epoch | train_loss | valid_loss | time |
---|---|---|---|
0 | 0.972090 | 0.962366 | 00:13 |
1 | 0.875591 | 0.885106 | 00:13 |
2 | 0.723798 | 0.839880 | 00:13 |
3 | 0.586002 | 0.823225 | 00:13 |
4 | 0.490980 | 0.823060 | 00:13 |
好多了!
创建我们自己的嵌入模块
到目前为止,我们使用Embedding
而没有考虑它是如何工作的。让我们重新创建DotProductBias
,不使用这个类。我们需要为每个嵌入初始化一个随机权重矩阵。然而,我们必须小心。回想一下第四章中提到的,优化器要求能够从模块的parameters
方法中获取模块的所有参数。然而,这并不是完全自动发生的。如果我们只是将一个张量作为Module
的属性添加,它不会包含在parameters
中:
class T(Module):
def __init__(self): self.a = torch.ones(3)
L(T().parameters())
(#0) []
要告诉Module
我们希望将一个张量视为参数,我们必须将其包装在nn.Parameter
类中。这个类不添加任何功能(除了自动为我们调用requires_grad_
)。它只用作一个“标记”,以显示要包含在parameters
中的内容:
class T(Module):
def __init__(self): self.a = nn.Parameter(torch.ones(3))
L(T().parameters())
(#1) [Parameter containing:
tensor([1., 1., 1.], requires_grad=True)]
所有 PyTorch 模块都使用nn.Parameter
来表示任何可训练参数,这就是为什么我们直到现在都不需要显式使用这个包装器:
class T(Module):
def __init__(self): self.a = nn.Linear(1, 3, bias=False)
t = T()
L(t.parameters())
(#1) [Parameter containing:
tensor([[-0.9595],
[-0.8490],
[ 0.8159]], requires_grad=True)]
type(t.a.weight)
torch.nn.parameter.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)
epoch | train_loss | valid_loss | time |
---|---|---|---|
0 | 0.962146 | 0.936952 | 00:14 |
1 | 0.858084 | 0.884951 | 00:14 |
2 | 0.740883 | 0.838549 | 00:14 |
3 | 0.592497 | 0.823599 | 00:14 |
4 | 0.473570 | 0.824263 | 00:14 |
现在,让我们看看我们的模型学到了什么。
解释嵌入和偏差
我们的模型已经很有用,因为它可以为我们的用户提供电影推荐,但看到它发现了什么参数也很有趣。最容易解释的是偏差。以下是偏差向量中值最低的电影:
movie_bias = learn.model.movie_bias.squeeze()
idxs = movie_bias.argsort()[:5]
[dls.classes['title'][i] for i in idxs]
['Children of the Corn: The Gathering (1996)',
'Lawnmower Man 2: Beyond Cyberspace (1996)',
'Beautician and the Beast, The (1997)',
'Crow: City of Angels, The (1996)',
'Home Alone 3 (1997)']
想想这意味着什么。它表明对于这些电影中的每一部,即使用户与其潜在因素非常匹配(稍后我们将看到,这些因素往往代表动作水平、电影年龄等等),他们通常仍然不喜欢它。我们本可以简单地按照电影的平均评分对其进行排序,但查看学到的偏差告诉我们更有趣的事情。它告诉我们不仅仅是电影是人们不喜欢观看的类型,而且即使是他们本来会喜欢的类型,人们也倾向于不喜欢观看!同样地,以下是偏差最高的电影:
idxs = movie_bias.argsort(descending=True)[:5]
[dls.classes['title'][i] for i in idxs]
['L.A. Confidential (1997)',
'Titanic (1997)',
'Silence of the Lambs, The (1991)',
'Shawshank Redemption, The (1994)',
'Star Wars (1977)']
因此,例如,即使您通常不喜欢侦探电影,您可能会喜欢LA 机密!
直接解释嵌入矩阵并不那么容易。对于人类来说,因素太多了。但有一种技术可以提取出这种矩阵中最重要的基础方向,称为主成分分析(PCA)。我们不会在本书中详细讨论这个,因为您要成为深度学习从业者并不特别重要,但如果您感兴趣,我们建议您查看 fast.ai 课程面向程序员的计算线性代数。图 8-3 显示了基于两个最强的 PCA 组件的电影的外观。
图 8-3. 基于两个最强的 PCA 组件的电影表示
我们可以看到模型似乎已经发现了经典与流行文化电影的概念,或者这里代表的是广受好评。
杰里米说
无论我训练多少模型,我永远不会停止被这些随机初始化的数字组合所感动和惊讶,这些数字通过如此简单的机制训练,竟然能够自己发现关于我的数据的东西。我几乎觉得可以欺骗,我可以创建一个能够做有用事情的代码,而从未真正告诉它如何做这些事情!
我们从头开始定义了我们的模型,以教给您内部情况,但您可以直接使用 fastai 库来构建它。我们将在下一节看看如何做到这一点。
使用 fastai.collab
我们可以使用 fastai 的collab_learner
使用先前显示的确切结构创建和训练协同过滤模型:
learn = collab_learner(dls, n_factors=50, y_range=(0, 5.5))
learn.fit_one_cycle(5, 5e-3, wd=0.1)
epoch | train_loss | valid_loss | time |
---|---|---|---|
0 | 0.931751 | 0.953806 | 00:13 |
1 | 0.851826 | 0.878119 | 00:13 |
2 | 0.715254 | 0.834711 | 00:13 |
3 | 0.583173 | 0.821470 | 00:13 |
4 | 0.496625 | 0.821688 | 00:13 |
通过打印模型可以看到层的名称:
learn.model
EmbeddingDotBias(
(u_weight): Embedding(944, 50)
(i_weight): Embedding(1635, 50)
(u_bias): Embedding(944, 1)
(i_bias): Embedding(1635, 1)
)
我们可以使用这些来复制我们在上一节中所做的任何分析,例如:
movie_bias = learn.model.i_bias.weight.squeeze()
idxs = movie_bias.argsort(descending=True)[:5]
[dls.classes['title'][i] for i in idxs]
['Titanic (1997)',
"Schindler's List (1993)",
'Shawshank Redemption, The (1994)',
'L.A. Confidential (1997)',
'Silence of the Lambs, The (1991)']
我们可以使用这些学到的嵌入来查看距离。
嵌入距离
在二维地图上,我们可以通过使用毕达哥拉斯定理的公式来计算两个坐标之间的距离:(假设x和y是每个轴上坐标之间的距离)。对于一个 50 维的嵌入,我们可以做完全相同的事情,只是将所有 50 个坐标距离的平方相加。
如果有两部几乎相同的电影,它们的嵌入向量也必须几乎相同,因为喜欢它们的用户几乎完全相同。这里有一个更一般的想法:电影的相似性可以由喜欢这些电影的用户的相似性来定义。这直接意味着两部电影的嵌入向量之间的距离可以定义这种相似性。我们可以利用这一点找到与“沉默的羔羊”最相似的电影:
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]
'Dial M for Murder (1954)'
现在我们已经成功训练了一个模型,让我们看看如何处理没有用户数据的情况。我们如何向新用户推荐?
引导协同过滤模型
在实践中使用协同过滤模型的最大挑战是“引导问题”。这个问题的最极端版本是没有用户,因此没有历史可供学习。您向您的第一个用户推荐什么产品?
但即使您是一家历史悠久的公司,拥有长期的用户交易记录,您仍然会面临一个问题:当新用户注册时,您该怎么办?实际上,当您向您的产品组合添加新产品时,您该怎么办?这个问题没有魔法解决方案,而我们建议的解决方案实际上只是“运用常识”的变体。您可以将新用户分配为其他用户所有嵌入向量的平均值,但这会带来一个问题,即该潜在因素的特定组合可能并不常见(例如,科幻因素的平均值可能很高,而动作因素的平均值可能很低,但很少有人喜欢科幻而不喜欢动作)。最好选择一个特定用户来代表“平均品味”。
更好的方法是使用基于用户元数据的表格模型来构建您的初始嵌入向量。当用户注册时,考虑一下您可以询问哪些问题来帮助您了解他们的口味。然后,您可以创建一个模型,其中因变量是用户的嵌入向量,而自变量是您问他们的问题的结果,以及他们的注册元数据。我们将在下一节中看到如何创建这些类型的表格模型。(您可能已经注意到,当您注册 Pandora 和 Netflix 等服务时,它们往往会问您一些关于您喜欢的电影或音乐类型的问题;这就是它们如何提出您的初始协同过滤推荐的方式。)
需要注意的一点是,一小部分非常热情的用户可能最终会有效地为整个用户群设置推荐。这是一个非常常见的问题,例如,在电影推荐系统中。看动漫的人往往会看很多动漫,而且不怎么看其他东西,花很多时间在网站上评分。因此,动漫往往在许多“有史以来最佳电影”列表中被过度代表。在这种特殊情况下,很明显您有一个代表性偏见的问题,但如果偏见发生在潜在因素中,可能一点也不明显。
这样的问题可能会改变您的用户群体的整体构成,以及您系统的行为。这特别是由于正反馈循环。如果您的一小部分用户倾向于设定您的推荐系统的方向,他们自然会吸引更多类似他们的人来到您的系统。这当然会放大原始的表征偏见。这种偏见是一种被指数级放大的自然倾向。您可能已经看到一些公司高管对他们的在线平台如何迅速恶化表示惊讶,以至于表达了与创始人价值观不符的价值观。在存在这种类型的反馈循环的情况下,很容易看到这种分歧如何迅速发生,以及以一种隐藏的方式,直到为时已晚。
在这样一个自我强化的系统中,我们可能应该预期这些反馈循环是常态,而不是例外。因此,您应该假设您会看到它们,为此做好计划,并提前确定如何处理这些问题。尝试考虑反馈循环可能在您的系统中表示的所有方式,以及您如何能够在数据中识别它们。最终,这又回到了我们关于如何在推出任何类型的机器学习系统时避免灾难的最初建议。这一切都是为了确保有人参与其中;有仔细的监控,以及一个渐进和周到的推出。
我们的点积模型效果相当不错,并且是许多成功的现实世界推荐系统的基础。这种协同过滤方法被称为概率矩阵分解(PMF)。另一种方法,通常在给定相同数据时效果类似,是深度学习。
协同过滤的深度学习
将我们的架构转换为深度学习模型的第一步是获取嵌入查找的结果并将这些激活连接在一起。这给我们一个矩阵,然后我们可以按照通常的方式通过线性层和非线性传递它们。
由于我们将连接嵌入矩阵,而不是取它们的点积,所以两个嵌入矩阵可以具有不同的大小(不同数量的潜在因素)。fastai 有一个函数get_emb_sz
,根据 fast.ai 发现在实践中往往效果良好的启发式方法,返回推荐的嵌入矩阵大小:
embs = get_emb_sz(dls)
embs
[(944, 74), (1635, 101)]
让我们实现这个类:
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)
CollabNN
以与本章中先前类似的方式创建我们的Embedding
层,只是现在我们使用embs
大小。self.layers
与我们在第四章为 MNIST 创建的迷你神经网络是相同的。然后,在forward
中,我们应用嵌入,连接结果,并通过迷你神经网络传递。最后,我们像以前的模型一样应用sigmoid_range
。
让我们看看它是否训练:
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3, wd=0.01)
epoch | train_loss | valid_loss | time |
---|---|---|---|
0 | 0.940104 | 0.959786 | 00:15 |
1 | 0.893943 | 0.905222 | 00:14 |
2 | 0.865591 | 0.875238 | 00:14 |
3 | 0.800177 | 0.867468 | 00:14 |
4 | 0.760255 | 0.867455 | 00:14 |
如果您在调用collab_learner
时传递use_nn=True
(包括为您调用get_emb_sz
),fastai 在fastai.collab
中提供了这个模型,并且让您轻松创建更多层。例如,在这里我们创建了两个隐藏层,分别为大小 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)
epoch | train_loss | valid_loss | time |
---|---|---|---|
0 | 1.002747 | 0.972392 | 00:16 |
1 | 0.926903 | 0.922348 | 00:16 |
2 | 0.877160 | 0.893401 | 00:16 |
3 | 0.838334 | 0.865040 | 00:16 |
4 | 0.781666 | 0.864936 | 00:16 |
learn.model
是EmbeddingNN
类型的对象。让我们看一下 fastai 对这个类的代码:
@delegates(TabularModel)
class EmbeddingNN(TabularModel):
def __init__(self, emb_szs, layers, **kwargs):
super().__init__(emb_szs, layers=layers, n_cont=0, out_sz=1, **kwargs)
哇,这不是很多代码!这个类继承自TabularModel
,这是它获取所有功能的地方。在__init__
中,它调用TabularModel
中的相同方法,传递n_cont=0
和out_sz=1
;除此之外,它只传递它收到的任何参数。
尽管EmbeddingNN
的结果比点积方法稍差一些(这显示了为领域精心构建架构的力量),但它确实允许我们做一件非常重要的事情:我们现在可以直接将其他用户和电影信息、日期和时间信息或任何可能与推荐相关的信息纳入考虑。这正是TabularModel
所做的。事实上,我们现在已经看到,EmbeddingNN
只是一个TabularModel
,其中n_cont=0
和out_sz=1
。因此,我们最好花一些时间了解TabularModel
,以及如何使用它获得出色的结果!我们将在下一章中做到这一点。
结论
对于我们的第一个非计算机视觉应用,我们研究了推荐系统,并看到梯度下降如何从评分历史中学习有关项目的内在因素或偏差。然后,这些因素可以为我们提供有关数据的信息。
我们还在 PyTorch 中构建了我们的第一个模型。在书的下一部分中,我们将做更多这样的工作,但首先,让我们完成对深度学习的其他一般应用的探讨,继续处理表格数据。
问卷
-
协同过滤解决了什么问题?
-
它是如何解决的?
-
为什么协同过滤预测模型可能无法成为非常有用的推荐系统?
-
协同过滤数据的交叉表表示是什么样的?
-
编写代码创建 MovieLens 数据的交叉表表示(您可能需要进行一些网络搜索!)。
-
什么是潜在因素?为什么它是“潜在”的?
-
什么是点积?使用纯 Python 和列表手动计算点积。
-
pandas.DataFrame.merge
是做什么的? -
什么是嵌入矩阵?
-
嵌入和一个独热编码向量矩阵之间的关系是什么?
-
如果我们可以使用独热编码向量来做同样的事情,为什么我们需要
Embedding
? -
在我们开始训练之前,嵌入包含什么内容(假设我们没有使用预训练模型)?
-
创建一个类(尽量不要偷看!)并使用它。
-
x[:,0]
返回什么? -
重写
DotProduct
类(尽量不要偷看!)并用它训练模型。 -
在 MovieLens 中使用什么样的损失函数是好的?为什么?
-
如果我们在 MovieLens 中使用交叉熵损失会发生什么?我们需要如何更改模型?
-
点积模型中偏差的用途是什么?
-
权重衰减的另一个名称是什么?
-
写出权重衰减的方程(不要偷看!)。
-
写出权重衰减的梯度方程。为什么它有助于减少权重?
-
为什么减少权重会导致更好的泛化?
-
PyTorch 中的
argsort
是做什么的? -
对电影偏差进行排序是否会得到与按电影平均评分相同的结果?为什么/为什么不?
-
如何打印模型中层的名称和详细信息?
-
协同过滤中的“自举问题”是什么?
-
如何处理新用户的自举问题?对于新电影呢?
-
反馈循环如何影响协同过滤系统?
-
在协同过滤中使用神经网络时,为什么我们可以为电影和用户使用不同数量的因素?
-
为什么在
CollabNN
模型中有一个nn.Sequential
? -
如果我们想要向协同过滤模型添加有关用户和项目的元数据,或者有关日期和时间等信息,应该使用什么样的模型?
进一步研究
-
看看
Embedding
版本的DotProductBias
和create_params
版本之间的所有差异,并尝试理解为什么需要进行每一项更改。如果不确定,尝试撤销每个更改以查看发生了什么。(注意:甚至在forward
中使用的括号类型也已更改!) -
找到另外三个协同过滤正在使用的领域,并在这些领域中确定这种方法的优缺点。
-
使用完整的 MovieLens 数据集完成这个笔记本,并将结果与在线基准进行比较。看看你能否提高准确性。在书的网站和 fast.ai 论坛上寻找想法。请注意,完整数据集中有更多列,看看你是否也可以使用这些列(下一章可能会给你一些想法)。
-
为 MovieLens 创建一个使用交叉熵损失的模型,并将其与本章中的模型进行比较。