基于注意力机制GRU网络的机器翻译

深度学习---机器翻译(GRU、注意力机制)

目录

一、机器翻译(MT)

二、注意力机制

2.1 背景

2.2 基本原理

2.2.1 基本概念

2.2.2 工作机制

2.2.3 数学表达

三、GRU门控循环单元

3.1 重置门和更新门

3.2 候选隐状态

3.3 最终隐藏状态

四、机器翻译实战(fr-en-small)

4.1 数据预处理

4.2 含注意力机制的编码器—解码器

4.2.1 编码器

4.2.2 解码器

4.3 模型训练

4.4 模型预测(预测不定长的序列)

4.5 模型评价

五、实验总结

六、参考文献

七、推荐阅读


一、机器翻译(MT)

        机器翻译(machine translation)指的是 将序列从一种语言自动翻译成另一种语言。 事实上,这个研究领域可以追溯到数字计算机发明后不久的20世纪40年代, 特别是在第二次世界大战中使用计算机破解语言编码。 几十年来,在使用神经网络进行端到端学习的兴起之前, 统计学方法在这一领域一直占据主导地位 (Brown et al., 1990Brown et al., 1988)。 因为统计机器翻译(statistical machine translation)涉及了 翻译模型和语言模型等组成部分的统计分析, 因此基于神经网络的方法通常被称为 神经机器翻译(neural machine translation), 用于将两种翻译模型区分开来。

        机器翻译作为自然语言处理(NLP)领域的重要分支,已经有数十年的发展历史。从最早的基于规则的方法,到后来统计机器翻译(Statistical Machine Translation,SMT)的出现,再到如今深度学习方法的应用,机器翻译技术不断进步。然而,传统方法在处理长句子或上下文依赖问题时常常表现不佳。


二、注意力机制

2.1 背景

        随着深度学习的兴起,神经机器翻译(Neural Machine Translation,NMT)成为研究热点。然而,早期的NMT模型,如序列到序列(Seq2Seq)模型,在处理长句子时仍存在信息丢失的问题。为了解决这一问题,2015年,Bahdanau等人提出了注意力机制(Attention Mechanism),该机制允许模型在翻译时动态地“关注”源句子的不同部分,从而显著提升了翻译效果。

2.2 基本原理

        注意力机制的核心思想是为每个输出词动态计算与所有输入词的关联权重,从而生成加权求和的上下文向量。具体来说,注意力机制通过计算查询(query)、键(key)和值(value)三者之间的相似度来获得权重。这种方法不仅有效缓解了长句翻译中的信息瓶颈问题,还提高了翻译的准确性和流畅度。

2.2.1 基本概念

注意力机制主要涉及三个核心组件:查询(Query),(Key),(Value)。这三个组件来自于输入数据,具体如下:

  • Query:当前或目标位置的表示,用于查找与之最相关的信息。
  • Key:与输入数据相关联的标识符,用于匹配查询。
  • Value:如果查询与键匹配,那么相关的值将被用来构造输出。

图2.2.1.1 注意力机制通过注意力汇聚将查询(自主性提示)和(非自主性提示)结合在一起,实现对(感官输入)的选择倾向

2.2.2 工作机制
  • 打分:系统计算查询与每个键之间的相似度或相关性得分。这通常通过点积或其他相似性函数(如加权和)来完成。
  • 归一化:使用softmax函数将得分转换为概率分布,确保所有得分的和为1,这样得分高的键对应的值将获得更高的关注度。
  • 加权和:将每个值乘以其对应键的归一化得分,然后将结果相加,得到最终的输出。输出是所有值的加权求和,权重由查询与每个键的匹配程度决定。

图2.2.2.1 计算注意力汇聚的输出为值的加权和

2.2.3 数学表达

        假设有一个序列到序列的任务,其中Query来自于目标序列的当前状态,而Keys和Values来自于源序列。注意力机制可以用下面的数学形式表达:

