Bootstrap

Xinhui学习NLP的笔记本:基于注意力机制的机器翻译

基于注意力机制的机器翻译

在下面的文章中,我们要实现基于注意力机制并使用Transformer模型的机器翻译实现机器翻译,在正式开始项目之前,我们将首先对对一些必要的储备知识进行探索。如下是本篇博客的大致内容:

  1. 编码器-解码器结构;
  2. 束约束;
  3. 注意力机制;
  4. 基于带注意力机制的机器翻译;
  5. 基于Transformer实现机器翻译(日译中);
  6. 总结

本项目所有相关数据/分词模型和语料已上传,可在标题下资源绑定文件中直接下载。

Xinhui

1 编码器-解码器机制

编码器-解码器机制(Encoder-Decoder Mechanism)是一种广泛用于序列到序列(sequence-to-sequence)任务的深度学习架构。它主要由两个部分组成:编码器(Encoder)和解码器(Decoder)。这两个部分通常是基于循环神经网络(RNN)、长短时记忆网络(LSTM)或门控循环单元(GRU)等架构构建的。

1.1 编码器-解码器机制概述

1.1.1 编码器

编码器负责将输入序列转换为一个固定维度的上下文向量(Context Vector)。

假设输入序列为 x = ( x 1 , x 2 , … , x T ) \mathbf{x} = (x_1, x_2, \ldots, x_T) x=(x1,x2,,xT),其中 x i x_i xi是在时间步 i i i处的输入,那么编码器通过一系列隐藏状态来处理该序列:

h t = f ( h t − 1 , x t ) \mathbf{h}_t = f(\mathbf{h}_{t-1}, \mathbf{x}_t) ht=f(ht1,xt)

其中, h t \mathbf{h}_t ht是在时间步 t t t处的隐藏状态, f f f是一个非线性函数(如RNN、LSTM或GRU单元)。最终,编码器输出一个上下文向量 c \mathbf{c} c,通常是最后一个隐藏状态:

c = h T \mathbf{c} = \mathbf{h}_T c=hT

1.1.2 解码器

