前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >FastAI 之书(面向程序员的 FastAI)(四)

FastAI 之书(面向程序员的 FastAI)(四)

作者头像
ApacheCN_飞龙
发布2024-02-17 09:53:32
2490
发布2024-02-17 09:53:32
举报
文章被收录于专栏:信数据得永生信数据得永生

第八章:协同过滤深入探讨

原文:www.bookstack.cn/read/th-fastai-book/d4afd9df315bb076.md 译者:飞龙 协议:CC BY-NC-SA 4.0

解决的一个常见问题是有一定数量的用户和产品,您想推荐哪些产品最有可能对哪些用户有用。存在许多变体:例如,推荐电影(如 Netflix 上),确定在主页上为用户突出显示什么,决定在社交媒体动态中显示什么故事等。解决这个问题的一般方法称为协同过滤,工作原理如下:查看当前用户使用或喜欢的产品,找到其他使用或喜欢类似产品的用户,然后推荐那些用户使用或喜欢的其他产品。

例如,在 Netflix 上,您可能观看了很多科幻、充满动作并且是上世纪 70 年代制作的电影。Netflix 可能不知道您观看的这些电影的特定属性,但它将能够看到观看了与您观看相同电影的其他人也倾向于观看其他科幻、充满动作并且是上世纪 70 年代制作的电影。换句话说,要使用这种方法,我们不一定需要了解电影的任何信息,只需要知道谁喜欢观看它们。

这种方法可以解决更一般的一类问题,不一定涉及用户和产品。实际上,在协同过滤中,我们更常用项目这个术语,而不是产品。项目可以是人们点击的链接、为患者选择的诊断等。

关键的基础概念是潜在因素。在 Netflix 的例子中,我们假设您喜欢老式、充满动作的科幻电影。但您从未告诉 Netflix 您喜欢这类电影。Netflix 也不需要在其电影表中添加列,说明哪些电影属于这些类型。尽管如此,必须存在一些关于科幻、动作和电影年龄的潜在概念,这些概念对于至少一些人的电影观看决策是相关的。

在本章中,我们将解决这个电影推荐问题。我们将从获取适合协同过滤模型的一些数据开始。

数据初探

我们无法访问 Netflix 的完整电影观看历史数据集,但有一个很好的数据集可供我们使用,称为MovieLens。该数据集包含数千万部电影排名(电影 ID、用户 ID 和数字评分的组合),尽管我们只会使用其中的 10 万部作为示例。如果您感兴趣,可以尝试在完整的 2500 万推荐数据集上复制这种方法,您可以从他们的网站上获取。

该数据集可通过通常的 fastai 函数获得:

代码语言:javascript
复制
from fastai.collab import *
from fastai.tabular.all import *
path = untar_data(URLs.ML_100k)

根据README,主表位于文件u.data中。它是以制表符分隔的,列分别是用户、电影、评分和时间戳。由于这些名称没有编码,我们需要在使用 Pandas 读取文件时指定它们。以下是打开此表并查看的方法:

代码语言:javascript
复制
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 之间,正数表示更强的匹配,负数表示更弱的匹配,类别是科幻、动作和老电影,那么我们可以表示电影《最后的绝地武士》如下:

代码语言:javascript
复制
last_skywalker = np.array([0.98,0.9,-0.9])

在这里,例如,我们将非常科幻评分为 0.98,非常不老评分为-0.9。我们可以表示喜欢现代科幻动作电影的用户如下:

代码语言:javascript
复制
user1 = np.array([0.9,0.8,-0.6])

现在我们可以计算这种组合之间的匹配:

代码语言:javascript
复制
(user1*last_skywalker).sum()
代码语言:javascript
复制
2.1420000000000003

当我们将两个向量相乘并将结果相加时,这被称为点积。它在机器学习中被广泛使用,并构成了矩阵乘法的基础。我们将在第十七章中更多地研究矩阵乘法和点积。

术语:点积

将两个向量的元素相乘,然后将结果相加的数学运算。

另一方面,我们可以表示电影《卡萨布兰卡》如下:

代码语言:javascript
复制
casablanca = np.array([-0.99,-0.3,0.8])

这种组合之间的匹配如下所示:

代码语言:javascript
复制
(user1*casablanca).sum()
代码语言:javascript
复制
-1.611

由于我们不知道潜在因子是什么,也不知道如何为每个用户和电影评分,我们应该学习它们。

学习潜在因子

在指定模型的结构和学习模型之间,实际上几乎没有什么区别,因为我们可以使用我们的一般梯度下降方法。

这种方法的第一步是随机初始化一些参数。这些参数将是每个用户和电影的一组潜在因子。我们将不得不决定要使用多少个。我们将很快讨论如何选择这些,但为了说明,让我们现在使用 5 个。因为每个用户将有一组这些因子,每部电影也将有一组这些因子,我们可以在交叉表中的用户和电影旁边显示这些随机初始化的值,然后我们可以填写这些组合的点积。例如,图 8-2 显示了在 Microsoft Excel 中的样子,顶部左侧的单元格公式显示为示例。

这种方法的第二步是计算我们的预测。正如我们讨论过的,我们可以通过简单地将每部电影与每个用户进行点积来实现这一点。例如,如果第一个潜在用户因子代表用户喜欢动作电影的程度,第一个潜在电影因子代表电影是否有很多动作,那么如果用户喜欢动作电影并且电影中有很多动作,或者用户不喜欢动作电影并且电影中没有任何动作,这两者的乘积将特别高。另一方面,如果存在不匹配(用户喜欢动作电影但电影不是动作片,或者用户不喜欢动作电影但电影是动作片),乘积将非常低。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
图 8-2. 交叉表中的潜在因子

第三步是计算我们的损失。我们可以使用任何损失函数,让我们现在选择均方误差,因为这是一种合理的表示预测准确性的方法。

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

要使用通常的Learner.fit函数,我们需要将我们的数据放入DataLoaders中,所以让我们现在专注于这一点。

创建 DataLoaders

在展示数据时,我们宁愿看到电影标题而不是它们的 ID。表u.item包含 ID 与标题的对应关系:

代码语言:javascript
复制
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表合并,以获得按标题分类的用户评分:

代码语言:javascript
复制
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:

代码语言:javascript
复制
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 中表示协同过滤,我们不能直接使用交叉表表示,特别是如果我们希望它适应我们的深度学习框架。我们可以将我们的电影和用户潜在因素表表示为简单的矩阵:

代码语言:javascript
复制
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 的单热编码向量会发生什么:

代码语言:javascript
复制
one_hot_3 = one_hot(3, n_users).float()
user_factors.t() @ one_hot_3
代码语言:javascript
复制
tensor([-0.4586, -0.9915, -0.4052, -0.3621, -0.5908])

它给我们的结果与矩阵中索引 3 处的向量相同:

代码语言:javascript
复制
user_factors[3]
代码语言:javascript
复制
tensor([-0.4586, -0.9915, -0.4052, -0.3621, -0.5908])

如果我们一次为几个索引这样做,我们将得到一个独热编码向量的矩阵,这个操作将是一个矩阵乘法!这将是使用这种架构构建模型的一种完全可接受的方式,只是它会比必要的使用更多的内存和时间。我们知道没有真正的基础原因来存储独热编码向量,或者通过搜索找到数字 1 的出现 - 我们应该能够直接使用整数索引到数组中。因此,大多数深度学习库,包括 PyTorch,都包括一个特殊的层,它就是这样做的;它使用整数索引到一个向量中,但其导数的计算方式使其与使用独热编码向量进行矩阵乘法时完全相同。这被称为嵌入

术语:嵌入

通过一个独热编码矩阵相乘,使用计算快捷方式,可以通过直接索引来实现。这是一个非常简单概念的相当花哨的词。您将独热编码矩阵相乘的东西(或者使用计算快捷方式,直接索引)称为嵌入矩阵

在计算机视觉中,我们有一种非常简单的方法通过其 RGB 值获取像素的所有信息:彩色图像中的每个像素由三个数字表示。这三个数字给我们红色、绿色和蓝色,这足以让我们的模型在之后工作。

对于手头的问题,我们没有同样简单的方法来描述用户或电影。可能与流派有关:如果给定用户喜欢爱情片,他们可能会给爱情片更高的评分。其他因素可能是电影是更注重动作还是对话,或者是否有一个特定的演员,用户可能特别喜欢。

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

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

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

我们现在有能力从头开始创建我们的整个模型。

从头开始协同过滤

在我们可以用 PyTorch 编写模型之前,我们首先需要学习面向对象编程和 Python 的基础知识。如果您以前没有进行过面向对象编程,我们将在这里为您进行快速介绍,但我们建议您在继续之前查阅教程并进行一些练习。

面向对象编程中的关键思想是。我们在本书中一直在使用类,比如DataLoaderStringLearner。Python 还让我们很容易地创建新类。这是一个简单类的示例:

代码语言:javascript
复制
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,因此你可以使用它来设置和获取任何你需要的属性:

代码语言:javascript
复制
ex = Example('Sylvain')
ex.say('nice to meet you')
代码语言:javascript
复制
'Hello Sylvain, nice to meet you.'

还要注意,创建一个新的 PyTorch 模块需要继承自Module继承是一个重要的面向对象的概念,在这里我们不会详细讨论——简而言之,它意味着我们可以向现有类添加额外的行为。PyTorch 已经提供了一个Module类,它提供了一些我们想要构建的基本基础。因此,我们在定义类的名称后面添加这个超类的名称,如下面的示例所示。

你需要知道创建一个新的 PyTorch 模块的最后一件事是,当调用你的模块时,PyTorch 将调用你的类中的一个名为forward的方法,并将包含在调用中的任何参数传递给它。这是定义我们的点积模型的类:

代码语言:javascript
复制
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。如前所述,我们使用嵌入层来表示我们的用户和电影潜在因子的矩阵:

代码语言:javascript
复制
x,y = dls.one_batch()
x.shape
代码语言:javascript
复制
torch.Size([64, 2])

现在我们已经定义了我们的架构并创建了参数矩阵,我们需要创建一个Learner来优化我们的模型。在过去,我们使用了特殊函数,比如cnn_learner,为特定应用程序为我们设置了一切。由于我们在这里从头开始做事情,我们将使用普通的Learner类:

代码语言:javascript
复制
model = DotProduct(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())

现在我们准备拟合我们的模型:

代码语言:javascript
复制
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)

代码语言:javascript
复制
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)
代码语言:javascript
复制
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

这是一个合理的开始,但我们可以做得更好。一个明显缺失的部分是,有些用户在推荐中只是更积极或更消极,有些电影只是比其他电影更好或更差。但在我们的点积表示中,我们没有任何方法来编码这两件事。如果你只能说一部电影,例如,它非常科幻,非常动作导向,非常不老旧,那么你实际上没有办法说大多数人是否喜欢它。

这是因为在这一点上我们只有权重;我们没有偏差。如果我们为每个用户有一个可以添加到我们的分数中的单个数字,对于每部电影也是如此,那么这将非常好地处理这个缺失的部分。因此,首先让我们调整我们的模型架构:

代码语言:javascript
复制
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)

让我们尝试训练这个模型,看看效果如何:

代码语言:javascript
复制
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越大,抛物线就越狭窄

不同 a 值的抛物线
不同 a 值的抛物线

因此,让我们的模型学习高参数可能导致它用一个过于复杂、具有非常尖锐变化的函数拟合训练集中的所有数据点,这将导致过拟合。

限制我们的权重过大会阻碍模型的训练,但会产生一个更好泛化的状态。回顾一下理论,权重衰减(或wd)是一个控制我们在损失中添加的平方和的参数(假设parameters是所有参数的张量):

代码语言:javascript
复制
loss_with_wd = loss + wd * (parameters**2).sum()

然而,在实践中,计算那个大和并将其添加到损失中将非常低效(也许在数值上不稳定)。如果你还记得一点高中数学,你可能会记得p**2关于p的导数是2*p,所以将那个大和添加到我们的损失中,实际上等同于这样做:

代码语言:javascript
复制
parameters.grad += wd * 2 * parameters

实际上,由于wd是我们选择的一个参数,我们可以使它变为两倍大,所以在这个方程中我们甚至不需要*2。要在 fastai 中使用权重衰减,在调用fitfit_one_cycle时传递wd即可(可以同时传递):

代码语言:javascript
复制
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中:

代码语言:javascript
复制
class T(Module):
    def __init__(self): self.a = torch.ones(3)

L(T().parameters())
代码语言:javascript
复制
(#0) []

要告诉Module我们希望将一个张量视为参数,我们必须将其包装在nn.Parameter类中。这个类不添加任何功能(除了自动为我们调用requires_grad_)。它只用作一个“标记”,以显示要包含在parameters中的内容:

代码语言:javascript
复制
class T(Module):
    def __init__(self): self.a = nn.Parameter(torch.ones(3))

L(T().parameters())
代码语言:javascript
复制
(#1) [Parameter containing:
tensor([1., 1., 1.], requires_grad=True)]

所有 PyTorch 模块都使用nn.Parameter来表示任何可训练参数,这就是为什么我们直到现在都不需要显式使用这个包装器:

代码语言:javascript
复制
class T(Module):
    def __init__(self): self.a = nn.Linear(1, 3, bias=False)

t = T()
L(t.parameters())
代码语言:javascript
复制
(#1) [Parameter containing:
tensor([[-0.9595],
        [-0.8490],
        [ 0.8159]], requires_grad=True)]
代码语言:javascript
复制
type(t.a.weight)
代码语言:javascript
复制
torch.nn.parameter.Parameter

我们可以创建一个张量作为参数,进行随机初始化,如下所示:

代码语言:javascript
复制
def create_params(size):
    return nn.Parameter(torch.zeros(*size).normal_(0, 0.01))

让我们再次使用这个来创建DotProductBias,但不使用Embedding

代码语言:javascript
复制
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)