\text{Attention}(Q,K,V)=\text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V

        具体来说,令编码器在时间步𝑡𝑡的隐藏状态为ℎ𝑡,且总时间步数为𝑇。那么解码器在时间步𝑡′′的背景变量为所有编码器隐藏状态的加权平均:

c_{t^{\prime}}=\sum_{t=1}^T\alpha_{t^{\prime}t}h_t,

        其中给定𝑡′时,权重𝛼𝑡′𝑡在𝑡=1,…,𝑇的值是一个概率分布。为了得到概率分布,我们可以使用softmax运算:

\alpha_{t't}=\frac{\exp(e_{t't})}{\sum_{k=1}^T\exp(e_{t'k})},\quad t=1,\ldots,T.

        现在,我们需要定义如何计算上式中softmax运算的输入𝑒𝑡′𝑡。由于𝑒𝑡′𝑡同时取决于解码器的时间步𝑡′和编码器的时间步𝑡,我们不妨以解码器在时间步𝑡′−1𝑡′−1的隐藏状态𝑠𝑡′−1与编码器在时间步𝑡𝑡的隐藏状态ℎ𝑡为输入,并通过函数𝑎𝑎计算𝑒𝑡′𝑡:

e_{t't}=a(s_{t'-1},h_t).

        这里函数𝑎有多种选择,如果两个输入向量长度相同,一个简单的选择是计算它们的内积𝑎(𝑠,ℎ)=𝑠⊤ℎ。而最早提出注意力机制的论文则将输入连结后通过含单隐藏层的多层感知机变换 [1]:

a(s,h)=v^\top\tanh(W_ss+W_hh),

        其中𝑣、𝑊𝑠、𝑊ℎ都是可以学习的模型参数。


三、GRU门控循环单元

        门控循环单元与普通的循环神经网络之间的关键区别在于: 前者支持隐状态的门控。 这意味着模型有专门的机制来确定应该何时更新隐状态, 以及应该何时重置隐状态。 这些机制是可学习的,并且能够解决了上面列出的问题。 例如,如果第一个词元非常重要, 模型将学会在第一次观测之后不更新隐状态。 同样,模型也可以学会跳过不相关的临时观测。 最后,模型还将学会在需要的时候重置隐状态。 下面我们将详细讨论各类门控。

3.1 重置门和更新门

        本文首先介绍重置门(reset gate)和更新门(update gate)。 我们把它们设计成(0,1)区间中的向量, 这样我们就可以进行凸组合。 重置门允许我们控制“可能还想记住”的过去状态的数量; 更新门将允许我们控制新状态中有多少个是旧状态的副本。

图3.1.1 重置门、更新门

        本文从构造这些门控开始。图中描述了门控循环单元中的重置门和更新门的输入, 输入是由当前时间步的输入和前一时间步的隐状态给出。 两个门的输出是由使用sigmoid激活函数的两个全连接层给出。

图3.1.2 在门控循环单元模型中计算重置门和更新

3.2 候选隐状态

       候选隐藏状态是一种临时状态,它包含了可能被用来更新实际隐藏状态的信息。它的计算考虑了重置门的影响:重置门𝑅𝑡 与中的常规隐状态更新机制集成, 得到在时间步𝑡的候选隐状态(candidate hidden state)。

        图3.2.1 候选隐状态

3.3 最终隐藏状态

        最终的隐藏状态是由更新门控制的,它决定了候选隐藏状态和前一个隐藏状态的信息如何合并来生成当前的隐藏状态:

图3.3.1 最终隐藏状态


四、机器翻译实战(fr-en-small)

4.1 数据预处理

        首先,获取组成的“英-法”数据集,解压数据集中的每一行都是制表符分隔的文本序列对, 序列对由英文文本序列和翻译后的法语文本序列组成。 请注意,每个文本序列可以是一个句子, 也可以是包含多个句子的一个段落。

        我们先定义一些特殊符号。其中“<pad>”(padding)符号用来添加在较短序列后,直到每个序列等长,而“<bos>”和“<eos>”符号分别表示序列的开始和结束。

import collections  # 导入 collections 模块,用于创建字典等数据结构
import os  # 导入 os 模块,用于与操作系统交互
import io  # 导入 io 模块,用于处理输入输出流
import math  # 导入 math 模块,用于数学计算
import torch  # 导入 torch 模块,用于深度学习
from torch import nn  # 从 torch 模块导入 nn 子模块,用于构建神经网络
import torch.nn.functional as F  # 导入 torch.nn.functional 模块,用于定义神经网络的激活函数等
import torchtext.vocab as Vocab  # 导入 torchtext.vocab 模块,用于词汇表操作
import torch.utils.data as Data  # 导入 torch.utils.data 模块,用于数据加载和处理

import sys  # 导入 sys 模块,用于系统相关操作
# sys.path.append("..")  # 将上级目录添加到 Python 模块搜索路径中
import d2lzh_pytorch as d2l  # 导入自定义的 d2lzh_pytorch 模块

PAD, BOS, EOS = '<pad>', '<bos>', '<eos>'  # 定义特殊符号:PAD(填充)、BOS(开始)、EOS(结束)
os.environ["CUDA_VISIBLE_DEVICES"] = "0"  # 设置环境变量,指定使用的 GPU 设备编号为 0
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')  # 根据是否有可用的 CUDA 设备,选择使用 CUDA 设备或 CPU

print(torch.__version__, device)  # 打印 torch 版本和当前使用的设备

        接着定义两个辅助函数对后面读取的数据进行预处理。

  • 函数 process_one_seq 主要用于将一个词序列处理成固定长度,通过添加结束符和填充符,并同时更新全局词列表和序列列表。
  • 函数 build_data 用于从所有词的集合中构建一个词典,并将所有处理过的序列转换为对应的词典索引形式,最后返回构建的词典和索引序列的张量表示。
# 将一个序列中所有的词记录在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)
    # 在序列末尾添加一个结束符EOS,并填充PAD直到达到max_seq_len长度
    seq_tokens += [EOS] + [PAD] * (max_seq_len - len(seq_tokens) - 1)
    # 将处理过的序列添加到序列列表中
    all_seqs.append(seq_tokens)

def build_data(all_tokens, all_seqs):
    # 根据所有词的频率构建词典,并包括特殊符号PAD, BOS, EOS
    vocab = Vocab.Vocab(collections.Counter(all_tokens), specials=[PAD, BOS, EOS])
    # 将所有序列中的词转换为词典中的索引,形成一个新的索引序列列表
    indices = [[vocab.stoi[w] for w in seq] for seq in all_seqs]
    # 返回构建的词典和索引序列的Tensor
    return vocab, torch.tensor(indices)

        本文在这里使用一个很小的法语—英语数据集。在这个数据集里,每一行是一对法语句子和它对应的英语句子,中间使用'\t'隔开。在读取数据时,我们在句末附上“<eos>”符号,并可能通过添加“<pad>”符号使每个序列的长度均为max_seq_len。我们为法语词和英语词分别创建词典。法语词的索引和英语词的索引相互独立。

def read_data(max_seq_len):
    # 初始化存储输入和输出序列的列表
    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)
    # 返回输入和输出的词典以及包含输入和输出数据的Tensor数据集
    return in_vocab, out_vocab, Data.TensorDataset(in_data, out_data)

        测试函数:将序列的最大长度设成7,然后查看读取到的第一个样本。该样本分别包含法语词索引序列和英语词索引序列。

max_seq_len = 7
in_vocab, out_vocab, dataset = read_data(max_seq_len)
dataset[0]

图4.1.1 测试结果

4.2 含注意力机制的编码器—解码器

4.2.1 编码器

        在编码器中,我们将输入语言的词索引通过词嵌入层得到词的表征,然后输入到一个多层门控循环单元中。正如我们在循环神经网络的简洁实现中提到的,PyTorch的nn.GRU实例在前向计算后也会分别返回输出和最终时间步的多层隐藏状态。其中的输出指的是最后一层的隐藏层在各个时间步的隐藏状态,并不涉及输出层计算。注意力机制将这些输出作为键项和值项。

初始化(init:这里定义了两个主要的层 —— 嵌入层(self.embedding)和GRU层(self.rnn)。嵌入层用于将词索引转换为密集的向量表示,而GRU层则用于处理这些嵌入向量并通过时间步迭代。

前向传播(forward):这个函数定义了数据如何通过网络流动。它首先将输入的索引转换为嵌入向量,然后调整向量维度以符合GRU的输入要求,最后将这些嵌入向量通过GRU层进行处理。

开始状态(begin_state):此函数用于初始化网络的隐藏状态,对于GRU,通常初始化为None,因为在没有提供时,PyTorch 默认会使用全零状态。这个函数在这里作为一个占位符,显示了如何设置初始状态的接口。

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)
        # 初始化GRU网络,用于处理时间序列数据
        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)
        # 通过RNN层处理嵌入向量,返回输出和隐藏状态
        return self.rnn(embedding, state)

    def begin_state(self):
        # 初始化隐藏状态,对于GRU来说通常是None,因为PyTorch会自动处理初始状态为全零
        return None

        测试函数:创建一个批量大小为4、时间步数为7的小批量序列输入。设门控循环单元的隐藏层个数为2,隐藏单元个数为16。编码器对该输入执行前向计算后返回的输出形状为(时间步数, 批量大小, 隐藏单元个数)。门控循环单元在最终时间步的多层隐藏状态的形状为(隐藏层个数, 批量大小, 隐藏单元个数)。对于门控循环单元来说,state就是一个元素,即隐藏状态;如果使用长短期记忆,state是一个元组,包含两个元素即隐藏状态和记忆细胞。