解码器使用这个上下文向量 c \mathbf{c} c来生成输出序列。假设输出序列为 y = ( y 1 , y 2 , … , y T ′ ) \mathbf{y} = (y_1, y_2, \ldots, y_T') y=(y1,y2,,yT),解码器在每个时间步预测下一个输出:

s t = g ( s t − 1 , y t − 1 , c ) \mathbf{s}_t = g(\mathbf{s}_{t-1}, \mathbf{y}_{t-1}, \mathbf{c}) st=g(st1,yt1,c)

y ^ t = softmax ( W s t ) \mathbf{\hat{y}}_t = \text{softmax}(W \mathbf{s}_t) y^t=softmax(Wst)

其中, s t \mathbf{s}_t st 是解码器在时间步 t t t 处的隐藏状态, g g g是解码器的非线性函数, y ^ t \mathbf{\hat{y}}_t y^t是时间步 t t t处的预测输出, W W W是一个线性变换矩阵。

  1. 编码器隐藏状态更新公式:

h t = f ( h t − 1 , x t ) \mathbf{h}_t = f(\mathbf{h}_{t-1}, \mathbf{x}_t) ht=f(ht1,xt)

  1. 上下文向量:

c = h T \mathbf{c} = \mathbf{h}_T c=hT

  1. 解码器隐藏状态更新公式:

s t = g ( s t − 1 , y t − 1 , c ) \mathbf{s}_t = g(\mathbf{s}_{t-1}, \mathbf{y}_{t-1}, \mathbf{c}) st=g(st1,yt1,c)

  1. 解码器输出预测:

y ^ t = softmax ( W s t ) \mathbf{\hat{y}}_t = \text{softmax}(W \mathbf{s}_t) y^t=softmax(Wst)

1.2 在机器翻译中

在机器翻译(Machine Translation)中,编码器-解码器机制被用来将一个语言的句子(源语言)翻译成另一个语言的句子(目标语言)。例如,假设要将英语句子翻译成法语句子:

  1. 编码器将英语句子作为输入,生成上下文向量 c \mathbf{c} c
  2. 解码器使用该上下文向量 c \mathbf{c} c 生成法语句子的每个单词,直到生成结束符号(如句号)。

当输入和输出都是不定长序列时,我们可以使用编码器—解码器(encoder-decoder)[1] 或者seq2seq模型 [2]。这两个模型本质上都用到了两个循环神经网络,分别叫做编码器和解码器。编码器用来分析输入序列,解码器用来生成输出序列。

下图描述了使用编码器—解码器将上述英语句子翻译成法语句子的一种方法。在训练数据集中,我们可以在每个句子后附上特殊符号“<eos>”(end of sequence)以表示序列的终止。编码器每个时间步的输入依次为英语句子中的单词、标点和特殊符号“<eos>”。图10.8中使用了编码器在最终时间步的隐藏状态作为输入句子的表征或编码信息。解码器在各个时间步中使用输入句子的编码信息和上个时间步的输出以及隐藏状态作为输入。我们希望解码器在各个时间步能正确依次输出翻译后的法语单词、标点和特殊符号"<eos>“。需要注意的是,解码器在最初时间步的输入用到了一个表示序列开始的特殊符号”<bos>"(beginning of sequence)。

在这里插入图片描述

1.3 模型训练时

根据最大似然估计,我们可以最大化输出序列基于输入序列的条件概率

P ( y 1 , … , y T ′ ∣ x 1 , … , x T ) = ∏ t ′ = 1 T ′ P ( y t ′ ∣ y 1 , … , y t ′ − 1 , x 1 , … , x T ) = ∏ t ′ = 1 T ′ P ( y t ′ ∣ y 1 , … , y t ′ − 1 , c ) , \begin{aligned} P(y_1, \ldots, y_{T'} \mid x_1, \ldots, x_T) &= \prod_{t'=1}^{T'} P(y_{t'} \mid y_1, \ldots, y_{t'-1}, x_1, \ldots, x_T)\\ &= \prod_{t'=1}^{T'} P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \boldsymbol{c}), \end{aligned} P(y1,,yTx1,,xT)=t=1TP(yty1,,yt1,x1,,xT)=t=1TP(yty1,,yt1,c),

并得到该输出序列的损失

− log ⁡ P ( y 1 , … , y T ′ ∣ x 1 , … , x T ) = − ∑ t ′ = 1 T ′ log ⁡ P ( y t ′ ∣ y 1 , … , y t ′ − 1 , c ) , -\log P(y_1, \ldots, y_{T'} \mid x_1, \ldots, x_T) = -\sum_{t'=1}^{T'} \log P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \boldsymbol{c}), logP(y1,,yTx1,,xT)=t=1TlogP(yty1,,yt1,c),

在模型训练中,所有输出序列损失的均值通常作为需要最小化的损失函数。在图10.8所描述的模型预测中,我们需要将解码器在上一个时间步的输出作为当前时间步的输入。与此不同,在训练中我们也可以将标签序列(训练集的真实输出序列)在上一个时间步的标签作为解码器在当前时间步的输入。这叫作强制教学(teacher forcing)。

文献援引

  1. Sutskever, I., Vinyals, O., & Le, Q. V. (2014). Sequence to Sequence Learning with Neural Networks. Advances in Neural Information Processing Systems (NIPS), 3104-3112.
  2. Bahdanau, D., Cho, K., & Bengio, Y. (2015). Neural Machine Translation by Jointly Learning to Align and Translate. International Conference on Learning Representations (ICLR).
  3. Cho, K., van Merriënboer, B., Gulcehre, C., Bougares, F., Schwenk, H., & Bengio, Y. (2014). Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation. Conference on Empirical Methods in Natural Language Processing (EMNLP), 1724-1734.

编码器-解码器机制不仅在机器翻译中有广泛应用,还在许多其他序列到序列任务中起到了关键作用。以下是一些主要的应用领域和相关设计方法:

1.3 编码器-解码器机制的其他应用

  1. 文本摘要(Text Summarization)

    • 自动生成文本的简短摘要。
    • 参考文献:Rush, A. M., Chopra, S., & Weston, J. (2015). A Neural Attention Model for Abstractive Sentence Summarization. EMNLP.
  2. 图像描述生成(Image Captioning)

    • 根据输入图像生成描述性文本。
    • 参考文献:Vinyals, O., Toshev, A., Bengio, S., & Erhan, D. (2015). Show and Tell: A Neural Image Caption Generator. CVPR.
  3. 对话系统(Dialogue Systems)

    • 在聊天机器人和对话代理中生成自然语言响应。
    • 参考文献:Vinyals, O., & Le, Q. (2015). A Neural Conversational Model. ICML Deep Learning Workshop.
  4. 语音识别(Speech Recognition)

    • 将语音信号转换为文本。
    • 参考文献:Chorowski, J., Bahdanau, D., Serdyuk, D., Cho, K., & Bengio, Y. (2015). Attention-Based Models for Speech Recognition. NIPS.
  5. 问答系统(Question Answering)

    • 从文本中提取和生成答案。
    • 参考文献:Hermann, K. M., Kocisky, T., Grefenstette, E., Espeholt, L., Kay, W., Suleyman, M., & Blunsom, P. (2015). Teaching Machines to Read and Comprehend. NIPS.

1.4 设计解码器输出层的方法

解码器的输出层设计在很大程度上取决于具体任务和应用场景。以下是几种常见的方法:

  1. Softmax输出层

    • 最常见的设计,用于分类任务。输出层是一个全连接层,接着是Softmax激活函数,产生一个概率分布。
    • 公式:
      y ^ t = softmax ( W s t + b ) \mathbf{\hat{y}}_t = \text{softmax}(W \mathbf{s}_t + b) y^t=softmax(Wst+b)
    • 适用场景:机器翻译、文本生成、问答系统等。
  2. Beam Search解码

    • 在生成序列时,为了避免贪婪搜索可能导致的次优结果,使用Beam Search来保留前k个最可能的序列分支。
    • 参考文献:Freitag, M., & Al-Onaizan, Y. (2017). Beam Search Strategies for Neural Machine Translation. WMT.
  3. Attention机制

    • 改进的解码方法,结合注意力机制,动态地关注输入序列的不同部分。

    • 公式:
      a t = Attention ( s t − 1 , h ) \mathbf{a}_t = \text{Attention}(\mathbf{s}_{t-1}, \mathbf{h}) at=Attention(st1,h)

      c o n t e x t t = ∑ i = 1 T a t i h i \mathbf{context}_t = \sum_{i=1}^{T} a_{ti} \mathbf{h}_i contextt=i=1Tatihi

    • 适用场景:所有序列到序列任务,尤其是长序列的处理。

    • 参考文献:Bahdanau, D., Cho, K., & Bengio, Y. (2015). Neural Machine Translation by Jointly Learning to Align and Translate. ICLR.

  4. Copy机制

    • 在某些任务中,模型需要复制输入的一部分到输出,如文本摘要或问答系统。
    • 公式:
      P ( y t = x i ∣ s t , h ) = softmax ( s t T h i ) P(y_t = x_i | \mathbf{s}_t, \mathbf{h}) = \text{softmax}(\mathbf{s}_t^T \mathbf{h}_i) P(yt=xist,h)=softmax(stThi)
    • 适用场景:文本摘要、问答系统等。
    • 参考文献:Gu, J., Lu, Z., Li, H., & Li, V. O. K. (2016). Incorporating Copying Mechanism in Sequence-to-Sequence Learning. ACL.
  5. Hierarchical Decoding

    • 分层解码器用于生成结构化的输出,如文档生成。
    • 参考文献:Serban, I. V., Sordoni, A., Bengio, Y., Courville, A. C., & Pineau, J. (2016). Building End-To-End Dialogue Systems Using Generative Hierarchical Neural Network Models. AAAI.

通过不同的解码方法,可以更好地适应特定的应用场景,提高模型的性能和生成效果。

以上就是我们对编码器-解码器机制(Encoder-Decoder Mechanism)的初步探索,下面我们对环境进行初始化,然后进一步探索新的内容。

# This Python 3 environment comes with many helpful analytics libraries installed
# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python
# For example, here's several helpful packages to load

import numpy as np # linear algebra
import pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)

# Input data files are available in the read-only "../input/" directory
# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory

import os
for dirname, _, filenames in os.walk('/kaggle/input'):
    for filename in filenames:
        print(os.path.join(dirname, filename))

# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using "Save & Run All" 
# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session

2 束搜索

束搜索(Beam Search)是一种广泛应用于自然语言处理(NLP)、机器翻译、语音识别等领域的启发式搜索算法。它是一种广度优先搜索算法的变体,用于在解空间中找到最优解或近似最优解。束搜索的主要特点是控制搜索空间的大小,以避免搜索过程中过于庞大的计算需求。以下是关于束搜索的详细介绍:

2.1 基本概念和步骤

束搜索的核心思想是在每一步搜索中,仅保留得分最高的前( k )个候选节点,这些候选节点形成一个“束”(beam)。这种方法通过限制束的宽度 ( k ) ,有效地控制了搜索空间的大小。

  1. 初始化:从初始状态开始,计算其得分,将其加入到束中。
  2. 迭代扩展
    • 对当前束中的每个状态进行扩展,生成所有可能的后续状态。
    • 计算这些后续状态的得分。
    • 将所有后续状态按得分排序,并选取得分最高的前 ( k ) 个状态,形成新的束。
  3. 终止条件:当达到预定的步骤数或所有束中的状态都达到终止状态时,停止搜索。
  4. 选择最优解:在最终的束中选择得分最高的状态作为最终解。

通过这样的步骤,束搜索(Beam Search)可以达到以下的优秀效果:

  1. 高效性:相比于全局搜索方法(如广度优先搜索),束搜索通过限制束的宽度,显著减少了需要评估的状态数量,从而降低了计算复杂度。
  2. 适应性:束搜索可以在计算资源受限的情况下工作,通过调整束宽度 ( k ),在计算资源与解的质量之间找到平衡。
  3. 适用于多种问题:广泛应用于自然语言处理(如机器翻译中的解码过程)、语音识别和其他需要从大量候选中选择最优解的问题。

但是,就像所有的硬币都有两面,束搜索(Beam Search)也有一些缺点,包括但不限于:

  1. 局部最优:由于束搜索只保留得分最高的前 ( k ) 个状态,可能会丢失全局最优解的信息,容易陷入局部最优。
  2. 参数敏感性:束宽度 ( k ) 的选择对算法性能有较大影响,过小的 ( k ) 可能导致解的质量较差,过大的 ( k ) 则会增加计算开销。

得益于束搜索(Beam Search)的一些特性,它可以应用于:

  1. 机器翻译:在神经机器翻译(NMT)中,束搜索用于解码阶段,从模型输出的概率分布中选择最优翻译句子。每一步保留得分最高的前 ( k ) 个部分翻译,并逐步扩展直到生成完整的句子。
  2. 语音识别:在语音识别中,束搜索用于从声学模型生成的可能音素序列中选择最优的词序列,逐步构建完整的句子。

下面展示一下束搜索(Beam Search)的伪代码:

def beam_search(initial_state, successor_fn, score_fn, beam_width, max_steps):
    beam = [initial_state]
    
    for step in range(max_steps):
        candidates = []
        
        for state in beam:
            successors = successor_fn(state)
            for succ in successors:
                score = score_fn(succ)
                candidates.append((score, succ))
        
        candidates.sort(reverse=True, key=lambda x: x[0])
        beam = [state for score, state in candidates[:beam_width]]
        
        if all(is_terminal(state) for state in beam):
            break
    
    return max(beam, key=score_fn)
  • initial_state:初始状态。
  • successor_fn:生成后续状态的函数。
  • score_fn:评估状态得分的函数。
  • beam_width:束宽度 ( k )。
  • max_steps:最大搜索步数。

总而言之,束搜索通过限制搜索宽度,在计算效率与解的质量之间找到平衡,是一种在处理大规模搜索问题时非常有效的方法。它在自然语言处理、机器翻译、语音识别等领域得到了广泛应用,并且不断被改进以提高其性能和适用性。

2.1 束搜索:Encoder-Decoder预测不定长序列

上一节介绍了如何训练输入和输出均为不定长序列的编码器—解码器。本节我们介绍如何使用编码器—解码器来预测不定长的序列。束搜索(Beam Search)是一种用于预测不定长序列的搜索算法,广泛应用于自然语言处理等领域。束搜索旨在解决使用编码器—解码器结构生成输出序列的问题,其中输出序列的长度不固定。它通过控制一个称为束宽的参数来平衡搜索的效率和生成序列的质量。

2.1.1 贪婪搜索

贪婪搜索是束搜索的简单形式,每一步选择条件概率最大的单个词作为输出。这种方法简单直接,但不能保证得到最优的输出序列。

2.1.2 穷举搜索

为了获得最优输出序列,可以考虑穷举搜索,即枚举所有可能的输出序列并选择条件概率最大的序列。然而,其计算开销随输出序列长度和词典大小的增加而急剧增加,往往不现实。

2.1.3 束搜索

束搜索是对贪婪搜索的改进,通过保留每一步最可能的若干个候选词来扩展搜索空间。具体步骤如下:

  1. 初始化:从编码器得到的初始状态开始。
  2. 逐步扩展:每一步根据当前的候选序列,生成所有可能的下一步候选序列,计算它们的条件概率。
  3. 剪枝:保留得分最高的前 k 个候选序列作为下一步的候选。
  4. 终止条件:当生成的候选序列中出现特殊符号“<eos>”或达到最大长度时,停止扩展。
  5. 选择最优解:从最终的候选序列中选择分数最高的序列作为输出。

束搜索广泛应用于机器翻译、语音识别等任务中,常用的优化包括调整束宽和使用长度惩罚以改进输出序列的选择。

束搜索通过在搜索过程中限制候选序列的数量,有效地平衡了搜索的复杂度与输出序列质量之间的关系,是目前许多序列生成任务中的常用方法之一。

3 Attention is all you need——注意力机制

为了更加详细地介绍注意力机制,没有什么资料可以比得上《Attention is All You Need》这篇论文,它对自然语言处理领域产生了深远影响,使注意力机制成为现代神经网络架构的核心组成部分。下面就写一写我的阅读笔记,作为这部分的原理阐述。

当然可以!以下是对论文《Attention is All You Need》的详细总结,用中文表示:

3.1 Summary of《Attention is All You Need》

论文介绍了Transformer模型,这个模型完全基于注意力机制,完全舍弃了循环神经网络(RNN)和卷积神经网络(CNN)。该架构旨在提高并行化能力,并克服之前序列到序列模型的局限性,如RNN中的梯度消失问题。

在Transformer之前,序列转换模型主要依赖于RNN、CNN或两者的结合。这些模型面临的挑战包括难以捕捉长距离依赖关系和由于其序列性质导致的训练效率低下问题。

Transformer模型由一堆编码器和解码器组成,每个部分都包含多个层。核心创新在于使用注意力机制。编码器由若干相同的层叠加而成,每层包含两个子层:

  1. 多头自注意力机制:这种机制允许模型对每个标记在输入序列中的不同位置进行关注。
  2. 位置前馈全连接网络:每层应用两个线性变换,中间有ReLU激活函数。

解码器也由若干相同的层叠加而成,但每层多一个子层:

  1. 掩码多头自注意力机制:这防止解码器关注序列中的未来标记。
  2. 编码器-解码器注意力:这个子层对编码器的输出执行多头注意力。
  3. 位置前馈全连接网络

多头注意力机制并不是执行单一的注意力功能,而是并行运行多个注意力层(头)。这些输出被连接并线性变换。这允许模型共同关注来自不同表示子空间的信息。由于模型没有循环或卷积结构,它使用位置编码来注入关于标记在序列中相对或绝对位置的信息。这些编码在编码器和解码器栈的底部加到输入嵌入上。

模型使用Adam优化器和一种新颖的学习率调度进行训练。论文报告了在翻译任务上显著超越现有技术水平的结果,特别是在训练时间和性能方面。Transformer在WMT 2014英语-德语和英语-法语翻译任务上表现优异。

3.2 优越之处

  1. 注意力机制:主要贡献在于证明了注意力机制在序列转换任务中的有效性,无需循环或卷积。
  2. 并行化:模型结构允许更高的并行化,大大加快了训练速度。
  3. 可扩展性:多头注意力的使用使模型能够捕捉输入序列的各种特征,提高其处理较长序列的能力。

Transformer模型通过完全依赖注意力机制简化了序列转换模型的架构。这带来了性能提升、更容易的并行化以及更有效捕捉长距离依赖的能力。该模型的成功奠定了后续自然语言处理领域的基础,包括BERT和GPT等模型的发展。《Attention is All You Need》这篇论文对自然语言处理领域产生了深远影响,使注意力机制成为现代神经网络架构的核心组成部分。

3.3 生动地了解一下注意力机制

更具体地说,注意力机制就像在阅读一篇长篇文章时,大脑在处理信息时的一种机制。当你阅读一段文字时,并不是所有的信息都同等重要,有些词汇或者句子可能比其他部分更加关键,会引起你更深入的注意和理解。

注意力机制就像是你在阅读时的放大镜:

  1. 焦点放大:当你开始阅读一段文字时,你的注意力并不是平均分布在每个词上,而是根据语境和需要,集中放大一些关键词或短语。这些关键词就像是放大镜下的焦点,吸引了你的注意力。

  2. 信息加权:类似于放大镜能够调整放大倍数,注意力机制也能够根据上下文调整对每个词的关注程度。有些词可能会被强调,而其他词则被较少地关注,这样你能更有效地理解整个句子的含义。

  3. 上下文敏感:就像在阅读一篇文章时,你会根据前后文的内容来调整你的理解和关注重点,注意力机制也是根据输入序列的不同部分来动态调整注意力的分配。

  4. 多重注意力:有时你可能需要同时关注文章中的多个部分,就像放大镜可以聚焦在不同位置一样,注意力机制也可以同时对多个位置或特征进行加权和处理。

在实际应用中,注意力机制类似地应用于处理序列数据,如机器翻译、语音识别和自然语言处理等领域。总之,注意力机制不仅在人类的认知过程中起着重要作用,也成为了现代人工智能模型中不可或缺的重要组成部分,帮助模型更加智能地处理和理解复杂的数据和任务。

3.4 注意力机制Q&A

问题 1: 基于模型设计,为什么不可以将解码器在不同时间步的隐藏状态连结成查询项矩阵从而同时计算不同时间步的含注意力机制的背景变量?

在基于注意力机制的序列到序列(seq2seq)模型中,解码器的每个时间步通常会依赖于前一个时间步的隐藏状态和生成的输出。因此,不能简单地将所有时间步的隐藏状态连结成一个查询项矩阵来同时计算背景变量(context vector)。具体原因如下:

  1. 时间步依赖性:解码器在每个时间步的隐藏状态是递归计算的,即 s t ′ = GRU ( s t ′ − 1 , y t ′ − 1 , c t ′ − 1 ) \boldsymbol{s}_{t'} = \text{GRU}(\boldsymbol{s}_{t'-1}, \boldsymbol{y}_{t'-1}, \boldsymbol{c}_{t'-1}) st=GRU(st1,yt1,ct1)。在时间步 t ′ t' t,解码器的隐藏状态 s t ′ \boldsymbol{s}_{t'} st 依赖于前一个时间步的隐藏状态 s t ′ − 1 \boldsymbol{s}_{t'-1} st1、生成的输出 y t ′ − 1 \boldsymbol{y}_{t'-1} yt1 以及前一个时间步的背景变量 c t ′ − 1 \boldsymbol{c}_{t'-1} ct1。如果我们将所有时间步的隐藏状态连结成矩阵进行并行计算,就无法保持这种递归依赖关系。

  2. 动态调整:注意力机制需要在每个时间步动态计算注意力权重和背景变量 c t ′ \boldsymbol{c}_{t'} ct。这些背景变量通常依赖于当前时间步的隐藏状态 s t ′ \boldsymbol{s}_{t'} st 和编码器输出的对齐。因此,不能提前并行计算所有时间步的背景变量。

  3. 计算复杂度:即使可以并行计算所有时间步的背景变量,也会导致计算复杂度的增加,因为需要在每个时间步计算完整的注意力权重矩阵。这在序列长度较长时,会显著增加计算开销。

综上所述,将解码器在不同时间步的隐藏状态连结成矩阵并行计算背景变量在实际操作中是不现实的,因为无法保持时间步之间的递归依赖关系,且会增加计算复杂度。

问题 2: 不修改“门控循环单元(GRU)”一节中的 gru 函数,应如何用它实现本节介绍的解码器?

要实现基于注意力机制的解码器,可以在每个时间步使用现有的 gru 函数。具体步骤如下:

  1. 初始化解码器隐藏状态:使用编码器的最终隐藏状态作为解码器的初始隐藏状态。
  2. 时间步递归计算
    • 对于每个时间步 t ′ t' t
      1. 使用当前隐藏状态 s t ′ − 1 \boldsymbol{s}_{t'-1} st1 和上一个时间步的输出 y t ′ − 1 \boldsymbol{y}_{t'-1} yt1 计算注意力权重和背景变量 c t ′ \boldsymbol{c}_{t'} ct
      2. 将背景变量 c t ′ \boldsymbol{c}_{t'} ct 连接到当前输入 y t ′ − 1 \boldsymbol{y}_{t'-1} yt1,形成新的输入 y ~ t ′ − 1 \tilde{\boldsymbol{y}}_{t'-1} y~t1
      3. 使用 gru 函数计算当前时间步的隐藏状态 s t ′ = GRU ( s t ′ − 1 , y ~ t ′ − 1 ) ‘ \boldsymbol{s}_{t'} = \text{GRU}(\boldsymbol{s}_{t'-1}, \tilde{\boldsymbol{y}}_{t'-1})` st=GRU(st1,y~t1)
      4. 基于当前隐藏状态 s t ′ \boldsymbol{s}_{t'} st 生成输出 y t ′ \boldsymbol{y}_{t'} yt

这样,在不修改 gru 函数的情况下,可以在每个时间步通过计算背景变量和重新构建输入来实现带有注意力机制的解码器。

问题 3: 除了自然语言处理,注意力机制还可以应用在哪些地方?

注意力机制作为一种通用的机制,可以应用于许多其他领域,除了自然语言处理外,还包括以下几个方面:

  1. 计算机视觉:在图像分类、目标检测、图像分割等任务中,注意力机制可以帮助模型关注图像中的重要区域。例如,Self-Attention可以用于图像的特征提取,帮助提高分类和检测的准确性。

  2. 语音处理:在语音识别、语音合成等任务中,注意力机制可以用来对音频序列的不同部分赋予不同的权重,从而提高模型的效果。

  3. 推荐系统:在推荐系统中,注意力机制可以帮助模型更好地理解用户的偏好,通过对用户历史行为的不同部分赋予不同的权重,来生成更个性化的推荐结果。

  4. 时间序列预测:在金融、气象等领域的时间序列预测任务中,注意力机制可以帮助模型更好地捕捉时间序列数据中的重要模式和趋势。

  5. 强化学习:在强化学习中,注意力机制可以帮助智能体更有效地从环境中提取重要信息,改善决策过程。

  6. 生物信息学:在蛋白质结构预测、基因序列分析等生物信息学任务中,注意力机制可以帮助模型更好地捕捉序列中的重要模式和关系。

  7. 图神经网络:在图数据处理任务中,注意力机制可以用于图神经网络中,帮助模型更好地关注图中节点和边的重要性,从而提高图的分类、链接预测等任务的性能。

注意力机制因其强大的表示能力和灵活性,正在越来越多的领域中发挥重要作用。

4 机器翻译

语言模型是自然语言处理的关键, 而机器翻译是语言模型最成功的基准测试。 因为机器翻译正是将输入序列转换成输出序列的 序列转换模型(sequence transduction)的核心问题。 序列转换模型在各类现代人工智能应用中发挥着至关重要的作用。
机器翻译(machine translation)指的是 将序列从一种语言自动翻译成另一种语言。

因为统计机器翻译(statistical machine translation)涉及了 翻译模型和语言模型等组成部分的统计分析, 因此基于神经网络的方法通常被称为神经机器翻译(neural machine translation), 用于将两种翻译模型区分开来。

博客将在本节将通过一个小型法语-英语数据集,介绍如何使用编码器-解码器(Encoder-Decoder)模型和注意力机制来实现机器翻译。在处理数据之前,我们需要定义一些特殊符号:

  • <pad>:用于在较短序列后添加填充,使得所有序列等长。
  • <bos>:表示序列的开始。
  • <eos>:表示序列的结束。
    我们读取的数据集,每行是一对法语句子和对应的英语句子。为了统一序列长度,在句末添加 <eos> 符号,并可能添加 <pad> 符号。

编码器将输入语言的词索引通过词嵌入层(embedding layer)转换为词的表征,然后输入到多层门控循环单元(GRU)中。GRU返回最后一层的隐藏状态,这些隐藏状态将作为注意力机制的键(key)和值(value)。

注意力机制的核心是计算上下文向量(context vector),它根据解码器的当前隐藏状态和编码器的所有隐藏状态,计算出一个加权和。权重由解码器隐藏状态和编码器隐藏状态通过一个多层感知机(MLP)计算得到。

解码器使用编码器在最终时间步的隐藏状态作为初始隐藏状态。每一步,解码器通过注意力机制计算当前时间步的背景向量(context vector),将其与当前输入词的嵌入表示连结,然后输入到GRU中。GRU的输出通过全连接层生成对下一个词的预测。

4.1 训练模型并预测不定长序列

在训练过程中,解码器的初始输入是特殊字符 <bos>。之后的输入是上一时间步的输出(强制教学)。使用交叉熵损失函数计算损失,并进行梯度下降更新模型参数。在预测阶段,可以使用贪婪搜索方法生成翻译结果。输入一个法语句子“ils regardent.”,模型输出对应的英语句子“they are watching.”。

评价机器翻译结果通常使用BLEU(Bilingual Evaluation Understudy)评分。BLEU考察模型生成的子序列是否出现在参考翻译中,并对较长子序列的匹配赋予更大权重,以此来计算精度。

4.2 练习

  1. 如果编码器和解码器的隐藏单元数或层数不同,如何改进解码器的隐藏状态初始化方法?
  2. 将强制教学替换为使用解码器在上一时间步的输出作为当前时间步的输入,观察结果变化。
  3. 使用更大的翻译数据集(如WMT和Tatoeba Project)训练模型。

4.3 练习解答

练习 1: 如果编码器和解码器的隐藏单元数或层数不同,如何改进解码器的隐藏状态初始化方法?

在编码器和解码器的隐藏单元数或层数不同的情况下,我们需要对编码器的隐藏状态进行处理,以适应解码器的输入要求。以下是几种常见的方法:

  1. 线性变换:使用线性层(全连接层)将编码器的隐藏状态转换为解码器的初始隐藏状态。

    import torch.nn as nn
    
    class EncoderToDecoderInit(nn.Module):
        def __init__(self, enc_hidden_size, dec_hidden_size):
            super(EncoderToDecoderInit, self).__init__()
            self.linear = nn.Linear(enc_hidden_size, dec_hidden_size)
        
        def forward(self, enc_hidden):
            return torch.tanh(self.linear(enc_hidden))
    

    通过这种方法,可以将编码器的隐藏状态变换为解码器所需的维度。

  2. 重复和裁剪:如果编码器的隐藏单元数少于解码器,可以重复编码器的隐藏状态。如果编码器的隐藏单元数多于解码器,可以裁剪多余部分。

    def adjust_hidden_size(enc_hidden, dec_hidden_size):
        if enc_hidden.size(2) < dec_hidden_size:
            return enc_hidden.repeat(1, 1, dec_hidden_size // enc_hidden.size(2))
        elif enc_hidden.size(2) > dec_hidden_size:
            return enc_hidden[:, :, :dec_hidden_size]
        else:
            return enc_hidden
    
  3. 平均池化:对编码器的多层隐藏状态进行平均池化,得到一个新的隐藏状态。

    def average_pooling(enc_hidden):
        return torch.mean(enc_hidden, dim=0, keepdim=True)
    

选择合适的方法取决于具体的模型结构和任务需求。

练习 2: 将强制教学替换为使用解码器在上一时间步的输出作为当前时间步的输入,结果有什么变化吗?

在训练过程中,强制教学(Teacher Forcing)是使用真实的目标输出作为解码器的输入,而不是使用解码器自己生成的输出。如果将强制教学替换为使用解码器在上一时间步的输出作为当前时间步的输入,训练和翻译结果会有所不同:

  1. 训练难度增加:由于解码器在每一步的输入都是自己生成的输出,错误可能会在序列生成过程中累积,导致梯度消失或爆炸,模型训练变得更困难。

  2. 性能波动:在没有强制教学的情况下,模型需要更长时间才能收敛,且容易陷入局部最优解。因此,训练过程中可能需要更小的学习率和更长的训练时间。

  3. 实际应用更接近:这种训练方式与实际应用场景更接近,因为在实际翻译过程中,解码器无法获得真实的目标输出,只能依赖自己生成的输出。这可以提高模型的鲁棒性和泛化能力。

练习 3: 试着使用更大的翻译数据集来训练模型,例如 WMT 和 Tatoeba Project。

使用更大的翻译数据集可以提高模型的性能和泛化能力。以下是使用 WMT 和 Tatoeba Project 数据集训练模型的步骤:

  1. 下载数据集:从 WMT 和 Tatoeba Project 官方网站下载翻译数据集。

  2. 数据预处理:对数据集进行清洗和预处理,包括去除特殊字符、分词、添加特殊符号(<bos><eos>),并构建词典。

    def preprocess_data(data):
        # 数据清洗和分词
        cleaned_data = clean_and_tokenize(data)
        # 添加特殊符号
        processed_data = add_special_tokens(cleaned_data)
        # 构建词典
        vocab = build_vocab(processed_data)
        return processed_data, vocab
    
  3. 模型训练:使用预处理后的数据集训练编码器-解码器模型,并设置合适的超参数(如学习率、批量大小、训练轮数等)。

    model = EncoderDecoderModel(vocab_size, embed_size, hidden_size)
    optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
    loss_fn = nn.CrossEntropyLoss()
    
    for epoch in range(num_epochs):
        for batch in data_loader:
            src, tgt = batch
            output = model(src, tgt)
            loss = loss_fn(output, tgt)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
    
  4. 评估模型:在验证集上评估模型的性能,计算 BLEU 得分等指标,并进行必要的调整和优化。

    bleu_score = compute_bleu(model, val_data)
    print(f'BLEU score: {bleu_score}')
    

通过以上步骤,可以使用更大的数据集训练更强大的机器翻译模型,从而获得更好的翻译效果。

4.4 代码

!tar -xf d2lzh_pytorch.tar
import collections
import os
import io
import math
import torch
from torch import nn
import torch.nn.functional as F
import torchtext.vocab as Vocab
import torch.utils.data as Data

import sys
# sys.path.append("..") 
import d2lzh_pytorch as d2l

PAD, BOS, EOS = '<pad>', '<bos>', '<eos>'
os.environ["CUDA_VISIBLE_DEVICES"] = "0"
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

print(torch.__version__, device)
1.5.0 cpu
# 将一个序列中所有的词记录在all_tokens中以便之后构造词典,然后在该序列后面添加PAD直到序列
# 长度变为max_seq_len,然后将序列保存在all_seqs中
def process_one_seq(seq_tokens, all_tokens, all_seqs, max_seq_len):
    all_tokens.extend(seq_tokens)
    seq_tokens += [EOS] + [PAD] * (max_seq_len - len(seq_tokens) - 1)
    all_seqs.append(seq_tokens)

# 使用所有的词来构造词典。并将所有序列中的词变换为词索引后构造Tensor
def build_data(all_tokens, all_seqs):
    vocab = Vocab.Vocab(collections.Counter(all_tokens),
                        specials=[PAD, BOS, EOS])
    indices = [[vocab.stoi[w] for w in seq] for seq in all_seqs]
    return vocab, torch.tensor(indices)
def read_data(max_seq_len):
    # in和out分别是input和output的缩写
    in_tokens, out_tokens, in_seqs, out_seqs = [], [], [], []
    with io.open('fr-en-small.txt') as f:
        lines = f.readlines()
    for line in lines:
        in_seq, out_seq = line.rstrip().split('\t')
        in_seq_tokens, out_seq_tokens = in_seq.split(' '), out_seq.split(' ')
        if max(len(in_seq_tokens), len(out_seq_tokens)) > max_seq_len - 1:
            continue  # 如果加上EOS后长于max_seq_len,则忽略掉此样本
        process_one_seq(in_seq_tokens, in_tokens, in_seqs, max_seq_len)
        process_one_seq(out_seq_tokens, out_tokens, out_seqs, max_seq_len)
    in_vocab, in_data = build_data(in_tokens, in_seqs)
    out_vocab, out_data = build_data(out_tokens, out_seqs)
    return in_vocab, out_vocab, Data.TensorDataset(in_data, out_data)
max_seq_len = 7
in_vocab, out_vocab, dataset = read_data(max_seq_len)
dataset[0]
(tensor([ 5,  4, 45,  3,  2,  0,  0]), tensor([ 8,  4, 27,  3,  2,  0,  0]))
class Encoder(nn.Module):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 drop_prob=0, **kwargs):
        super(Encoder, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=drop_prob)

    def forward(self, inputs, state):
        # 输入形状是(批量大小, 时间步数)。将输出互换样本维和时间步维
        embedding = self.embedding(inputs.long()).permute(1, 0, 2) # (seq_len, batch, input_size)
        return self.rnn(embedding, state)

    def begin_state(self):
        return None
encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
output, state = encoder(torch.zeros((4, 7)), encoder.begin_state())
output.shape, state.shape # GRU的state是h, 而LSTM的是一个元组(h, c)
(torch.Size([7, 4, 16]), torch.Size([2, 4, 16]))
def attention_model(input_size, attention_size):
    model = nn.Sequential(nn.Linear(input_size, attention_size, bias=False),
                          nn.Tanh(),
                          nn.Linear(attention_size, 1, bias=False))
    return model
def attention_forward(model, enc_states, dec_state):
    """
    enc_states: (时间步数, 批量大小, 隐藏单元个数)
    dec_state: (批量大小, 隐藏单元个数)
    """
    # 将解码器隐藏状态广播到和编码器隐藏状态形状相同后进行连结
    dec_states = dec_state.unsqueeze(dim=0).expand_as(enc_states)
    enc_and_dec_states = torch.cat((enc_states, dec_states), dim=2)
    e = model(enc_and_dec_states)  # 形状为(时间步数, 批量大小, 1)
    alpha = F.softmax(e, dim=0)  # 在时间步维度做softmax运算
    return (alpha * enc_states).sum(dim=0)  # 返回背景变量
seq_len, batch_size, num_hiddens = 10, 4, 8
model = attention_model(2*num_hiddens, 10) 
enc_states = torch.zeros((seq_len, batch_size, num_hiddens))
dec_state = torch.zeros((batch_size, num_hiddens))
attention_forward(model, enc_states, dec_state).shape
torch.Size([4, 8])
class Decoder(nn.Module):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 attention_size, drop_prob=0):
        super(Decoder, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.attention = attention_model(2*num_hiddens, attention_size)
        # GRU的输入包含attention输出的c和实际输入, 所以尺寸是 num_hiddens+embed_size
        self.rnn = nn.GRU(num_hiddens + embed_size, num_hiddens, 
                          num_layers, dropout=drop_prob)
        self.out = nn.Linear(num_hiddens, vocab_size)

    def forward(self, cur_input, state, enc_states):
        """
        cur_input shape: (batch, )
        state shape: (num_layers, batch, num_hiddens)
        """
        # 使用注意力机制计算背景向量
        c = attention_forward(self.attention, enc_states, state[-1])
        # 将嵌入后的输入和背景向量在特征维连结, (批量大小, num_hiddens+embed_size)
        input_and_c = torch.cat((self.embedding(cur_input), c), dim=1) 
        # 为输入和背景向量的连结增加时间步维,时间步个数为1
        output, state = self.rnn(input_and_c.unsqueeze(0), state)
        # 移除时间步维,输出形状为(批量大小, 输出词典大小)
        output = self.out(output).squeeze(dim=0)
        return output, state

    def begin_state(self, enc_state):
        # 直接将编码器最终时间步的隐藏状态作为解码器的初始隐藏状态
        return enc_state
def batch_loss(encoder, decoder, X, Y, loss):
    batch_size = X.shape[0]
    enc_state = encoder.begin_state()
    enc_outputs, enc_state = encoder(X, enc_state)
    # 初始化解码器的隐藏状态
    dec_state = decoder.begin_state(enc_state)
    # 解码器在最初时间步的输入是BOS
    dec_input = torch.tensor([out_vocab.stoi[BOS]] * batch_size)
    # 我们将使用掩码变量mask来忽略掉标签为填充项PAD的损失, 初始全1
    mask, num_not_pad_tokens = torch.ones(batch_size,), 0
    l = torch.tensor([0.0])
    for y in Y.permute(1,0): # Y shape: (batch, seq_len)
        dec_output, dec_state = decoder(dec_input, dec_state, enc_outputs)
        l = l + (mask * loss(dec_output, y)).sum()
        dec_input = y  # 使用强制教学
        num_not_pad_tokens += mask.sum().item()
        # EOS后面全是PAD. 下面一行保证一旦遇到EOS接下来的循环中mask就一直是0
        mask = mask * (y != out_vocab.stoi[EOS]).float()
    return l / num_not_pad_tokens
def train(encoder, decoder, dataset, lr, batch_size, num_epochs):
    enc_optimizer = torch.optim.Adam(encoder.parameters(), lr=lr)
    dec_optimizer = torch.optim.Adam(decoder.parameters(), lr=lr)

    loss = nn.CrossEntropyLoss(reduction='none')
    data_iter = Data.DataLoader(dataset, batch_size, shuffle=True)
    for epoch in range(num_epochs):
        l_sum = 0.0
        for X, Y in data_iter:
            enc_optimizer.zero_grad()
            dec_optimizer.zero_grad()
            l = batch_loss(encoder, decoder, X, Y, loss)
            l.backward()
            enc_optimizer.step()
            dec_optimizer.step()
            l_sum += l.item()
        if (epoch + 1) % 10 == 0:
            print("epoch %d, loss %.3f" % (epoch + 1, l_sum / len(data_iter)))
embed_size, num_hiddens, num_layers = 64, 64, 2
attention_size, drop_prob, lr, batch_size, num_epochs = 10, 0.5, 0.01, 2, 50
encoder = Encoder(len(in_vocab), embed_size, num_hiddens, num_layers,
                  drop_prob)
decoder = Decoder(len(out_vocab), embed_size, num_hiddens, num_layers,
                  attention_size, drop_prob)
train(encoder, decoder, dataset, lr, batch_size, num_epochs)
epoch 10, loss 0.462
epoch 20, loss 0.241
epoch 30, loss 0.106
epoch 40, loss 0.095
epoch 50, loss 0.027
def translate(encoder, decoder, input_seq, max_seq_len):
    in_tokens = input_seq.split(' ')
    in_tokens += [EOS] + [PAD] * (max_seq_len - len(in_tokens) - 1)
    enc_input = torch.tensor([[in_vocab.stoi[tk] for tk in in_tokens]]) # batch=1
    enc_state = encoder.begin_state()
    enc_output, enc_state = encoder(enc_input, enc_state)
    dec_input = torch.tensor([out_vocab.stoi[BOS]])
    dec_state = decoder.begin_state(enc_state)
    output_tokens = []
    for _ in range(max_seq_len):
        dec_output, dec_state = decoder(dec_input, dec_state, enc_output)
        pred = dec_output.argmax(dim=1)
        pred_token = out_vocab.itos[int(pred.item())]
        if pred_token == EOS:  # 当任一时间步搜索出EOS时,输出序列即完成
            break
        else:
            output_tokens.append(pred_token)
            dec_input = pred
    return output_tokens
input_seq = 'ils regardent .'
translate(encoder, decoder, input_seq, max_seq_len)
['they', 'are', 'watching', '.']
def bleu(pred_tokens, label_tokens, k):
    len_pred, len_label = len(pred_tokens), len(label_tokens)
    score = math.exp(min(0, 1 - len_label / len_pred))
    for n in range(1, k + 1):
        num_matches, label_subs = 0, collections.defaultdict(int)
        for i in range(len_label - n + 1):
            label_subs[''.join(label_tokens[i: i + n])] += 1
        for i in range(len_pred - n + 1):
            if label_subs[''.join(pred_tokens[i: i + n])] > 0:
                num_matches += 1
                label_subs[''.join(pred_tokens[i: i + n])] -= 1
        score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
    return score
def score(input_seq, label_seq, k):
    pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)
    label_tokens = label_seq.split(' ')
    print('bleu %.3f, predict: %s' % (bleu(pred_tokens, label_tokens, k),
                                      ' '.join(pred_tokens)))
score('ils regardent .', 'they are watching .', k=2)
bleu 1.000, predict: they are watching .
score('ils sont canadienne .', 'they are canadian .', k=2)
bleu 0.658, predict: they are russian .

5 基于Transformer实现日译中机器翻译

下面我们将使用 PyTorch 基于Transformer模型进行日语到中文的翻译的自然语言处理(NLP)。我们将从一下几个步骤开始实验:

  1. 数据处理步骤,包括分词和张量转换。
  2. 创建用于训练过程中批处理数据的 DataLoader。
  3. 实现一个序列到序列的 Transformer 模型。
  4. 保存和加载词汇表和模型状态,以供以后使用或继续训练。

5.1 导入相关包

首先我们确保所有需要的模块都完成导入,设置torch.manual_seed(0) 和设备设置.注释掉的打印语句演示了如何检查并打印 GPU 设备名称(如果可用),这在开发过程中很有用,可以确保代码在需要时利用 GPU 资源。

import math  # 导入 math 模块,用于数学函数
import torchtext  # 导入 torchtext 库,用于文本处理工具
import torch  # 导入 PyTorch 深度学习框架
import torch.nn as nn  # 从 PyTorch 导入神经网络模块
from torch import Tensor  # 从 torch 导入 Tensor 类
from torch.nn.utils.rnn import pad_sequence  # 导入用于批量填充序列的函数
from torch.utils.data import DataLoader  # 从 PyTorch 导入 DataLoader 类,用于处理数据集
from collections import Counter  # 从 collections 模块导入 Counter 类,用于计数可哈希对象
from torchtext.vocab import Vocab  # 从 torchtext.vocab 导入 Vocab 类,用于处理词汇表
from torch.nn import TransformerEncoder, TransformerDecoder, TransformerEncoderLayer, TransformerDecoderLayer  # 从 PyTorch 导入 Transformer 模型架构的类和函数
import io  # 导入 io 模块,用于处理流
import time  # 导入 time 模块,用于处理时间相关的函数
import pandas as pd  # 导入 pandas 库,用于数据处理和分析
import numpy as np  # 导入 numpy 库,用于数值操作
import pickle  # 导入 pickle 模块,用于序列化和反序列化 Python 对象
import tqdm  # 导入 tqdm 库,用于显示进度条
import sentencepiece as spm  # 导入 sentencepiece 库,用于分词

torch.manual_seed(0)  # 设置随机种子以便复现性
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')  # 检查是否有 GPU 可用,根据情况设置设备

# 取消下面的注释以打印 GPU 设备名称(如果可用)
# print(torch.cuda.get_device_name(0))
# 注意:当取消注释时,此行代码将打印 GPU 设备的名称,有助于检查代码是否在使用 GPU 资源
# 但这行代码是可选的,如果不需要显示 GPU 信息,可以将其注释掉
device
device(type='cuda')

以上输出表明,目前可以使用GPU进行加速训练。本次实验使用GPU P100进行加速。GPU P100是由NVIDIA推出的一款高性能计算图形处理器(GPU),属于Pascal架构,拥有3584个CUDA核心。

在这里插入图片描述

5.2 获取并行数据集

下面我们从一个名为 zh-ja.bicleaner05.txt 的文件中读取数据,并将其中的中文和日文句子分别存储在 trainen 和 trainja 列表中。

我们假设 zh-ja.bicleaner05.txt 文件是一个以制表符分隔的文本文件,其中包含两列数据:第二列是中文句子 (trainen),第三列是对应的日文句子 (trainja)。注释部分展示了如何删除指定索引位置(例如第 5972 行)的句子,但当前是被注释掉的状态,因此不会执行这些删除操作。

在导入所有的日语和对应的英语数据后,我删除了数据集中最后一个数据,因为它有一个缺失值。总的来说,trainen和trainja中的句子数都是5,973,071,但出于学习的目的,通常建议在一次性使用所有数据之前对数据进行抽样,并确保一切正常,以节省时间。

# 从指定路径 '/kaggle/input/jp2cntranslation/zh-ja.bicleaner05.txt' 读取数据
# 使用 '\t' 作为分隔符,'python' 引擎来解析文件(因为 '\t' 是制表符,'python' 引擎可以正确解析)
# 不使用头部信息作为列名,因此 header=None
df = pd.read_csv('/kaggle/input/jp2cntranslation/zh-ja.bicleaner05.txt', sep='\\t', engine='python', header=None)
# 提取第 2 列(索引为 2)的数据,并转换为 Python 列表,存储在 trainen 中
trainen = df[2].values.tolist()  # 句子的英文部分存储在 trainen 列表中

# 提取第 3 列(索引为 3)的数据,并转换为 Python 列表,存储在 trainja 中
trainja = df[3].values.tolist()  # 句子的日文部分存储在 trainja 列表中

# 可选操作:注释掉的代码是如何删除指定索引处的句子(第 5972 行)的示例
# trainen.pop(5972)
# trainja.pop(5972)

trainen.pop(5972)
'2014年和2017年,它被《星期日泰晤士报》(Sunday Times)评为英国最宜居的城市,并获得了欧洲绿色之都(European Green Capital)的美名。'
trainja.pop(5972)
'2014年と2017年のサンデータイムズ紙によってイギリス国内で生活に最も適した街と名付けられ、またヨーロッパグリーンキャピタルの賞も受賞しています。'

我们还可以使用不同的并行数据集来学习本文,只需确保我们可以将数据处理为上面所示的两个字符串列表,其中包含中文和日语语句子。

5.3 进行分词-——Prepare the tokenizers

下面我们使用 sentencepiece 库中的 SentencePieceProcessor 类来加载预先训练好的分词模型文件,分别用于中文 (en_tokenizer) 和日文 (ja_tokenizer) 的分词处理。

这里有一个疑问在这里,我们针对的场景是日译中的情形,但是使用的分词模型却是en_tokenizer,且通过打开/kaggle/input/jp2cntranslation/zh-ja.bicleaner05.txt,可以发现parallel dataset中的中文似乎来自机器翻译的结果而非原始文本(即语料不佳,但仅为猜测),可能导师对此并行数据集进行过改动。但为了实验的正常推动,对此不作过多探索,先按照指引继续推进实验

这段代码假设在 /kaggle/input/enja-spm-models/ 目录下有两个分词模型文件:spm.en.nopretok.model (用于英文)和 spm.ja.nopretok.model (用于日文)。这些模型文件由 sentencepiece 库生成,用于将文本分割成子词或词片段,以便后续的文本处理和建模任务。

# 使用 SentencePieceProcessor 类加载中文分词模型文件
# '/kaggle/input/enja-spm-models/spm.en.nopretok.model' 是中文分词模型的文件路径
en_tokenizer = spm.SentencePieceProcessor(model_file='/kaggle/input/enja-spm-models/spm.en.nopretok.model')
# 使用 SentencePieceProcessor 类加载日文分词模型文件
# '/kaggle/input/enja-spm-models/spm.ja.nopretok.model' 是日文分词模型的文件路径
ja_tokenizer = spm.SentencePieceProcessor(model_file='/kaggle/input/enja-spm-models/spm.ja.nopretok.model')
encoded_sentence = en_tokenizer.encode("All residents aged 20 to 59 years who live in Japan must enroll in public pension system.")
# Encoding: The encode method is used to convert the input sentence into a sequence of integer IDs based on the tokenization rules of the model (spm.en.nopretok.model in the case).
print(encoded_sentence)
decoded_sentence = en_tokenizer.decode(encoded_sentence)
# this decodes the list of integer IDs (encoded_sentence) back into a human-readable string (decoded_sentence).
[4, 31, 346, 912, 10050, 222, 1337, 372, 820, 4559, 858, 750, 3, 13118, 31, 346, 2000, 10, 8978, 5461, 5]
print(decoded_sentence)
年金 日本に住んでいる20歳~60歳の全ての人は、公的年金制度に加入しなければなりません。
encoded_sentence = ja_tokenizer.encode("年金 日本に住んでいる20歳~60歳の全ての人は、公的年金制度に加入しなければなりません。")
print(encoded_sentence)
decoded_sentence = ja_tokenizer.decode(encoded_sentence)
[4, 31, 346, 912, 10050, 222, 1337, 372, 820, 4559, 858, 750, 3, 13118, 31, 346, 2000, 10, 8978, 5461, 5]
print(decoded_sentence)
年金 日本に住んでいる20歳~60歳の全ての人は、公的年金制度に加入しなければなりません。

5.4 构建TorchText词汇表对象并将句子转换为Torch张量

下面我们构建词汇表并处理数据,准备用于训练模型。首先定义一个函数 build_vocab,接受句子列表 sentences 和一个分词器 tokenizer 作为输入。
函数使用 tokenizer 对每个句子进行编码,并统计每个词元的频率。
使用统计信息构建一个 Vocab 对象,并指定特殊标记 ( [ ′ < u n k > ′ , ′ < p a d > ′ , ′ < b o s > ′ , ′ < e o s > ′ ] ) (['<unk>', '<pad>', '<bos>', '<eos>']) [<unk>,<pad>,<bos>,<eos>]

然后定义一个data_process函数,在函数中,接受日语 (ja) 和中文 (en) 句子作为输入。函数遍历每对句子,并使用相应的分词器 (ja_tokenizeren_tokenizer) 对其进行编码。将编码后的词元转换为张量 (torch.tensor),使用之前构建的词汇表 (ja_vocaben_vocab)。构建一个包含编码后句子张量对的列表 (ja_tensor_, en_tensor_)

另外需要注意的是在新版本的 torchtext 库中, torchtext.vocab.Vocab 类的构造函数不支持 specials 参数。要定义特殊符号(如 , , , )时需要使用 specials_first=True 参数来实现。

自此,我们准备了用于训练模型的数据,通过构建词汇表和将句子编码为张量,为后续基于Transformer的神经网络模型提供了必要的输入。

from torchtext.vocab import build_vocab_from_iterator
from collections import Counter
import torch

def build_vocab(sentences, tokenizer):
    counter = Counter()
    for sentence in sentences:
        counter.update(tokenizer.encode(sentence, out_type=str))
    return build_vocab_from_iterator([counter.keys()], specials=['<unk>', '<pad>', '<bos>', '<eos>'])

# 使用 build_vocab 函数分别为日语 (trainja) 和英语 (trainen) 句子构建词汇表。
ja_vocab = build_vocab(trainja, ja_tokenizer)
en_vocab = build_vocab(trainen, en_tokenizer)

def data_process(ja, en):
    data = []
    for (raw_ja, raw_en) in zip(ja, en):
        ja_tensor_ = torch.tensor([ja_vocab[token] for token in ja_tokenizer.encode(raw_ja.rstrip("\n"), out_type=str)],
                                  dtype=torch.long)
        en_tensor_ = torch.tensor([en_vocab[token] for token in en_tokenizer.encode(raw_en.rstrip("\n"), out_type=str)],
                                  dtype=torch.long)
        data.append((ja_tensor_, en_tensor_))
    return data

# 使用 data_process 函数处理训练数据 (trainja 和 trainen),并将处理后的数据存储在 train_data 中。
train_data = data_process(trainja, trainen)

5.5 DataLoader

下面,我们要进一步创建要在训练期间迭代的DataLoader对象。

在指引中,我得到以下消息——

Here, I set the BATCH_SIZE to 16 to prevent “cuda out of memory”, but this depends on various things such as your machine memory capacity, size of data, etc., so feel free to change the batch size according to your needs (note: the tutorial from PyTorch sets the batch size as 128 using the Multi30k German-English dataset.)

在下面的训练中,我们所使用的GPU显存约为16GB,个人认为可以考虑设置BATCH_SIZE为32,下面会进行进一步的测试。

BATCH_SIZE = 32
PAD_IDX = ja_vocab['<pad>']
BOS_IDX = ja_vocab['<bos>']
EOS_IDX = ja_vocab['<eos>']
def generate_batch(data_batch):
  ja_batch, en_batch = [], []
  for (ja_item, en_item) in data_batch:
    ja_batch.append(torch.cat([torch.tensor([BOS_IDX]), ja_item, torch.tensor([EOS_IDX])], dim=0))
    en_batch.append(torch.cat([torch.tensor([BOS_IDX]), en_item, torch.tensor([EOS_IDX])], dim=0))
  ja_batch = pad_sequence(ja_batch, padding_value=PAD_IDX)
  en_batch = pad_sequence(en_batch, padding_value=PAD_IDX)
  return ja_batch, en_batch
train_iter = DataLoader(train_data, batch_size=BATCH_SIZE,
                        shuffle=True, collate_fn=generate_batch)

5.6 Transformer设计

首先定义一个 Seq2SeqTransformer 类,使它继承自 nn.Module,用于构建一个序列到序列的Transformer模型。

初始化模型的各种组件,包括编码器层、解码器层、生成器、嵌入层和位置编码。

在前向传播函数 forward中定义模型的前向传播过程,包括对源输入和目标输入进行嵌入、位置编码、编码和解码,最终通过生成器映射到目标词汇表。

在编码函数 encode中,仅对源输入进行编码,输出编码后的表示。

在解码函数 decode中,使用目标输入和编码器的输出进行解码,输出解码后的表示。

该Transformer模型包括多个编码器层和解码器层,能够处理变长的输入序列并生成相应的输出序列。

from torch.nn import (TransformerEncoder, TransformerDecoder,
                      TransformerEncoderLayer, TransformerDecoderLayer)

# 定义一个序列到序列的Transformer模型
class Seq2SeqTransformer(nn.Module):
    def __init__(self, num_encoder_layers: int, num_decoder_layers: int,
                 emb_size: int, src_vocab_size: int, tgt_vocab_size: int,
                 dim_feedforward: int = 512, dropout: float = 0.1):
        super(Seq2SeqTransformer, self).__init__()

        # 定义Transformer编码器层
        encoder_layer = TransformerEncoderLayer(d_model=emb_size, nhead=NHEAD,
                                                dim_feedforward=dim_feedforward)
        # 将多个编码器层堆叠起来形成完整的Transformer编码器
        self.transformer_encoder = TransformerEncoder(encoder_layer, num_layers=num_encoder_layers)

        # 定义Transformer解码器层
        decoder_layer = TransformerDecoderLayer(d_model=emb_size, nhead=NHEAD,
                                                dim_feedforward=dim_feedforward)
        # 将多个解码器层堆叠起来形成完整的Transformer解码器
        self.transformer_decoder = TransformerDecoder(decoder_layer, num_layers=num_decoder_layers)

        # 定义生成器,将解码器的输出映射到目标词汇表大小
        self.generator = nn.Linear(emb_size, tgt_vocab_size)

        # 定义源语言和目标语言的嵌入层
        self.src_tok_emb = TokenEmbedding(src_vocab_size, emb_size)
        self.tgt_tok_emb = TokenEmbedding(tgt_vocab_size, emb_size)

        # 定义位置编码,用于加入位置信息
        self.positional_encoding = PositionalEncoding(emb_size, dropout=dropout)

    # 前向传播函数
    def forward(self, src: Tensor, trg: Tensor, src_mask: Tensor,
                tgt_mask: Tensor, src_padding_mask: Tensor,
                tgt_padding_mask: Tensor, memory_key_padding_mask: Tensor):
        # 对源输入进行嵌入和位置编码
        src_emb = self.positional_encoding(self.src_tok_emb(src))
        # 对目标输入进行嵌入和位置编码
        tgt_emb = self.positional_encoding(self.tgt_tok_emb(trg))
        
        # 将源输入通过编码器
        memory = self.transformer_encoder(src_emb, src_mask, src_padding_mask)
        
        # 将目标输入和编码器的输出通过解码器
        outs = self.transformer_decoder(tgt_emb, memory, tgt_mask, None,
                                        tgt_padding_mask, memory_key_padding_mask)
        # 将解码器的输出通过生成器映射到目标词汇表大小
        return self.generator(outs)

    # 编码函数,只对源输入进行编码
    def encode(self, src: Tensor, src_mask: Tensor):
        return self.transformer_encoder(self.positional_encoding(
                            self.src_tok_emb(src)), src_mask)

    # 解码函数,将目标输入和编码器的输出进行解码
    def decode(self, tgt: Tensor, memory: Tensor, tgt_mask: Tensor):
        return self.transformer_decoder(self.positional_encoding(
                          self.tgt_tok_emb(tgt)), memory,
                          tgt_mask)

进行位置编码,首先定义 PositionalEncoding 类,用于在嵌入向量中加入位置信息,以便模型能够利用序列中各个词元的位置。使用__init__ 方法初始化位置编码的参数、计算位置编码矩阵 pos_embedding,其中偶数位置使用 sin 函数,奇数位置使用 cos 函数,同时定义 Dropout 层;最后使用 register_buffer 将位置编码矩阵注册为缓冲区,以避免其作为模型参数进行更新。在 forward 方法中将位置编码加入到输入的嵌入向量中,并应用 Dropout

接着定义 TokenEmbedding 类,用于将词元索引映射到嵌入向量。同样使用__init__ 方法初始化词汇表大小和嵌入向量的维度并定义嵌入层 nn.Embedding。在forward 方法中将词元索引映射到嵌入向量,并乘以嵌入向量维度的平方根以进行缩放。

这两个类为序列到序列Transformer模型提供了基础的嵌入层和位置编码功能。TokenEmbedding 类用于将词汇表中的词元索引转换为高维向量,而 PositionalEncoding 类则为这些高维向量添加位置信息,以便模型能够感知序列中各个词元的位置。

import math
import torch
import torch.nn as nn
from torch import Tensor

# 位置编码类,用于在嵌入向量中加入位置信息
class PositionalEncoding(nn.Module):
    def __init__(self, emb_size: int, dropout: float, maxlen: int = 5000):
        super(PositionalEncoding, self).__init__()
        
        # 计算位置编码的分母
        den = torch.exp(- torch.arange(0, emb_size, 2) * math.log(10000) / emb_size)
        
        # 生成位置索引
        pos = torch.arange(0, maxlen).reshape(maxlen, 1)
        
        # 初始化位置编码矩阵
        pos_embedding = torch.zeros((maxlen, emb_size))
        
        # 在偶数索引位置计算sin函数
        pos_embedding[:, 0::2] = torch.sin(pos * den)
        
        # 在奇数索引位置计算cos函数
        pos_embedding[:, 1::2] = torch.cos(pos * den)
        
        # 增加一个维度以便于后续操作
        pos_embedding = pos_embedding.unsqueeze(-2)

        # 定义Dropout层
        self.dropout = nn.Dropout(dropout)
        
        # 将位置编码注册为缓冲区,不作为模型参数
        self.register_buffer('pos_embedding', pos_embedding)

    # 前向传播函数
    def forward(self, token_embedding: Tensor):
        # 将位置编码加入到输入的嵌入向量中,并应用Dropout
        return self.dropout(token_embedding +
                            self.pos_embedding[:token_embedding.size(0), :])

# 词元嵌入类,用于将词元索引映射到嵌入向量
class TokenEmbedding(nn.Module):
    def __init__(self, vocab_size: int, emb_size: int):
        super(TokenEmbedding, self).__init__()
        
        # 定义嵌入层
        self.embedding = nn.Embedding(vocab_size, emb_size)
        
        # 保存嵌入向量的维度
        self.emb_size = emb_size

    # 前向传播函数
    def forward(self, tokens: Tensor):
        # 将词元索引映射到嵌入向量,并乘以嵌入向量维度的平方根
        return self.embedding(tokens.long()) * math.sqrt(self.emb_size)

下面进行添加上三角掩码矩阵,以确保Transformer模型在解码过程中不会看到未来的词元;并为源输入和目标输入创建掩码,包括方阵掩码和填充掩码。

  1. 定义 generate_square_subsequent_mask 函数

    • 该函数用于生成一个上三角掩码矩阵,以确保Transformer模型在解码过程中不会看到未来的词元。

    • mask 矩阵:

      • 创建一个大小为 (sz, sz) 的上三角矩阵,使用 torch.triu(torch.ones((sz, sz), device=device)) 表示可以看到当前词元及之前的词元。
      • 转置矩阵以确保其形状正确。
      • 将矩阵中的0填充为负无穷,表示这些位置不应该被注意。
      • 将矩阵中的1填充为0,表示这些位置可以被注意。
  2. 定义 create_mask 函数

    • 该函数用于为源输入和目标输入创建掩码,包括方阵掩码和填充掩码。

    • 获取序列长度:

      • src_seq_len 获取源输入的序列长度。
      • tgt_seq_len 获取目标输入的序列长度。
    • 生成目标输入的方阵掩码:

      • 使用 generate_square_subsequent_mask 函数生成大小为 tgt_seq_len 的方阵掩码 tgt_mask
    • 生成源输入的掩码:

      • 因为在编码阶段没有时序限制,所以源输入的掩码为全零矩阵 src_mask
    • 生成填充掩码:

      • src_padding_mask 用于标记源输入中的填充位置。
      • tgt_padding_mask 用于标记目标输入中的填充位置。
      • 填充掩码通过比较输入张量与 PAD_IDX 是否相等生成,表示哪些位置是填充标记。
    • 返回结果:

      • 函数返回源掩码、目标掩码、源填充掩码和目标填充掩码,用于Transformer模型的后续处理。

这两个函数为Transformer模型的掩码机制提供了支持。generate_square_subsequent_mask 函数生成防止解码过程中查看未来词元的方阵掩码,而 create_mask 函数则生成源输入和目标输入的掩码及填充掩码,以确保模型正确处理变长序列和填充标记。这些掩码在训练和推理过程中非常重要,以保持模型的自注意力机制正确运行。

import torch

# 生成一个方阵掩码,用于防止Transformer模型在解码时查看未来的词元
def generate_square_subsequent_mask(sz):
    # 创建一个上三角矩阵,矩阵中值为1,表示可以看到当前词元及之前的词元
    mask = (torch.triu(torch.ones((sz, sz), device=device)) == 1).transpose(0, 1)
    # 将掩码中的0填充为负无穷,表示这些位置不应该被注意
    mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, float(0.0))
    return mask

# 创建掩码函数,包括源输入和目标输入的掩码以及填充掩码
def create_mask(src, tgt):
    # 获取源输入和目标输入的序列长度
    src_seq_len = src.shape[0]
    tgt_seq_len = tgt.shape[0]

    # 生成目标输入的方阵掩码
    tgt_mask = generate_square_subsequent_mask(tgt_seq_len)
    # 源输入不需要方阵掩码,因为在编码阶段没有时序限制
    src_mask = torch.zeros((src_seq_len, src_seq_len), device=device).type(torch.bool)

    # 生成源输入和目标输入的填充掩码
    src_padding_mask = (src == PAD_IDX).transpose(0, 1)
    tgt_padding_mask = (tgt == PAD_IDX).transpose(0, 1)

    # 返回源掩码、目标掩码、源填充掩码和目标填充掩码
    return src_mask, tgt_mask, src_padding_mask, tgt_padding_mask

下面是模型的训练函数。

超参数包括词汇表大小、嵌入向量维度、多头注意力机制的头数、前馈神经网络的维度、批次大小、编码器层数、解码器层数和训练轮数。我们的思路如下:

  1. 初始化模型

    • 使用定义好的 Seq2SeqTransformer 类初始化一个Transformer模型。
    • 使用Xavier初始化方法初始化模型的所有权重参数。
    • 将模型移动到指定设备(CPU或GPU)。
  2. 定义损失函数和优化器

    • 使用交叉熵损失函数,并忽略填充标记的损失。
    • 使用Adam优化器进行优化。
  3. 定义 train_epoch 函数

    • 该函数用于训练模型一个epoch。
    • 首先将模型设置为训练模式。
    • 对于每个批次的数据,进行前向传播、计算损失、反向传播和优化器更新参数。
    • 累加所有批次的损失,并返回平均损失。
  4. 定义 evaluate 函数

    • 该函数用于评估模型在验证集上的性能。
    • 首先将模型设置为评估模式。
    • 使用 torch.no_grad() 上下文管理器确保评估时不计算梯度。
    • 对于每个批次的数据,进行前向传播和计算损失。
    • 累加所有批次的损失,并返回平均损失。
import torch
import torch.nn as nn
import torch.optim as optim

# 定义一些超参数
SRC_VOCAB_SIZE = len(ja_vocab)      # 源语言词汇表大小
TGT_VOCAB_SIZE = len(en_vocab)      # 目标语言词汇表大小
EMB_SIZE = 512                      # 嵌入向量维度
NHEAD = 8                           # 多头注意力机制的头数
FFN_HID_DIM = 512                   # 前馈神经网络的维度
BATCH_SIZE = 16                     # 批次大小
NUM_ENCODER_LAYERS = 3              # 编码器层数
NUM_DECODER_LAYERS = 3              # 解码器层数
NUM_EPOCHS = 16                     # 训练的轮数

# 初始化Seq2SeqTransformer模型
transformer = Seq2SeqTransformer(NUM_ENCODER_LAYERS, NUM_DECODER_LAYERS,
                                 EMB_SIZE, SRC_VOCAB_SIZE, TGT_VOCAB_SIZE,
                                 FFN_HID_DIM)

# 使用Xavier初始化所有权重参数
for p in transformer.parameters():
    if p.dim() > 1:
        nn.init.xavier_uniform_(p)

# 将模型移动到设备(CPU或GPU)
transformer = transformer.to(device)

# 定义损失函数,这里使用交叉熵损失,并忽略填充标记的损失
loss_fn = nn.CrossEntropyLoss(ignore_index=PAD_IDX)

# 定义优化器,这里使用Adam优化器
optimizer = optim.Adam(
    transformer.parameters(), lr=0.0001, betas=(0.9, 0.98), eps=1e-9
)

# 定义训练一个epoch的函数
def train_epoch(model, train_iter, optimizer):
    model.train()  # 将模型设置为训练模式
    losses = 0     # 初始化损失值
    for idx, (src, tgt) in enumerate(train_iter):
        src = src.to(device)    # 将源输入移动到设备
        tgt = tgt.to(device)    # 将目标输入移动到设备

        tgt_input = tgt[:-1, :]  # 获取目标输入(去掉最后一个词元)

        # 生成掩码
        src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)

        # 前向传播
        logits = model(src, tgt_input, src_mask, tgt_mask,
                       src_padding_mask, tgt_padding_mask, src_padding_mask)

        optimizer.zero_grad()  # 梯度清零

        tgt_out = tgt[1:, :]  # 获取目标输出(去掉第一个词元)
        
        # 计算损失
        loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
        
        # 反向传播
        loss.backward()
        
        # 优化器更新参数
        optimizer.step()
        
        losses += loss.item()  # 累加损失
    return losses / len(train_iter)  # 返回平均损失

# 定义评估函数
def evaluate(model, val_iter):
    model.eval()  # 将模型设置为评估模式
    losses = 0    # 初始化损失值
    with torch.no_grad():  # 评估时不计算梯度
        for idx, (src, tgt) in enumerate(val_iter):
            src = src.to(device)  # 将源输入移动到设备
            tgt = tgt.to(device)  # 将目标输入移动到设备

            tgt_input = tgt[:-1, :]  # 获取目标输入(去掉最后一个词元)

            # 生成掩码
            src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)

            # 前向传播
            logits = model(src, tgt_input, src_mask, tgt_mask,
                           src_padding_mask, tgt_padding_mask, src_padding_mask)
            
            tgt_out = tgt[1:, :]  # 获取目标输出(去掉第一个词元)
            
            # 计算损失
            loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
            
            losses += loss.item()  # 累加损失
    return losses / len(val_iter)  # 返回平均损失

/opt/conda/lib/python3.10/site-packages/torch/nn/modules/transformer.py:282: UserWarning: enable_nested_tensor is True, but self.use_nested_tensor is False because encoder_layer.self_attn.batch_first was not True(use batch_first for better inference performance)
  warnings.warn(f"enable_nested_tensor is True, but self.use_nested_tensor is False because {why_not_sparsity_fast_path}")

上面的警告说明,这个警告提示了在使用 torch.nn.Transformer 模块时的一些优化设置问题。具体来说,它提示 enable_nested_tensor 被设置为 True,但由于 encoder_layer.self_attn.batch_first 不是 Trueself.use_nested_tensor 被设置为 False。这是因为使用 batch_first=True 可以提高推理性能。通过查询资料,我们得到以下解决方法

  1. 设置 batch_first=True
    在定义 TransformerEncoderLayerTransformerDecoderLayer 时,将 batch_first 参数设置为 True。这会确保输入和输出的第一个维度是 batch size,而不是序列长度。

  2. 调整数据的维度
    在数据处理部分,需要确保数据的维度与 batch_first=True 的设置相匹配。

下面是代码示例:

  1. 更新 Seq2SeqTransformer

首先,在定义 TransformerEncoderLayerTransformerDecoderLayer 时,添加 batch_first=True

class Seq2SeqTransformer(nn.Module):
    def __init__(self, num_encoder_layers: int, num_decoder_layers: int,
                 emb_size: int, src_vocab_size: int, tgt_vocab_size: int,
                 dim_feedforward:int = 512, dropout:float = 0.1):
        super(Seq2SeqTransformer, self).__init__()
        encoder_layer = TransformerEncoderLayer(d_model=emb_size, nhead=NHEAD,
                                                dim_feedforward=dim_feedforward,
                                                batch_first=True)
        self.transformer_encoder = TransformerEncoder(encoder_layer, num_layers=num_encoder_layers)
        decoder_layer = TransformerDecoderLayer(d_model=emb_size, nhead=NHEAD,
                                                dim_feedforward=dim_feedforward,
                                                batch_first=True)
        self.transformer_decoder = TransformerDecoder(decoder_layer, num_layers=num_decoder_layers)

        self.generator = nn.Linear(emb_size, tgt_vocab_size)
        self.src_tok_emb = TokenEmbedding(src_vocab_size, emb_size)
        self.tgt_tok_emb = TokenEmbedding(tgt_vocab_size, emb_size)
        self.positional_encoding = PositionalEncoding(emb_size, dropout=dropout)

    def forward(self, src: Tensor, trg: Tensor, src_mask: Tensor,
                tgt_mask: Tensor, src_padding_mask: Tensor,
                tgt_padding_mask: Tensor, memory_key_padding_mask: Tensor):
        src_emb = self.positional_encoding(self.src_tok_emb(src))
        tgt_emb = self.positional_encoding(self.tgt_tok_emb(trg))
        memory = self.transformer_encoder(src_emb, src_mask, src_padding_mask)
        outs = self.transformer_decoder(tgt_emb, memory, tgt_mask, None,
                                        tgt_padding_mask, memory_key_padding_mask)
        return self.generator(outs)

    def encode(self, src: Tensor, src_mask: Tensor):
        return self.transformer_encoder(self.positional_encoding(
                            self.src_tok_emb(src)), src_mask)

    def decode(self, tgt: Tensor, memory: Tensor, tgt_mask: Tensor):
        return self.transformer_decoder(self.positional_encoding(
                          self.tgt_tok_emb(tgt)), memory,
                          tgt_mask)
  1. 调整数据维度

确保数据的第一个维度是 batch size 而不是序列长度:

def train_epoch(model, train_iter, optimizer):
    model.train()
    losses = 0
    for idx, (src, tgt) in enumerate(train_iter):
        src = src.transpose(0, 1).to(device)  # 确保 batch size 在第一个维度
        tgt = tgt.transpose(0, 1).to(device)  # 确保 batch size 在第一个维度

        tgt_input = tgt[:, :-1]  # 获取目标输入(去掉最后一个词元)

        src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)

        logits = model(src, tgt_input, src_mask, tgt_mask,
                       src_padding_mask, tgt_padding_mask, src_padding_mask)

        optimizer.zero_grad()

        tgt_out = tgt[:, 1:]  # 获取目标输出(去掉第一个词元)
        loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
        loss.backward()

        optimizer.step()
        losses += loss.item()
    return losses / len(train_iter)

def evaluate(model, val_iter):
    model.eval()
    losses = 0
    with torch.no_grad():
        for idx, (src, tgt) in enumerate(val_iter):
            src = src.transpose(0, 1).to(device)  # 确保 batch size 在第一个维度
            tgt = tgt.transpose(0, 1).to(device)  # 确保 batch size 在第一个维度

            tgt_input = tgt[:, :-1]  # 获取目标输入(去掉最后一个词元)

            src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)

            logits = model(src, tgt_input, src_mask, tgt_mask,
                           src_padding_mask, tgt_padding_mask, src_padding_mask)
            tgt_out = tgt[:, 1:]  # 获取目标输出(去掉第一个词元)
            loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
            losses += loss.item()
    return losses / len(val_iter)