然后让我们再次训练它,以检查我们是否得到了与前一节中看到的大致相同的结果:

代码语言:javascript
复制
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

现在,让我们看看我们的模型学到了什么。

解释嵌入和偏差

我们的模型已经很有用,因为它可以为我们的用户提供电影推荐,但看到它发现了什么参数也很有趣。最容易解释的是偏差。以下是偏差向量中值最低的电影:

代码语言:javascript
复制
movie_bias = learn.model.movie_bias.squeeze()
idxs = movie_bias.argsort()[:5]
[dls.classes['title'][i] for i in idxs]
代码语言:javascript
复制
['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)']

想想这意味着什么。它表明对于这些电影中的每一部,即使用户与其潜在因素非常匹配(稍后我们将看到,这些因素往往代表动作水平、电影年龄等等),他们通常仍然不喜欢它。我们本可以简单地按照电影的平均评分对其进行排序,但查看学到的偏差告诉我们更有趣的事情。它告诉我们不仅仅是电影是人们不喜欢观看的类型,而且即使是他们本来会喜欢的类型,人们也倾向于不喜欢观看!同样地,以下是偏差最高的电影:

代码语言:javascript
复制
idxs = movie_bias.argsort(descending=True)[:5]
[dls.classes['title'][i] for i in idxs]
代码语言:javascript
复制
['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 组件的电影的外观。

基于两个最强的 PCA 组件的电影表示
基于两个最强的 PCA 组件的电影表示
图 8-3. 基于两个最强的 PCA 组件的电影表示

我们可以看到模型似乎已经发现了经典流行文化电影的概念,或者这里代表的是广受好评

杰里米说

无论我训练多少模型,我永远不会停止被这些随机初始化的数字组合所感动和惊讶,这些数字通过如此简单的机制训练,竟然能够自己发现关于我的数据的东西。我几乎觉得可以欺骗,我可以创建一个能够做有用事情的代码,而从未真正告诉它如何做这些事情!

我们从头开始定义了我们的模型,以教给您内部情况,但您可以直接使用 fastai 库来构建它。我们将在下一节看看如何做到这一点。

使用 fastai.collab

我们可以使用 fastai 的collab_learner使用先前显示的确切结构创建和训练协同过滤模型:

代码语言:javascript
复制
learn = collab_learner(dls, n_factors=50, y_range=(0, 5.5))
代码语言:javascript
复制
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

通过打印模型可以看到层的名称:

代码语言:javascript
复制
learn.model
代码语言:javascript
复制
EmbeddingDotBias(
  (u_weight): Embedding(944, 50)
  (i_weight): Embedding(1635, 50)
  (u_bias): Embedding(944, 1)
  (i_bias): Embedding(1635, 1)
)

我们可以使用这些来复制我们在上一节中所做的任何分析,例如:

代码语言:javascript
复制
movie_bias = learn.model.i_bias.weight.squeeze()
idxs = movie_bias.argsort(descending=True)[:5]
[dls.classes['title'][i] for i in idxs]
代码语言:javascript
复制
['Titanic (1997)',
 "Schindler's List (1993)",
 'Shawshank Redemption, The (1994)',
 'L.A. Confidential (1997)',
 'Silence of the Lambs, The (1991)']

我们可以使用这些学到的嵌入来查看距离

嵌入距离

在二维地图上,我们可以通过使用毕达哥拉斯定理的公式来计算两个坐标之间的距离:x 2 + y 2(假设xy是每个轴上坐标之间的距离)。对于一个 50 维的嵌入,我们可以做完全相同的事情,只是将所有 50 个坐标距离的平方相加。

如果有两部几乎相同的电影,它们的嵌入向量也必须几乎相同,因为喜欢它们的用户几乎完全相同。这里有一个更一般的想法:电影的相似性可以由喜欢这些电影的用户的相似性来定义。这直接意味着两部电影的嵌入向量之间的距离可以定义这种相似性。我们可以利用这一点找到与“沉默的羔羊”最相似的电影:

代码语言:javascript
复制
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]
代码语言:javascript
复制
'Dial M for Murder (1954)'

现在我们已经成功训练了一个模型,让我们看看如何处理没有用户数据的情况。我们如何向新用户推荐?

引导协同过滤模型

在实践中使用协同过滤模型的最大挑战是“引导问题”。这个问题的最极端版本是没有用户,因此没有历史可供学习。您向您的第一个用户推荐什么产品?

但即使您是一家历史悠久的公司,拥有长期的用户交易记录,您仍然会面临一个问题:当新用户注册时,您该怎么办?实际上,当您向您的产品组合添加新产品时,您该怎么办?这个问题没有魔法解决方案,而我们建议的解决方案实际上只是“运用常识”的变体。您可以将新用户分配为其他用户所有嵌入向量的平均值,但这会带来一个问题,即该潜在因素的特定组合可能并不常见(例如,科幻因素的平均值可能很高,而动作因素的平均值可能很低,但很少有人喜欢科幻而不喜欢动作)。最好选择一个特定用户来代表“平均品味”。

更好的方法是使用基于用户元数据的表格模型来构建您的初始嵌入向量。当用户注册时,考虑一下您可以询问哪些问题来帮助您了解他们的口味。然后,您可以创建一个模型,其中因变量是用户的嵌入向量,而自变量是您问他们的问题的结果,以及他们的注册元数据。我们将在下一节中看到如何创建这些类型的表格模型。(您可能已经注意到,当您注册 Pandora 和 Netflix 等服务时,它们往往会问您一些关于您喜欢的电影或音乐类型的问题;这就是它们如何提出您的初始协同过滤推荐的方式。)

需要注意的一点是,一小部分非常热情的用户可能最终会有效地为整个用户群设置推荐。这是一个非常常见的问题,例如,在电影推荐系统中。看动漫的人往往会看很多动漫,而且不怎么看其他东西,花很多时间在网站上评分。因此,动漫往往在许多“有史以来最佳电影”列表中被过度代表。在这种特殊情况下,很明显您有一个代表性偏见的问题,但如果偏见发生在潜在因素中,可能一点也不明显。

这样的问题可能会改变您的用户群体的整体构成,以及您系统的行为。这特别是由于正反馈循环。如果您的一小部分用户倾向于设定您的推荐系统的方向,他们自然会吸引更多类似他们的人来到您的系统。这当然会放大原始的表征偏见。这种偏见是一种被指数级放大的自然倾向。您可能已经看到一些公司高管对他们的在线平台如何迅速恶化表示惊讶,以至于表达了与创始人价值观不符的价值观。在存在这种类型的反馈循环的情况下,很容易看到这种分歧如何迅速发生,以及以一种隐藏的方式,直到为时已晚。

在这样一个自我强化的系统中,我们可能应该预期这些反馈循环是常态,而不是例外。因此,您应该假设您会看到它们,为此做好计划,并提前确定如何处理这些问题。尝试考虑反馈循环可能在您的系统中表示的所有方式,以及您如何能够在数据中识别它们。最终,这又回到了我们关于如何在推出任何类型的机器学习系统时避免灾难的最初建议。这一切都是为了确保有人参与其中;有仔细的监控,以及一个渐进和周到的推出。

我们的点积模型效果相当不错,并且是许多成功的现实世界推荐系统的基础。这种协同过滤方法被称为概率矩阵分解(PMF)。另一种方法,通常在给定相同数据时效果类似,是深度学习。

协同过滤的深度学习

将我们的架构转换为深度学习模型的第一步是获取嵌入查找的结果并将这些激活连接在一起。这给我们一个矩阵,然后我们可以按照通常的方式通过线性层和非线性传递它们。

由于我们将连接嵌入矩阵,而不是取它们的点积,所以两个嵌入矩阵可以具有不同的大小(不同数量的潜在因素)。fastai 有一个函数get_emb_sz,根据 fast.ai 发现在实践中往往效果良好的启发式方法,返回推荐的嵌入矩阵大小:

代码语言:javascript
复制
embs = get_emb_sz(dls)
embs
代码语言:javascript
复制
[(944, 74), (1635, 101)]

让我们实现这个类:

代码语言:javascript
复制
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)

并使用它创建一个模型:

代码语言:javascript
复制
model = CollabNN(*embs)

CollabNN以与本章中先前类似的方式创建我们的Embedding层,只是现在我们使用embs大小。self.layers与我们在第四章为 MNIST 创建的迷你神经网络是相同的。然后,在forward中,我们应用嵌入,连接结果,并通过迷你神经网络传递。最后,我们像以前的模型一样应用sigmoid_range

让我们看看它是否训练:

代码语言:javascript
复制
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:

代码语言:javascript
复制
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.modelEmbeddingNN类型的对象。让我们看一下 fastai 对这个类的代码:

代码语言:javascript
复制
@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=0out_sz=1;除此之外,它只传递它收到的任何参数。

尽管EmbeddingNN的结果比点积方法稍差一些(这显示了为领域精心构建架构的力量),但它确实允许我们做一件非常重要的事情:我们现在可以直接将其他用户和电影信息、日期和时间信息或任何可能与推荐相关的信息纳入考虑。这正是TabularModel所做的。事实上,我们现在已经看到,EmbeddingNN只是一个TabularModel,其中n_cont=0out_sz=1。因此,我们最好花一些时间了解TabularModel,以及如何使用它获得出色的结果!我们将在下一章中做到这一点。

结论

对于我们的第一个非计算机视觉应用,我们研究了推荐系统,并看到梯度下降如何从评分历史中学习有关项目的内在因素或偏差。然后,这些因素可以为我们提供有关数据的信息。

我们还在 PyTorch 中构建了我们的第一个模型。在书的下一部分中,我们将做更多这样的工作,但首先,让我们完成对深度学习的其他一般应用的探讨,继续处理表格数据。

问卷

  1. 协同过滤解决了什么问题?
  2. 它是如何解决的?
  3. 为什么协同过滤预测模型可能无法成为非常有用的推荐系统?
  4. 协同过滤数据的交叉表表示是什么样的?
  5. 编写代码创建 MovieLens 数据的交叉表表示(您可能需要进行一些网络搜索!)。
  6. 什么是潜在因素?为什么它是“潜在”的?
  7. 什么是点积?使用纯 Python 和列表手动计算点积。
  8. pandas.DataFrame.merge是做什么的?
  9. 什么是嵌入矩阵?
  10. 嵌入和一个独热编码向量矩阵之间的关系是什么?
  11. 如果我们可以使用独热编码向量来做同样的事情,为什么我们需要Embedding
  12. 在我们开始训练之前,嵌入包含什么内容(假设我们没有使用预训练模型)?
  13. 创建一个类(尽量不要偷看!)并使用它。
  14. x[:,0]返回什么?
  15. 重写DotProduct类(尽量不要偷看!)并用它训练模型。
  16. 在 MovieLens 中使用什么样的损失函数是好的?为什么?
  17. 如果我们在 MovieLens 中使用交叉熵损失会发生什么?我们需要如何更改模型?
  18. 点积模型中偏差的用途是什么?
  19. 权重衰减的另一个名称是什么?
  20. 写出权重衰减的方程(不要偷看!)。
  21. 写出权重衰减的梯度方程。为什么它有助于减少权重?
  22. 为什么减少权重会导致更好的泛化?
  23. PyTorch 中的argsort是做什么的?
  24. 对电影偏差进行排序是否会得到与按电影平均评分相同的结果?为什么/为什么不?
  25. 如何打印模型中层的名称和详细信息?
  26. 协同过滤中的“自举问题”是什么?
  27. 如何处理新用户的自举问题?对于新电影呢?
  28. 反馈循环如何影响协同过滤系统?
  29. 在协同过滤中使用神经网络时,为什么我们可以为电影和用户使用不同数量的因素?
  30. 为什么在CollabNN模型中有一个nn.Sequential
  31. 如果我们想要向协同过滤模型添加有关用户和项目的元数据,或者有关日期和时间等信息,应该使用什么样的模型?

进一步研究

  1. 看看Embedding版本的DotProductBiascreate_params版本之间的所有差异,并尝试理解为什么需要进行每一项更改。如果不确定,尝试撤销每个更改以查看发生了什么。(注意:甚至在forward中使用的括号类型也已更改!)
  2. 找到另外三个协同过滤正在使用的领域,并在这些领域中确定这种方法的优缺点。
  3. 使用完整的 MovieLens 数据集完成这个笔记本,并将结果与在线基准进行比较。看看你能否提高准确性。在书的网站和 fast.ai 论坛上寻找想法。请注意,完整数据集中有更多列,看看你是否也可以使用这些列(下一章可能会给你一些想法)。
  4. 为 MovieLens 创建一个使用交叉熵损失的模型,并将其与本章中的模型进行比较。

第九章:表格建模深入探讨

原文:www.bookstack.cn/read/th-fastai-book/863394bdf5dc8421.md 译者:飞龙 协议:CC BY-NC-SA 4.0

表格建模将数据以表格形式(如电子表格或 CSV 文件)呈现。目标是基于其他列中的值来预测一列中的值。在本章中,我们将不仅看深度学习,还将看更一般的机器学习技术,如随机森林,因为根据您的问题,它们可能会给出更好的结果。

我们将看看我们应该如何预处理和清理数据,以及如何在训练后解释我们模型的结果,但首先我们将看看如何通过使用嵌入将包含类别的列馈送到期望数字的模型中。

分类嵌入

在表格数据中,某些列可能包含数值数据,如“年龄”,而其他列包含字符串值,如“性别”。数值数据可以直接输入模型(经过一些可选的预处理),但其他列需要转换为数字。由于这些值对应不同的类别,我们通常将这种类型的变量称为分类变量。第一种类型被称为连续 变量