# 门控循环单元的隐藏层个数为2,隐藏单元个数为16
encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
# 批量大小为4、时间步数为7的小批量序列输入
output, state = encoder(torch.zeros((4, 7)), encoder.begin_state())
output.shape, state.shape # GRU的state是h, 而LSTM的是一个元组(h, c)

图4.2.1.1 测试结果

        注意力机制:将输入连结后通过含单隐藏层的多层感知机变换。其中隐藏层的输入是解码器的隐藏状态与编码器在所有时间步上隐藏状态的一一连结,且使用tanh函数作为激活函数。输出层的输出个数为1。两个Linear实例均不使用偏差。其中函数𝑎𝑎定义里向量𝑣𝑣的长度是一个超参数,即attention_size

def attention_model(input_size, attention_size):
    # 创建一个顺序模型
    model = nn.Sequential(
        # 第一层是一个全连接层,从输入特征到注意力内部的表示大小,不使用偏置
        nn.Linear(input_size, attention_size, bias=False),
        # 应用Tanh激活函数增加非线性,有助于模型学习更复杂的模式
        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)
    # 通过注意力模型计算每个时间步的注意力得分
    # 注意力模型的输出形状为(时间步数, 批量大小, 1)
    e = model(enc_and_dec_states)
    # 在时间步维度上应用softmax函数,得到每个时间步的注意力权重
    # 这个权重表明在解码的每一步中,每个编码器状态的重要性
    alpha = F.softmax(e, dim=0)
    # 使用注意力权重对编码器的状态进行加权求和,得到背景向量
    # 背景向量是对整个输入序列的加权表示,将用于解码器的下一步
    return (alpha * enc_states).sum(dim=0)  # 返回背景变量

        测试函数:编码器的时间步数为10,批量大小为4,编码器和解码器的隐藏单元个数均为8。注意力机制返回一个小批量的背景向量,每个背景向量的长度等于编码器的隐藏单元个数。因此输出的形状为(4, 8)。