通过设置 batch_first=True 并确保数据的第一个维度是 batch size,可以提高模型的推理性能并消除警告信息。这样可以确保模型更高效地进行训练和推理。但是出于这是博主的作业,实验即将截止,修改会对我的及时提交作业、拿到学分添加变数,让我们首先按早原本的指引,测试,后续可以继续开发优化的版本。

但是很好的一点是,这为我们后续继续优化模型提供了方向。

5.7 模型训练

for epoch in tqdm.tqdm(range(1, NUM_EPOCHS+1)):
  start_time = time.time()
  train_loss = train_epoch(transformer, train_iter, optimizer)
  end_time = time.time()
  print((f"Epoch: {epoch}, Train loss: {train_loss:.3f}, "
          f"Epoch time = {(end_time - start_time):.3f}s"))
  0%|          | 0/16 [00:00<?, ?it/s]/opt/conda/lib/python3.10/site-packages/torch/nn/functional.py:5076: UserWarning: Support for mismatched key_padding_mask and attn_mask is deprecated. Use same type for both instead.
  warnings.warn(
  6%|▋         | 1/16 [07:31<1:52:52, 451.48s/it]

Epoch: 1, Train loss: 4.893, Epoch time = 451.476s


 12%|█▎        | 2/16 [15:01<1:45:12, 450.92s/it]

Epoch: 2, Train loss: 3.648, Epoch time = 450.522s


 19%|█▉        | 3/16 [22:32<1:37:38, 450.65s/it]

Epoch: 3, Train loss: 3.177, Epoch time = 450.336s


 25%|██▌       | 4/16 [30:02<1:30:03, 450.26s/it]

Epoch: 4, Train loss: 2.846, Epoch time = 449.668s


 31%|███▏      | 5/16 [37:31<1:22:30, 450.03s/it]

Epoch: 5, Train loss: 2.597, Epoch time = 449.621s


 38%|███▊      | 6/16 [45:01<1:15:01, 450.14s/it]

Epoch: 6, Train loss: 2.400, Epoch time = 450.357s


 44%|████▍     | 7/16 [52:32<1:07:31, 450.17s/it]

Epoch: 7, Train loss: 2.239, Epoch time = 450.213s


 50%|█████     | 8/16 [1:00:02<1:00:02, 450.28s/it]

Epoch: 8, Train loss: 2.107, Epoch time = 450.519s


 56%|█████▋    | 9/16 [1:07:33<52:32, 450.40s/it]  

Epoch: 9, Train loss: 1.997, Epoch time = 450.657s


 62%|██████▎   | 10/16 [1:15:03<45:02, 450.35s/it]

Epoch: 10, Train loss: 1.902, Epoch time = 450.246s


 69%|██████▉   | 11/16 [1:22:34<37:32, 450.46s/it]

Epoch: 11, Train loss: 1.821, Epoch time = 450.710s


 75%|███████▌  | 12/16 [1:30:04<30:01, 450.31s/it]

Epoch: 12, Train loss: 1.750, Epoch time = 449.974s


 81%|████████▏ | 13/16 [1:37:35<22:31, 450.48s/it]

Epoch: 13, Train loss: 1.686, Epoch time = 450.857s


 88%|████████▊ | 14/16 [1:45:04<15:00, 450.11s/it]

Epoch: 14, Train loss: 1.629, Epoch time = 449.270s


 94%|█████████▍| 15/16 [1:52:34<07:30, 450.12s/it]

Epoch: 15, Train loss: 1.580, Epoch time = 450.136s


100%|██████████| 16/16 [2:00:05<00:00, 450.32s/it]

Epoch: 16, Train loss: 1.535, Epoch time = 450.609s

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
将BATCH_SIZE设置为32,几乎跑满GPU P100显存。平均450s跑完一个epoch,每个16个epoch中,loss都在继续下降,但GPU时间不足,没有继续跑新的epoch。

在这里插入图片描述

5.8 小试牛刀

def greedy_decode(model, src, src_mask, max_len, start_symbol):
    src = src.to(device)
    src_mask = src_mask.to(device)
    memory = model.encode(src, src_mask)
    ys = torch.ones(1, 1).fill_(start_symbol).type(torch.long).to(device)
    for i in range(max_len-1):
        memory = memory.to(device)
        memory_mask = torch.zeros(ys.shape[0], memory.shape[0]).to(device).type(torch.bool)
        tgt_mask = (generate_square_subsequent_mask(ys.size(0))
                                    .type(torch.bool)).to(device)
        out = model.decode(ys, memory, tgt_mask)
        out = out.transpose(0, 1)
        prob = model.generator(out[:, -1])
        _, next_word = torch.max(prob, dim = 1)
        next_word = next_word.item()
        ys = torch.cat([ys,
                        torch.ones(1, 1).type_as(src.data).fill_(next_word)], dim=0)
        if next_word == EOS_IDX:
          break
    return ys
def translate(model, src, src_vocab, tgt_vocab, src_tokenizer):
    model.eval()
    tokens = [BOS_IDX] + [src_vocab.stoi[tok] for tok in src_tokenizer.encode(src, out_type=str)]+ [EOS_IDX]
    num_tokens = len(tokens)
    src = (torch.LongTensor(tokens).reshape(num_tokens, 1) )
    src_mask = (torch.zeros(num_tokens, num_tokens)).type(torch.bool)
    tgt_tokens = greedy_decode(model,  src, src_mask, max_len=num_tokens + 5, start_symbol=BOS_IDX).flatten()
    return " ".join([tgt_vocab.itos[tok] for tok in tgt_tokens]).replace("<bos>", "").replace("<eos>", "")
from torchtext.vocab import build_vocab_from_iterator

# 定义特殊标记的索引
BOS_IDX = 0
EOS_IDX = 1
PAD_IDX = 2
UNK_IDX = 3

# 构建词汇表
def build_vocab(sentences, tokenizer):
    def yield_tokens(sentences):
        for sentence in sentences:
            yield tokenizer.encode(sentence, out_type=str)
    vocab = build_vocab_from_iterator(yield_tokens(sentences),
                                      specials=['<bos>', '<eos>', '<pad>', '<unk>'])
    vocab.set_default_index(UNK_IDX)
    return vocab

# 使用 build_vocab 函数分别为日语 (trainja) 和英语 (trainen) 句子构建词汇表
ja_vocab = build_vocab(trainja, ja_tokenizer)
en_vocab = build_vocab(trainen, en_tokenizer)

def translate(model, src, src_vocab, tgt_vocab, src_tokenizer):
    model.eval()
    tokens = [BOS_IDX] + [src_vocab[tok] for tok in src_tokenizer.encode(src, out_type=str)] + [EOS_IDX]
    num_tokens = len(tokens)
    src = (torch.LongTensor(tokens).reshape(num_tokens, 1)).to(device)
    src_mask = (torch.zeros(num_tokens, num_tokens)).type(torch.bool).to(device)
    tgt_tokens = greedy_decode(model, src, src_mask, max_len=num_tokens + 5, start_symbol=BOS_IDX).flatten()
    return " ".join([tgt_vocab.get_itos()[tok] for tok in tgt_tokens]).replace("<bos>", "").replace("<eos>", "")

# 现在可以尝试翻译句子
translated_sentence = translate(transformer, "HSコード 8515 はんだ付け用、ろう付け用又は溶接用の機器(電気式(電気加熱ガス式を含む。)", ja_vocab, en_vocab, ja_tokenizer)
print(translated_sentence)
 配 无 配 ▁ 氘 常 ▁zone 机 ▁café 理 氘 _ 配 ▁multiple ▁A 阂 配 诡 ▁teachers 工 omi ▁wedding :12 Keeping Since Keeping ▁inform ▁side 岚 像 决 ▁certified 潮 ▁A ▁certified 潮
trainen.pop(5)
'Chinese HS Code Harmonized Code System < HS编码 8515 : 电气(包括电热气体)、激光、其他光、光子束、超声波、电子束、磁脉冲或等离子弧焊接机器及装置,不论是否 HS Code List (Harmonized System Code) for US, UK, EU, China, India, France, Japan, Russia, Germany, Korea, Canada ...'
trainja.pop(5)
'Japanese HS Code Harmonized Code System < HSコード 8515 はんだ付け用、ろう付け用又は溶接用の機器(電気式(電気加熱ガス式を含む。)、レーザーその他の光子ビーム式、超音波式、電子ビーム式、 HS Code List (Harmonized System Code) for US, UK, EU, China, India, France, Japan, Russia, Germany, Korea, Canada ...'

其实可以看出,训练得到的效果不够好,得到的序列解码结果不够准确,通过增加epoch、增加语料,或许我们可以得到更好的效果。

5.9 保存词汇表和模型

import pickle
# open a file, where you want to store the data
file = open('en_vocab.pkl', 'wb')
# dump information to that file
pickle.dump(en_vocab, file)
file.close()
file = open('ja_vocab.pkl', 'wb')
pickle.dump(ja_vocab, file)
file.close()
# save model for inference
torch.save(transformer.state_dict(), 'inference_model')
# save model + checkpoint to resume training later
torch.save({
  'epoch': NUM_EPOCHS,
  'model_state_dict': transformer.state_dict(),
  'optimizer_state_dict': optimizer.state_dict(),
  'loss': train_loss,
  }, 'model_checkpoint.tar')

6 总结

本博客详细介绍了Transformer模型的实现过程,该模型是机器翻译和自然语言处理领域的重要突破。通过引入自注意力机制,Transformer克服了传统序列模型在处理长距离依赖时的局限性。本文的主要内容包括:

  1. 模型架构:介绍了Transformer模型的整体架构,包括编码器和解码器的设计。编码器由多个层堆叠而成,每层包含一个自注意力子层和一个前馈神经网络子层。解码器的结构类似,但在每个层中增加了一个用于处理编码器输出的注意力子层。

  2. 位置编码:为了使模型能够利用序列中各个词元的位置信息,本文介绍了位置编码的实现方式。位置编码通过在嵌入向量中加入位置信息,使得模型能够感知词元在序列中的位置。

  3. 嵌入层:本文详细讲解了如何将词元索引映射为高维向量的过程,包括使用词嵌入层和位置编码进行处理。

  4. 训练过程:涵盖了数据准备、模型训练、损失函数计算、优化器选择等各个方面。通过PyTorch框架,逐步实现了Transformer模型的训练过程。

  5. 模型推理:介绍了如何使用训练好的模型进行推理,包括翻译输入序列的具体步骤。

通过以上步骤,读者可以了解到Transformer模型的基本原理和实现方法,并能够利用该模型进行实际的自然语言处理任务。

Transformer模型自提出以来,在自然语言处理领域取得了显著的成果。其高效的自注意力机制和并行计算能力,使得Transformer在处理大规模数据和复杂任务时表现出色。本文通过详细的代码实现和讲解,帮助读者深入理解了Transformer的工作原理和应用场景。未来,随着技术的不断发展,Transformer及其衍生模型将在更多领域展现出强大的潜力。希望本文能为读者在学习和应用Transformer模型方面提供有益的参考和帮助。

笔者和大家一起继续努力!

;