术语:连续和分类变量

连续变量是数值数据,如“年龄”,可以直接输入模型,因为可以直接进行加法和乘法。分类变量包含多个离散级别,如“电影 ID”,对于这些级别,加法和乘法没有意义(即使它们以数字形式存储)。

2015 年底,Rossmann 销售竞赛在 Kaggle 上举行。参赛者获得了有关德国各个商店的各种信息,并被要求尝试预测若干天的销售额。目标是帮助公司适当管理库存,并能够满足需求而不必持有不必要的库存。官方训练集提供了大量有关商店的信息。允许参赛者使用额外的数据,只要这些数据是公开的并对所有参与者可用。

其中一位金牌得主使用了深度学习,在已知的最先进深度学习表格模型的早期示例中。他们的方法涉及远少于其他金牌得主的基于领域知识的特征工程。论文“分类变量的实体嵌入”描述了他们的方法。在书籍网站的在线专章中,我们展示了如何从头开始复制它,并获得论文中显示的相同准确性。在论文的摘要中,作者(Cheng Guo 和 Felix Bekhahn)说:

实体嵌入不仅可以减少内存使用量并加快神经网络的速度,与独热编码相比,更重要的是通过将相似值映射到嵌入空间中的相邻位置,揭示了分类变量的固有属性…[它]在具有大量高基数特征的数据集中特别有用,其他方法往往容易过拟合…由于实体嵌入为分类变量定义了距离度量,因此可以用于可视化分类数据和数据聚类。

当我们构建协同过滤模型时,我们已经注意到了所有这些要点。然而,我们可以清楚地看到这些见解远不止于协同过滤。

该论文还指出(正如我们在前一章中讨论的),嵌入层与在每个独热编码输入层之后放置普通线性层完全等效。作者使用图 9-1 中的图表来展示这种等效性。请注意,“密集层”是与“线性层”相同含义的术语,而独热编码层代表输入。

这一见解很重要,因为我们已经知道如何训练线性层,所以从架构和训练算法的角度来看,嵌入层只是另一层。我们在前一章中实践中也看到了这一点,当我们构建了一个与这个图表完全相同的协同过滤神经网络时。

就像我们分析了电影评论的嵌入权重一样,实体嵌入论文的作者分析了他们的销售预测模型的嵌入权重。他们发现的结果非常惊人,并展示了他们的第二个关键见解:嵌入将分类变量转换为连续且有意义的输入。

神经网络中的实体嵌入
神经网络中的实体嵌入
图 9-1。神经网络中的实体嵌入(由 Cheng Guo 和 Felix Berkhahn 提供)

图 9-2 中的图像说明了这些想法。它们基于论文中使用的方法,以及我们添加的一些分析。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
图 9-2。状态嵌入和地图(由 Cheng Guo 和 Felix Berkhahn 提供)

左侧是State类别可能值的嵌入矩阵图。对于分类变量,我们称变量的可能值为其“级别”(或“类别”或“类别”),因此这里一个级别是“柏林”,另一个是“汉堡”等。右侧是德国地图。德国各州的实际物理位置不是提供的数据的一部分,但模型本身学会了它们必须在哪里,仅基于商店销售的行为!

您还记得我们谈论过嵌入之间的距离吗?论文的作者绘制了商店嵌入之间的距离与商店之间的实际地理距离之间的关系(参见图 9-3)。他们发现它们非常接近!

商店距离
商店距离
图 9-3。存储距离(由 Cheng Guo 和 Felix Berkhahn 提供)

我们甚至尝试绘制一周中的日期和一年中的月份的嵌入,发现在日历上彼此相邻的日期和月份也在嵌入中靠近,如图 9-4 所示。

这两个示例中突出的是,我们向模型提供了基本关于离散实体的分类数据(例如德国各州或一周中的日期),然后模型学习了这些实体的嵌入,定义了它们之间的连续距离概念。由于嵌入距离是基于数据中的真实模式学习的,因此该距离往往与我们的直觉相匹配。

日期嵌入
日期嵌入
图 9-4。日期嵌入(由 Cheng Guo 和 Felix Berkhahn 提供)

此外,嵌入本身是有价值的,因为模型更擅长理解连续变量。这并不奇怪,因为模型由许多连续参数权重和连续激活值构成,这些值通过梯度下降(一种用于找到连续函数最小值的学习算法)进行更新。

另一个好处是,我们可以将连续嵌入值与真正连续的输入数据简单地结合在一起:我们只需连接变量并将连接输入到我们的第一个密集层中。换句话说,在与原始连续输入数据交互之前,原始分类数据通过嵌入层进行转换。这就是 fastai 和 Guo 和 Berkhahn 处理包含连续和分类变量的表格模型的方式。

使用这种连接方法的一个示例是谷歌在 Google Play 上进行推荐的方式,正如在论文“广泛和深度学习用于推荐系统”中所解释的那样。图 9-5 说明了这一点。

有趣的是,谷歌团队结合了我们在上一章看到的两种方法:点积(他们称之为交叉乘积)和神经网络方法。

谷歌 Play 推荐系统
谷歌 Play 推荐系统
图 9-5. 谷歌 Play 推荐系统

让我们暂停一下。到目前为止,解决我们所有建模问题的方法都是训练一个深度学习模型。确实,对于像图像、声音、自然语言文本等复杂的非结构化数据,这是一个相当好的经验法则。深度学习在协同过滤方面也表现得非常出色。但对于分析表格数据来说,它并不总是最佳的起点。

超越深度学习

大多数机器学习课程会向你介绍几十种算法,简要介绍它们背后的数学原理,可能还会有一个玩具示例。你会被展示的各种技术茫然不解,对如何应用它们几乎没有实际的理解。

好消息是,现代机器学习可以归结为几种广泛适用的关键技术。最近的研究表明,绝大多数数据集最适合用两种方法建模:

  • 决策树集成(即随机森林和梯度提升机),主要用于结构化数据(比如大多数公司数据库表中可能找到的数据)
  • 使用 SGD 学习的多层神经网络(即浅层和/或深度学习),主要用于非结构化数据(比如音频、图像和自然语言)

尽管深度学习几乎总是在非结构化数据方面明显优越,但对于许多种结构化数据,这两种方法往往给出相似的结果。但决策树集成往往训练更快,通常更容易解释,不需要特殊的 GPU 硬件进行规模推断,并且通常需要更少的超参数调整。它们也比深度学习流行得早得多,因此在它们周围有更成熟的工具和文档生态系统。

最重要的是,解释表格数据模型的关键步骤对于决策树集成来说要容易得多。有工具和方法可以回答相关问题,比如:数据集中哪些列对你的预测最重要?它们与因变量有什么关系?它们如何相互作用?哪些特定特征对某个特定观察最重要?

因此,决策树集成是我们分析新表格数据集的第一步方法。

这一准则的例外情况是当数据集符合以下条件之一时:

  • 有一些高基数分类变量非常重要(“基数”指代表示类别的离散级别的数量,因此高基数分类变量是指像邮政编码这样可能有数千个可能级别的变量)。
  • 有一些包含最好用神经网络理解的数据的列,比如纯文本数据。

在实践中,当我们处理符合这些特殊条件的数据集时,我们总是尝试决策树集成和深度学习,看哪个效果更好。在我们的协同过滤示例中,深度学习可能是一个有用的方法,因为我们至少有两个高基数分类变量:用户和电影。但在实践中,事情往往没有那么明确,通常会有高基数和低基数分类变量以及连续变量的混合。

无论如何,很明显我们需要将决策树集成添加到我们的建模工具箱中!

到目前为止,我们几乎所有的繁重工作都是使用 PyTorch 和 fastai 完成的。但是这些库主要设计用于进行大量矩阵乘法和导数计算(即,类似深度学习的操作!)。决策树根本不依赖于这些操作,因此 PyTorch 没有太多用处。

相反,我们将主要依赖一个名为scikit-learn(也称为sklearn)的库。Scikit-learn 是一个流行的库,用于创建机器学习模型,使用的方法不包括深度学习。此外,我们需要进行一些表格数据处理和查询,因此我们将使用 Pandas 库。最后,我们还需要 NumPy,因为这是 sklearn 和 Pandas 都依赖的主要数值编程库。

我们没有时间在本书中深入研究所有这些库,因此我们只会涉及每个库的一些主要部分。对于更深入的讨论,我们强烈建议阅读 Wes McKinney 的Python 数据分析(O’Reilly)。McKinney 是 Pandas 的创始人,因此您可以确信信息是准确的!

首先,让我们收集我们将使用的数据。

数据集

本章中使用的数据集来自于蓝皮书对推土机的 Kaggle 竞赛,该竞赛的描述如下:“比赛的目标是根据其使用情况、设备类型和配置来预测拍卖中特定重型设备的销售价格。数据来源于拍卖结果发布,并包括有关使用情况和设备配置的信息。”

这是一种非常常见的数据集类型和预测问题,类似于您在项目或工作场所中可能遇到的情况。该数据集可以在 Kaggle 上下载,Kaggle 是一个举办数据科学竞赛的网站。

Kaggle 竞赛

Kaggle 是一个非常棒的资源,适合有志成为数据科学家或任何希望提高机器学习技能的人。没有什么比亲自动手实践并获得实时反馈来帮助您提高技能。

Kaggle 提供了以下内容:

  • 有趣的数据集
  • 关于您的表现的反馈
  • 排行榜可以看到什么是好的,什么是可能的,以及什么是最先进的
  • 获奖选手分享有用的技巧和技术的博客文章

到目前为止,我们所有的数据集都可以通过 fastai 的集成数据集系统下载。然而,在本章中我们将使用的数据集只能从 Kaggle 获取。因此,您需要在该网站上注册,然后转到比赛页面。在该页面上点击规则,然后点击我理解并接受。(尽管比赛已经结束,您不会参加,但您仍然需要同意规则才能下载数据。)

下载 Kaggle 数据集的最简单方法是使用 Kaggle API。您可以通过使用pip安装它,并在笔记本单元格中运行以下命令:

代码语言:javascript
复制
!pip install kaggle

使用 Kaggle API 需要一个 API 密钥;要获取一个,点击 Kaggle 网站上的个人资料图片,选择我的账户;然后点击创建新的 API 令牌。这将在您的 PC 上保存一个名为kaggle.json的文件。您需要将此密钥复制到您的 GPU 服务器上。为此,请打开您下载的文件,复制内容,并将其粘贴到与本章相关的笔记本中的以下单引号内(例如,creds = {"username":"*xxx*","key":"*xxx*"}’``):

代码语言:javascript
复制
creds = ''

然后执行此单元格(这只需要运行一次):

代码语言:javascript
复制
cred_path = Path('~/.kaggle/kaggle.json').expanduser()
if not cred_path.exists():
    cred_path.parent.mkdir(exist_ok=True)
    cred_path.write(creds)
    cred_path.chmod(0o600)

现在您可以从 Kaggle 下载数据集!选择一个路径来下载数据集:

代码语言:javascript
复制
path = URLs.path('bluebook')
path
代码语言:javascript
复制
Path('/home/sgugger/.fastai/archive/bluebook')

然后使用 Kaggle API 将数据集下载到该路径并解压缩:

代码语言:javascript
复制
if not path.exists():
    path.mkdir()
    api.competition_download_cli('bluebook-for-bulldozers', path=path)
    file_extract(path/'bluebook-for-bulldozers.zip')