# 定义序列长度、批量大小和隐藏单元的数量
seq_len, batch_size, num_hiddens = 10, 4, 8
# 创建注意力模型,输入大小是编码器和解码器状态的两倍,注意力内部表示的大小设为10
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))
# 调用注意力前向函数,传入模型、编码器状态和解码器状态
output = attention_forward(model, enc_states, dec_state)
# 输出背景向量的形状,预期为(批量大小, 隐藏单元个数)
print(output.shape)  
4.2.2 解码器

        我们直接将编码器在最终时间步的隐藏状态作为解码器的初始隐藏状态。这要求编码器和解码器的循环神经网络使用相同的隐藏层个数和隐藏单元个数。

        由于解码器的输入来自输出语言的词索引,我们将输入通过词嵌入层得到表征,然后和背景向量在特征维连结。我们将连结后的结果与上一时间步的隐藏状态通过门控循环单元计算出当前时间步的输出与隐藏状态。最后,我们将输出通过全连接层变换为有关各个输出词的预测,形状为(批量大小, 输出词典大小)。

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层:结合嵌入输入和注意力上下文向量进行解码
        # 注意,GRU的输入大小是嵌入大小加上隐藏状态大小,因为我们将它们连结起来
        self.rnn = nn.GRU(num_hiddens + embed_size, num_hiddens, num_layers, dropout=drop_prob)
        # 输出层:将GRU的输出映射到词汇表大小,用于生成最终的词汇输出
        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) -- 当前解码器的隐藏状态
        enc_states shape: (seq_len, batch, num_hiddens) -- 编码器的所有时间步的隐藏状态
        """
        # 使用注意力机制计算背景向量c,考虑当前解码状态和所有编码器状态
        c = attention_forward(self.attention, enc_states, state[-1])
        # 将当前输入的嵌入与背景向量在特征维度上连结
        input_and_c = torch.cat((self.embedding(cur_input), c), dim=1)
        # 添加一个时间步维度,并通过RNN处理
        output, state = self.rnn(input_and_c.unsqueeze(0), state)
        # 将RNN输出的时间步维度去掉,准备传给输出层
        output = self.out(output.squeeze(0))
        # 返回解码的输出和新的隐藏状态
        return output, state

    def begin_state(self, enc_state):
        # 初始化解码器的隐藏状态为编码器的最终状态
        return enc_state

4.3 模型训练

        本文实现batch_loss函数计算一个小批量的损失。解码器在最初时间步的输入是特殊字符BOS。之后,解码器在某时间步的输入为样本输出序列在上一时间步的词,即强制教学。此外,同word2vec的实现中的实现一样,我们在这里也使用掩码变量避免填充项对损失函数计算的影响。

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)  # 对输入X进行编码得到编码器的输出和最终状态

    # 初始化解码器的隐藏状态为编码器的最终状态
    dec_state = decoder.begin_state(enc_state)
    # 解码器在最初时间步的输入是开始符号BOS
    dec_input = torch.tensor([out_vocab.stoi[BOS]] * batch_size)

    # 初始化掩码变量mask,用于忽略损失计算中的填充项PAD
    mask, num_not_pad_tokens = torch.ones(batch_size,), 0
    l = torch.tensor([0.0])  # 初始化损失为0
    for y in Y.permute(1,0):  # 调整Y的维度以按时间步迭代
        dec_output, dec_state = decoder(dec_input, dec_state, enc_outputs)  # 解码器产生输出和更新状态
        l = l + (mask * loss(dec_output, y)).sum()  # 计算损失,并用mask忽略PAD的损失
        dec_input = y  # 下一个时间步使用实际的标签作为输入(强制教学)

        # 更新有效的非PAD词元的总数,用于损失平均化
        num_not_pad_tokens += mask.sum().item()
        # 更新mask,一旦遇到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):
    # 初始化编码器和解码器的优化器,这里使用Adam优化算法
    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()

        # 每10个周期输出一次当前的平均损失
        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
# 定义注意力大小、dropout概率、学习率、批量大小和训练周期数
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)
# 假设 'dataset' 已经适当准备并加载
train(encoder, decoder, dataset, lr, batch_size, num_epochs)

图4.3.1 训练过程及损失

4.4 模型预测(预测不定长的序列)

        这里我们实现最简单的贪婪搜索。translate 函数是一个用于序列翻译的函数,主要应用在基于神经网络的编码器-解码器模型中。整合了编码器和解码器的功能,实现了从输入文本到输出文本的翻译过程,处理了输入的预处理、编解码交互和输出生成的所有步骤。

def translate(encoder, decoder, input_seq, max_seq_len):
    # 分割输入序列成单词列表
    in_tokens = input_seq.split(' ')
    # 在序列末尾添加EOS(结束符),并用PAD(填充符)补齐至最大序列长度
    in_tokens += [EOS] + [PAD] * (max_seq_len - len(in_tokens) - 1)
    # 将单词转换为索引,并构造成张量,以适配模型输入(batch_size=1)
    enc_input = torch.tensor([[in_vocab.stoi[tk] for tk in in_tokens]])

    # 初始化编码器的隐藏状态
    enc_state = encoder.begin_state()
    # 通过编码器处理输入
    enc_output, enc_state = encoder(enc_input, enc_state)

    # 解码器的初始输入是BOS(开始符)
    dec_input = torch.tensor([out_vocab.stoi[BOS]])
    # 使用编码器的输出状态初始化解码器的隐藏状态
    dec_state = decoder.begin_state(enc_state)

    # 存储输出序列的单词
    output_tokens = []
    # 开始序列解码过程,最多迭代max_seq_len次
    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())]
        # 检查是否生成了EOS,如果是则结束解码
        if pred_token == EOS:
            break
        else:
            # 将预测的单词添加到输出序列中
            output_tokens.append(pred_token)
            # 更新解码器的输入为当前预测的单词索引
            dec_input = pred

    # 返回生成的输出序列
    return output_tokens

        测试模型:简单测试一下模型。输入法语句子“ils regardent.”,翻译后的英语句子应该是“they are watching.”。

input_seq = 'ils regardent .'
translate(encoder, decoder, input_seq, max_seq_len)

图4.4.1 模型测试结果

4.5 模型评价

        评价机器翻译结果通常使用BLEU(Bilingual Evaluation Understudy)[1]。对于模型预测序列中任意的子序列,BLEU考察这个子序列是否出现在标签序列中。

        最初的BLEU计算特别简单, 通常的讲, 当我们自己计算两个文本之间的相似程度的时候, 我们会考虑单词的频率, 最早的BLEU就是采用了这种思想, 计算方法是: 使用一个累加器表示candidate中的词在reference doc中出现的次数, 从candidate doc 中的第一个词开始比较, 如果在参考文本中出现过, 那么计数加1. 最后使用这个累加值除以candidate doc 中的单词数目即可计算得到文本的BLEU取值。

        具体来说,设词数为𝑛的子序列的精度为𝑝𝑛。它是预测序列与标签序列匹配词数为𝑛𝑛的子序列的数量与预测序列中词数为𝑛的子序列的数量之比。举个例子,假设标签序列为𝐴、𝐵、𝐶、𝐷、𝐸、𝐹,预测序列为𝐴、𝐵、𝐵、𝐶、𝐷,那么𝑝1=4/5,𝑝2=3/4,𝑝3=1/3,𝑝4=0。设𝑙𝑒𝑛label和𝑙𝑒𝑛pred分别为标签序列和预测序列的词数,那么,BLEU的定义为:

\exp\biggl(\min\biggl(0,1-\frac{len_{\mathrm{label}}}{len_{\mathrm{pred}}}\biggr)\biggr)\prod_{n=1}^kp_n^{1/2^n},

        其中𝑘是我们希望匹配的子序列的最大词数。可以看到当预测序列和标签序列完全一致时,BLEU为1。

def bleu(pred_tokens, label_tokens, k):
    # 计算预测序列和参考序列的长度
    len_pred, len_label = len(pred_tokens), len(label_tokens)
    # 计算简短惩罚(brevity penalty)
    score = math.exp(min(0, 1 - len_label / len_pred))
    
    # 遍历从1到k的每一个n-gram阶数
    for n in range(1, k + 1):
        num_matches = 0  # 用于存储n-gram匹配的数量
        label_subs = collections.defaultdict(int)  # 用于统计参考文本中n-gram的字典

        # 为参考文本构建n-gram字典
        for i in range(len_label - n + 1):
            label_subs[''.join(label_tokens[i: i + n])] += 1
        
        # 计算预测文本中的n-gram与参考文本的匹配数量
        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
        
        # 计算每个n-gram阶的准确率,然后加权求和
        score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
    
    # 返回最终的BLEU分数
    return score

        接下来,定义一个辅助打印函数。

def score(input_seq, label_seq, k):
    # 使用已经训练好的编码器和解码器对输入序列进行翻译
    pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)
    # 将参考序列分割成单词列表
    label_tokens = label_seq.split(' ')
    # 计算BLEU分数,这是一个常用的机器翻译评估指标
    # 打印BLEU分数以及预测的翻译结果
    print('bleu %.3f, predict: %s' % (bleu(pred_tokens, label_tokens, k),
                                      ' '.join(pred_tokens)))

图4.5.1 预测结果


五、实验总结

        在本实验中,我们探索了基于深度学习的机器翻译系统,特别是聚焦于GRU(门控循环单元)和注意力机制的应用。我们从机器翻译的基本概念入手,详细讨论了注意力机制的工作原理,并通过构建一个编码器-解码器模型来实际应用这些理论。以下是实验的主要部分和总结:

        注意力机制的引入是为了解决传统序列到序列模型在处理长序列时信息丢失的问题。我们详细解释了注意力机制如何通过计算输入序列中每个元素对输出的贡献度来动态地聚焦于关键信息。通过实现和测试一个注意力机制模型,我们展示了其在提高翻译质量和处理长句子方面的有效性。

        GRU是改进传统循环神经网络的关键技术之一。它通过引入更新门和重置门来控制信息流,有效地缓解了梯度消失的问题。我们实验中的GRU模型展示了其在序列处理中的优势,尤其是在保持长期依赖时。

        通过一个简单的法语到英语的翻译任务,我们将理论付诸实践。在预处理数据、构建模型、训练和预测的每一步,都有详细的代码和解释。实验结果表明,模型能够有效地进行翻译,并通过添加注意力机制,显著提升了模型对输入信息的处理能力。

        本文使用BLEU分数评估翻译质量,这是一种广泛接受的机器翻译性能评估方法。实验结果显示,模型具有合理的翻译精度,但还有提升空间,特别是在处理非常复杂句子结构时。

        本课题成功展示了GRU和注意力机制在机器翻译任务中的应用。尽管我们的模型在简单的数据集上表现良好,但机器翻译是一个复杂的领域,面对真实世界的多样化和复杂性,还需要进一步优化和调整模型。未来的工作可以探索更多的深度学习策略,如Transformer模型,以及更大和更复杂的数据集上的应用。

六、参考文献

基于GRU与注意力机制实现法语-葡萄牙语的翻译详细教程

深度学习——机器翻译、注意力机制、transformer_机器翻译 注意力机制解决了什么问题

七、推荐阅读

动手学深度学习 2.0.0 documentation (d2l.ai)

  • 22
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值