Transcription
大家好,欢迎来到关于 Transformer 的另一集。在本集中,我们将使用 PyTorch 从头开始构建 Transformer,也就是说,我们将从零开始编写代码。我们将构建模型,还将构建用于训练、推理和可视化注意力分数的代码。请继续关注,因为这将是一个漫长的视频,但我向您保证,到视频结束时,您将对 Transformer 模型有深入的了解,不仅从概念角度,而且从实践角度。我们将构建一个翻译模型,这意味着我们的模型将能够将一种语言翻译成另一种语言。我选择了一个名为 Opus Books 的数据集,它是从著名书籍中提取的合成数据。我选择了英语到意大利语,因为我是意大利人,所以我可以理解并判断翻译是否良好,但我会向您展示您可以更改语言的点,以便您可以测试相同的模型与您选择的语言。让我们开始吧。让我们打开我们选择的 IDE。就我而言,我真的很喜欢 Visual Studio Code,让我们创建我们的第一个文件,即 Transformer 模型。好的,让我们先看看 Transformer 模型,这样我们就知道我们将首先构建哪一部分,然后我们将逐一构建每一部分。我们将构建的第一部分是输入嵌入。正如您所见,输入嵌入接收输入并将其转换为嵌入。什么是输入嵌入?正如您从我之前的视频中记得的那样,输入嵌入允许将原始句子转换为 512 维的向量。例如,在这个句子“你的猫是一只可爱的猫”中,我们首先将句子转换为输入 ID 的列表,这些 ID 是对应于每个单词在词汇表中位置的数字,然后这些数字中的每一个都对应于一个嵌入,这是一个大小为 512 的向量。所以让我们先构建这个层。我们需要做的第一件事是导入 torch,然后我们需要创建我们的类。这是构造函数。我们需要告诉它模型的维度是多少,即向量的维度。在论文中,这称为 D_model。我们还需要告诉它词汇表的大小是多少,即词汇表中总共有多少个单词。我们保存这两个值,现在我们可以创建实际的嵌入。实际上,PyTorch 已经提供了一个层,它能做到我们想做的 exactly。给定一个数字,它将始终为您提供相同的向量,这正是嵌入的作用。它只是数字和大小为 512 的向量之间的映射。这里的 512,大写表示 D_model。这由嵌入层完成,即 `nn.Embedding`,词汇表大小和 D_model。让我检查一下为什么我的自动完成不起作用。好的,现在让我们实现 forward 方法。我们在嵌入中所做的就是使用 PyTorch 提供的嵌入层来完成这种映射。所以返回 `self.embedding(x)`。实际上,论文中有一个小细节,让我们看看论文。实际上,让我们在这里看看,如果我们检查嵌入和 Softmax,我们会看到在这个句子中,在嵌入层中,我们将嵌入的权重乘以 `sqrt(D_model)`。所以外层做了什么?他们获取嵌入层提供的嵌入,我提醒您,它只是一个字典式的层,它只是将数字映射到相同的向量,并且该向量由模型学习。所以我们只是将这个乘以 `math.sqrt(D_model)`。您还需要导入 `math`。好的,现在输入嵌入已准备就绪。让我们转到下一个模块。我们将构建的下一个模块是位置编码。让我们也快速看一下位置编码是什么。我们之前看到,我们的原始句子被嵌入层映射到一个向量列表,这是我们的嵌入。现在我们想做什么?我们想将每个单词在句子中的位置信息传达给模型。这是通过添加另一个与嵌入大小相同的向量来完成的,即大小为 512,其中包含一些由我稍后将展示的公式给出的特殊值,它告诉模型该特定单词在句子中占据该位置。所以我们将创建这些称为位置嵌入的向量,并将它们添加到嵌入中。好的,让我们开始吧。好的,让我们定义类 `PositionalEncoding`。我们定义构造函数。我们需要给构造函数什么?当然是 D_model,因为这是位置编码应该的向量大小。还有序列长度,这是句子的最大长度。因为我们需要为每个位置创建一个向量。我们还需要给出 dropout,dropout 是为了让模型不过度拟合。好的,让我们实际构建位置编码。首先,位置编码是一个矩阵,我们将构建一个形状为 `(sequence_length, D_model)` 的矩阵。为什么是 `sequence_length x D_model`?因为我们需要 D_model 大小的向量,即 512,但我们需要 `sequence_length` 个,因为句子的最大长度是 `sequence_length`。所以让我们这样做。在我们计算之前,我们创建矩阵,我们知道如何创建矩阵。让我们看看用于创建位置编码的公式。所以让我们看看用于创建位置编码的公式。这是我之前视频的幻灯片。让我们看看如何构建向量。所以正如您记得的,我们有一个句子,假设在这种情况下我们有三个单词。我们使用这两个公式,取自论文。我们创建一个大小为 512 的向量,每个可能的位置一个,最多 `sequence_length`。在偶数位置,我们应用第一个公式,在向量的奇数位置,我们应用第二个公式。在这种情况下,我将实际简化计算,因为我在网上看到它也被简化了。我们将使用 log 空间进行稍微修改的计算,这是为了数值稳定性。所以当你应用指数然后指数内的东西的对数时,结果是相同的数字,但它在数值上更稳定。所以首先我们创建一个称为 `position` 的向量,它将表示单词在句子中的位置。这个单词向量可以从 0 到 `sequence_length - 1`。所以实际上我们正在创建一个形状为 `(sequence_length, 1)` 的张量。这是错误的。好的,现在我们创建公式的分母。这是公式中的两个项。让我们回到幻灯片。所以我们构建的第一个张量,称为 `position`,就是这里的 `pos`。第二个张量是我们在这里构建的分母,但我们为了数值稳定性以 log 空间计算它。值实际上会略有不同,但结果将是相同的。模型将学习这种位置编码,如果您不完全理解这部分,请不要担心。它只是非常特殊的函数,可以为模型传达这种位置信息。如果您观看我之前的视频,您也会理解为什么。现在我们将它应用于分母,并将分母应用于正弦和余弦。正如您记得的,正弦仅用于偶数位置,余弦仅用于奇数位置。所以我们将应用两次。让我们这样做。应用 `position` 将具有正弦,但仅限于偶数维度,从零开始到结束,每隔两个数字,这意味着从零开始,然后是数字二,然后是数字四,等等。`position` 乘以 `div_term`。在这种情况下,我们对余弦做同样的事情。在这种情况下,我们从一开始,然后每隔两个数字前进。这意味着一、三、五等等。然后我们需要将批次维度添加到此张量,以便我们可以将其应用于整个句子,即所有句子批次。因为现在形状是 `(sequence_length, D_model)`,但我们将有一个句子批次。所以我们所做的是向这个 PE 添加一个新维度。这是使用 `unsqueeze` 完成的,在第一个位置。所以它将变成一个形状为 `(1, sequence_length, D_model)` 的张量。最后,我们可以将此张量注册到此模块的缓冲区中。那么模块的缓冲区是什么?让我们先这样做。注册缓冲区。基本上,当您有一个想要保留在模块内的张量,而不是作为学习参数,但您希望在保存模型文件时保存它时,您应该将其注册为缓冲区。这样,张量将与模型状态一起保存在文件中。然后我们做 forward 方法。所以正如您之前记得的,我们需要将此位置编码添加到句子中的每个单词。所以让我们这样做。我们只是做 `x = x + self.pe`。对于这个特定的句子,我们还告诉模型我们不想学习这个位置编码,因为它们是固定的,它们将始终相同,它们不会在训练过程中学习。所以我们只是这样做 `requires_grad=False`。这将使这个特定的张量不会被学习。然后我们应用 dropout,就这样。这就是位置编码。让我们看看下一个模块。我们首先将构建 Transformer 的编码器部分,即这里的左侧。我们仍然需要构建多头注意力、Add & Norm 和前馈网络。实际上还有另一个层连接这个跳跃连接到所有这些子层。所以让我们从最简单的开始。让我们从层归一化开始,即这个 Add & Norm。正如您从我之前的视频中记得的那样,让我们快速回顾一下层归一化。层归一化基本上意味着,如果您有一个批次,其中有 N 个项目(在本例中只有三个),每个项目都会有一些特征。假设这些实际上是句子,每个句子由许多带有其数字的单词组成。所以这是我们的三个项目。层归一化意味着,对于批次中的每个项目,我们独立于批次中的其他项目计算均值和方差。然后我们使用它们自己的均值和它们自己的方差来计算它们每个的新值。在层归一化中,我们通常还会引入一些称为 gamma 和 beta 的参数。有些人称之为 alpha 和 beta,有些人称之为 alpha 和 bias。没关系。一个是乘法的,所以它乘以这些 X 中的每一个。一个是加法的,所以它加到这些 X 中的每一个。为什么我们要这样做?因为我们希望模型有机会在需要放大这些值时放大它们。所以模型将学会将这个 gamma 乘以这些值,从而放大它想要放大的值。好的,让我们去构建这个层的代码。让我们定义层归一化类和构造函数。像往常一样,在这种情况下,我们不需要任何参数,除了我将要向您展示的一个参数,即 Epsilon。通常 EPS 代表 Epsilon,这是一个非常小的数字,您需要提供给模型。我也会向您展示为什么我们需要这个数字。在这种情况下,我们使用 10 的 -6 次方。让我们保存它。这个 Epsilon 是必需的,因为如果我们看一下幻灯片,我们在这里的公式分母中看到了这个 Epsilon。所以 `x_hat = (x_j - mu) / sqrt(sigma^2 + epsilon)`。为什么我们需要这个 Epsilon?因为想象一下这个分母,如果 sigma 恰好为 0 或非常接近于零,那么这个 X_nu 将变得非常大,这是不可取的。因为我们知道 CPU 或 GPU 只能表示到一定位置和尺度的数字。所以我们不想要非常大或非常小的数字。所以通常为了数值稳定性,我们使用这个 Epsilon 来避免除以零。让我们继续。现在让我们引入我们将用于层归一化的两个参数。一个是称为 alpha 的,它将被乘以。一个是称为 bias 的,它将被添加。通常,加法称为 bias,它总是被添加。alpha 是被乘的那个。在这种情况下,我们将使用 `nn.Parameter`。这使得参数可学习。我们还定义了 bias。我想提醒您,这是被乘的,这是被加的。让我们定义 forward 方法。正如您记得的,我们需要计算均值和标准差或方差。对于两者,我们将计算最后一个维度的标准差,即批次之后的所有内容。并且我们保留维度。这个 `keepdim` 参数意味着,通常均值会抵消它所应用的维度,但我们希望保留它。然后我们只是应用我们在幻灯片上看到的公式。所以 alpha 乘以 (x - 它的均值) 除以标准差 + self.eps。所有这些都加上 bias。这就是我们的层归一化。好的,让我们看看下一个层。我们将构建的下一个层是前馈网络。您可以在这里看到它。前馈网络基本上是一个全连接层。模型在编码器和解码器中都使用它。让我们先看看论文,看看这个前馈层的细节。在论文中,前馈层基本上是两个矩阵,W1 和 W2,它们一个接一个地乘以这个 X,中间有一个 ReLU,还有一个 bias。我们可以使用线性层在 PyTorch 中做到这一点,其中我们定义第一个为矩阵 W1 和 B1,第二个为 W2 和 B2。中间我们应用。在论文中,我们也可以看到这些矩阵的维度。所以第一个基本上是 `D_model` 到 `D_ff`,第二个是从 `D_ff` 到 `D_model`。所以 `D_ff` 是 2048,`D_model` 是 512。让我们去构建它。类 `FeedForwardBlock`。我们也构建了构造函数。在构造函数中,我们需要定义我们在论文中看到的这两个值,即 `D_model`、`D_ff`。在这种情况下还有 dropout。我们定义第一个矩阵 W1 和 B1 为线性层,它是从 `D_model` 到 `D_ff`。然后我们应用 dropout。实际上,我们定义了 dropout,然后我们定义了第二个矩阵 W2 和 B2。所以让我在这里写注释。它是 W1 和 B1,从 `D_ff` 到 `D_model`。这是 W2 和 B2。为什么我们有 B2?因为实际上正如您在这里看到的,bias 默认情况下是 true,所以它已经为我们定义了一个 bias 矩阵。好的,让我们定义 forward 方法。在这种情况下,我们将要做的是,我们有一个输入句子,它是批次,它是形状为 `(batch, sequence_length, D_model)` 的张量。首先,我们将使用 `linear1` 将其转换为另一个形状为 `(batch, sequence_length, D_ff)` 的张量。因为如果我们应用这个线性层,它会将 `D_model` 转换为 `D_ff`。然后我们应用将将其转换回 `D_model` 的线性层。您在中间应用 dropout。这就是我们的前馈块。让我们看看下一个块。我们下一个要构建的块是最重要也是最有趣的块,那就是多头注意力。我们在上一个视频中简要地(实际上是详细地)看到了多头注意力是如何工作的。所以我现在将打开幻灯片再次展示它是如何工作的,然后我们将通过编码来实际实现它。正如您记得的,在编码器中,我们有多头注意力,它接收编码器的输入并使用三次。一次称为查询(query),一次称为键(key),一次称为值(value)。您也可以将其视为输入的复制三次,或者您可以说它应用了三次相同的输入。多头注意力基本上是这样工作的:我们有输入序列,即 `(sequence_length, D_model)`。我们将其转换为三个矩阵 Q、K 和 V,它们与输入完全相同。在这种情况下,因为我们谈论的是编码器,我们将看到解码器略有不同。然后我们将这些乘以称为 WQ、WK 和 WV 的矩阵。这会产生一个新的 `(sequence_length, D_model)` 形状的矩阵。然后我们将这些矩阵分成 H 个更小的矩阵,为什么是 H?因为这是我们想要的多头注意力的头数。我们沿着嵌入维度分割这些矩阵,而不是沿着序列维度。这意味着每个头都可以访问完整的句子,但每个单词的嵌入的不同部分。我们使用这个公式对这些更小的矩阵应用注意力,这将产生更小的矩阵作为结果。然后我们将它们组合起来。所以我们可以像论文中说的那样将它们切开。头 1 到头 H 的连接。最后,我们将其乘以 WO 以获得多头注意力输出,它再次是一个具有与输入矩阵相同维度的矩阵。正如您所见,多头注意力的输出也是 `(sequence_length, D_model)`。实际上,在这张幻灯片中,我没有显示批次维度,因为我们谈论的是一个句子。但当我们编写 Transformer 时,我们不仅处理一个句子,而是处理多个句子。所以我们需要考虑这里还有另一个维度,那就是批次。好的,让我们去编写这个多头注意力的代码。我会稍微慢一点,这样我们就可以详细看到一切是如何完成的,但我真的很想让您再次了解它的工作原理以及我们为什么这样做。所以让我们去编写代码。类。同样在这种情况下,我们定义构造函数。我们需要给这个多头注意力什么参数?当然是模型的 D_model,在我们这里是 512。头的数量,我们称之为 H,就像论文中一样。所以实际上 H 表示我们想要的头数。然后是 Dropout 值。我们保存这些值。正如您所见,我们需要将这个嵌入向量分成 H 个头。这意味着 D_model 应该能被 H 整除,否则我们就不能将相同的嵌入向量平均分成 H 个相等的部分。所以我们确保模型可以被 H 整除。基本上,如果我们再次查看我的幻灯片,我们可以看到 D_model 除以 H 的值称为 DK。正如我们在这里看到的,如果我们用 H 个头除以 D_model,我们会得到一个称为 DK 的新值。为了与论文中使用的命名法保持一致,我们也将其称为 DK。所以 DK 是 D_model 除以 H。好的,让我们也定义我们将乘以查询、键和值的矩阵,以及输出矩阵 WO。这再次是一个线性层,从 `D_model` 到 `D_model`。为什么是从 `D_model` 到 `D_model`?因为正如您从我的幻灯片中看到的,这是 `D_model x D_model`,所以输出将是 `(sequence_length, D_model)`。所以这是 WQ,这是 WK,这是 WV。最后,我们还有输出矩阵,称为 WO。这里的 WO 是 H x DV x D_model。H 是头的数量。DV 是什么?DV 实际上等于 DK,因为它是 D_model 除以 H。但为什么这里称为 DV,这里称为 DK?因为这个头实际上是这个乘法的结果,最后一个乘法是乘以 V。在论文中,他们称这个值为 DV。但在实际操作中,它等于 DK。所以我们的 WO 也是一个 `D_model x D_model` 的矩阵,因为 H x DV 等于 D_model。我们创建 dropout。让我们实现 forward 方法,并详细看看多头注意力在编码过程中的工作原理。我们定义了查询、键和值。还有这个掩码。这个掩码是什么?掩码基本上是,如果我们想让某些单词不与其他单词交互,我们就掩码它们。我在之前的视频中看到过,但现在让我们回到那些幻灯片,看看掩码的作用。正如您记得的,当我们使用这个公式计算注意力时:Softmax(Q * K^T / sqrt(DK)) * V。我们得到这个头矩阵。但在乘以 V 之前,只有这个 Q 乘以 K 的乘法,我们得到这个矩阵,它是每个单词与每个其他单词的交互。它是一个 `(sequence_length, sequence_length)` 矩阵。如果我们不希望某些单词与其他单词交互,我们就基本上将它们的值,即它们的注意力分数替换为非常小的值,然后我们应用 Softmax。当我们在 Softmax 中应用时,这些值将变为零。因为正如您记得的,Softmax 的分子是 e 的 X 次方。如果 X 趋向于负无穷,非常小的数字,e 的负无穷次方将变得非常小,非常接近于零。所以基本上我们隐藏了这两个单词的注意力。这就是掩码的作用。遵循我的幻灯片,我们逐一进行乘法。所以正如我们记得的,我们首先计算查询乘以 WQ。所以 `self.W_Q(query)` 得到一个新矩阵,在我的幻灯片中称为 Q',我在这里只称为查询。我们对键和值也做同样的事情。让我写下维度。我们从 `(batch, sequence_length, D_model)` 开始。通过这个乘法,我们得到另一个形状为 `(batch, sequence_length, D_model)` 的矩阵。您可以在幻灯片中看到。所以当我们做 `(sequence_length, D_model)` 乘以 `(D_model, D_model)` 时,我们得到一个新的形状为 `(sequence_length, D_model)` 的矩阵。对于所有三个都是如此。现在我们想做的是,我们想将这个查询、键和值分成更小的矩阵,以便我们可以将每个小矩阵交给不同的头。所以让我们这样做。我们将使用 PyTorch 的 `view` 方法将其分成。这意味着我们保留批次维度,因为我们不想分割句子。我们也不想分割序列。第三个维度,即 D_model,我们想将其分成两个更小的维度,即 H x DK。`self.h`, `self.dk`。正如您记得的,DK 基本上是 D_model 除以 H。所以这个乘以这个会给你 D_model。然后我们转置 `(1, 2)`。为什么我们转置?因为我们更喜欢将 H 维度放在第三个维度而不是第二个维度。这样每个视图头都能看到整个句子。所以我们将看到这个维度,即 `(sequence_length, dk)`。让我在这里写注释。所以我们从 `(batch, sequence_length, D_model)` 转到 `(batch, sequence_length, H, dk)`。然后通过转置,我们得到 `(batch, H, sequence_length, dk)`。这非常重要,因为如果我们想要每个批次都有,我们希望每个头都能看到这个东西,即 `(sequence_length, dk)`。这意味着每个头都能看到完整的句子,每个单词,但只有嵌入的一小部分。我们对查询、键和值也做同样的事情。现在我们有了更小的矩阵。所以让我回到幻灯片,这样我就可以向您展示我们在哪里。我们进行了这个乘法,得到了查询、键和值。我们将其分成更小的矩阵。现在我们需要使用这个公式计算注意力。在我们可以计算注意力之前,让我们创建一个计算注意力的函数。如果我们创建一个新函数,也可以稍后使用。`self.attention`。让我们将其定义为静态方法。静态方法基本上意味着您可以调用此函数而无需类的实例。您可以只说 `MultiHeadAttentionBlock.attention` 而不是类的实例。我们还给他 dropout 层。我们所做的是获取 DK。DK 是查询、键和值的最后一个维度。我们将使用这个函数。让我先称呼它,这样您就可以理解如何使用它,然后我们定义它。我们想要从这个函数中得到两样东西:输出和注意力分数。Softmax 注意力分数的输出。我们将这样称呼它:`self.attention(query, key, value, mask, dropout)`。现在让我们回到这里。我们有了 DK。现在我们所做的是,我们首先应用公式的第一部分,即查询乘以键的转置除以 DK 的平方根。所以这是我们的注意力分数。查询矩阵乘法。这个 `addsign` 在 PyTorch 中表示矩阵乘法。如果我们转置最后两个维度,`-2, -1` 表示转置最后两个维度。这将变成最后一个维度是 `(sequence_length, dk)`,它将变成 `(dk, sequence_length)`。然后我们将其除以 `math.dk`。我们之前说过,在应用 Softmax 之前,我们需要应用掩码。我们想隐藏一些单词之间的交互。我们应用掩码,然后应用 Softmax。Softmax 将处理我们替换的值。我们如何应用掩码?我们只是将所有我们想掩码的值替换为非常非常小的值,这样 Softmax 就会将它们替换为零。所以如果定义了掩码,就应用它。这意味着基本上将所有满足此条件的值替换为这个值。稍后我们还将看到如何构建掩码。现在,只需假定这些是我们不想要的所有值。所以我们不希望某些单词看到未来的单词,例如当我们构建解码器时。或者我们不希望填充值与其他值交互,因为它们只是填充单词以达到序列长度。我们将它们替换为 `-1 * 10^9`,这是一个非常大的负数,基本上代表负无穷。然后当我们现在应用 Softmax 时,它将被替换为零。我们将其应用于这个维度。好的,让我写一些注释。所以在这种情况下,我们有一个 `(batch, H)`。所以每个头。然后是 `(sequence_length, sequence_length)`。好的,如果我们也有 dropout。如果 dropout 不是 None,我们也应用 dropout。最后,正如我们在原始幻灯片中看到的,我们将 Softmax 的输出乘以 V 矩阵。矩阵乘法。所以我们返回注意力分数乘以值,也返回注意力分数本身。为什么我们返回一个元组?因为我们当然需要它,因为我们需要将其提供给下一层。但这将用于可视化。多头注意力的输出,在这种情况下,实际上将在这里,我们将使用它进行可视化。模型为那个特定的交互给出的分数是多少。让我写一些注释。所以在这里我们这样做:`(batch, H, sequence_length, dk)`。让我们回到这里。所以现在我们有了多头注意力。多头注意力的输出是什么?我们最终要做的是,好的,让我们先回到幻灯片。我们在哪里?我们计算了这些更小的矩阵。所以我们应用了 Softmax(Q * K^T / sqrt(DV)),然后我们还乘以了 V。我们在这里可以看到它,它产生了这个小矩阵,头 1、头 2、头 3、头 4。现在我们需要将它们组合起来。连接,就像论文中的公式说的那样。最后乘以 WO。所以让我们这样做。我们转置,因为之前我们将矩阵转换为 `(sequence_length, dk)`。我们希望序列长度在第一个位置。为了组合它们,因为我们想要的结果张量中的序列长度在第二个位置。所以让我先写下我们想做什么。我们从这个开始。所以我们可以选择。首先,我们进行转置。然后我们想要这个。这个转置将我们带到这里。然后我们进行视图。但我们不能这样做。我们需要使用 `contiguous`。这意味着基本上 PyTorch 为了改变张量的形状,需要将内存变为连续的。所以它可以就地完成。然后我们进行视图。我们想要 `(batch, sequence_length, D_model)`。这是 `self.h * self.dk`。正如您记得的,这是 D_model,因为我们将 DK 定义为 D_model 除以 H。最后,我们将这个 X 乘以 WO,这是我们的输出矩阵。这将使我们从 `(batch, sequence_length, D_model)` 到 `(batch, sequence_length, D_model)`。这就是我们的多头注意力块。我认为我们现在已经有了所有组件。我们只缺少一个小层。让我们看看它。首先,还有一个最后的层我们需要构建,那就是连接。我们可以看到这里,例如,这里有一些输出。这个层的输出。Add & Norm。它在这里被带走,与这个连接。这个部分被发送到这里。然后这个的输出被发送到 Add & Norm,然后由这个层组合起来。所以我们需要创建管理这个跳跃连接的层。我们取输入,将其传递给两个。跳过一层。我们取前一层的输出。在这种情况下,多头注意力。我们将其传递给这一层,但同时将其与这个部分组合起来。所以让我们构建这个层。我将称之为残差连接,因为它基本上是一个跳跃连接。好的,让我们构建这个残差连接。像往常一样,我们定义构造函数。在这种情况下,我们只需要 dropout。正如您记得的,跳跃连接是在 Add & Norm 和前一层的之间。所以我们还需要 Norm,即我们的层归一化,我们之前已经定义了。然后我们定义 forward 方法。所以 `sub_layer`,即前一层的输出。我们所做的是,我们取 X 并将其与下一层的输出组合起来,在这种情况下称为 `sub_layer`。然后我们应用 dropout。这就是 Add & Norm 的定义。实际上,有一个细微的差别是,我们首先应用归一化,然后应用子层。在论文的情况下,他们首先应用子层,然后应用归一化。我看到很多实现,其中大多数实际上是这样做的。所以我们也坚持这个特定的。正如您记得的,我们有这些块组合在一起,由这个更大的块组成。我们有 N 个这样的块。所以这个大块,我们将称之为编码器块。这些编码器块中的每一个都重复 N 次,其中前一个的输出被发送到下一个。最后一个的输出被发送到解码器。所以我们需要创建这个块,它将包含一个多头注意力、一个 Add & Norm 和一个前馈网络。所以让我们这样做。我们将这个块称为编码器块,因为解码器有三个块,而编码器只有两个。我们之前说过,我们有一个自注意力块。在其中,它是多头注意力。我们称之为自注意力。因为在编码器的情况下,它应用于相同的输入,具有三个不同的角色:查询、键和值的角色。这是前馈网络。然后我们有一个 dropout,它是一个浮点数。然后我们定义。然后我们定义两个残差连接。我们使用 `nn.ModuleList`,它是一种组织模块列表的方式。在这种情况下,我们需要两个。好的,让我们定义 forward 方法。我定义了 `source_mask`。什么是 `source_mask`?它是我们想应用于编码器输入的掩码。为什么我们需要编码器的输入掩码?因为我们想隐藏填充词与其他词的交互。我们不希望填充词与其他词交互。所以我们应用掩码。让我们进行第一个残差连接。让我们回到检查视频,检查幻灯片,这样我们就可以理解我们现在在做什么。所以第一个跳跃连接是这里的 X,它被发送到这里。但在此之前,它被添加并与 Add & Norm 组合。我们首先需要应用多头注意力。所以我们取这个 X,将其发送到多头注意力。同时,我们也将其发送到这里。然后我们组合这两个。所以第一个跳跃连接是在 X 和然后另一个 X 之间,它来自自注意力。这是一个函数。我将使用 lambda 来定义子层。这基本上意味着首先应用自注意力。自注意力,其中我们给查询、键和值是我们的 X,即我们的输入。所以这就是为什么它被称为自注意力,因为查询、键和值的角色是 X 本身,即输入本身。所以是句子在观察自己。所以同一个句子中的每个单词都与其他同一个句子中的单词进行交互。我们将看到在解码器中情况有所不同,因为我们有交叉注意力。所以来自解码器的查询正在观察来自编码器的键和值。我们给它源掩码。这是什么?基本上,我们正在调用多头注意力块的 forward 函数。我们给它查询、键、值和掩码。这将通过残差连接与此组合。然后我们再次做第二个。第二个是前馈网络。我实际上需要 lambda。然后我们返回 X。这意味着组合前馈网络,然后是 X 本身。所以是前一层的输出,即这个。然后应用残差连接。这就定义了我们的编码器块。现在我们可以定义编码器对象了。因为编码器由许多编码器块组成,根据论文,我们可以有 N 个。所以让我们定义编码器。我们将有多少层?我们将有 N 层,所以我们将有很多层,它们一个接一个地应用。这是一个 `nn.ModuleList`。最后,我们将应用层归一化。所以我们一个接一个地应用层。前一层的输出成为下一层的输入。我在这里忘记了什么?最后,我们应用归一化。这结束了我们围绕编码器的旅程。让我们简要回顾一下我们所做的。我们已经接收了输入,将其发送到我们还没有。我们还没有将所有块组合在一起。现在我们只构建了这个名为编码器的大块,它包含两个小块,即这个跳跃连接。第一个跳跃连接是在多头注意力和这里发送的 X 之间。第二个跳跃连接是在前馈网络和这里发送的 X 之间。我们有 N 个这样的块,一个接一个。最后一个的输出将被发送到解码器。但在我们应用归一化之前。现在我们将构建解码器部分。在解码器中,输出嵌入与输入嵌入相同。我的意思是,我们需要定义的类是相同的。所以我们将初始化两次。位置编码也是如此。我们可以使用与编码器相同的值。我们需要定义的是这个大块。它由 Masked Multi-Head Attention、Add & Norm 组成。所以一个跳跃连接在这里。另一个 Multi-Head Attention 带有另一个跳跃连接。以及带跳跃连接的前馈网络。我们定义 Multi-Head Attention 类的方式实际上已经考虑了掩码。所以我们不需要为解码器重新发明轮子。我们只需要定义解码器块,即这个大块,它由三个子层组成。然后我们使用 N 个这样的解码器块来构建解码器。所以让我们这样做。让我们首先定义解码器块。在解码器中,我们有自注意力。让我们回去。这是自注意力,因为我们有这个输入被三次用于 Masked Multi-Head Attention。这被称为自注意力,因为相同的输入扮演查询、键和值的角色。这是前馈网络。然后我们有一个 dropout。好的,我们还定义了残差连接。在这种情况下,我们有三个。太棒了。好的,让我们构建 forward 方法。它与编码器非常相似,但有一个细微的差别,我将重点介绍。我们需要 X。X 是解码器的输入。但我们也需要编码器的输出。我们需要源掩码,即应用于编码器的掩码。以及目标掩码,即应用于解码器的掩码。为什么它们被称为源掩码和目标掩码?因为在这种特定情况下,我们正在处理翻译任务。所以我们有一个源语言,在这种情况下是英语。我们有一个目标语言,在我们这里是意大利语。所以你可以称之为编码器掩码或解码器掩码。但基本上,我们有一个掩码来自编码器,一个来自解码器。所以我们称之为源。源掩码是来自编码器的那个。目标掩码是来自解码器的那个。并且像以前一样,我们计算自注意力。这是解码器块的第一部分。其中查询、键和值是相同的输入,但带有解码器的掩码。因为这是解码器的自注意力块。然后我们需要组合。我们需要计算交叉注意力。这是我们的第二个残差连接。我们给它。在这种情况下,我们给它来自解码器的查询,即 X,来自编码器的键和值,以及编码器的掩码。最后是前馈网络块,就像以前一样。就是这样。我们实际上已经有了构建解码器的所有组件。它只是 N 次这个块一个接一个地应用,就像我们为编码器所做的那样。在这种情况下,我们也提供很多层。所以 `layers` 只是一个 `nn.ModuleList`。最后我们也将有一个归一化,就像以前一样。我们将输入应用于一个层,然后使用前一层的输出作为下一层的输入。所以每一层都是解码器块。我们需要给它 X,我们需要给它编码器输出,然后是源掩码和目标掩码。它们中的每一个都是这样。我们正在调用这里的 forward 方法。所以没有什么不同。最后,我们应用归一化。这就是我们的解码器。还有一个最后的组件我们需要拥有。什么是完整的 Transformer?让我们看看。我们需要的最后一个组件是这个层,线性层。所以正如您从我的幻灯片中记得的,多头注意力的输出是 `(sequence_length, D_model)` 的东西。所以我们期望这里有输出是 `(sequence_length, D_model)`,如果我们不考虑批次维度。然而,我们想将这些单词映射回词汇表。这就是为什么我们需要这个线性层,它将把嵌入转换为词汇表中的一个位置。我将称这个层为投影层,因为它将嵌入投影到词汇表中。让我们去构建它。我们需要这个层什么?D_model,这是一个整数。还有词汇表大小。但这基本上是一个线性层,它将 D_model 转换为词汇表大小。所以,开始。投影层是。让我们定义 forward 方法。我们想做什么?让我写这个小注释。我们想将 `(batch, sequence_length, D_model)` 转换为 `(batch, sequence_length, vocabulary_size)`。在这种情况下,我们还将应用 Softmax。实际上,我们将应用 Log Softmax 以获得数值稳定性,就像我之前展示的那样,应用于最后一个维度。就是这样。这就是我们的投影层。现在我们有了 Transformer 的所有组件。所以让我们定义我们的 Transformer 块。在一个 Transformer 中,我们有一个编码器,它是我们的编码器。我们有一个解码器,它是我们的解码器。我们有一个源嵌入。为什么我们需要源嵌入和目标嵌入?因为我们处理的是多种语言。所以我们有一个源语言的输入嵌入和一个目标语言的输入嵌入。我们有目标嵌入。然后我们有源位置和目标位置,它们实际上是相同的。然后我们有投影层。我们现在保存它。我们定义三个方法:一个用于编码,一个用于解码,一个用于投影。我们将它们按顺序应用。为什么我们不只构建一个 forward 方法?因为正如我们将在推理过程中看到的,我们可以重用编码器的输出,我们不必每次都计算它。而且我们也更愿意将这些输出分开,也用于可视化注意力。所以对于编码器,我们有源的。因为我们有源语言和源掩码。所以我们所做的是,我们首先应用嵌入,然后应用位置编码,最后应用编码器。
然后我们定义解码方法,它接收编码器输出(一个张量)、源掩码(一个张量)、目标和目标掩码。我们所做的是,首先将目标嵌入应用于目标句子,然后将位置编码应用于目标句子,最后进行解码。这基本上是解码器的前向方法。所以我们的参数顺序是相同的。是的,最后我们定义投影方法,在这个方法中我们只应用投影。我们从嵌入到词汇表大小。好的,这也是我们必须构建的最后一个块。但是我们没有创建一个方法来组合所有这些块。所以我们构建了许多块,我们需要一个方法,给定 Transformer 的超参数,为我们构建一个单一的 Transformer,初始化所有编码器、解码器、嵌入等。所以,让我们构建这个函数,我们称之为 build Transformer,它将根据所有超参数为我们构建 Transformer,并用一些初始值初始化参数。我们肯定需要定义一个 Transformer。在这种情况下,我们谈论的是翻译。我们正在构建的这个模型将用于翻译,但你可以用它来完成任何任务。所以,我使用的命名基本上是翻译任务中使用的那些。稍后你可以更改命名,但结构是相同的,所以你可以用它来完成任何其他适用于 Transformer 的任务。所以,我们需要的第一件事是源和目标的词汇表大小,因为我们需要构建嵌入。嵌入是因为嵌入需要将词汇表中的标记转换为大小为 512 的向量,所以它需要知道词汇表有多大,所以它需要创建多少个向量。然后是目标,它也是一个整数。然后我们需要告诉它源序列长度和目标序列长度。这非常重要。它们也可以是相同的。在我们的例子中,它们将是相同的,但它们也可以不同。例如,如果你正在使用处理两种非常不同语言的 Transformer,例如翻译,其中源语言所需的标记比另一种语言高得多或低得多。所以你不需要保持相同的长度,你可以使用不同的长度。下一个超参数是 D_model,我们将其初始化为 512,因为我们希望保持与论文相同的值。然后我们定义超参数 N,它是层数。所以我们将使用的编码器块和解码器块的数量根据论文是六个。然后我们定义超参数 H,是我们想要的头的数量,根据论文是八个。Dropout 是 0.1。最后,我们有前馈层的隐藏层 DFF,正如我们在论文中看到的,它是 2048。我们首先做的是创建嵌入层。源嵌入,然后是目标嵌入。然后我们创建位置编码层。我们不需要创建两个位置编码层,因为它们实际上做的是相同的工作。它们也不添加任何参数。但是因为它们有 Dropout,而且因为我想让它更详细,这样你就可以理解每个部分而不进行任何优化,我认为实际上没关系,因为这是为了教育目的,我不想优化代码,我想让它尽可能易于理解。所以我做我需要做的每一个部分,我不走捷径。然后我们创建了编码器块,我们有 N 个。所以让我们创建一个空数组,我们有 N 个。所以每个编码器块都有一个自注意力。所以一个编码器潜在的,它是一个多头注意力块。多头注意力需要 D_model、H 和 Dropout 值。然后我们有一个前馈块。正如你所看到的,我使用的名称也相当长,主要是因为我想让它尽可能地易于每个人理解。所以每个编码器块由一个自注意力和一个前馈组成。最后,我们告诉它 Dropout 的值是多少。然后我们添加这个编码器块,然后我们可以创建解码器块。我们也有解码器的交叉注意力。我们也有前馈,就像编码器一样。然后我们定义解码器块本身,它是一个解码器块交叉注意力,最后是前馈和 Dropout。最后,我们将其保存在其数组中。我们现在可以创建编码器和解码器。我们给他所有的块,也就是 N 个,然后也给他解码器。我们创建投影层,它将模型转换为词汇表大小。当然,目标词汇表,因为我们想从源语言到目标语言。所以我们想将我们的输出投影到目标词汇表中。然后我们构建 Transformer。
编码器、解码器、源嵌入、目标嵌入、源位置编码、目标位置编码,最后是投影层。就是这样。现在我们可以使用 Xavier uniform 来初始化参数。这是一种初始化参数的方法,可以加快训练速度,所以它们不会一开始就只是随机值。有很多算法可以做到这一点。我看到很多实现都使用了 Xavier,所以我认为这是一个很好的起点,模型可以从中学习。最后,我们返回我们亲爱的 Transformer。就是这样,这就是如何构建模型。现在我们已经构建了模型,我们将继续使用它。所以我们将首先看一下数据集,然后我们将构建训练循环。在训练循环之后,我们还将构建推理部分和可视化注意力的代码。所以请稍等,喝杯咖啡,喝杯茶,因为它会有点长,但它会是值得的。现在我们已经构建了模型的代码,我们的下一步是构建训练代码。但在我们构建训练代码之前,我们首先,让我们重新检查一下代码,因为我们可能会有一些拼写错误。我实际上已经做了这个检查,代码中有几个错误。我比较了旧的和新的。它是各种小问题。所以我们写了 feed forward 而不是 feed forward。所以同样的问题也存在于每一个对 feed forward 的引用中,以及在这里构建解码器块时。另一个问题是,在这里构建解码器块时,我们只写了 nn.module,而它应该是 nn.modulelist。然后 feed forward 也应该在这里和这里修复。在 build Transformer 方法中。现在我可以删除旧的了,我们不再需要它了。让我检查一下模型。它是正确的,带有 feed forward。是的。好的,我们的下一步是构建训练代码。但在我们构建训练代码之前,我们必须看看数据。我们要处理什么样的数据?所以正如我之前所说,我们正在处理翻译任务,我选择了这个名为 Opus books 的数据集,我们可以在 hugging face 上找到它。我们还将使用 hugging face 的库来为我们下载这个数据集。这是我们除了 PyTorch 之外将使用的唯一库,因为我们当然不能自己重新发明数据集。所以我们将使用这个数据集,我们还将使用 hugging face 的 tokenizer 库将这些文本转换为词汇表,因为我们的目标是构建 Transformer,而不是已经匹配好轮子。所以我们将只专注于构建和训练 Transformer。在我这里,我将使用英语到意大利语的子集。但我们将以这样一种方式构建代码,你可以选择语言,代码将相应地运行。如果我们看数据,我们可以看到每个数据项都是一对句子,一个英语,一个意大利语。例如,那天没有可能散步,这在意大利语中的意思是。所以我们将训练我们的 Transformer 从源语言(英语)翻译到目标语言(意大利语)。所以,让我们开始吧。我们将一步一步地进行。所以首先,我们将编写代码来下载这个数据集并创建分词器。那么什么是分词器?让我们回到幻灯片,简要概述一下我们将如何处理这些数据。分词器是输入嵌入之前的。所以我们有一个英语句子。例如,你的猫是一只可爱的猫。但这个句子将来自我们的数据集。分词器的目标是创建这个标记。将这个句子分割成单个单词,这有很多策略,正如你在这里看到的。我们有一个句子,它是“你的猫是一只可爱的猫”。分词器的目标是将这个句子分割成单个单词,这有很多方法。有 BPE 分词器,有词级别分词器,有子词级别分词器,有很多分词器。我们将使用最简单的一种,称为词级别分词器。所以词级别分词器基本上会分割这个句子,比如说按空格分割。所以每个空格定义一个单词的边界,然后分割成单个单词。每个单词将被映射到一个数字。所以这是分词器的任务,构建词汇表和这些数字,并将每个单词映射到一个数字。当我们构建了分词器后,我们还可以创建特殊的标记,我们将用于 Transformer。例如,称为 padding 的标记,称为 start of sentence、end of sentence 的标记,这些对于训练 Transformer 是必需的。但我们将一步一步来。所以让我们先编写构建分词器的代码,并下载数据集。好的,让我们创建一个新文件,称之为 train.py。好的,让我们导入我们常用的库。所以 torch。我们还将导入 torch.nn。而且,因为我们正在使用 hugging face 的库,我们还需要导入这两个库。我们将使用 datasets 库,你可以用 pip 安装它。所以 datasets。实际上,我们将使用 load_dataset。我们还将使用 tokenizers 库,也来自 hugging face,你可以用 pip 安装它。我们还需要我们需要哪个分词器。所以我们正在使用,我们将使用词级别分词器。还有 trainers。所以,分词器,分词器类,它将训练分词器。所以它将根据句子列表创建词汇表。我们将根据空格分割单词。我将一个接一个地构建方法。我将首先构建创建分词器的方法,我将描述每个参数。现在你还没有全局图景,但稍后当我们组合所有这些方法时,你就会有全局图景。所以,让我们先创建一个构建分词器的方法。我们将称之为 get_or_build_tokenizer。这个方法接收配置,这是我们模型的配置,我们稍后会定义它。数据集和我们要构建分词器的语言。我们定义分词器路径,即我们将保存此分词器的文件。我们这样做是 config.tokenizer_file。好的,让我先定义一些东西。首先,这个路径来自 pathlib。这是允许你通过相对路径创建绝对路径的库。我们假设我们有一个名为 tokenizer_file 的配置,它是分词器文件的路径。这个路径是可格式化的,使用语言。例如,我们可以有这样的东西。例如,这样的东西。这将是,给定语言,它将创建一个分词器,英语或同步或意大利语,例如。如果不存在,我们创建它。我实际上是从 hugging face 的分词器库的快速入门中获取了所有这些代码。它并不复杂。我只是采用了他们的快速入门。它真的很容易使用。而且它为你节省了很多时间,因为构建分词器真的是在重新发明轮子。我们还将引入未知词,未知词。所以这是什么意思?如果我们的分词器看到一个它在词汇表中不认识的词,它将用这个未知词替换它。它将把它映射到对应于这个未知词的数字。print_tokenizer 的意思基本上是我们按空格分割,然后我们训练,我们构建 trainer 来训练我们的分词器。好的,这是 trainer。这是什么意思?它意味着它将是一个词级别 trainer。所以它将使用空格和单个单词来分割单词。它还将有四个特殊标记。一个是 unknown,这意味着如果你找不到那个特定的词在词汇表中,就用 unknown 替换它。它还将有 padding,我们将用它来训练 Transformer。start_of_sentence 和 end_of_sentence 特殊标记。frequency 的意思是,一个词要出现在我们的词汇表中,它必须至少出现两次。现在我们可以训练分词器了。我们使用这个方法,这意味着我们首先构建一个方法,该方法从我们的数据集中获取所有句子。我们稍后会构建它。所以,让我们也构建这个名为 get_all_sentences 的方法,这样我们就可以迭代数据集以获取与我们正在创建分词器的特定语言对应的所有句子。正如你所记得的,数据集中的每个项目都是一对句子,一个英语,一个意大利语。我们只想提取一种特定的语言。这是代表对的项。从这个对中,我们只提取我们想要的语言。这就是构建分词器的代码。现在让我们编写加载数据集并构建分词器的代码。我们将调用这个方法来获取数据集,它也接收我们模型的配置,我们稍后会定义它。所以,让我们加载数据集。我们将称之为 raw。好的,hugging face 允许我们非常容易地下载其数据集。我们只需要告诉它数据集的名称,然后告诉它我们想要的子集。我们想要英语到意大利语的子集。但我们也想让它可配置,以便你们可以快速更改语言。所以,让我们动态构建这个子集。我们将在配置中设置两个参数,一个称为 language_source,一个称为 language_target。稍后我们还可以定义我们想要的分割。在我们的例子中,hugging face 的原始数据集中只有一个训练分割。但我们将自己将其分割成验证和训练数据。所以,让我们构建这个子集。这是原始数据集,我们也有目标。好的。现在,因为我们只有 hugging face 的训练分割,我们可以自己将其分割成训练和验证。我们将 90% 的数据用于训练,10% 用于验证。random_split 方法允许,它是 PyTorch 的一个方法,允许使用我们输入的尺寸分割数据集。在这种情况下,这意味着将此数据集分割成这两个较小的数据集,一个尺寸为这个,一个尺寸为这个。但是,让我们从 torch 导入方法。让我们也导入我们稍后需要的那个。和 random_split。现在我们需要创建数据集。我们的模型将用于访问张量的数据集。所以,让我们创建数据集。我们称之为 bilingual_dataset。为此,我们创建一个新文件。好的,这里我们也导入 torch。就是这样。我们将调用数据集。我们将称之为 bilingual_dataset。作为惯例,我们定义构造函数。在这个构造函数中,我们需要给他从 hugging face 下载的数据集,源语言的分词器,目标语言的分词器,源语言,源语言的名称,目标语言的名称,以及我们将使用的序列长度。我们保存所有这些值。我们还可以保存 token,我们将用于创建模型张量的特定 token。所以我们需要 start_of_sentence、end_of_sentence 和 padding token。如何将 start_of_sentence token 转换为数字,转换为 input ID?有一个分词器的特殊方法可以做到这一点。所以,让我们这样做。这是 start_of_sentence token。我们想把它构建成一个张量。这个张量将只包含一个数字,由我们可以使用源或目标的分词器给出,这无关紧要,因为它们都包含这些特定的标记。这是将 token 转换为数字的方法。start_of_sentence。以及这个张量的类型,我们想要 long,因为词汇表可能超过 32 位长。这个词汇表大小。所以我们通常使用 long 64 位。我们对 end_of_sentence 和 padding token 也这样做。我们还需要定义这个数据集的 length 方法,它告诉数据集本身的长度。所以基本上就是 hugging face 数据集的长度。然后我们需要定义 get_item 方法。好的,首先,我们将从 hugging face 数据集中提取原始对。然后我们提取源文本和目标文本。最后,我们将每个文本转换为 token,然后转换为 input IDs。这是什么意思?首先,分词器将把句子分割成单个单词。然后它会将每个单词映射到它在词汇表中的相应数字。它将一次完成。这是由 encode 方法完成的。IDs。这给了我们 input IDs。每个单词在原始句子中的相应数字。它将以数组形式给出。我们对解码器也做了同样的处理。现在,正如你所记得的,我们还需要填充句子以达到序列长度。这非常重要,因为我们希望我们的模型始终工作,我的意思是模型始终使用固定长度的序列长度。但我们并不是每个句子都有足够的单词。所以我们使用 padding token。所以这个 pad here 作为 padding token 来填充句子,直到它达到序列长度。我们计算需要为编码器端和解码器端添加多少 padding token。基本上需要多少才能达到序列长度减去 2。为什么这里是减 2?所以我们已经有了这个数量的 token。我们需要达到这个。但我们还将添加 start_of_sentence token 和 end_of_sentence token 到编码器端。所以这里也有减 2。而这里只有减 1。如果你还记得我之前的视频,当我们进行训练时,我们只将 start_of_sentence token 添加到解码器端。然后在标签中,我们只添加 end_of_sentence token。所以,在这种情况下,我们只需要向句子添加一个特殊标记。我们还确保我们选择的这个序列长度足以表示我们数据集中所有句子的长度。如果我们选择一个太小的,我们希望它引发一个异常。所以,基本上,这个 padding token 的数量永远不应该变成负数。现在,让我们构建用于编码器输入和解码器输入的两个张量,也用于标签。所以一个句子将被发送到编码器的输入。一个句子将被发送到解码器的输入。一个句子是我们期望的解码器输出。我们将把那个输出称为标签。通常它被称为 target 或 label。我称之为 label。我们可以切出张量。首先是 start_of_sentence token。然后是源文本的 token。然后是 end_of_sentence token。然后是足够的 padding token 来达到序列长度。我们已经计算了需要添加到这个句子中的嵌入 token 的数量。所以,让我们就这样做。这就是编码器输入。让我写一些注释。这里是添加 SOS 和 EOS 到源文本。然后我们构建解码器输入,它也是 token 的连接。在这种情况下,我们没有 start_of_sentence。我们只有,我们没有 end_of_sentence。我们只有 start_of_sentence。最后,我们添加了足够的 padding token 来达到序列长度。我们已经计算了我们需要多少。现在就使用这个值。然后我们构建标签。在标签中,我们只添加了 end_of_sentence token。让我复制一下,这样更快。是的,因为我们需要与解码器输入相同数量的 padding token。让我们双重检查一下,只是为了调试。让我们双重检查一下,我们确实达到了这个序列长度。好的,现在我们已经做了这个检查。让我再写一些注释。这里我们只添加 EOS,而不是这里,SOS 与解码器输入。而这里是向 label 添加 EOS。但我们期望它是解码器的输出。现在我们可以返回所有这些张量,以便我们的训练可以使用它们。我们返回一个字典,包括 encoder_input。encoder_input 是什么?基本上是 batch_size x sequence_length。然后我们有 decoder_input,它也是一个 sequence_length 的数字 token。我在这里忘了逗号。然后我们有 encoder_mask。encoder_mask 是什么?正如你所记得的,我们通过添加 padding token 来增加编码器输入句子的长度。但我们不希望这些 padding token 参与自注意力。所以我们需要构建一个掩码,说明我们不希望这些 token 被自注意力机制看到。所以我们为编码器构建掩码。我们如何构建这个掩码?我们只说所有不是 padding 的 token 都可以。所有 padding 的 token 都不可以。我们还 unsqueeze 以添加这个序列维度,以及稍后添加批次维度。我们将其转换为整数。这是一个 sequence_length。因为这将用于自注意力机制。然而,对于解码器,我们需要一个特殊的掩码,它是一个因果掩码。这意味着每个单词只能查看前面的单词。而且每个单词只能查看不是 padding 的单词。所以我们再次不希望 padding token 参与自注意力。我们只希望真正的单词参与。而且我们也不希望每个单词查看后面的单词,只查看前面的单词。所以我将在这里使用一个名为 causal_mask 的方法,我们稍后会构建它。所以现在我只是调用它来向你展示它是如何使用的,然后我们将继续构建它。在这种情况下,我们不想要 padding token。我们添加必要的维度。我们还进行了一个布尔与操作,与 causal_mask。这是一个我们现在将构建的方法。这个 causal_mask 需要构建一个大小为 sequence_length x sequence_length 的矩阵。sequence_length 是什么?基本上是我们解码器输入的尺寸。而这个,让我为你写一个注释。这是 1 到 sequence_length 组合的。与 1 到 sequence_length 的序列相与。而这个可以广播。Co 定义这个方法 causal_mask。那么 causal_mask 是什么?causal_mask 基本上意味着我们想要,让我们回到幻灯片。正如你从幻灯片中记得的,我们希望解码器中的每个单词只查看前面的单词。所以我们想要的是掩盖这个对角线以上的所有值。这个矩阵代表查询与键在自注意力机制中的乘法。我们想要的是隐藏所有这些值。所以你不能看到单词“cat is a lovely cat”。你只能看到自己。但这个单词,例如,“lovely”,可以查看它之前的所有内容。从“your”到“lovely”本身。但不是后面的单词“cat”。所以我们想要的是掩盖这里的所有值。这也意味着我们想要掩盖这个对角线以上的所有值。在 PyTorch 中有一个非常实用的方法可以做到这一点。所以,让我们这样做。让我们构建这个方法。掩码基本上是 torch.triu。这意味着给我所有我告诉你的对角线以上的值。我们想要一个由所有 1 组成的矩阵。这个方法将返回对角线以上的所有值,而其他所有值都将变为零。我们想要对角线类型为 1。我们想要它是整数。我们所做的是返回 mask = 0。这将返回对角线以上的所有值,而对角线以下的所有值都将变为零。但我们实际上想要相反。所以我们说,好的,所有为零的值都将通过这个表达式变为 true。所有非零的值都将变为 false。所以我们在这里应用它来构建这个掩码。这个掩码将是 1 x sequence_length x sequence_length,这正是我们想要的。好的,让我们也添加标签。标签也是。我忘了逗号。sequence_length。然后我们有源文本,只是为了可视化。我们可以发送源文本和目标文本。这就是我们的数据集。现在让我们回到我们的训练方法,继续编写训练循环。所以,现在我们有了数据集,我们可以创建它。我们可以创建两个数据集,一个用于训练,一个用于验证。然后我们将其发送到数据加载器,最后发送到我们的训练循环。我们忘了导入数据集。所以,让我们在这里导入它。让我们导入 causal_mask,我们稍后会需要它。我们的源语言是什么?它在配置中。我们的目标语言是什么?我们的序列长度也在配置中。我们对验证也这样做。但唯一的区别是我们使用这个。其余的都一样。我们还,只是为了选择最大序列长度,我们还想看看我们创建的两个分割中,源和目标中每个句子的最大长度。所以,如果我们选择一个非常小的序列长度,我们就会知道。所以,基本上,我加载源语言和目标语言的每个句子。我使用分词器将其转换为 IDs。我检查长度。如果长度是,比如说 180,我们可以选择 200 作为序列长度,因为它将覆盖我们在这个数据集中拥有的所有可能的句子。如果它是,比如说 500,我们可以使用 510 或类似的东西。因为我们还需要将 start_of_sentence 和 end_of_sentence token 添加到这些句子中。
然后,让我们也创建目标 IDs。这是目标语言。然后我们只说源最大长度是当前句子的长度和目标的最大值。目标是目标。目标 ID 是。然后我们打印这两个值。我们也为目标这样做。就是这样。现在我们可以继续创建数据加载器了。我们根据我们的配置定义批次大小,我们还没有定义它,但你已经可以猜到它的值了。我们希望它被洗牌。好的,对于验证,我将使用批次大小为 1,因为我想逐个处理每个句子。这个方法返回训练的数据加载器,验证的数据加载器,源语言的分词器,以及目标语言的分词器。现在我们可以开始构建模型了。所以,让我们定义一个名为 get_model 的新方法,它将根据我们的配置,我们的词汇表大小,构建模型,Transformer 模型。所以,模型,我们还没有导入模型。所以,让我们导入它。build_Transformer。首先是源词汇表大小和目标词汇表大小。然后是序列长度。我们有源语言的序列长度和目标语言的序列长度。我们将两者都使用。然后是 D_model,它是嵌入的大小。我们可以将其余的都保留为默认值,就像论文中一样。如果模型太大,无法在你的 GPU 上训练,你可以尝试减小头的数量或层的数量。当然,这会影响模型的性能。但我认为考虑到数据集不是那么大,也不是那么复杂,应该不是什么大问题,因为我们并没有构建一个巨大的数据集。好的,现在我们有了模型,我们可以开始构建训练循环了。但在我们构建训练循环之前,让我先定义这个配置,因为它一直在出现,我认为最好现在就定义结构。所以,让我们创建一个名为 config.py 的新文件,我们在其中定义两个方法,一个称为 get_config,一个称为 map_to_get_path,我们将保存模型权重的路径。好的,让我们定义批次大小。我选择 8。如果你的电脑允许,你可以选择更大的。训练的 epoch 数量。我说 20 足够了。学习率。我使用 10 的 -4 次方。你可以使用其他值。我曾认为这个学习率是合理的。有可能在训练过程中改变学习率。实际上,在每个 epoch 逐渐降低学习率是很常见的。我们不会使用它,因为它会使代码稍微复杂一点,而这并不是这个视频的目标。这个视频的目标是教 Transformer 如何工作。我已经检查了这个序列长度。对于这个特定的英语到意大利语的数据集,350 足够了。我们将使用的 D_model 是默认的 512。源语言是英语,所以我们从英语开始。目标语言是意大利语。我们将翻译成意大利语。我们将把模型保存在一个名为 weights 的文件夹中。模型的文件名将是 t_model,即 Transformer 模型。我还构建了预加载模型的代码,以防我们想在可能崩溃后重新开始训练。这是分词器文件。它将像这样保存,tokenizer_en 和 tokenizer_it,根据语言。这是 tensorboard 的实验名称,我们将在上面保存训练过程中的损失。我认为这里有一个逗号。好的,现在让我们定义另一个方法,允许找到我们需要保存权重的路径。我创建这样一个复杂的结构是因为,我也会提供笔记本电脑在 Google Colab 上运行这个训练。所以我们只需要改变这些参数就可以让它在 Google Colab 上工作,并直接将权重保存到你的 Google Drive 中。我实际上已经创建了这段代码,它将在 GitHub 上提供,我也会在视频中提供链接。谢谢。
文件是根据模型名称构建的。然后是 epoch.pt。这里也导入 path 库。好的,现在让我们回到我们的训练循环。好的,我们现在终于可以构建训练循环了。所以,train_model(config)。首先,我们需要定义将在哪个设备上放置所有张量。所以,定义设备。如果我的电脑上有 GPU。好的。然后我们也打印。我们确保创建了 weights 文件夹。然后我们加载我们的数据集。你可以直接取这些值,说它等于 get_dataset(config)。这也是模型。获取词汇表大小。有一个名为 get_vocab_size 的方法。而且我认为我们没有其他参数了。最后,我们将模型传输到我们的设备。我们还启动 tensorboard。tensorboard 允许可视化损失、图形、图表。让我们也导入 tensorboard。让我们回去。让我们也创建优化器。我将使用 Adam 优化器。好的。因为我们也有允许恢复训练的配置,以防模型崩溃或发生其他崩溃。让我们实现那个。这将允许我们恢复模型的状态和优化器的状态。让我们实现导入这个方法。我们在数据集中定义了。我们加载文件。这里有一个标题。损失函数。我们将使用的是交叉熵损失。我们需要告诉它忽略哪个索引。所以我们不希望它忽略 padding token。基本上,我们不希望 padding token 对损失做出贡献。我们还将使用 label smoothing。label smoothing 基本上允许我们的模型对其决策不太自信。所以,怎么说呢?想象一下,我们的模型告诉我们选择数字 3 的单词,并且概率非常高。所以,我们将用 label smoothing 做的是,取那个概率的一小部分,并将其分配给其他 token。这样我们的模型就会对其选择不太确定。有点不那么过拟合。这实际上提高了模型的准确性。所以我们将使用 0.1 的 label smoothing。这意味着从每个最高概率的 token 中,取 0.1% 的分数,并将其分配给其他 token。好的,让我们最终构建训练循环。返回模型进行训练。我构建了一个批次迭代器,用于数据加载器,使用 tqdm。它将显示一个非常漂亮的进度条。我们需要导入 tqdm。好的,最终我们得到张量。encoder_input。这个张量的尺寸是多少?它是 batch_size x sequence_length。decoder_input 是 batch_size x decoder_input。我们也将其移动到我们的设备。sequence_length。我们还得到两个掩码。这是尺寸。然后是 decoder_mask。为什么这两个掩码不同?因为在一种情况下,我们只告诉它隐藏 padding token。在另一种情况下,我们还告诉它隐藏所有后续单词。对于每个单词,隐藏所有后续单词,将它们掩盖掉。好的,现在我们运行张量到 Transformer。首先,我们计算编码器的输出。并使用 encoder_input 和 encoder_mask 进行编码。然后我们使用 encoder_output、源、encoder_mask、decoder_input 和 decoder_mask 来计算解码器的输出。正如我们所知,这个模型.encode 的结果将是 batch_size x sequence_length x D_model。解码器的输出也将是 batch_size x sequence_length x D_model。但我们想将其映射回词汇表。所以我们需要投影。所以,让我们获取投影输出。这将产生一个 batch_size x sequence_length x target_vocab_size。好的,现在我们有了模型的输出,我们想将其与我们的标签进行比较。所以,首先,让我们从批次中提取标签。我们也将其放到另一个设备上。标签是什么?它是 batch_size x sequence_length。其中每个位置都告诉我们,标签是每个 batch_size 和 sequence_length 的。所以,对于每个维度,它告诉我们那个单词在词汇表中的位置。我们希望这两个可比较。所以,我们首先需要计算损失。projection_output.view(-1, ...)。这是做什么的?它基本上将这个尺寸转换为这个尺寸。batch_size * sequence_length,然后是 target_vocab_size。因为我们想与这个进行比较。这就是交叉熵想要的张量格式。也包括标签。好的,现在我们可以,我们已经计算了损失。我们可以更新我们的进度条。用我们计算的损失。这将显示我们进度条上的损失。我们也可以将其记录在 tensorboard 上。基本上刷新它。现在我们可以反向传播损失。loss.backward()。最后,我们更新模型的权重。这是优化器的作用。最后,我们可以清零梯度。并将全局步数加一。全局步数主要用于 tensorboard 来跟踪损失。我们可以每年保存模型。模型文件名,我们从我们的特殊方法中获取。这个方法告诉你配置。以及文件名,即 epoch,但前面有零。我们保存我们的模型。当我们想能够恢复训练时,保存模型的状态以及优化器的状态是一个非常好的主意。因为优化器也跟踪一些统计数据,每个权重一个,以了解如何移动每个权重。独立地。通常,实际上,我看到优化器字典相当大。所以,即使它很大,如果你想让你的训练可以恢复,你需要保存它。否则,优化器将始终从零开始。并且即使你从之前的 epoch 开始,它也必须从零开始找出如何移动每个权重。所以,每次我们保存一个快照时,我总是包含模型的状态。这是模型的所有权重。我们还想保存优化器。我们也这样做全局步数。我们想将所有这些保存到文件名中。所以,model_filename。就是这样。现在,让我们构建运行它的代码。所以,if __name__ == "__main__":。我真的很讨厌警告。所以我不想每次都可视化它们,因为我有很多库,尤其是 Cuda。我已经知道内容了。所以,但当然,对于你们来说,我建议至少看一次,以了解是否有任何重大问题。否则,它们只是来自 Cuda 的抱怨。好的,让我们尝试运行这段代码,看看一切是否正常。我们应该,我们期望的是代码应该第一次下载数据集。然后它应该创建分词器并将其保存到其文件中。它还应该开始训练模型 30 个 epoch。当然,它永远不会完成。但让我们这样做。让我再次检查配置。分词器。好的,让我们运行它。
分词器。我们在这里遇到了一些问题。sequence_length。好的,最后模型正在训练。我给大家回顾一下我犯的错误。首先,sequence_length 被错误地写成了。这里有一个大写的 L。而且在数据集中,我忘了在这里和这里也写成大写。所以 L 是大写的。现在训练正在进行。正如你所看到的,训练相当快。至少在我的电脑上。实际上不是那么快,因为我选择了批次大小为 8。我可以尝试增加它。它正在 Cuda 上进行。损失正在下降。权重将保存在这里。所以,如果我们到达 epoch 的末尾,它将在这里创建第一个权重。所以,让我们等到 epoch 结束,看看权重是否真的被创建了。在实际完成模型训练之前。让我们再做一件事。我们还想在训练时可视化模型的输出。这称为验证。所以我们想检查我们的模型在训练过程中是如何演变的。所以,我们要构建的是一个验证循环。它将允许我们评估模型。这也意味着我们要从这个模型进行推理。并改变一些示例文本,看看它们是如何被翻译的。所以,让我们开始构建验证循环。我们做的第一件事是,我们将我们的模型设置为评估模式。所以,我们这样做 model.eval()。这意味着它告诉 PyTorch 我们正在评估我们的模型。然后,我们将进行推理。推理两个句子,看看模型的输出是什么。所以,使用 torch.no_grad()。我们禁用了这个 with 块内我们将运行的每个张量的梯度计算。这正是我们想要的。我们只想从模型进行推理。我们不想在这个循环中训练它。所以,让我们从验证数据集中获取一个批次。因为我们只处理两个。所以我们保持一个计数器,记录我们已经处理了多少。我们获取当前批次的输入。我想提醒你,对于 validation_ds,我们只有一个批次大小为 1。这是 encoder_input。我们还可以获取 encoder_mask。让我们验证一下批次的大小确实是 1。现在,让我们进入有趣的部分。所以,正如你所记得的,当我们进行推理时,我们需要只计算一次编码器的输出,并为模型将从解码器输出的每个 token 重用它。所以,让我们创建另一个函数来在我们的模型上运行贪婪解码。我们将看到它只运行一次编码器。所以,让我们称这个函数为 greedy_decode。好的,让我们创建一些我们需要使用的 token。所以,SOS token,它是 start_of_sentence。我们可以从任何一个分词器中获取它,源或目标,它们都有。EOS。然后,我们所做的是,我们预先计算编码器的输出,并为我们从解码器获得的每个 token 重用它。所以,我们只给出源和源掩码,也就是 encoder_input 和 encoder_mask。我们也可以称之为 encoder_input 和 encoder_mask。然后我们得到。好的,我们如何进行推理?我们做的第一件事是,我们将 start_of_sentence token 提供给解码器。这样解码器就会输出翻译句子的第一个 token。然后在每次迭代中,就像我们在幻灯片中看到的那样,在每次迭代中,我们将前一个 token 添加到解码器的输入中。这样解码器就可以输出下一个 token。然后我们取下一个 token。我们将其再次放在解码器的输入前面。我们得到连续的 token。所以,让我们为第一次迭代构建解码器输入。它只是 start_of_sentence token。我们用 start_of_sentence token 填充这个。它的类型与 encoder_input 相同。现在,我们将不断要求解码器输出下一个 token,直到我们达到 end_of_sentence token 或我们在这里定义的 max_len。所以,我们可以做一个 while True。然后我们的第一个停止条件是,如果解码器输出,它成为下一步的输入,大于 max_len 或达到 max_len。这里为什么有两个维度?一个用于批次,一个用于 token。
解码器的输入,现在我们也需要为它创建一个掩码。我们可以使用我们的函数 `causal_mask` 来表示我们不希望输入看到未来的词语,而且我们不需要另一个掩码,因为这里没有填充标记,正如你所见。
现在我们计算输出。在循环的每一次迭代中,我们重用编码器的输出。我们重用源掩码,即编码器的输入掩码。然后我们给出解码器的输入以及它的掩码,即解码器掩码,然后我们得到下一个词。所以我们使用投影层得到下一个词的概率,但我们只想要最后一个词的投影,也就是我们已经给编码器的最后一个词之后的下一个词。
现在我们可以使用 `max`,所以我们得到概率最大的词。这就是贪婪搜索。然后我们得到这个词,并将其附加到这个词后面,因为它将成为下一次迭代的输入。我们进行拼接,所以我们取解码器的输入,并附加下一个词。所以我们为此创建另一个张量。是的,应该是正确的。
如果下一个词或标记等于句子结束标记,那么我们也停止循环。这就是我们的贪婪搜索。现在我们可以返回输出。所以输出基本上是解码器的输入,因为每次我们都将下一个词附加到它上面。我们移除批次维度,所以我们对其进行挤压,这就是我们的贪婪解码。
现在我们可以在这个函数中使用它,在验证函数中。所以我们可以最终得到模型输出等于 `3D decode`,我们给他所有参数,然后我们想将这个模型输出与我们预期的进行比较,也就是与标签进行比较。所以让我们将所有这些附加起来。所以我们输入给模型的,模型输出的,模型的输出,也就是预测的,以及我们预期的输出。我们将所有这些保存在这些列表中,然后在循环结束时,我们将在控制台上打印它们。
要获得模型输出的文本,我们需要再次使用分词器将标记转换回文本,当然我们使用目标分词器,因为这是目标语言。好的,现在我们将所有这些保存在各自的列表中,并且我们也可以在控制台上打印它。
当我们使用 `print_message` 这个函数时,为什么不直接使用 Python 的 `print` 呢?因为我们在主循环,训练循环中使用了 `tqdm`,这是一个非常漂亮的进度条。但是当这个进度条运行时,不建议直接在控制台上打印。所以要打印到控制台,有一个方法叫做 `tqdm` 提供的 `write` 方法,我们将这个方法传递给这个函数,这样输出就不会干扰进度条的打印。我们可以打印一些条形图,然后我们打印所有消息。如果我们已经处理了示例数量,那么我们就中断。
为什么我们创建了这些列表?实际上,我们也可以将所有这些发送到 TensorBoard。所以,例如,如果我们启用了 TensorBoard,我们可以将所有这些发送到 TensorBoard。要做到这一点,我们实际上需要另一个库,它允许我们计算一些指标。我认为我们可以跳过这一部分,但如果你真的感兴趣,在我发布的 GitHub 代码中,你会发现我使用了这个叫做 `torch_metrics` 的库,它允许计算字符错误率、BLEU 指标(这对翻译任务非常有用)和词错误率。所以如果你真的感兴趣,你可以在 GitHub 上找到代码。但对于我们的演示,我认为没有必要。实际上,考虑到我们没有做这一部分,我们也可以移除它。
好的,现在我们有了 `run_validation` 方法,我们可以直接调用它。我通常的做法是每隔几个步骤运行一次验证。但因为我们想尽快看到它,所以我们将首先在我们的迭代中运行它。我们还将 `model.train` 放在这个循环中,这样每次运行验证后,模型就会回到它的训练模式。
现在我们可以直接运行验证,并给出它运行验证所需的所有参数。所以给它更多的模型。对于打印消息,我们是否打印任何消息?我们正在打印,所以让我们创建一个 Lambda 函数,然后我们这样做。这是要与 `tqdm` 一起写入的消息。然后我们需要给出全局步数和 `writer`,我们不会使用它,但好的。
现在我认为我们可以再次运行训练,看看验证是否有效。看起来它正在工作。模型正在每一步运行验证,这完全不是我们想要的,但至少我们知道贪婪搜索正在工作,而且它至少看起来正在工作。模型还没有预测出任何有用的东西,它只是预测了一批逗号,因为它根本没有训练过。但是如果我们训练模型一段时间,我们应该会看到,经过几个 epoch 后,模型应该会越来越好。
所以让我们停止这个训练,并将这个放回它应该在的地方,也就是每个 epoch 的末尾。这个我们可以保留在这里,没问题。
我现在将快进到一个已经预训练过的模型。我预训练了几个小时,以便我们可以进行推理并可视化注意力。我已经复制了我预先计算好的预训练权重,并且我还创建了这个笔记本,重用了我们在 `train` 文件中之前定义的函数。代码实际上非常简单,我只是从 `train` 文件中复制粘贴了代码。我只是加载模型并运行验证,也就是我们刚刚编写的同一个方法,然后我在预训练模型上运行了验证。
例如,让我们再次运行它。正如你所看到的,模型正在推理 10 个示例句子,结果还不错。我的意思是,我们可以看到 11 个微笑,Levinson 比赛 11 个系列,它正在匹配,而且大多数都匹配。实际上,我们也可以说它几乎过拟合了,对于这个特定的数据。但这正是 Transformer 的强大之处。我没有训练很多天,我只训练了几个小时,如果我没记错的话,结果真的非常好。
现在让我们编写我们将用于可视化这个预训练模型的注意力的笔记本。给定我们之前构建的文件 `train.py`,你也可以训练自己的模型,选择你喜欢的语言,我强烈建议你改变语言,看看模型表现如何,并尝试诊断为什么模型表现不好,如果它表现不好,或者如果它表现好,尝试理解如何进一步改进它。
所以,让我们尝试可视化注意力。让我们创建一个新的笔记本,称之为,比如说,`attention_visualization`。
首先,我们导入所有需要的库。我还会使用这个叫做 `altair` 的库,它是一个图表可视化库,与深度学习无关。它只是一个可视化函数,而这个特定的可视化函数实际上是我在网上找到的,不是我写的,就像你可以在网上轻松找到的大多数可视化函数一样,如果你想构建一个图表或者一个直方图等等。我主要使用这个库是因为我从网上复制了代码来可视化它,但其余的都是我自己的代码。所以让我们导入它。
好的,让我们导入所有这些。当然,当你运行代码时,你需要在你的电脑上安装这个特定的库。我们还定义了设备文件。你可以直接从这里复制代码。然后我们加载模型,我们可以从这里复制,就像这样。让我们粘贴在这里,这个变成了 `vocabulary_source` 和 `vocabulary_target`。
加载批次时出错。我将使用分词器将批次转换为标记。当然,对于解码器,我们使用目标词汇表,也就是目标分词器。所以让我们只使用我们的贪婪代码算法进行推理。我们提供模型,我们返回所有这些信息。
现在我将构建必要的功能来可视化注意力。我将从另一个文件中复制一些函数,因为实际上我们要构建的东西在学习点上对深度学习来说没有什么有趣的,它主要是可视化数据的函数。我将复制它,因为它写起来很长,而我当然会解释关键部分。
这个函数是做什么的?基本上,我们有从编码器获得的注意力。如何从编码器获得注意力?例如,注意力有三个位置。第一个在编码器中。第二个在解码器的开头,所以是解码器的自注意力。然后我们有编码器和解码器之间的交叉注意力。所以我们可以可视化三种注意力。
如何获取关于注意力的信息?嗯,我们加载其他模型,我们有编码器,我们选择我们想从中获取注意力的层。然后从每一层,我们可以得到自注意力块,然后是它的注意力分数。
这个变量是从哪里来的?如果你还记得,当我们在这里定义注意力计算时,当我们计算注意力时,我们不仅将输出返回到下一层,我们还提供了这些注意力分数,也就是 softmax 的输出。我们还在这里将它保存在这个变量 `self.attention_scores` 中。现在我们可以检索它并可视化它。
这个函数将根据我们想要从哪一层、哪个头获取哪种注意力,来选择正确的矩阵。这个函数构建一个数据框来可视化信息,即从这个矩阵中提取的标记和分数。我们将从这个矩阵中提取行和列。然后我们也构建图表。图表是用 Altair 构建的。我们实际上将构建的是,我们将获得所有头的注意力,我构建了这个方法来获取所有我们传递给这个函数的头和层的注意力。
所以,让我运行这个单元格。好的,让我们创建一个新的单元格,然后运行它。首先,我们想可视化我们正在处理的句子,也就是批次的输入标记。我们加载一个批次,然后可视化源和目标是什么。对于目标,我们最终也计算长度。长度是什么?基本上是填充字符之前的所有字符。所以是填充字符的第一次出现,因为这是从数据集中获取的批次,它已经是为训练构建的张量,所以它们已经包含了填充。在我们的情况下,我们只想检索句子中实际字符的数量。
所以我们可以检查填充之前的词的数量。所以我们可以运行这个。这里有一个问题。我忘了这个函数是错误的。现在它应该可以工作了。这个句子太短了。让我们获取一个更长的。好的,让我检查一下质量。你不能保持原样,尤其是你。看起来还不错。
好的,让我们打印层的注意力。比如说 0、1 和 2,因为我们有六个,如果你还记得参数 `n` 等于 6。所以我们将只可视化三层,并且我们将可视化所有八个头。所以头号 0、1、2、3、4、5、6、7。
让我们先可视化编码器的自注意力。我们得到所有的注意力图。我们想要哪一个?编码器的一个。我们想要这些层和这些头。它们的行标记是什么?编码器的输入标记。而我们想要列中的是什么?因为我们要构建一个网格。正如你所知,注意力是一个将行与列关联起来的网格。在我们的例子中,我们谈论的是编码器的自注意力,所以是同一个句子在关注自己。所以我们需要在行和列上都提供编码器的输入句子。
我们想要可视化的最大长度是多少?比如说,我们想可视化不超过 20 个,所以是 20 和句子长度的最小值。
这是可视化。我们可以看到,正如我们所预期的,当我们可视化注意力时,我们期望对角线上的值很高,因为它是每个标记与其自身的点积。我们还可以看到其他有趣的关联。例如,我们说句子开始标记和句子结束标记,至少对于头 0 和层 0 来说,它们与我预期的其他词语无关。但是其他头确实学习到了一些非常小的映射。
如果我们悬停在每个网格单元格上,我们可以看到自注意力的实际值,也就是自注意力的分数。例如,我们可以看到这里的注意力非常强,所以“especially”和“especially”这两个词是相关的,这是同一个词与它本身,但也是“especially”和“now”。
我们可以可视化所有层的这种注意力。因为每个头都会关注每个词的不同方面,因为我们将词嵌入平均分配给头,所以每个头都会看到词嵌入的不同部分。我们也希望它们学习到不同的映射。这确实是这种情况。
在层与层之间,我们也有不同的 `w_q`、`w_k` 和 `w_v` 矩阵,所以它们也应该学习到不同的关系。
现在我们也可以想要可视化解码器的注意力。让我们这样做。让我复制代码并更改参数。这里我们想要解码器的一个。我们想要相同的层等等。但是行和列上的标记是解码器的标记,也就是解码器的输入标记和解码器的输入标记。让我们可视化。
我们也应该在这里看到意大利语,因为我们正在使用解码器的自注意力。所以这里我们看到解码器方面的不同注意力。而且这里我们也有多个头,它们应该学习不同的映射,而且不同的层也应该学习不同的词语之间的映射。
我发现最有趣的是交叉注意力。所以让我们来看看。让我复制代码并再次运行它。
好的,所以如果你还记得这个方法,它是编码器解码器,相同的层。所以这里在行上,我们将显示编码器的输入,在列上,我们将显示解码器的输入标记,因为它是编码器和解码器之间的交叉注意力。
这就是编码器和解码器之间的交互是如何工作的,以及它是如何发生的。这就是我们找到交叉注意力的地方,它使用来自编码器的键和值来计算,而查询来自解码器。这就是翻译任务发生的地方,这就是模型如何学会将这两个句子关联起来以实际计算翻译。
我邀请大家自己运行代码。我的第一个建议是和我一起写代码,你可以暂停视频,写下代码,自己写。花点时间,不要马上看解决方案。尝试找出哪里出了问题。如果你真的无法在几分钟内找出问题所在,你可以瞥一眼视频,但要尝试自己解决。
当然,有些事情你无法自己解决。例如,位置编码和所有这些计算基本上只是公式的应用。但关键是,你应该至少能够自己构建一个结构,也就是所有层是如何相互作用的。这是我的第一个建议。
至于训练循环,训练部分实际上非常标准。它与其他你可能见过的训练循环非常相似。有趣的部分是我们如何计算损失,以及我们如何使用 Transformer 模型。最后一件非常重要的事情是我们如何进行模型推理,也就是在 `greedy_decode` 中。
感谢大家观看视频并与我一起度过这么长时间。我可以向你保证,这是值得的。我希望在接下来的视频中,我能制作更多关于 Transformer 和我熟悉的其他模型的示例,并且我也想与大家一起探索。所以请告诉我是否有任何你不理解或者你想让我更好地解释的地方。我肯定会关注评论区。请写下“谢谢”,祝你一天愉快。