path.ls(file_type='text')
代码语言:javascript
复制
(#7) [Path('Valid.csv'),Path('Machine_Appendix.csv'),Path('ValidSolution.csv'),P
 > ath('TrainAndValid.csv'),Path('random_forest_benchmark_test.csv'),Path('Test.
 > csv'),Path('median_benchmark.csv')]

现在我们已经下载了数据集,让我们来看一下!

查看数据

Kaggle 提供了有关我们数据集中某些字段的信息。数据页面解释了train.csv中的关键字段如下:

SalesID

销售的唯一标识符。

MachineID

机器的唯一标识符。一台机器可以被多次出售。

销售价格

机器在拍卖中的售价(仅在train.csv中提供)。

销售日期

销售日期。

在任何数据科学工作中,直接查看数据是很重要的,以确保您了解格式、存储方式、包含的值类型等。即使您已经阅读了数据的描述,实际数据可能并非您所期望的。我们将从将训练集读入 Pandas DataFrame 开始。通常,除非 Pandas 实际耗尽内存并返回错误,否则最好也指定low_memory=Falselow_memory参数默认为True,告诉 Pandas 一次只查看几行数据,以确定每列中包含的数据类型。这意味着 Pandas 最终可能会为不同的行使用不同的数据类型,这通常会导致数据处理错误或模型训练问题。

让我们加载数据并查看列:

代码语言:javascript
复制
df = pd.read_csv(path/'TrainAndValid.csv', low_memory=False)
代码语言:javascript
复制
df.columns
代码语言:javascript
复制
Index(['SalesID', 'SalePrice', 'MachineID', 'ModelID', 'datasource',
       'auctioneerID', 'YearMade', 'MachineHoursCurrentMeter', 'UsageBand',
       'saledate', 'fiModelDesc', 'fiBaseModel', 'fiSecondaryDesc',
       'fiModelSeries', 'fiModelDescriptor', 'ProductSize',
       'fiProductClassDesc', 'state', 'ProductGroup', 'ProductGroupDesc',
       'Drive_System', 'Enclosure', 'Forks', 'Pad_Type', 'Ride_Control',
       'Stick', 'Transmission', 'Turbocharged', 'Blade_Extension',
       'Blade_Width', 'Enclosure_Type', 'Engine_Horsepower', 'Hydraulics',
       'Pushblock', 'Ripper', 'Scarifier', 'Tip_Control', 'Tire_Size',
       'Coupler', 'Coupler_System', 'Grouser_Tracks', 'Hydraulics_Flow',
       'Track_Type', 'Undercarriage_Pad_Width', 'Stick_Length', 'Thumb',
       'Pattern_Changer', 'Grouser_Type', 'Backhoe_Mounting', 'Blade_Type',
       'Travel_Controls', 'Differential_Type', 'Steering_Controls'],
      dtype='object')

这是我们要查看的许多列!尝试浏览数据集,了解每个列中包含的信息类型。我们很快将看到如何“聚焦”于最有趣的部分。

在这一点上,一个很好的下一步是处理有序列的列。这指的是包含字符串或类似内容的列,但其中这些字符串具有自然排序。例如,这里是ProductSize的级别:

代码语言:javascript
复制
df['ProductSize'].unique()
代码语言:javascript
复制
array([nan, 'Medium', 'Small', 'Large / Medium', 'Mini', 'Large', 'Compact'],
 > dtype=object)

我们可以告诉 Pandas 这些级别的适当排序方式如下:

代码语言:javascript
复制
sizes = 'Large','Large / Medium','Medium','Small','Mini','Compact'
代码语言:javascript
复制
df['ProductSize'] = df['ProductSize'].astype('category')
df['ProductSize'].cat.set_categories(sizes, ordered=True, inplace=True)

最重要的数据列是因变量——我们想要预测的变量。请记住,模型的度量是反映预测有多好的函数。重要的是要注意项目使用的度量标准。通常,选择度量标准是项目设置的重要部分。在许多情况下,选择一个好的度量标准将需要不仅仅是选择一个已经存在的变量。这更像是一个设计过程。您应该仔细考虑哪种度量标准,或一组度量标准,实际上衡量了对您重要的模型质量概念。如果没有变量代表该度量标准,您应该看看是否可以从可用的变量构建度量标准。

然而,在这种情况下,Kaggle 告诉我们要使用的度量标准是实际和预测拍卖价格之间的平方对数误差(RMLSE)。我们只需要进行少量处理即可使用这个度量标准:我们取价格的对数,这样该值的m_rmse将给出我们最终需要的值:

代码语言:javascript
复制
dep_var = 'SalePrice'
代码语言:javascript
复制
df[dep_var] = np.log(df[dep_var])

我们现在准备探索我们的第一个用于表格数据的机器学习算法:决策树。

决策树

决策树集成,顾名思义,依赖于决策树。所以让我们从那里开始!决策树对数据提出一系列关于数据的二元(是或否)问题。在每个问题之后,树的那部分数据在“是”和“否”分支之间分割,如图 9-6 所示。经过一个或多个问题后,可以基于所有先前答案做出预测,或者需要另一个问题。

现在,这一系列问题是一个过程,用于获取任何数据项,无论是来自训练集还是新数据项,并将该项分配到一个组中。换句话说,在提出问题并回答问题之后,我们可以说该项属于与所有其他训练数据项相同的组,这些数据项对问题的答案相同。但这有什么好处呢?我们模型的目标是预测项目的值,而不是将它们分配到训练数据集中的组中。好处在于我们现在可以为这些组中的每个项目分配一个预测值——对于回归,我们取该组中项目的目标均值。

决策树示例
决策树示例
图 9-6. 决策树示例

让我们考虑如何找到正确的问题。当然,我们不希望自己创建所有这些问题-这就是计算机的作用!训练决策树的基本步骤可以很容易地写下来:

  1. 依次循环数据集的每一列。
  2. 对于每一列,依次循环该列的每个可能级别。
  3. 尝试将数据分成两组,基于它们是否大于或小于该值(或者如果它是一个分类变量,则基于它们是否等于或不等于该分类变量的水平)。
  4. 找到这两组中每组的平均销售价格,并查看这与该组中每个设备的实际销售价格有多接近。将这视为一个非常简单的“模型”,其中我们的预测只是该项组的平均销售价格。
  5. 在循环遍历所有列和每个可能的级别后,选择使用该简单模型给出最佳预测的分割点。
  6. 现在我们的数据有两组,基于这个选定的分割。将每个组视为一个单独的数据集,并通过返回到步骤 1 为每个组找到最佳分割。
  7. 递归地继续这个过程,直到每个组达到某个停止标准-例如,当组中只有 20 个项目时停止进一步分割。

尽管这是一个很容易自己实现的算法(这是一个很好的练习),但我们可以节省一些时间,使用内置在 sklearn 中的实现。

然而,首先,我们需要做一些数据准备。

Alexis 说

这是一个值得思考的有益问题。如果您考虑到定义决策树的过程本质上选择了一个关于变量的分割问题序列,您可能会问自己,我们如何知道这个过程选择了正确的序列?规则是选择产生最佳分割(即最准确地将项目分为两个不同类别)的分割问题,然后将同样的规则应用于该分割产生的组,依此类推。这在计算机科学中被称为“贪婪”方法。您能想象出一个情景,其中提出一个“不那么强大”的分割问题会使未来的分割更好(或者我应该说更好地导致更好的结果)吗?

处理日期

我们需要做的第一件数据准备工作是丰富我们对日期的表示。我们刚刚描述的决策树的基本基础是二分 - 将一组分成两组。我们查看序数变量,并根据变量的值是大于(或小于)阈值来划分数据集,我们查看分类变量,并根据变量的级别是否是特定级别来划分数据集。因此,这个算法有一种根据序数和分类数据划分数据集的方法。

但是这如何适用于常见的数据类型,日期呢?您可能希望将日期视为序数值,因为说一个日期比另一个日期更大是有意义的。然而,日期与大多数序数值有所不同,因为一些日期在某种方面与其他日期有质的不同,这通常与我们建模的系统相关。

为了帮助我们的算法智能处理日期,我们希望我们的模型不仅知道一个日期是否比另一个日期更近或更早。我们可能希望我们的模型根据日期的星期几、某一天是否是假期、所在月份等来做决策。为此,我们用一组日期元数据列替换每个日期列,例如假期、星期几和月份。这些列提供了我们认为会有用的分类数据。

fastai 带有一个函数,可以为我们执行此操作-我们只需传递包含日期的列名:

代码语言:javascript
复制
df = add_datepart(df, 'saledate')

在那里的同时,让我们为测试集做同样的事情:

代码语言:javascript
复制
df_test = pd.read_csv(path/'Test.csv', low_memory=False)
df_test = add_datepart(df_test, 'saledate')

我们可以看到我们的 DataFrame 中现在有很多新的列:

代码语言:javascript
复制
' '.join(o for o in df.columns if o.startswith('sale'))
代码语言:javascript
复制
'saleYear saleMonth saleWeek saleDay saleDayofweek saleDayofyear
 > saleIs_month_end saleIs_month_start saleIs_quarter_end saleIs_quarter_start
 > saleIs_year_end saleIs_year_start saleElapsed'

这是一个很好的第一步,但我们需要做更多的清理。为此,我们将使用 fastai 对象TabularPandasTabularProc

使用 TabularPandas 和 TabularProc

第二个预处理步骤是确保我们可以处理字符串和缺失数据。默认情况下,sklearn 都不能处理。相反,我们将使用 fastai 的TabularPandas类,它包装了一个 Pandas DataFrame 并提供了一些便利。为了填充一个TabularPandas,我们将使用两个TabularProcCategorifyFillMissingTabularProc类似于常规的Transform,但有以下不同:

  • 它返回传递给它的完全相同的对象,在原地修改对象后返回。
  • 它在数据首次传入时运行变换,而不是在访问数据时懒惰地运行。

Categorify是一个TabularProc,用数字分类列替换列。FillMissing是一个TabularProc,用列的中位数替换缺失值,并创建一个新的布尔列,对于任何值缺失的行,该列设置为True。这两个变换几乎适用于您将使用的每个表格数据集,因此这是您数据处理的一个很好的起点:

代码语言:javascript
复制
procs = [Categorify, FillMissing]

TabularPandas还将为我们处理数据集的拆分为训练集和验证集。但是,我们需要非常小心处理我们的验证集。我们希望设计它,使其类似于 Kaggle 将用来评判比赛的测试集

回想一下验证集和测试集之间的区别,如第一章中所讨论的。验证集是我们从训练中保留的数据,以确保训练过程不会在训练数据上过拟合。测试集是更深层次地被我们自己保留的数据,以确保我们在探索各种模型架构和超参数时不会在验证数据上过拟合。

我们看不到测试集。但我们确实希望定义我们的验证数据,使其与训练数据具有与测试集相同类型的关系。

在某些情况下,随机选择数据点的子集就足够了。但这不是这种情况,因为这是一个时间序列。

如果您查看测试集中表示的日期范围,您会发现它覆盖了 2012 年 5 月的六个月期间,这比训练集中的任何日期都要晚。这是一个很好的设计,因为竞赛赞助商希望确保模型能够预测未来。但这意味着如果我们要有一个有用的验证集,我们也希望验证集比训练集更晚。Kaggle 的训练数据在 2012 年 4 月结束,因此我们将定义一个更窄的训练数据集,其中只包括 2011 年 11 月之前的 Kaggle 训练数据,并且我们将定义一个验证集,其中包括 2011 年 11 月之后的数据。

为了做到这一点,我们使用np.where,这是一个有用的函数,返回(作为元组的第一个元素)所有True值的索引:

代码语言:javascript
复制
cond = (df.saleYear<2011) | (df.saleMonth<10)
train_idx = np.where( cond)[0]
valid_idx = np.where(~cond)[0]

splits = (list(train_idx),list(valid_idx))

TabularPandas需要告诉哪些列是连续的,哪些是分类的。我们可以使用辅助函数cont_cat_split自动处理:

代码语言:javascript
复制
cont,cat = cont_cat_split(df, 1, dep_var=dep_var)
代码语言:javascript
复制
to = TabularPandas(df, procs, cat, cont, y_names=dep_var, splits=splits)

TabularPandas的行为很像一个 fastai 的Datasets对象,包括提供trainvalid属性:

代码语言:javascript
复制
len(to.train),len(to.valid)
代码语言:javascript
复制
(404710, 7988)

我们可以看到数据仍然显示为类别的字符串(这里我们只显示了一些列,因为完整的表太大了,无法放在一页上)。

代码语言:javascript
复制
to.show(3)

state

ProductGroup

Drive_System

Enclosure

SalePrice

0

Alabama

WL

#na#

EROPS w AC

11.097410

1

North Carolina

WL

#na#

EROPS w AC

10.950807

2

New York

SSL

#na#

OROPS

9.210340

然而,底层项目都是数字:

代码语言:javascript
复制
to.items.head(3)

state

ProductGroup

Drive_System

Enclosure

0

1

6

0

3

1

33

6

0

3

2

32

3

0

6

将分类列转换为数字是通过简单地用数字替换每个唯一级别来完成的。与级别相关联的数字是按照它们在列中出现的顺序连续选择的,因此在转换后的分类列中,数字没有特定的含义。唯一的例外是,如果您首先将列转换为 Pandas 有序类别(就像我们之前为ProductSize所做的那样),那么您选择的排序将被使用。我们可以通过查看classes属性来查看映射:

代码语言:javascript
复制
to.classes['ProductSize']
代码语言:javascript
复制
(#7) ['#na#','Large','Large / Medium','Medium','Small','Mini','Compact']

由于处理数据到这一点需要一分钟左右的时间,我们应该保存它,这样以后我们可以继续从这里继续工作,而不必重新运行之前的步骤。fastai 提供了一个使用 Python 的pickle系统保存几乎任何 Python 对象的save方法:

代码语言:javascript
复制
(path/'to.pkl').save(to)

以后要读回来,您将键入:

代码语言:javascript
复制
to = (path/'to.pkl').load()

现在所有这些预处理都完成了,我们准备创建一个决策树。

创建决策树

首先,我们定义我们的自变量和因变量:

代码语言:javascript
复制
xs,y = to.train.xs,to.train.y
valid_xs,valid_y = to.valid.xs,to.valid.y

现在我们的数据都是数字的,没有缺失值,我们可以创建一个决策树:

代码语言:javascript
复制
m = DecisionTreeRegressor(max_leaf_nodes=4)
m.fit(xs, y);

为了简单起见,我们告诉 sklearn 只创建了四个叶节点。要查看它学到了什么,我们可以显示决策树:

代码语言:javascript
复制
draw_tree(m, xs, size=7, leaves_parallel=True, precision=2)

理解这幅图片是理解决策树的最好方法之一,所以我们将从顶部开始,逐步解释每个部分。

顶部节点代表初始模型,在进行任何分割之前,所有数据都在一个组中。这是最简单的模型。这是在不问任何问题的情况下得到的结果,将始终预测值为整个数据集的平均值。在这种情况下,我们可以看到它预测销售价格的对数值为 10.1。它给出了均方误差为 0.48。这个值的平方根是 0.69。(请记住,除非您看到m_rmse,或者均方根误差,否则您看到的值是在取平方根之前的,因此它只是差异的平方的平均值。)我们还可以看到在这个组中有 404,710 条拍卖记录,这是我们训练集的总大小。这里显示的最后一部分信息是找到的最佳分割的决策标准,即基于coupler_system列进行分割。

向下移动并向左移动,这个节点告诉我们,在coupler_system小于 0.5 的设备拍卖记录中有 360,847 条。这个组中我们的因变量的平均值是 10.21。从初始模型向下移动并向右移动,我们来到了coupler_system大于 0.5 的记录。

底部行包含我们的叶节点:没有答案出现的节点,因为没有更多问题需要回答。在这一行的最右边是包含coupler_system大于 0.5 的记录的节点。平均值为 9.21,因此我们可以看到决策树算法确实找到了一个单一的二进制决策,将高价值与低价值的拍卖结果分开。仅询问coupler_system预测的平均值为 9.21,而不是 10.1。

在第一个决策点后返回到顶部节点后,我们可以看到已经进行了第二个二进制决策分割,基于询问YearMade是否小于或等于 1991.5。对于这个条件为真的组(请记住,这是根据coupler_systemYearMade进行的两个二进制决策),平均值为 9.97,在这个组中有 155,724 条拍卖记录。对于这个条件为假的拍卖组,平均值为 10.4,有 205,123 条记录。因此,我们可以看到决策树算法成功地将我们更昂贵的拍卖记录分成了两组,这两组在价值上有显著差异。

我们可以使用 Terence Parr 强大的dtreeviz 库显示相同的信息:

代码语言:javascript
复制
samp_idx = np.random.permutation(len(y))[:500]
dtreeviz(m, xs.iloc[samp_idx], y.iloc[samp_idx], xs.columns, dep_var,
        fontname='DejaVu Sans', scale=1.6, label_fontsize=10,
        orientation='LR')

这显示了每个分割点数据分布的图表。我们可以清楚地看到我们的YearMade数据存在问题:显然有一些在 1000 年制造的推土机!很可能,这只是一个缺失值代码(在数据中没有出现的值,用作占位符的值,用于在值缺失的情况下)。对于建模目的,1000 是可以的,但正如你所看到的,这个异常值使得我们感兴趣的数值更难以可视化。所以,让我们用 1950 年替换它:

代码语言:javascript
复制
xs.loc[xs['YearMade']<1900, 'YearMade'] = 1950
valid_xs.loc[valid_xs['YearMade']<1900, 'YearMade'] = 1950

这个改变使得树的可视化中的分割更加清晰,尽管这并没有在模型结果上有任何显著的改变。这是决策树对数据问题有多么弹性的一个很好的例子!

代码语言:javascript
复制
m = DecisionTreeRegressor(max_leaf_nodes=4).fit(xs, y)
dtreeviz(m, xs.iloc[samp_idx], y.iloc[samp_idx], xs.columns, dep_var,
        fontname='DejaVu Sans', scale=1.6, label_fontsize=10,
        orientation='LR')

现在让决策树算法构建一个更大的树。在这里,我们没有传递任何停止标准,比如max_leaf_nodes

代码语言:javascript
复制
m = DecisionTreeRegressor()
m.fit(xs, y);

我们将创建一个小函数来检查我们模型的均方根误差(m_rmse),因为比赛是根据这个来评判的:

代码语言:javascript
复制
def r_mse(pred,y): return round(math.sqrt(((pred-y)**2).mean()), 6)
def m_rmse(m, xs, y): return r_mse(m.predict(xs), y)
代码语言:javascript
复制
m_rmse(m, xs, y)
代码语言:javascript
复制
0.0

所以,我们的模型完美了,对吧?不要那么快……记住,我们真的需要检查验证集,以确保我们没有过拟合:

代码语言:javascript
复制
m_rmse(m, valid_xs, valid_y)
代码语言:javascript
复制
0.337727

哎呀——看起来我们可能过拟合得很严重。原因如下:

代码语言:javascript
复制
m.get_n_leaves(), len(xs)
代码语言:javascript
复制
(340909, 404710)

我们的叶节点数几乎和数据点一样多!这似乎有点过于热情。事实上,sklearn 的默认设置允许它继续分裂节点,直到每个叶节点只包含一个项目。让我们改变停止规则,告诉 sklearn 确保每个叶节点至少包含 25 个拍卖记录:

代码语言:javascript
复制
m = DecisionTreeRegressor(min_samples_leaf=25)
m.fit(to.train.xs, to.train.y)
m_rmse(m, xs, y), m_rmse(m, valid_xs, valid_y)
代码语言:javascript
复制
(0.248562, 0.32368)

看起来好多了。让我们再次检查叶节点的数量:

代码语言:javascript
复制
m.get_n_leaves()
代码语言:javascript
复制
12397

更加合理!

Alexis 说

对于一个叶节点比数据项更多的过拟合决策树,这是我的直觉。考虑一下“二十个问题”游戏。在那个游戏中,选择者秘密想象一个物体(比如,“我们的电视机”),猜测者可以提出 20 个是或否的问题来猜测物体是什么(比如“它比一个面包盒大吗?”)。猜测者并不是在尝试预测一个数值,而只是在识别所有可想象物体集合中的特定物体。当你的决策树的叶节点多于域中可能的物体时,它本质上是一个训练有素的猜测者。它已经学会了识别训练集中特定数据项所需的问题序列,并且只是通过描述该项的值来“预测”。这是一种记忆训练集的方式,即过拟合。

构建决策树是创建数据模型的好方法。它非常灵活,因为它可以清楚地处理变量之间的非线性关系和交互作用。但我们可以看到在如何泛化(通过创建小树可以实现)和在训练集上的准确性(通过使用大树可以实现)之间存在一个基本的妥协。

那么我们如何兼顾两全呢?我们将在处理一个重要的遗漏细节之后向您展示:如何处理分类变量。

分类变量

在前一章中,当使用深度学习网络时,我们通过独热编码处理分类变量,并将其馈送到嵌入层。嵌入层帮助模型发现这些变量不同级别的含义(分类变量的级别没有固有含义,除非我们使用 Pandas 手动指定一个排序)。在决策树中,我们没有嵌入层,那么这些未处理的分类变量如何在决策树中发挥作用呢?例如,像产品代码这样的东西如何使用?

简短的答案是:它就是有效!想象一种情况,其中一个产品代码在拍卖中比其他任何产品代码都要昂贵得多。在这种情况下,任何二元分割都将导致该产品代码位于某个组中,而该组将比其他组更昂贵。因此,我们简单的决策树构建算法将选择该分割。稍后,在训练过程中,算法将能够进一步分割包含昂贵产品代码的子组,随着时间的推移,树将聚焦于那一个昂贵的产品。

还可以使用一位编码来替换单个分类变量,其中每一列代表变量的一个可能级别。Pandas 有一个get_dummies方法可以做到这一点。

然而,实际上并没有证据表明这种方法会改善最终结果。因此,我们通常会尽可能避免使用它,因为它确实会使您的数据集更难处理。在 2019 年,这个问题在 Marvin Wright 和 Inke König 的论文“Splitting on Categorical Predictors in Random Forests”中得到了探讨:

对于名义预测器,标准方法是考虑所有 2^(k − 1) − 1 个k预测类别的 2-分区。然而,这种指数关系会产生大量需要评估的潜在分割,增加了计算复杂性并限制了大多数实现中可能的类别数量。对于二元分类和回归,已经证明按照每个分割中的预测类别进行排序会导致与标准方法完全相同的分割。这减少了计算复杂性,因为对于具有k个类别的名义预测器,只需要考虑k − 1 个分割。

现在您了解了决策树的工作原理,是时候尝试那种最佳的解决方案了:随机森林。

随机森林

1994 年,伯克利大学教授 Leo Breiman 在退休一年后发表了一份名为“Bagging Predictors”的小型技术报告,这个报告成为现代机器学习中最有影响力的想法之一。报告开始说:

Bagging 预测器是一种生成预测器的多个版本并使用这些版本来获得聚合预测器的方法。聚合平均了这些版本……通过对学习集进行自助复制并将其用作新的学习集来形成多个版本。测试表明,bagging 可以显著提高准确性。关键因素是预测方法的不稳定性。如果扰动学习集可以导致构建的预测器发生显著变化,那么 bagging 可以提高准确性。

这是 Breiman 提出的程序:

  1. 随机选择数据的子集(即“学习集的自助复制”)。
  2. 使用这个子集训练模型。
  3. 保存该模型,然后返回到步骤 1 几次。
  4. 这将为您提供多个经过训练的模型。要进行预测,请使用所有模型进行预测,然后取每个模型预测的平均值。

这个过程被称为bagging。它基于一个深刻而重要的观点:尽管在数据子集上训练的每个模型会比在完整数据集上训练的模型产生更多错误,但这些错误不会相互关联。不同的模型会产生不同的错误。因此,这些错误的平均值为零!因此,如果我们取所有模型预测的平均值,我们应该得到一个预测,随着模型数量的增加,它会越来越接近正确答案。这是一个非凡的结果——这意味着我们可以通过多次在不同随机数据子集上训练它来改进几乎任何类型的机器学习算法的准确性,并对其预测进行平均。

2001 年,Breiman 继续展示了这种建模方法,当应用于决策树构建算法时,特别强大。他甚至比仅仅随机选择每个模型训练的行更进一步,还在每棵决策树的每个分裂点随机选择了一部分列。他将这种方法称为随机森林。今天,它可能是最广泛使用和实际重要的机器学习方法。

实质上,随机森林是一个模型,它平均了大量决策树的预测结果,这些决策树是通过随机变化各种参数生成的,这些参数指定了用于训练树和其他树参数的数据。Bagging 是一种特定的集成方法,或者将多个模型的结果组合在一起。为了看看它在实践中是如何工作的,让我们开始创建我们自己的随机森林!

创建随机森林

我们可以像创建决策树一样创建随机森林,只是现在我们还指定了指示森林中应该有多少树,如何对数据项(行)进行子集化以及如何对字段(列)进行子集化的参数。

在下面的函数定义中,n_estimators定义了我们想要的树的数量,max_samples定义了每棵树训练时要抽样的行数,max_features定义了在每个分裂点抽样的列数(其中0.5表示“取一半的总列数”)。我们还可以指定何时停止分裂树节点,有效地限制树的深度,通过包含我们在前一节中使用的相同min_samples_leaf参数。最后,我们传递n_jobs=-1告诉 sklearn 使用所有 CPU 并行构建树。通过创建一个小函数,我们可以更快地尝试本章其余部分的变化:

代码语言:javascript
复制
def rf(xs, y, n_estimators=40, max_samples=200_000,
       max_features=0.5, min_samples_leaf=5, **kwargs):
    return RandomForestRegressor(n_jobs=-1, n_estimators=n_estimators,
        max_samples=max_samples, max_features=max_features,
        min_samples_leaf=min_samples_leaf, oob_score=True).fit(xs, y)
代码语言:javascript
复制
m = rf(xs, y);

我们的验证 RMSE 现在比我们上次使用DecisionTreeRegressor生成的结果要好得多,后者只使用了所有可用数据生成了一棵树:

代码语言:javascript
复制
m_rmse(m, xs, y), m_rmse(m, valid_xs, valid_y)
代码语言:javascript
复制
(0.170896, 0.233502)

随机森林最重要的特性之一是它对超参数选择不太敏感,比如max_features。您可以将n_estimators设置为尽可能高的数字,以便训练更多的树,树越多,模型就越准确。max_samples通常可以保持默认值,除非您有超过 200,000 个数据点,在这种情况下,将其设置为 200,000 将使其在准确性上有很小影响的情况下更快地训练。max_features=0.5min_samples_leaf=4通常效果很好,尽管 sklearn 的默认值也很好。

sklearn 文档展示了一个例子,展示了不同max_features选择的效果,以及树的数量增加。在图中,蓝色曲线使用最少的特征,绿色曲线使用最多的特征(使用所有特征)。正如您在图 9-7 中所看到的,使用较少特征但具有更多树的模型具有最低的错误结果。

sklearn max_features 图表
sklearn max_features 图表
图 9-7. 基于最大特征和树的数量的错误(来源:https://oreil.ly/E0Och

为了查看n_estimators的影响,让我们从森林中的每棵树获取预测结果(这些在estimators_属性中):

代码语言:javascript
复制
preds = np.stack([t.predict(valid_xs) for t in m.estimators_])

如您所见,preds.mean(0)给出了与我们的随机森林相同的结果:

代码语言:javascript
复制
r_mse(preds.mean(0), valid_y)
代码语言:javascript
复制
0.233502

让我们看看随着树的数量增加,RMSE 会发生什么变化。如您所见,大约在 30 棵树后,改进水平就会显著减少:

代码语言:javascript
复制
plt.plot([r_mse(preds[:i+1].mean(0), valid_y) for i in range(40)]);

我们在验证集上的表现比在训练集上差。但这是因为我们过拟合了,还是因为验证集涵盖了不同的时间段,或者两者都有?根据我们已经看到的信息,我们无法确定。然而,随机森林有一个非常聪明的技巧叫做袋外(OOB)误差,可以帮助我们解决这个问题(以及更多!)。

袋外误差

回想一下,在随机森林中,每棵树都是在训练数据的不同子集上训练的。OOB 错误是一种通过在计算行的错误时仅包括那些行包含在训练中的树来测量训练数据集中的预测错误的方法。这使我们能够看到模型是否过拟合,而无需单独的验证集。

Alexis 说

我对此的直觉是,由于每棵树都是在不同的随机选择的行子集上训练的,因此袋外错误有点像想象每棵树因此也有自己的验证集。该验证集只是未被选中用于该树训练的行。

这在我们只有少量训练数据的情况下特别有益,因为它使我们能够看到我们的模型是否在不移除物品以创建验证集的情况下泛化。OOB 预测可在oob_prediction_属性中找到。请注意,我们将它们与训练标签进行比较,因为这是在使用训练集的树上计算的:

代码语言:javascript
复制
r_mse(m.oob_prediction_, y)
代码语言:javascript
复制
0.210686

我们可以看到我们的 OOB 错误远低于验证集错误。这意味着除了正常的泛化错误之外,还有其他原因导致了该错误。我们将在本章后面讨论这些原因。

这是解释我们模型预测的一种方式——现在让我们更专注于这些。

模型解释

对于表格数据,模型解释尤为重要。对于给定的模型,我们最有兴趣的是以下内容:

  • 我们对使用特定数据行进行的预测有多自信?
  • 对于使用特定数据行进行预测,最重要的因素是什么,它们如何影响该预测?
  • 哪些列是最强的预测因子,哪些可以忽略?
  • 哪些列在预测目的上实际上是多余的?
  • 当我们改变这些列时,预测会如何变化?

正如我们将看到的,随机森林特别适合回答这些问题。让我们从第一个问题开始!

用于预测置信度的树方差

我们看到模型如何平均每棵树的预测以获得整体预测——也就是说,一个值的估计。但是我们如何知道估计的置信度?一种简单的方法是使用树之间预测的标准差,而不仅仅是均值。这告诉我们预测的相对置信度。一般来说,我们会更谨慎地使用树给出非常不同结果的行的结果(更高的标准差),而不是在树更一致的情况下使用结果(更低的标准差)。

在"创建随机森林"中,我们看到如何使用 Python 列表推导来对验证集进行预测,对森林中的每棵树都这样做:

代码语言:javascript
复制
preds = np.stack([t.predict(valid_xs) for t in m.estimators_])
代码语言:javascript
复制
preds.shape
代码语言:javascript
复制
(40, 7988)

现在我们对验证集中的每棵树和每个拍卖都有一个预测(40 棵树和 7,988 个拍卖)。

使用这种方法,我们可以获得每个拍卖的所有树的预测的标准差:

代码语言:javascript
复制
preds_std = preds.std(0)

以下是前五个拍卖的预测的标准差——也就是验证集的前五行:

代码语言:javascript
复制
preds_std[:5]
代码语言:javascript
复制
array([0.21529149, 0.10351274, 0.08901878, 0.28374773, 0.11977206])

正如您所看到的,预测的置信度差异很大。对于一些拍卖,标准差较低,因为树是一致的。对于其他拍卖,标准差较高,因为树不一致。这是在生产环境中会有用的信息;例如,如果您使用此模型来决定在拍卖中对哪些物品进行竞标,低置信度的预测可能会导致您在竞标之前更仔细地查看物品。

特征重要性

仅仅知道一个模型能够做出准确的预测通常是不够的,我们还想知道它是如何做出预测的。特征重要性给了我们这种洞察力。我们可以直接从 sklearn 的随机森林中获取这些信息,方法是查看feature_importances_属性。这里有一个简单的函数,我们可以用它将它们放入一个 DataFrame 并对它们进行排序:

代码语言:javascript
复制
def rf_feat_importance(m, df):
    return pd.DataFrame({'cols':df.columns, 'imp':m.feature_importances_}
                       ).sort_values('imp', ascending=False)

我们模型的特征重要性显示,前几个最重要的列的重要性得分比其余的要高得多,其中(不出所料)YearMadeProductSize位于列表的顶部:

代码语言:javascript
复制
fi = rf_feat_importance(m, xs)
fi[:10]

cols

imp

69

YearMade

0.182890

6

ProductSize

0.127268

30

Coupler_System

0.117698

7

fiProductClassDesc

0.069939

66

ModelID

0.057263

77

saleElapsed

0.050113

32

Hydraulics_Flow

0.047091

3

fiSecondaryDesc

0.041225

31

Grouser_Tracks

0.031988

1

fiModelDesc

0.031838

特征重要性的图表显示了相对重要性更清晰:

代码语言:javascript
复制
def plot_fi(fi):
    return fi.plot('cols', 'imp', 'barh', figsize=(12,7), legend=False)

plot_fi(fi[:30]);

这些重要性是如何计算的相当简单而优雅。特征重要性算法循环遍历每棵树,然后递归地探索每个分支。在每个分支,它查看用于该分割的特征是什么,以及模型由于该分割而改善了多少。该改善(按该组中的行数加权)被添加到该特征的重要性分数中。这些分数在所有树的所有分支中求和,最后对分数进行归一化,使它们总和为 1。

去除低重要性变量

看起来我们可以通过去除低重要性的变量来使用列的子集,并且仍然能够获得良好的结果。让我们尝试只保留那些具有特征重要性大于 0.005 的列:

代码语言:javascript
复制
to_keep = fi[fi.imp>0.005].cols
len(to_keep)
代码语言:javascript
复制
21

我们可以使用列的这个子集重新训练我们的模型:

代码语言:javascript
复制
xs_imp = xs[to_keep]
valid_xs_imp = valid_xs[to_keep]
代码语言:javascript
复制
m = rf(xs_imp, y)

这里是结果:

代码语言:javascript
复制
m_rmse(m, xs_imp, y), m_rmse(m, valid_xs_imp, valid_y)
代码语言:javascript
复制
(0.181208, 0.232323)

我们的准确率大致相同,但我们有更少的列需要研究:

代码语言:javascript
复制
len(xs.columns), len(xs_imp.columns)
代码语言:javascript
复制
(78, 21)

我们发现,通常改进模型的第一步是简化它——78 列对我们来说太多了,我们无法深入研究它们!此外,在实践中,通常更简单、更易解释的模型更容易推出和维护。

这也使得我们的特征重要性图更容易解释。让我们再次看一下:

代码语言:javascript
复制
plot_fi(rf_feat_importance(m, xs_imp));

使这个更难解释的一点是,似乎有一些含义非常相似的变量:例如,ProductGroupProductGroupDesc。让我们尝试去除任何冗余特征。

去除冗余特征

让我们从这里开始:

代码语言:javascript
复制
cluster_columns(xs_imp)

在这个图表中,最相似的列对是在树的左侧远离“根”处早期合并在一起的。毫不奇怪,ProductGroupProductGroupDesc字段很早就合并了,saleYearsaleElapsed,以及fiModelDescfiBaseModel也是如此。它们可能是如此密切相关,以至于它们实际上是彼此的同义词。

确定相似性

最相似的对是通过计算秩相关性来找到的,这意味着所有的值都被它们的(在列内的第一、第二、第三等)替换,然后计算相关性。(如果你愿意,可以跳过这个细节,因为它在本书中不会再次出现!)

让我们尝试删除一些这些密切相关特征,看看模型是否可以简化而不影响准确性。首先,我们创建一个快速训练随机森林并返回 OOB 分数的函数,通过使用较低的max_samples和较高的min_samples_leaf。OOB 分数是由 sklearn 返回的一个数字,范围在 1.0(完美模型)和 0.0(随机模型)之间。(在统计学中被称为R²,尽管这些细节对于这个解释并不重要。)我们不需要它非常准确——我们只是要用它来比较不同的模型,基于删除一些可能冗余的列:

代码语言:javascript
复制
def get_oob(df):
    m = RandomForestRegressor(n_estimators=40, min_samples_leaf=15,
        max_samples=50000, max_features=0.5, n_jobs=-1, oob_score=True)
    m.fit(df, y)
    return m.oob_score_

这是我们的基线:

代码语言:javascript
复制
get_oob(xs_imp)
代码语言:javascript
复制
0.8771039618198545

现在我们尝试逐个删除我们可能冗余的变量:

代码语言:javascript
复制
{c:get_oob(xs_imp.drop(c, axis=1)) for c in (
    'saleYear', 'saleElapsed', 'ProductGroupDesc','ProductGroup',
    'fiModelDesc', 'fiBaseModel',
    'Hydraulics_Flow','Grouser_Tracks', 'Coupler_System')}
代码语言:javascript
复制
{'saleYear': 0.8759666979317242,
 'saleElapsed': 0.8728423449081594,
 'ProductGroupDesc': 0.877877012281002,
 'ProductGroup': 0.8772503407182847,
 'fiModelDesc': 0.8756415073829513,
 'fiBaseModel': 0.8765165299438019,
 'Hydraulics_Flow': 0.8778545895742573,
 'Grouser_Tracks': 0.8773718142788077,
 'Coupler_System': 0.8778016988955392}

现在让我们尝试删除多个变量。我们将从我们之前注意到的紧密对齐的一对中的每个变量中删除一个。让我们看看这样做会发生什么:

代码语言:javascript
复制
to_drop = ['saleYear', 'ProductGroupDesc', 'fiBaseModel', 'Grouser_Tracks']
get_oob(xs_imp.drop(to_drop, axis=1))
代码语言:javascript
复制
0.8739605718147015

看起来不错!这与拥有所有字段的模型相比几乎没有差别。让我们创建没有这些列的数据框,并保存它们:

代码语言:javascript
复制
xs_final = xs_imp.drop(to_drop, axis=1)
valid_xs_final = valid_xs_imp.drop(to_drop, axis=1)
代码语言:javascript
复制
(path/'xs_final.pkl').save(xs_final)
(path/'valid_xs_final.pkl').save(valid_xs_final)

我们可以稍后重新加载它们:

代码语言:javascript
复制
xs_final = (path/'xs_final.pkl').load()
valid_xs_final = (path/'valid_xs_final.pkl').load()

现在我们可以再次检查我们的 RMSE,以确认准确性没有发生实质性变化:

代码语言:javascript
复制
m = rf(xs_final, y)
m_rmse(m, xs_final, y), m_rmse(m, valid_xs_final, valid_y)
代码语言:javascript
复制
(0.183263, 0.233846)

通过专注于最重要的变量并删除一些冗余的变量,我们大大简化了我们的模型。现在,让我们看看这些变量如何影响我们的预测,使用部分依赖图。

部分依赖

正如我们所看到的,最重要的预测变量是ProductSizeYearMade。我们想要了解这些预测变量与销售价格之间的关系。首先,最好检查每个类别的值的计数(由 Pandas 的value_counts方法提供),看看每个类别有多常见:

代码语言:javascript
复制
p = valid_xs_final['ProductSize'].value_counts(sort=False).plot.barh()
c = to.classes['ProductSize']
plt.yticks(range(len(c)), c);
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

最大的组是#na#,这是 fastai 用于缺失值的标签。

让我们对YearMade做同样的事情。由于这是一个数值特征,我们需要绘制一个直方图,将年份值分组为几个离散的箱:

代码语言:javascript
复制
ax = valid_xs_final['YearMade'].hist()

除了我们用于编码缺失年份值的特殊值 1950 之外,大多数数据都是 1990 年后的。

现在我们准备看部分依赖图。部分依赖图试图回答这个问题:如果一行除了关注的特征之外没有变化,它会如何影响因变量?

例如,YearMade 如何影响销售价格,其他条件都相同?为了回答这个问题,我们不能简单地取每个YearMade的平均销售价格。这种方法的问题在于,许多其他因素也会随着年份的变化而变化,比如销售哪些产品、有多少产品带空调、通货膨胀等等。因此,仅仅对具有相同YearMade的所有拍卖品进行平均会捕捉到每个其他字段如何随着YearMade的变化而变化以及这种整体变化如何影响价格的效果。

相反,我们将YearMade列中的每个值替换为 1950,然后计算每个拍卖品的预测销售价格,并对所有拍卖品进行平均。然后我们对 1951、1952 等年份做同样的操作,直到我们的最终年份 2011。这隔离了仅YearMade的影响(即使通过对一些想象中的记录进行平均,我们分配了一个可能永远不会实际存在的YearMade值以及一些其他值)。

Alexis 说

如果你有哲学头脑,思考我们为了做出这个计算而搅乱的不同种类的假设性可能会让人眩晕。首先,每个预测都是假设的,因为我们没有记录实证数据。其次,我们不仅仅是想知道如果我们改变YearMade以及所有其他因素会如何改变销售价格。相反,我们非常明确地询问在一个假设的世界中,只有YearMade改变了销售价格会如何改变。哎呀!我们能够提出这样的问题令人印象深刻。如果你对更深入探讨分析这些微妙之处的形式主义感兴趣,我推荐 Judea Pearl 和 Dana Mackenzie 最近的关于因果关系的书籍为什么之书(Basic Books)。

有了这些平均值,我们就可以在 x 轴上绘制每年,y 轴上绘制每个预测。最终,这是一个偏依赖图。让我们来看一下:

代码语言:javascript
复制
from sklearn.inspection import plot_partial_dependence

fig,ax = plt.subplots(figsize=(12, 4))
plot_partial_dependence(m, valid_xs_final, ['YearMade','ProductSize'],
                        grid_resolution=20, ax=ax);

首先看YearMade的图表,特别是涵盖 1990 年后的年份部分(因为,正如我们注意到的,这是我们拥有最多数据的地方),我们可以看到年份和价格之间几乎是线性关系。请记住我们的因变量是取对数后的,所以这意味着实际上价格呈指数增长。这是我们所期望的:折旧通常被认为是随时间的乘法因子,因此对于给定的销售日期,改变制造年份应该显示出与销售价格的指数关系。

ProductSize的部分图有点令人担忧。它显示我们看到的最终组,即缺失值,价格最低。要在实践中使用这一见解,我们需要找出为什么它经常缺失以及这意味着什么。缺失值有时可以是有用的预测因子-这完全取决于导致它们缺失的原因。然而,有时它们可能表明数据泄漏

数据泄漏

在论文“数据挖掘中的泄漏:制定、检测和避免”中,Shachar Kaufman 等人描述了泄漏如下:

关于数据挖掘问题的目标的信息引入,这些信息不应该合法地从中挖掘出来。泄漏的一个微不足道的例子是一个模型将目标本身用作输入,因此得出例如“雨天下雨”的结论。实际上,引入这种非法信息是无意的,并且由数据收集、聚合和准备过程促成。

他们举了一个例子:

在 IBM 的一个实际商业智能项目中,根据其网站上发现的关键词,识别了某些产品的潜在客户。结果证明这是泄漏,因为用于训练的网站内容是在潜在客户已经成为客户的时间点进行采样的,网站包含了 IBM 购买的产品的痕迹,比如“Websphere”这样的词(例如,在关于购买的新闻稿或客户使用的特定产品功能中)。

数据泄漏是微妙的,可以采取多种形式。特别是,缺失值通常代表数据泄漏。

例如,Jeremy 参加了一个 Kaggle 竞赛,旨在预测哪些研究人员最终会获得研究资助。这些信息是由一所大学提供的,包括成千上万个研究项目的示例,以及有关涉及的研究人员和每个资助是否最终被接受的数据。大学希望能够使用在这次竞赛中开发的模型来排名哪些资助申请最有可能成功,以便优先处理。

Jeremy 使用随机森林对数据进行建模,然后使用特征重要性来找出哪些特征最具预测性。他注意到了三件令人惊讶的事情:

  • 该模型能够在 95%以上的时间内正确预测谁将获得资助。
  • 显然,毫无意义的标识列是最重要的预测因子。
  • 星期几和一年中的日期列也具有很高的预测性;例如,大多数在星期日日期的资助申请被接受,许多被接受的资助申请日期在 1 月 1 日。

对于标识列,部分依赖图显示,当信息缺失时,申请几乎总是被拒绝。实际上,事实证明,大学在接受资助申请后才填写了大部分这些信息。通常,对于未被接受的申请,这些信息只是留空。因此,这些信息在申请接收时并不可用,并且不会对预测模型可用——这是数据泄漏。

同样,成功申请的最终处理通常在一周或一年结束时自动完成。最终处理日期最终出现在数据中,因此,尽管这些信息具有预测性,但实际上在接收申请时并不可用。

这个例子展示了识别数据泄漏最实用和简单方法,即构建模型,然后执行以下操作:

  • 检查模型的准确性是否过于完美
  • 寻找在实践中不合理的重要预测因子。
  • 寻找在实践中不合理的部分依赖图结果。

回想一下我们的熊探测器,这与我们在第二章中提供的建议相符——通常先构建模型,然后进行数据清理是一个好主意,而不是反过来。模型可以帮助您识别潜在的数据问题。

它还可以帮助您确定哪些因素影响特定预测,使用树解释器。

树解释器

在本节开始时,我们说我们想要能够回答五个问题:

  • 我们对使用特定数据行进行预测有多自信?
  • 对于预测特定数据行,最重要的因素是什么,它们如何影响该预测?
  • 哪些列是最强的预测因子?
  • 哪些列在预测目的上实际上是多余的?
  • 当我们改变这些列时,预测会如何变化?

我们已经处理了其中四个;只剩下第二个问题。要回答这个问题,我们需要使用treeinterpreter库。我们还将使用waterfallcharts库来绘制结果图表。您可以通过在笔记本单元格中运行以下命令来安装这些:

代码语言:javascript
复制
!pip install treeinterpreter
!pip install waterfallcharts

我们已经看到如何计算整个随机森林中的特征重要性。基本思想是查看每个变量对模型改进的贡献,在每棵树的每个分支处,然后将每个变量的所有这些贡献相加。

我们可以完全相同的方式做,但只针对单个数据行。例如,假设我们正在查看拍卖中的特定物品。我们的模型可能预测这个物品会非常昂贵,我们想知道原因。因此,我们取出那一行数据并将其通过第一棵决策树,查看树中每个点处使用的分割。对于每个分割,我们找到相对于树的父节点的增加或减少。我们对每棵树都这样做,并将每个分割变量的重要性变化相加。

例如,让我们选择验证集的前几行:

代码语言:javascript
复制
row = valid_xs_final.iloc[:5]

然后我们可以将这些传递给treeinterpreter

代码语言:javascript
复制
prediction,bias,contributions = treeinterpreter.predict(m, row.values)

prediction只是随机森林的预测。bias是基于取因变量的平均值(即每棵树的根模型)的预测。contributions是最有趣的部分-它告诉我们由于每个独立变量的变化而导致的预测总变化。因此,对于每行,contributions加上bias必须等于prediction。让我们只看第一行:

代码语言:javascript
复制
prediction[0], bias[0], contributions[0].sum()
代码语言:javascript
复制
(array([9.98234598]), 10.104309759725059, -0.12196378442186026)

瀑布图最清晰地显示贡献。这显示了所有独立变量的正负贡献如何相加以创建最终预测,这里标有“净”标签的右侧列:

代码语言:javascript
复制
waterfall(valid_xs_final.columns, contributions[0], threshold=0.08,
          rotation_value=45,formatting='{:,.3f}');

这种信息在生产中最有用,而不是在模型开发过程中。您可以使用它为数据产品的用户提供有关预测背后的基本推理的有用信息。

现在我们已经介绍了一些经典的机器学习技术来解决这个问题,让我们看看深度学习如何帮助!

外推和神经网络

随机森林存在的问题,就像所有机器学习或深度学习算法一样,它们并不总是很好地推广到新数据。我们将看到在哪些情况下神经网络更好地推广,但首先,让我们看看随机森林存在的外推问题以及它们如何帮助识别域外数据。

外推问题

让我们考虑一个简单的任务,从显示略带噪音的线性关系的 40 个数据点中进行预测:

代码语言:javascript
复制
x_lin = torch.linspace(0,20, steps=40)
y_lin = x_lin + torch.randn_like(x_lin)
plt.scatter(x_lin, y_lin);

虽然我们只有一个独立变量,但 sklearn 期望独立变量的矩阵,而不是单个向量。因此,我们必须将我们的向量转换为一个具有一列的矩阵。换句话说,我们必须将shape[40]更改为[40,1]。一种方法是使用unsqueeze方法,在请求的维度上为张量添加一个新的单位轴:

代码语言:javascript
复制
xs_lin = x_lin.unsqueeze(1)
x_lin.shape,xs_lin.shape
代码语言:javascript
复制
(torch.Size([40]), torch.Size([40, 1]))

更灵活的方法是使用特殊值None切片数组或张量,这会在该位置引入一个额外的单位轴:

代码语言:javascript
复制
x_lin[:,None].shape
代码语言:javascript
复制
torch.Size([40, 1])

现在我们可以为这些数据创建一个随机森林。我们将只使用前 30 行来训练模型:

代码语言:javascript
复制
m_lin = RandomForestRegressor().fit(xs_lin[:30],y_lin[:30])

然后我们将在完整数据集上测试模型。蓝点是训练数据,红点是预测:

代码语言:javascript
复制
plt.scatter(x_lin, y_lin, 20)
plt.scatter(x_lin, m_lin.predict(xs_lin), color='red', alpha=0.5);

我们有一个大问题!我们在训练数据范围之外的预测都太低了。你认为这是为什么?

请记住,随机森林只是对多棵树的预测进行平均。而树只是预测叶子中行的平均值。因此,树和随机森林永远无法预测超出训练数据范围的值。这对于表示随时间变化的数据,如通货膨胀,且希望对未来时间进行预测的数据尤为棘手。您的预测将系统性地过低。

但问题不仅限于时间变量。随机森林无法对其未见过的数据类型进行外推,从更一般的意义上讲。这就是为什么我们需要确保我们的验证集不包含域外数据。

查找域外数据

有时很难知道您的测试集是否与训练数据以相同方式分布,或者如果不同,哪些列反映了这种差异。有一种简单的方法可以弄清楚这一点,那就是使用随机森林!

但在这种情况下,我们不使用随机森林来预测我们实际的因变量。相反,我们尝试预测一行是在验证集还是训练集中。要看到这一点,让我们将训练集和验证集结合起来,创建一个代表每行来自哪个数据集的因变量,使用该数据构建一个随机森林,并获取其特征重要性:

代码语言:javascript
复制
df_dom = pd.concat([xs_final, valid_xs_final])
is_valid = np.array([0]*len(xs_final) + [1]*len(valid_xs_final))

m = rf(df_dom, is_valid)
rf_feat_importance(m, df_dom)[:6]

cols

imp

5

saleElapsed

0.859446

9

SalesID

0.119325

13

MachineID

0.014259

0

YearMade

0.001793

8

fiModelDesc

0.001740

11

Enclosure

0.000657

这显示训练集和验证集之间有三列显着不同:saleElapsedSalesIDMachineIDsaleElapsed 的差异相当明显:它是数据集开始和每行之间的天数,因此直接编码了日期。SalesID 的差异表明拍卖销售的标识符可能会随时间递增。MachineID 表明类似的情况可能发生在这些拍卖中出售的个别物品上。

让我们先获取原始随机森林模型的 RMSE 基线,然后逐个确定移除这些列的影响:

代码语言:javascript
复制
m = rf(xs_final, y)
print('orig', m_rmse(m, valid_xs_final, valid_y))

for c in ('SalesID','saleElapsed','MachineID'):
    m = rf(xs_final.drop(c,axis=1), y)
    print(c, m_rmse(m, valid_xs_final.drop(c,axis=1), valid_y))
代码语言:javascript
复制
orig 0.232795
SalesID 0.23109
saleElapsed 0.236221
MachineID 0.233492

看起来我们应该能够移除 SalesIDMachineID 而不会失去任何准确性。让我们检查一下:

代码语言:javascript
复制
time_vars = ['SalesID','MachineID']
xs_final_time = xs_final.drop(time_vars, axis=1)
valid_xs_time = valid_xs_final.drop(time_vars, axis=1)

m = rf(xs_final_time, y)
m_rmse(m, valid_xs_time, valid_y)
代码语言:javascript
复制
0.231307

删除这些变量略微提高了模型的准确性;但更重要的是,这应该使其随时间更具弹性,更易于维护和理解。我们建议对所有数据集尝试构建一个以 is_valid 为因变量的模型,就像我们在这里所做的那样。它通常可以揭示您可能会忽略的微妙的领域转移问题。

在我们的情况下,可能有助于简单地避免使用旧数据。通常,旧数据显示的关系已经不再有效。让我们尝试只使用最近几年的数据:

代码语言:javascript
复制
xs['saleYear'].hist();

在这个子集上训练的结果如下:

代码语言:javascript
复制
filt = xs['saleYear']>2004
xs_filt = xs_final_time[filt]
y_filt = y[filt]
代码语言:javascript
复制
m = rf(xs_filt, y_filt)
m_rmse(m, xs_filt, y_filt), m_rmse(m, valid_xs_time, valid_y)
代码语言:javascript
复制
(0.17768, 0.230631)

稍微好一点,这表明您不应该总是使用整个数据集;有时候子集可能更好。

让我们看看使用神经网络是否有帮助。

使用神经网络

我们可以使用相同的方法构建一个神经网络模型。让我们首先复制设置 TabularPandas 对象的步骤:

代码语言:javascript
复制
df_nn = pd.read_csv(path/'TrainAndValid.csv', low_memory=False)
df_nn['ProductSize'] = df_nn['ProductSize'].astype('category')
df_nn['ProductSize'].cat.set_categories(sizes, ordered=True, inplace=True)
df_nn[dep_var] = np.log(df_nn[dep_var])
df_nn = add_datepart(df_nn, 'saledate')

我们可以通过使用相同的列集合来为我们的神经网络利用我们在随机森林中修剪不需要的列的工作:

代码语言:javascript
复制
df_nn_final = df_nn[list(xs_final_time.columns) + [dep_var]]

在神经网络中,与决策树方法相比,分类列的处理方式大不相同。正如我们在第八章中看到的,在神经网络中,处理分类变量的一个很好的方法是使用嵌入。为了创建嵌入,fastai 需要确定哪些列应该被视为分类变量。它通过比较变量中不同级别的数量与 max_card 参数的值来实现这一点。如果较低,fastai 将把该变量视为分类变量。嵌入大小大于 10,000 通常只应在测试是否有更好的方法来分组变量之后使用,因此我们将使用 9,000 作为我们的 max_card 值:

代码语言:javascript
复制
cont_nn,cat_nn = cont_cat_split(df_nn_final, max_card=9000, dep_var=dep_var)

然而,在这种情况下,有一个变量绝对不能被视为分类变量:saleElapsed。按定义,分类变量无法在其所见值范围之外进行外推,但我们希望能够预测未来的拍卖销售价格。因此,我们需要将其作为连续变量处理:

代码语言:javascript
复制
cont_nn.append('saleElapsed')
cat_nn.remove('saleElapsed')

让我们来看看我们目前选择的每个分类变量的基数:

代码语言:javascript
复制
df_nn_final[cat_nn].nunique()
代码语言:javascript
复制
YearMade                73
ProductSize              6
Coupler_System           2
fiProductClassDesc      74
ModelID               5281
Hydraulics_Flow          3
fiSecondaryDesc        177
fiModelDesc           5059
ProductGroup             6
Enclosure                6
fiModelDescriptor      140
Drive_System             4
Hydraulics              12
Tire_Size               17
dtype: int64

有关设备“型号”的两个变量,都具有类似非常高的基数,这表明它们可能包含相似的冗余信息。请注意,当分析冗余特征时,我们不一定会注意到这一点,因为这依赖于相似变量按相同顺序排序(即,它们需要具有类似命名的级别)。拥有 5,000 个级别的列意味着我们的嵌入矩阵需要 5,000 列,如果可能的话最好避免。让我们看看删除其中一个这些型号列对随机森林的影响:

代码语言:javascript
复制
xs_filt2 = xs_filt.drop('fiModelDescriptor', axis=1)
valid_xs_time2 = valid_xs_time.drop('fiModelDescriptor', axis=1)
m2 = rf(xs_filt2, y_filt)
m_rmse(m, xs_filt2, y_filt), m_rmse(m2, valid_xs_time2, valid_y)
代码语言:javascript
复制
(0.176706, 0.230642)

影响很小,因此我们将其作为神经网络的预测变量移除:

代码语言:javascript
复制
cat_nn.remove('fiModelDescriptor')

我们可以像创建随机森林那样创建我们的TabularPandas对象,但有一个非常重要的补充:归一化。随机森林不需要任何归一化——树构建过程只关心变量中值的顺序,而不关心它们的缩放。但正如我们所见,神经网络确实关心这一点。因此,在构建TabularPandas对象时,我们添加Normalize处理器:

代码语言:javascript
复制
procs_nn = [Categorify, FillMissing, Normalize]
to_nn = TabularPandas(df_nn_final, procs_nn, cat_nn, cont_nn,
                      splits=splits, y_names=dep_var)

表格模型和数据通常不需要太多的 GPU 内存,因此我们可以使用更大的批量大小:

代码语言:javascript
复制
dls = to_nn.dataloaders(1024)

正如我们讨论过的,为回归模型设置y_range是一个好主意,所以让我们找到我们因变量的最小值和最大值:

代码语言:javascript
复制
y = to_nn.train.y
y.min(),y.max()
代码语言:javascript
复制
(8.465899897028686, 11.863582336583399)

现在我们可以创建Learner来创建这个表格模型。像往常一样,我们使用特定于应用程序的学习函数,以利用其应用程序定制的默认值。我们将损失函数设置为 MSE,因为这就是这个比赛使用的损失函数。

默认情况下,对于表格数据,fastai 创建一个具有两个隐藏层的神经网络,分别具有 200 和 100 个激活。这对于小数据集效果很好,但在这里我们有一个相当大的数据集,所以我们将层大小增加到 500 和 250:

代码语言:javascript
复制
from fastai.tabular.all import *
代码语言:javascript
复制
learn = tabular_learner(dls, y_range=(8,12), layers=[500,250],
                        n_out=1, loss_func=F.mse_loss)
代码语言:javascript
复制
learn.lr_find()
代码语言:javascript
复制
(0.005754399299621582, 0.0002754228771664202)

不需要使用fine_tune,所以我们将使用fit_one_cycle进行几个周期的训练,看看效果如何:

代码语言:javascript
复制
learn.fit_one_cycle(5, 1e-2)

epoch

train_loss

valid_loss

time

0

0.069705

0.062389

00:11

1

0.056253

0.058489

00:11

2

0.048385

0.052256

00:11

3

0.043400

0.050743

00:11

4

0.040358

0.050986

00:11

我们可以使用我们的r_mse函数将结果与之前得到的随机森林结果进行比较:

代码语言:javascript
复制
preds,targs = learn.get_preds()
r_mse(preds,targs)
代码语言:javascript
复制
0.2258

它比随机森林要好得多(尽管训练时间更长,对超参数调整要求更高)。

在继续之前,让我们保存我们的模型,以防以后想再次使用它:

代码语言:javascript
复制
learn.save('nn')

另一件可以帮助泛化的事情是使用几个模型并平均它们的预测——一个技术,如前面提到的,称为集成

集成

回想一下随机森林之所以效果如此好的最初原因:每棵树都有错误,但这些错误彼此之间不相关,因此一旦有足够多的树,这些错误的平均值应该趋向于零。类似的推理可以用来考虑平均使用不同算法训练的模型的预测。

在我们的情况下,我们有两个非常不同的模型,使用非常不同的算法进行训练:一个是随机森林,一个是神经网络。可以合理地期望每个模型产生的错误类型会有很大不同。因此,我们可能会期望它们的预测平均值会比任何一个单独的预测都要好。

正如我们之前看到的,随机森林本身就是一个集成模型。但是我们可以将一个随机森林包含在另一个集成中——一个由随机森林和神经网络组成的集成!虽然集成不会决定建模过程的成功与否,但它确实可以为您构建的任何模型增加一点小提升。

我们必须注意的一个小问题是,我们的 PyTorch 模型和我们的 sklearn 模型创建了不同类型的数据:PyTorch 给我们一个秩为 2 的张量(列矩阵),而 NumPy 给我们一个秩为 1 的数组(向量)。squeeze会从张量中删除任何单位轴,to_np将其转换为 NumPy 数组:

代码语言:javascript
复制
rf_preds = m.predict(valid_xs_time)
ens_preds = (to_np(preds.squeeze()) + rf_preds) /2

这给我们比任何一个模型单独取得的结果都要好:

代码语言:javascript
复制
r_mse(ens_preds,valid_y)
代码语言:javascript
复制
0.22291

实际上,这个结果比 Kaggle 排行榜上显示的任何分数都要好。然而,它并不直接可比,因为 Kaggle 排行榜使用了一个我们无法访问的单独数据集。Kaggle 不允许我们提交到这个旧的比赛中,以找出我们的表现如何,但我们的结果确实看起来令人鼓舞!

提升

到目前为止,我们集成的方法是使用装袋,它涉及将许多模型(每个模型在不同的数据子集上训练)组合起来通过平均它们。正如我们所看到的,当应用于决策树时,这被称为随机森林

在另一种重要的集成方法中,称为提升,我们添加模型而不是对它们进行平均。以下是提升的工作原理:

  1. 训练一个欠拟合数据集的小模型。
  2. 计算该模型在训练集中的预测。
  3. 从目标中减去预测值;这些被称为残差,代表了训练集中每个点的误差。
  4. 回到第 1 步,但是不要使用原始目标,而是使用残差作为训练的目标。
  5. 继续这样做,直到达到停止标准,比如最大树的数量,或者观察到验证集错误变得更糟。

使用这种方法,每棵新树都将尝试拟合所有先前树的错误。因为我们不断通过从先前树的残差中减去每棵新树的预测来创建新的残差,残差会变得越来越小。

使用提升树集成进行预测,我们计算每棵树的预测,然后将它们全部加在一起。有许多遵循这种基本方法的模型,以及许多相同模型的名称。梯度提升机(GBMs)和梯度提升决策树(GBDTs)是您最有可能遇到的术语,或者您可能会看到实现这些模型的特定库的名称;在撰写本文时,XGBoost是最受欢迎的。

请注意,与随机森林不同,使用这种方法,没有什么可以阻止我们过拟合。在随机森林中使用更多树不会导致过拟合,因为每棵树都是独立的。但是在提升集成中,拥有更多树,训练错误就会变得更好,最终您将在验证集上看到过拟合。

我们不会在这里详细介绍如何训练梯度提升树集成,因为这个领域发展迅速,我们提供的任何指导几乎肯定会在您阅读时过时。在我们撰写本文时,sklearn 刚刚添加了一个HistGradientBoostingRegressor类,提供了出色的性能。对于这个类,以及我们见过的所有梯度提升树方法,有许多要调整的超参数。与随机森林不同,梯度提升树对这些超参数的选择非常敏感;在实践中,大多数人使用一个循环来尝试一系列超参数,找到最适合的那些。

另一种取得很好结果的技术是在机器学习模型中使用神经网络学习的嵌入。

将嵌入与其他方法结合

我们在本章开头提到的实体嵌入论文的摘要中指出:“从训练的神经网络中获得的嵌入在作为输入特征时显著提高了所有测试的机器学习方法的性能。”它包括在图 9-8 中显示的非常有趣的表格。

嵌入与其他方法结合
嵌入与其他方法结合
图 9-8。使用神经网络嵌入作为其他机器学习方法的输入的效果(由 Cheng Guo 和 Felix Berkhahn 提供)

这显示了四种建模技术之间的平均百分比误差(MAPE)的比较,其中三种我们已经看过,还有一种是k-最近邻(KNN),这是一种非常简单的基准方法。第一列数字包含在比赛中使用这些方法的结果;第二列显示如果您首先使用具有分类嵌入的神经网络,然后在模型中使用这些分类嵌入而不是原始分类列会发生什么。正如您所看到的,在每种情况下,使用嵌入而不是原始类别可以显著改善模型。

这是一个非常重要的结果,因为它表明您可以在推断时获得神经网络的性能改进的大部分,而无需使用神经网络。您可以只使用一个嵌入,这实际上只是一个数组查找,以及一个小的决策树集成。

这些嵌入甚至不需要为组织中的每个模型或任务单独学习。相反,一旦为特定任务的列学习了一组嵌入,它们可以存储在一个中心位置,并在多个模型中重复使用。实际上,我们从与其他大公司的从业者的私下交流中得知,这在许多地方已经发生了。

结论

我们已经讨论了表格建模的两种方法:决策树集成和神经网络。我们还提到了两种决策树集成:随机森林和梯度提升机。每种方法都是有效的,但也需要做出妥协:

  • 随机森林是最容易训练的,因为它们对超参数选择非常有韧性,需要很少的预处理。它们训练速度快,如果有足够的树,就不会过拟合。但是它们可能会稍微不够准确,特别是在需要外推的情况下,比如预测未来的时间段。
  • 梯度提升机理论上训练速度与随机森林一样快,但实际上您将不得不尝试很多超参数。它们可能会过拟合,但通常比随机森林稍微准确一些。
  • 神经网络需要最长的训练时间,并需要额外的预处理,比如归一化;这种归一化也需要在推断时使用。它们可以提供很好的结果并很好地外推,但只有在您小心处理超参数并注意避免过拟合时才能实现。

我们建议从随机森林开始分析。这将为您提供一个强大的基准线,您可以确信这是一个合理的起点。然后,您可以使用该模型进行特征选择和部分依赖分析,以更好地了解您的数据。

基于这个基础,您可以尝试神经网络和 GBM,如果它们在合理的时间内在验证集上给出显著更好的结果,您可以使用它们。如果决策树集成对您有效,尝试将分类变量的嵌入添加到数据中,看看这是否有助于您的决策树学习更好。

问卷

  1. 什么是连续变量?
  2. 什么是分类变量?
  3. 分类变量可能的取值中使用的两个词是什么?
  4. 什么是密集层?
  5. 实体嵌入如何减少内存使用量并加快神经网络的速度?
  6. 实体嵌入特别适用于哪些类型的数据集?
  7. 机器学习算法的两个主要家族是什么?
  8. 为什么有些分类列需要在它们的类别中有特殊的排序?如何在 Pandas 中实现这一点?
  9. 总结决策树算法的作用。
  10. 日期与常规分类或连续变量有何不同,如何预处理它以使其能够在模型中使用?
  11. 在推土机比赛中应该选择一个随机的验证集吗?如果不是,应该选择什么样的验证集?
  12. pickle 是什么,它有什么用?
  13. 在本章中绘制的决策树中,如何计算msesamplesvalues
  14. 在构建决策树之前,我们如何处理异常值?
  15. 我们如何在决策树中处理分类变量?
  16. 什么是装袋法?
  17. 创建随机森林时,max_samplesmax_features之间有什么区别?
  18. 如果将n_estimators增加到一个非常高的值,是否会导致过拟合?为什么或为什么不?
  19. 在“创建随机森林”部分,在图 9-7 之后,为什么preds.mean(0)给出了与我们的随机森林相同的结果?
  20. 什么是袋外误差?
  21. 列出模型的验证集误差可能比 OOB 误差更糟糕的原因。如何测试您的假设?
  22. 解释为什么随机森林很适合回答以下每个问题:
    • 我们对使用特定数据行的预测有多自信?
    • 对于预测特定数据行,最重要的因素是什么,它们如何影响该预测?
    • 哪些列是最强的预测因子?
    • 随着这些列的变化,预测会如何变化?
  23. 移除不重要变量的目的是什么?
  24. 哪种类型的图表适合展示树解释器的结果?
  25. 什么是外推问题?
  26. 如何判断测试或验证集的分布方式是否与训练集不同?
  27. 为什么我们将saleElapsed作为连续变量,即使它的唯一值不到 9,000 个?
  28. 什么是提升?
  29. 我们如何在随机森林中使用嵌入?我们是否期望这有所帮助?
  30. 为什么我们不总是使用神经网络进行表格建模?

进一步研究

  1. 选择一个 Kaggle 上的带表格数据的比赛(当前或过去),并尝试调整本章中所见的技术以获得最佳结果。将您的结果与私人排行榜进行比较。
  2. 自己从头开始实现本章中的决策树算法,并在第一个练习中使用的数据集上尝试它。
  3. 在本章中使用神经网络中的嵌入在随机森林中,并查看是否可以改进我们看到的随机森林结果。
  4. 解释TabularModel源代码的每一行做了什么(除了BatchNorm1dDropout层)。
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2024-02-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 第八章:协同过滤深入探讨
  • 数据初探
  • 术语:点积
  • 学习潜在因子
  • 创建 DataLoaders
  • 术语:嵌入
  • 从头开始协同过滤
    • Weight Decay
      • 创建我们自己的嵌入模块
      • 解释嵌入和偏差
      • 杰里米说
        • 使用 fastai.collab
          • 嵌入距离
          • 引导协同过滤模型
          • 协同过滤的深度学习
          • 结论
          • 问卷
            • 进一步研究
            • 第九章:表格建模深入探讨
            • 分类嵌入
            • 术语:连续和分类变量
            • 超越深度学习
            • 数据集
              • Kaggle 竞赛
                • 查看数据
                • 决策树
                • Alexis 说
                  • 处理日期
                    • 使用 TabularPandas 和 TabularProc
                      • 创建决策树
                      • Alexis 说
                        • 分类变量
                        • 随机森林
                          • 创建随机森林
                            • 袋外误差
                            • Alexis 说
                            • 模型解释
                              • 用于预测置信度的树方差
                                • 特征重要性
                                  • 去除低重要性变量
                                    • 去除冗余特征
                                    • 确定相似性
                                      • 部分依赖
                                      • Alexis 说
                                        • 数据泄漏
                                          • 树解释器
                                          • 外推和神经网络
                                            • 外推问题
                                              • 查找域外数据
                                                • 使用神经网络
                                                • 集成
                                                  • 提升
                                                    • 将嵌入与其他方法结合
                                                    • 结论
                                                    • 问卷
                                                      • 进一步研究
                                                      领券
                                                      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档