现代循环神经网络实战:机器翻译

专栏:神经网络复现目录

机器翻译

机器翻译是一种利用计算机技术将一种语言的文本自动翻译成另一种语言的过程。机器翻译技术旨在解决语言障碍问题,使不同语言之间的交流更加便捷。机器翻译使用的算法包括统计机器翻译、神经机器翻译等。机器翻译技术已经取得了很大的进步,但是仍然存在很多挑战,如语言差异、歧义性和多义性等。



数据集

读取数据集

本文数据集来自Tatoeba项目的双语句子对(英:法):http://www.manythings.org/anki/

def read_data_nmt():
    """载入“英语-法语”数据集"""
    data_dir = d2l.download_extract('fra-eng')
    with open(os.path.join('fra.txt'), 'r',
             encoding='utf-8') as f:
        return f.read()

raw_text = read_data_nmt()
print(raw_text[:75])

在这里插入图片描述

数据预处理

替换空格方便后续的切割

#@save
def preprocess_nmt(text):
    """预处理“英语-法语”数据集"""
    def no_space(char, prev_char):
        return char in set(',.!?') and prev_char != ' '

    # 使用空格替换不间断空格
    # 使用小写字母替换大写字母
    text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()
    # 在单词和标点符号之间插入空格
    out = [' ' + char if i > 0 and no_space(char, text[i - 1]) else char
           for i, char in enumerate(text)]
    return ''.join(out)

text = preprocess_nmt(raw_text)
print(text[:80])

词元化

#@save
def tokenize_nmt(text, num_examples=None):
    """词元化“英语-法语”数据数据集"""
    source, target = [], []
    for i, line in enumerate(text.split('\n')):
        if num_examples and i > num_examples:
            break
        parts = line.split('\t')
        if len(parts) == 2:
            source.append(parts[0].split(' '))
            target.append(parts[1].split(' '))
    return source, target

source, target = tokenize_nmt(text)
source[:]

绘制图表

#@save
def show_list_len_pair_hist(legend, xlabel, ylabel, xlist, ylist):
    """绘制列表长度对的直方图"""
    d2l.set_figsize()
    _, _, patches = d2l.plt.hist(
        [[len(l) for l in xlist], [len(l) for l in ylist]])
    d2l.plt.xlabel(xlabel)
    d2l.plt.ylabel(ylabel)
    for patch in patches[1].patches:
        patch.set_hatch('/')
    d2l.plt.legend(legend)

show_list_len_pair_hist(['source', 'target'], '# tokens per sequence',
                        'count', source, target);

在这里插入图片描述

定义词表

import collections
class Vocab:
    """Vocabulary for text."""
    def __init__(self, tokens=None, min_freq=0, reserved_tokens=None):
        """Defined in :numref:`sec_text_preprocessing`"""
        if tokens is None:
            tokens = []
        if reserved_tokens is None:
            reserved_tokens = []
        # Sort according to frequencies
        counter = count_corpus(tokens)
        self._token_freqs = sorted(counter.items(), key=lambda x: x[1],
                                   reverse=True)
        # The index for the unknown token is 0
        self.idx_to_token = ['<unk>'] + reserved_tokens
        self.token_to_idx = {
    
    token: idx
                             for idx, token in enumerate(self.idx_to_token)}
        for token, freq in self._token_freqs:
            if freq < min_freq:
                break
            if token not in self.token_to_idx:
                self.idx_to_token.append(token)
                self.token_to_idx[token] = len(self.idx_to_token) - 1

    def __len__(self):
        return len(self.idx_to_token)

    def __getitem__(self, tokens):
        if not isinstance(tokens, (list, tuple)):
            return self.token_to_idx.get(tokens, self.unk)
        return [self.__getitem__(token) for token in tokens]

    def to_tokens(self, indices):
        if not isinstance(indices, (list, tuple)):
            return self.idx_to_token[indices]
        return [self.idx_to_token[index] for index in indices]

    @property
    def unk(self):  # Index for the unknown token
        return 0

    @property
    def token_freqs(self):  # Index for the unknown token
        return self._token_freqs

def count_corpus(tokens):
    """Count token frequencies.

    Defined in :numref:`sec_text_preprocessing`"""
    # Here `tokens` is a 1D list or 2D list
    if len(tokens) == 0 or isinstance(tokens[0], list):
        # Flatten a list of token lists into a list of tokens
        tokens = [token for line in tokens for token in line]
    return collections.Counter(tokens)

该代码定义了一个名为 Vocab 的类,用于构建文本的词汇表。下面对每个方法和属性进行解释:

init(self, tokens=None, min_freq=0, reserved_tokens=None):构造函数,创建一个词汇表。参数 tokens 是一个包含文本所有单词的列表,min_freq 是单词在文本中最少出现的次数,reserved_tokens 是一个保留的单词列表。构造函数首先使用 count_corpus() 函数统计单词的出现次数,并将单词按照出现频率从高到低排序。接着将预先定义的 单词加入到词汇表中,并根据单词出现的顺序构建一个单词到索引的字典 token_to_idx,同时根据出现频率构建一个索引到单词的列表 idx_to_token。对于出现次数小于 min_freq 的单词,直接跳过不加入词汇表中。最终得到的词汇表中,索引 0 对应的是 ,索引 1 到 n-1 对应的是出现频率较高的单词,索引 n 及之后的值是出现频率较低的单词。

len(self):返回词汇表的大小,即单词总数。

getitem(self, tokens):根据单词或单词列表 tokens 返回其对应的索引或索引列表。如果 tokens 是一个单词,则返回其对应的索引。如果 tokens 是一个单词列表,则对其中的每个单词递归调用 getitem() 方法,并返回索引列表。

to_tokens(self, indices):根据索引或索引列表 indices 返回其对应的单词或单词列表。如果 indices 是一个索引,则返回其对应的单词。如果 indices 是一个索引列表,则对其中的每个索引递归调用 to_tokens() 方法,并返回单词列表。

unk:属性,返回 单词的索引,即 0。

token_freqs:属性,返回单词出现频率从高到低排序的列表。该属性在创建词汇表时被初始化。

count_corpus(tokens):定义在 Vocab 类外部的函数,用于计算单词出现频率。参数 tokens 是一个包含文本所有单词的列表,可能是一个二维列表。如果 tokens 是一个二维列表,则首先将其转化为一个一维列表。该函数使用 collections.Counter() 函数统计每个单词在文本中出现的次数,并返回一个字典。

src_vocab = Vocab(source, min_freq=2,
                      reserved_tokens=['<pad>', '<bos>', '<eos>'])
len(src_vocab)

填充

#@save
def truncate_pad(line, num_steps, padding_token):
    """截断或填充文本序列"""
    if len(line) > num_steps:
        return line[:num_steps]  # 截断
    return line + [padding_token] * (num_steps - len(line))  # 填充

truncate_pad(src_vocab[source[0]], 10, src_vocab['<pad>'])

组织数据集

#@save
def build_array_nmt(lines, vocab, num_steps):
    """将机器翻译的文本序列转换成小批量"""
    lines = [vocab[l] for l in lines]#token to id
    lines = [l + [vocab['<eos>']] for l in lines]# 加上eos代表结束
    array = torch.tensor([truncate_pad(
        l, num_steps, vocab['<pad>']) for l in lines])# 转换为数组
    valid_len = (array != vocab['<pad>']).type(torch.int32).sum(1)#有效长度
    return array, valid_len

这个函数将机器翻译的文本序列转换为小批量,其中输入lines是一个包含文本序列的列表,vocab是一个词汇表对象,num_steps是每个序列中包含的最大令牌数。该函数返回两个张量,第一个是包含num_steps个令牌的序列的张量,第二个是每个序列的有效长度。对于一个序列而言,它的有效长度是指从开头算起,第一个填充符之前的所有标记的数量。

函数首先将每个文本序列转换为词汇表中的整数列表,然后将每个序列追加一个标记。接下来,它将所有序列截断或填充为长度为num_steps。如果一个序列在num_steps个令牌之后仍有令牌,那么它的其余令牌将被截断。如果一个序列少于num_steps个令牌,那么填充令牌将被添加到序列的末尾。最后,函数计算每个序列的有效长度,并返回两个张量。

#@save
from torch.utils import data
def load_array(data_arrays, batch_size, is_train=True):
    """Construct a PyTorch data iterator.

    Defined in :numref:`sec_linear_concise`"""
    dataset = data.TensorDataset(*data_arrays)
    return data.DataLoader(dataset, batch_size, shuffle=is_train)

def load_data_nmt(batch_size, num_steps, num_examples=600):
    """返回翻译数据集的迭代器和词表"""
    text = preprocess_nmt(read_data_nmt())
    source, target = tokenize_nmt(text, num_examples)
    src_vocab = Vocab(source, min_freq=2,
                          reserved_tokens=['<pad>', '<bos>', '<eos>'])
    tgt_vocab = Vocab(target, min_freq=2,
                          reserved_tokens=['<pad>', '<bos>', '<eos>'])
    src_array, src_valid_len = build_array_nmt(source, src_vocab, num_steps)
    tgt_array, tgt_valid_len = build_array_nmt(target, tgt_vocab, num_steps)
    data_arrays = (src_array, src_valid_len, tgt_array, tgt_valid_len)
    data_iter = load_array(data_arrays, batch_size)
    return data_iter, src_vocab, tgt_vocab
train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size=2, num_steps=8)
for X, X_valid_len, Y, Y_valid_len in train_iter:
    print('X:', X.type(torch.int32))
    print('X的有效长度:', X_valid_len)
    print('Y:', Y.type(torch.int32))
    print('Y的有效长度:', Y_valid_len)
    break

在这里插入图片描述

编码器-解码器架构

正如我们上节中所讨论的, 机器翻译是序列转换模型的一个核心问题, 其输入和输出都是长度可变的序列。 为了处理这种类型的输入和输出, 我们可以设计一个包含两个主要组件的架构: 第一个组件是一个编码器(encoder): 它接受一个长度可变的序列作为输入, 并将其转换为具有固定形状的编码状态。 第二个组件是解码器(decoder): 它将固定形状的编码状态映射到长度可变的序列。 这被称为编码器-解码器(encoder-decoder)架构, 如下图所示。
在这里插入图片描述
我们以英语到法语的机器翻译为例: 给定一个英文的输入序列:“They”“are”“watching”“.”。 首先,这种“编码器-解码器”架构将长度可变的输入序列编码成一个“状态”, 然后对该状态进行解码, 一个词元接着一个词元地生成翻译后的序列作为输出: “Ils”“regordent”“.”。 由于“编码器-解码器”架构是形成后续章节中不同序列转换模型的基础, 因此本节将把这个架构转换为接口方便后面的代码实现。

编码器

from torch import nn
#@save
class Encoder(nn.Module):
    """编码器-解码器架构的基本编码器接口"""
    def __init__(self, **kwargs):
        super(Encoder, self).__init__(**kwargs)

    def forward(self, X, *args):
        raise NotImplementedError

解码器

#@save
class Decoder(nn.Module):
    """编码器-解码器架构的基本解码器接口"""
    def __init__(self, **kwargs):
        super(Decoder, self).__init__(**kwargs)

    def init_state(self, enc_outputs, *args):
        raise NotImplementedError

    def forward(self, X, state):
        raise NotImplementedError

合并编码器和解码器

#@save
class EncoderDecoder(nn.Module):
    """编码器-解码器架构的基类"""
    def __init__(self, encoder, decoder, **kwargs):
        super(EncoderDecoder, self).__init__(**kwargs)
        self.encoder = encoder
        self.decoder = decoder

    def forward(self, enc_X, dec_X, *args):
        enc_outputs = self.encoder(enc_X, *args)
        dec_state = self.decoder.init_state(enc_outputs, *args)
        return self.decoder(dec_X, dec_state)

序列到序列学习(seq2seq)

遵循编码器-解码器架构的设计原则, 循环神经网络编码器使用长度可变的序列作为输入, 将其转换为固定形状的隐状态。 换言之,输入序列的信息被编码到循环神经网络编码器的隐状态中。 为了连续生成输出序列的词元, 独立的循环神经网络解码器是基于输入序列的编码信息 和输出序列已经看见的或者生成的词元来预测下一个词元。下图演示了 如何在机器翻译中使用两个循环神经网络进行序列到序列学习。
在这里插入图片描述
在图中, 特定的“”表示序列结束词元。 一旦输出序列生成此词元,模型就会停止预测。 在循环神经网络解码器的初始化时间步,有两个特定的设计决定: 首先,特定的“”表示序列开始词元,它是解码器的输入序列的第一个词元。 其次,使用循环神经网络编码器最终的隐状态来初始化解码器的隐状态。 例如,在 (Sutskever et al., 2014)的设计中, 正是基于这种设计将输入序列的编码信息送入到解码器中来生成输出序列的。 在其他一些设计中 (Cho et al., 2014), 如 图9.7.1所示, 编码器最终的隐状态在每一个时间步都作为解码器的输入序列的一部分。 可以允许标签成为原始的输出序列, 从源序列词元“”“Ils”“regardent”“.” 到新序列词元 “Ils”“regardent”“.”“”来移动预测的位置。

编码器

代码详解

#@save
class Seq2SeqEncoder(Encoder):
    """用于序列到序列学习的循环神经网络编码器"""
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 dropout=0, **kwargs):
        super(Seq2SeqEncoder, self).__init__(**kwargs)
        # 嵌入层
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.rnn = nn.GRU(embed_size, num_hiddens, num_layers,
                          dropout=dropout)

    def forward(self, X, *args):
        # 输出'X'的形状:(batch_size,num_steps,embed_size)
        X = self.embedding(X)
        # 在循环神经网络模型中,第一个轴对应于时间步
        X = X.permute(1, 0, 2)
        # 如果未提及状态,则默认为0
        output, state = self.rnn(X)
        # output的形状:(num_steps,batch_size,num_hiddens)
        # state的形状:(num_layers,batch_size,num_hiddens)
        return output, state

这段代码定义了一个用于序列到序列学习的循环神经网络编码器。该编码器由嵌入层和多层循环神经网络组成。具体解释如下:

class Seq2SeqEncoder(Encoder)::定义了一个名为Seq2SeqEncoder的类,该类继承自Encoder类。

def init(self, vocab_size, embed_size, num_hiddens, num_layers, dropout=0, **kwargs)::定义了类的初始化函数,该函数接受以下参数:

vocab_size:词汇表大小。
embed_size:嵌入向量的维度。
num_hiddens:循环神经网络中隐藏状态的维度。
num_layers:循环神经网络中的层数。
dropout:dropout概率,默认为0。
**kwargs:其他参数。
super(Seq2SeqEncoder, self).init(**kwargs):调用父类Encoder的初始化函数并传入参数。

self.embedding = nn.Embedding(vocab_size, embed_size):定义一个嵌入层,用于将输入序列中的每个单词转换为嵌入向量。

self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=dropout):定义一个多层循环神经网络,其中包含num_layers个GRU层。GRU层的输入维度为embed_size,输出维度为num_hiddens,同时还包括dropout层,dropout的概率为dropout。

def forward(self, X, *args)::定义了类的前向传播函数,该函数接受输入序列X和其他参数(*args)。

X = self.embedding(X):将输入序列X中的每个单词转换为嵌入向量。

X = X.permute(1, 0, 2):将输入序列X的维度从(batch_size, num_steps, embed_size)转换为(num_steps, batch_size, embed_size)。这样做是为了在循环神经网络模型中,第一个轴对应于时间步。

output, state = self.rnn(X):将转换后的输入序列X传入多层循环神经网络中进行计算,并返回输出和状态。其中,output的形状为(num_steps, batch_size, num_hiddens),表示每个时间步的输出;state的形状为(num_layers, batch_size, num_hiddens),表示最后一个时间步的状态。

return output, state:返回输出和状态。

X.permute
在PyTorch中,X.permute(dims)是一个张量的方法,它可以用来对张量的维度进行重新排列。dims是一个整数元组,表示对于原始张量中的每个维度,新张量中应该放置的位置。例如,如果一个张量的维度是(3,4,5),并且dims=(1,0,2),则表示在新张量中第一个维度是原始张量的第二个维度,第二个维度是原始张量的第一个维度,第三个维度是原始张量的第三个维度。
具体来说,X.permute(1, 0, 2)表示将张量X的第一个维度和第二个维度交换,第三个维度不变。在代码中,这个操作被用于将形状为(batch_size, num_steps, embed_size)的输入序列张量X的维度进行调整,使得第一个维度是时间步,第二个维度是批量大小,从而符合循环神经网络的输入要求。

为什么X = X.permute(1, 0, 2)

在循环神经网络中,输入数据的维度需要满足以下要求:

  1. 第一个维度是时间步。
  2. 第二个维度是批量大小。

因此,对于一个形状为(batch_size, num_steps, embed_size)的输入序列X,需要将它的维度调整为(num_steps, batch_size, embed_size)。

在代码中,通过调用X.permute(1, 0, 2)来实现维度的调整。具体地,X.permute(1, 0, 2)表示将X的维度从(batch_size, num_steps, embed_size)转换为(num_steps, batch_size, embed_size),即将第一个维度(num_steps)和第二个维度(batch_size)进行交换。这样做的目的是为了将输入序列X中的每个时间步作为输入,而每个时间步中的所有样本(batch_size个)作为批量进行处理。这样做可以充分利用GPU的并行计算能力,提高训练效率。

encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,
                         num_layers=2)
encoder.eval()
X = torch.zeros((4, 7), dtype=torch.long)
output, state = encoder(X)
output.shape

在这里插入图片描述

嵌入层

在自然语言处理中,嵌入层(Embedding Layer)是用于将离散的词汇或字符映射为连续的向量表示的一种常用技术。嵌入层可以将每个词或字符表示成一个固定长度的向量,这些向量通常被称为嵌入向量或词向量(Word Embeddings)。嵌入向量可以捕捉词汇或字符之间的语义和关联关系,并且可以作为神经网络模型的输入。

在PyTorch中,nn.Embedding是一个内置的嵌入层类,它可以将整数编码的词汇或字符映射为连续的向量。nn.Embedding的输入是一个形状为(batch_size, seq_length)的张量,其中每个元素都是一个整数,表示词汇或字符的索引。nn.Embedding的输出是一个形状为(batch_size, seq_length, embedding_size)的张量,其中每个元素都是一个向量,表示词汇或字符的嵌入向量。embedding_size是指定的嵌入向量的维度。

在上面的代码中,nn.Embedding被用于创建一个嵌入层,将一个大小为vocab_size的词汇表中的单词编码为一个embed_size维的向量表示。在Seq2SeqEncoder的forward方法中,输入序列X被传递到嵌入层中,以便将每个单词的索引编码成一个嵌入向量。输出的张量形状为(batch_size, num_steps, embed_size),其中num_steps是序列的时间步数,batch_size是序列的批量大小。

嵌入层的实现方式很简单,可以使用一个权重矩阵 E ∈ R v × d E \in \mathbb{R}^{v \times d} ERv×d,将每个输入单词 x i x_i xi 映射为其对应的嵌入向量 e i ∈ R d e_i \in \mathbb{R}^d eiRd。具体来说,给定一个大小为 n × m n \times m n×m 的输入矩阵 X X X,其中 n n n 表示样本数量, m m m 表示输入的长度,那么嵌入层的输出矩阵 E ∈ R n × m × d E \in \mathbb{R}^{n \times m \times d} ERn×m×d 可以通过以下方式计算:

E i , j , : = E X i , j E_{i,j,:} = E_{X_{i,j}} Ei,j,:=EXi,j

其中 E i , j , : E_{i,j,:} Ei,j,: 表示输出矩阵 E E E 中第 i i i 个样本、第 j j j 个输入位置对应的嵌入向量, X i , j X_{i,j} Xi,j 表示输入矩阵 X X X 中第 i i i 个样本、第 j j j 个位置对应的单词索引。

这个公式是在嵌入层中使用的。假设 E E E是一个形状为 ( V , d ) (V,d) (V,d)的张量,其中 V V V是词汇表的大小, d d d是嵌入向量的维度。 X X X是一个形状为 ( n , m ) (n,m) (n,m)的整数张量,其中 n n n是批量大小, m m m是序列长度。那么 E i , j , : E_{i,j,:} Ei,j,:表示词汇表中索引为 X i , j X_{i,j} Xi,j的单词的嵌入向量。其中 i i i表示批量中的样本索引, j j j表示序列中的位置索引。因此,该公式的含义是:对于批量中的第 i i i个样本和序列中的第 j j j个位置,嵌入层将单词的索引 X i , j X_{i,j} Xi,j转换为该单词的嵌入向量 E X i , j E_{X_{i,j}} EXi,j

在深度学习模型中,嵌入层通常作为模型的第一层。在模型的训练过程中,嵌入层的权重矩阵可以通过反向传播进行更新,以最小化模型的损失函数。同时,在测试时,可以使用预训练的嵌入层,将输入转换为向量表示,然后将其输入到模型中进行预测。

解码器

class Seq2SeqDecoder(Decoder):
    """用于序列到序列学习的循环神经网络解码器"""
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 dropout=0, **kwargs):
        super(Seq2SeqDecoder, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
                          dropout=dropout)
        self.dense = nn.Linear(num_hiddens, vocab_size)

    def init_state(self, enc_outputs, *args):
        return enc_outputs[1]

    def forward(self, X, state):
        # 输出'X'的形状:(batch_size,num_steps,embed_size)
        X = self.embedding(X).permute(1, 0, 2)
        # 广播context,使其具有与X相同的num_steps
        context = state[-1].repeat(X.shape[0], 1, 1)
        X_and_context = torch.cat((X, context), 2)
        output, state = self.rnn(X_and_context, state)
        output = self.dense(output).permute(1, 0, 2)
        # output的形状:(batch_size,num_steps,vocab_size)
        # state的形状:(num_layers,batch_size,num_hiddens)
        return output, state

这段代码定义了一个序列到序列学习的循环神经网络解码器 Seq2SeqDecoder,其作用是将输入序列转换为目标序列。

解码器的构建与编码器相似,只是将输入序列的嵌入向量和编码器的输出向量连接起来,一起作为解码器的输入。

具体解释如下:

init 函数:初始化解码器。与编码器相似,它包含了一个嵌入层、一个循环神经网络层和一个全连接层。其中嵌入层和循环神经网络层的输入维度为 embed_size + num_hiddens,即当前时间步的输入和前一时间步的输出向量在特征维上的拼接。全连接层的输出维度为目标词汇表大小 vocab_size。

init_state 函数:初始化解码器的初始状态。由于解码器的初始状态需要从编码器的输出中获取,因此这里实现了一个 init_state 函数,它将编码器的最后一层隐状态作为解码器的初始状态。

forward 函数:解码器的前向计算过程。首先将输入序列 X 通过嵌入层进行词嵌入,然后在特征维上进行维度置换,使得时间步维度为第一维度。然后通过广播,将编码器的最后一层隐状态 context 重复 X 的时间步维度次,并与 X 在特征维上进行拼接,得到 X_and_context。将 X_and_context 输入到循环神经网络层中,得到解码器的输出向量 output 和最终隐状态 state。最后,通过全连接层将 output 转换为目标词汇表大小的输出向量,并在维度上进行置换,使得时间步维度恢复为第二维度,返回解码器的输出向量 output 和最终隐状态 state。

上述模型结构如图:
在这里插入图片描述

损失函数

在每个时间步,解码器预测了输出词元的概率分布。 类似于语言模型,可以使用softmax来获得分布, 并通过计算交叉熵损失函数来进行优化。 回想一下上节中, 特定的填充词元被添加到序列的末尾, 因此不同长度的序列可以以相同形状的小批量加载。 但是,我们应该将填充词元的预测排除在损失函数的计算之外。

为此,我们可以使用下面的sequence_mask函数通过零值化屏蔽不相关的项, 以便后面任何不相关预测的计算都是与零的乘积,结果都等于零。 例如,如果两个序列的有效长度(不包括填充词元)分别为1和2, 则第一个序列的第一项和第二个序列的前两项之后的剩余项将被清除为零。

#@save
def sequence_mask(X, valid_len, value=0):
    """在序列中屏蔽不相关的项"""
    maxlen = X.size(1)
    mask = torch.arange((maxlen), dtype=torch.float32,
                        device=X.device)[None, :] < valid_len[:, None]
    X[~mask] = value
    return X

X = torch.tensor([[1, 2, 3], [4, 5, 6]])
sequence_mask(X, torch.tensor([1, 2]))

这是一个用于屏蔽序列中不相关项的函数,主要用于在计算序列损失函数时排除填充项。它的输入包括三个参数:

X:需要屏蔽的序列,形状为(batch_size, sequence_length, embedding_size);
valid_len:每个序列的有效长度,形状为(batch_size,);
value:用于替换不相关项的值,默认为0。
函数中,首先通过X.size(1)获取序列的最大长度maxlen,然后使用torch.arange()创建一个形状为(1, maxlen)的张量,表示序列中每个位置的下标,该张量的dtype和device与X相同。接下来,使用broadcasting机制将valid_len转换为形状为(batch_size, maxlen)的张量,其中每行的前valid_len[i]个元素为True,其余为False,即表示序列中第i个位置之前的元素是有效的。最后,通过将X[~mask]设置为value,将不相关的项替换为指定的值,得到屏蔽后的序列。最终,该函数返回被屏蔽后的序列。

广播机制
广播(broadcasting)是PyTorch中一个重要的机制,它允许在两个张量之间进行一些形状不同的操作,如张量加减、乘除、逐元素操作等。当两个张量的形状不同时,如果它们满足一定的规则,就可以通过广播使它们的形状相同,从而进行相应的操作。
广播规则如下:

  1. 如果两个张量的维数不同,则在较小的张量的形状前面添加1,直到维数相同。
  2. 如果两个张量在某个维度的大小不同,且其中一个大小为1,则可以沿着该维度扩展该张量,使其大小与另一个张量相同。
  3. 如果两个张量在某个维度的大小不同,且两个大小都不为1,则无法进行广播,操作将失败并报错。

例如,当执行a + b时,如果a的形状为(3, 4),b的形状为(4,),则按照广播规则,可以将b扩展为(1, 4),然后再与a相加,得到的结果形状为(3, 4)。这样,我们就可以在不显式复制数据的情况下,对两个不同形状的张量进行相应的操作,大大提高了代码的可读性和效率。

#@save
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
    """带遮蔽的softmax交叉熵损失函数"""
    # pred的形状:(batch_size,num_steps,vocab_size)
    # label的形状:(batch_size,num_steps)
    # valid_len的形状:(batch_size,)
    def forward(self, pred, label, valid_len):
        weights = torch.ones_like(label)
        weights = sequence_mask(weights, valid_len)
        self.reduction='none'
        unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
            pred.permute(0, 2, 1), label)
        weighted_loss = (unweighted_loss * weights).mean(dim=1)
        return weighted_loss

这是一个继承自PyTorch内置的交叉熵损失函数nn.CrossEntropyLoss的类,被称为带遮蔽的softmax交叉熵损失函数。这个类的作用是实现一个能够忽略掉标签序列中填充部分的损失函数。

具体来说,该类的forward函数接受三个输入:模型预测的结果pred、真实标签序列label和有效序列长度valid_len。其中,pred的形状是(batch_size, num_steps, vocab_size),表示模型在每个时间步为每个类别预测的概率;label的形状是(batch_size, num_steps),表示真实标签序列(类别编号);valid_len的形状是(batch_size,),表示每个样本的有效长度, 在计算损失时,我们首先创建一个与label形状相同的张量weights,并调用前面实现的sequence_mask函数将weights中无效位置(序列长度之后)的值设置为0。接着,我们使用PyTorch内置的交叉熵损失函数计算未加权的损失,这里需要将pred的形状从(batch_size, vocab_size, num_steps)转换为(batch_size, num_steps, vocab_size),以便与label的形状相匹配。最后,我们将损失按时间步的维度求平均并返回。由于在计算损失时已经考虑了每个样本的有效长度,因此我们在返回前不需要再除以有效长度。

注意:unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(pred.permute(0, 2, 1), label)

在计算交叉熵损失函数时,输入的 pred 通常是一个三维张量,形状为 (batch_size, num_steps, vocab_size),其中 batch_size 是批量大小,num_steps 是时间步数,vocab_size 是词表大小。而标签 label 的形状为 (batch_size, num_steps),是一个二维张量。在计算交叉熵损失函数时,需要将 pred 和 label 的最后一个维度对齐,也就是将 pred 的最后一个维度移到第一个维度,这样才能调用 nn.CrossEntropyLoss 计算交叉熵损失。
因此,代码中使用 pred.permute(0, 2, 1) 将 pred 的最后两个维度进行调换,变为 (batch_size, vocab_size, num_steps) 的形状,然后将其传入 nn.CrossEntropyLoss 的 forward 方法进行计算交叉熵损失。

训练

#@save
def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
    """训练序列到序列模型"""
    def xavier_init_weights(m):
        if type(m) == nn.Linear:
            nn.init.xavier_uniform_(m.weight)
        if type(m) == nn.GRU:
            for param in m._flat_weights_names:
                if "weight" in param:
                    nn.init.xavier_uniform_(m._parameters[param])

    net.apply(xavier_init_weights)
    net.to(device)
    optimizer = torch.optim.Adam(net.parameters(), lr=lr)
    loss = MaskedSoftmaxCELoss()
    net.train()
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                     xlim=[10, num_epochs])
    for epoch in range(num_epochs):
        timer = d2l.Timer()
        metric = d2l.Accumulator(2)  # 训练损失总和,词元数量
        for batch in data_iter:
            optimizer.zero_grad()
            X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
            bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0],
                          device=device).reshape(-1, 1)
            dec_input = torch.cat([bos, Y[:, :-1]], 1)  # 强制教学
            Y_hat, _ = net(X, dec_input, X_valid_len)
            l = loss(Y_hat, Y, Y_valid_len)
            l.sum().backward()      # 损失函数的标量进行“反向传播”
            d2l.grad_clipping(net, 1)
            num_tokens = Y_valid_len.sum()
            optimizer.step()
            with torch.no_grad():
                metric.add(l.sum(), num_tokens)
        if (epoch + 1) % 10 == 0:
            animator.add(epoch + 1, (metric[0] / metric[1],))
    print(f'loss {
      
      metric[0] / metric[1]:.3f}, {
      
      metric[1] / timer.stop():.1f} '
        f'tokens/sec on {
      
      str(device)}')
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 300, d2l.try_gpu()

train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
encoder = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers,
                        dropout)
decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers,
                        dropout)
net = d2l.EncoderDecoder(encoder, decoder)
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

在这里插入图片描述

预测

在这里插入图片描述

#@save
def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,
                    device, save_attention_weights=False):
    """序列到序列模型的预测"""
    # 在预测时将net设置为评估模式
    net.eval()
    src_tokens = src_vocab[src_sentence.lower().split(' ')] + [
        src_vocab['<eos>']]
    enc_valid_len = torch.tensor([len(src_tokens)], device=device)
    src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab['<pad>'])
    # 添加批量轴
    enc_X = torch.unsqueeze(
        torch.tensor(src_tokens, dtype=torch.long, device=device), dim=0)
    enc_outputs = net.encoder(enc_X, enc_valid_len)
    dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)
    # 添加批量轴
    dec_X = torch.unsqueeze(torch.tensor(
        [tgt_vocab['<bos>']], dtype=torch.long, device=device), dim=0)
    output_seq, attention_weight_seq = [], []
    for _ in range(num_steps):
        Y, dec_state = net.decoder(dec_X, dec_state)
        # 我们使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
        dec_X = Y.argmax(dim=2)
        pred = dec_X.squeeze(dim=0).type(torch.int32).item()
        # 保存注意力权重(稍后讨论)
        if save_attention_weights:
            attention_weight_seq.append(net.decoder.attention_weights)
        # 一旦序列结束词元被预测,输出序列的生成就完成了
        if pred == tgt_vocab['<eos>']:
            break
        output_seq.append(pred)
    return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq

这段代码实现了一个序列到序列(seq2seq)模型的预测,其中给定一个源语言句子,使用训练好的seq2seq模型将其翻译为目标语言句子。

具体步骤如下:

首先,将seq2seq模型设置为评估模式(net.eval())。

对源语言句子进行预处理:将其转换为对应的词汇索引,然后添加一个结束符“”,最后使用truncate_pad函数将其截断或填充到指定的长度num_steps。

将处理后的源语言序列enc_X输入到seq2seq模型的encoder中,得到encoder的输出enc_outputs和enc_X的有效长度enc_valid_len。

将encoder的输出enc_outputs和enc_valid_len输入到seq2seq模型的decoder的init_state函数中,初始化decoder的隐藏状态dec_state。

将decoder的输入初始化为起始符“”,并将其添加到第一个时间步的输入中。

迭代num_steps次进行预测:

a) 将decoder的输入dec_X和当前的隐藏状态dec_state输入到seq2seq模型的decoder中,得到当前时间步的输出Y和新的隐藏状态dec_state。

b) 根据Y的softmax输出,选择具有最高概率的词作为下一个时间步的输入,即使用Y.argmax(dim=2)得到下一个时间步的输入dec_X。

c) 将dec_X转换为int类型,并将其作为下一个时间步的预测值保存到输出序列output_seq中。如果预测值为结束符“”,则停止预测。

d) 如果需要保存注意力权重(save_attention_weights=True),则将注意力权重net.decoder.attention_weights保存到attention_weight_seq中。

将output_seq中的预测值转换为目标语言句子,使用tgt_vocab.to_tokens函数将预测值转换为目标语言词汇。如果需要保存注意力权重,也将attention_weight_seq返回。

这段代码的主要作用是演示如何使用训练好的seq2seq模型进行翻译预测。需要注意的是,这里的预测是逐步进行的,而不是一次性预测整个目标语言句子。在每个时间步,模型都只预测下一个词,并使用其作为下一个时间步的输入。

代码详解:

enc_X = torch.unsqueeze(torch.tensor(src_tokens, dtype=torch.long, device=device), dim=0)

这行代码的作用是将输入的 src_tokens 转换为 PyTorch 的张量格式,并在第一维上增加一个维度,即增加了一个批次的维度。具体来说,src_tokens 是一个包含输入序列的 Python 列表,列表中的每个元素都是一个整数,表示输入序列中的一个单词。torch.tensor(src_tokens, dtype=torch.long, device=device) 将 src_tokens 转换为一个 PyTorch 的张量格式,并指定数据类型为 long,设备为 device。而 torch.unsqueeze 函数则在第一维上增加了一个维度,将形状为 (num_steps,) 的张量转换为形状为 (1, num_steps) 的张量。最终得到的 enc_X 就是形状为 (1, num_steps) 的张量,表示一个大小为 1 的批次中的输入序列。

预测序列的评估

我们可以通过与真实的标签序列进行比较来评估预测序列。 虽然 (Papineni et al., 2002) 提出的BLEU(bilingual evaluation understudy) 最先是用于评估机器翻译的结果, 但现在它已经被广泛用于测量许多应用的输出序列的质量。 原则上说,对于预测序列中的任意元语法(n-grams), BLEU的评估都是这个n元语法是否出现在标签序列中。

BLUE

BLEU(Bilingual Evaluation Understudy)是机器翻译领域常用的一种评价指标,旨在比较机器翻译结果和人工参考翻译结果之间的相似程度。

BLEU评估机器翻译结果的质量,计算方法包括两个方面:一是考虑机器翻译的结果与参考译文之间的词重叠度,越高说明机器翻译结果越好;二是考虑机器翻译结果与参考译文之间的词序信息是否一致。

BLEU算法的计算方式比较复杂,它首先将机器翻译结果按照不同的n-gram分成多个词组,然后分别计算每个词组在参考翻译中出现的次数和在机器翻译结果中出现的次数,最后综合考虑这些n-gram的precision值,得到最终的BLEU得分。

常用的n-gram范围是1-4,即分别计算unigram、bigram、trigram、fourgram的得分,最终综合考虑这四个得分来计算BLEU得分。通常,BLEU得分越高,说明机器翻译结果越好,但具体的评价还需要结合其他因素来综合考虑。

计算公式

BLEU(Bilingual Evaluation Understudy)是一种用于自动评估机器翻译结果的指标,它综合了翻译结果的准确性、流畅度等因素。具体而言,它通过计算翻译结果与参考答案之间的相似度,给出了一个0到1之间的分数,分数越高表示翻译结果越好。下面是BLEU的计算公式:

BLEU ⁡ = BP ⁡ ⋅ exp ⁡ ( ∑ n = 1 N w n log ⁡ p n ) \operatorname{BLEU} = \operatorname{BP}\cdot\exp\left(\sum_{n=1}^Nw_n\log p_n\right) BLEU=BPexp(n=1Nwnlogpn)

其中, BP ⁡ \operatorname{BP} BP是短语惩罚项, w n w_n wn n n n元组的权重(通常 w n = 1 N w_n=\frac{1}{N} wn=N1), p n p_n pn n n n元组的精度。

n n n元组的精度指的是在翻译结果中,出现在 n n n元组中的词汇与参考答案中出现在相同 n n n元组中的词汇数量之比。具体而言,对于参考答案中的每个 n n n元组,计算其在翻译结果中出现的次数,然后将这些次数取最小值作为分子,将参考答案中的 n n n元组的总次数作为分母,最后将这些比值相加,就得到了 n n n元组的精度 p n p_n pn

短语惩罚项 BP ⁡ \operatorname{BP} BP是为了避免翻译结果过度倾向于使用短语,导致评分过高而引入的。它的计算公式如下:

BP ⁡ = { 1 , if  c > r e 1 − r c , otherwise \operatorname{BP} = \begin{cases} 1, & \text{if }c>r \\ e^{1-\frac{r}{c}}, & \text{otherwise} \end{cases} BP={ 1,e1cr,if c>rotherwise

其中, c c c是翻译结果中的总词汇数, r r r是参考答案中的总词汇数。如果翻译结果中的词汇数多于参考答案中的词汇数,则惩罚项 BP ⁡ \operatorname{BP} BP等于1;否则, BP ⁡ \operatorname{BP} BP等于 e 1 − r c e^{1-\frac{r}{c}} e1cr

p n p_n pn 是计算BLEU指标中用于计算n-gram的精确度的一个指标。它表示预测序列中n-gram在参考序列中出现的频率。

具体来说,假设参考翻译有m个句子,对于每一个n,预测序列和参考翻译的n-gram计数可以用下面的公式计算:

count ⁡ n = ∑ i : c i , … , i + n − 1 ∈ c min ⁡ ( freq ⁡ ( c i , … , i + n − 1 ) , max ⁡ j = 1 m freq ⁡ ( r j , … , j + n − 1 ) ) \operatorname{count}n = \sum{\boldsymbol{i}:\boldsymbol{c}{\boldsymbol{i}, \ldots, \boldsymbol{i}+n-1}\in \boldsymbol{c}} \min(\operatorname{freq}(\boldsymbol{c}{\boldsymbol{i}, \ldots, \boldsymbol{i}+n-1}), \max_{\boldsymbol{j}=1}^{m} \operatorname{freq}(\boldsymbol{r}_{\boldsymbol{j}, \ldots, \boldsymbol{j}+n-1})) countn=i:ci,,i+n1cmin(freq(ci,,i+n1),j=1maxmfreq(rj,,j+n1))

其中, c \boldsymbol{c} c是预测序列, r j \boldsymbol{r}_j rj是第 j j j个参考翻译。 freq ⁡ ( x ) \operatorname{freq}(\boldsymbol{x}) freq(x)表示n-gram x \boldsymbol{x} x在一个序列中出现的频率, i i i是所有与 c \boldsymbol{c} c中的n-gram相匹配的位置的下标。

这个公式是BLEU指标中的一个计数函数,表示n-gram在机器翻译输出中出现的次数。其中, c c c表示机器翻译的输出序列, r 1 , … , r m r_1,\ldots,r_m r1,,rm表示参考翻译的序列, i \boldsymbol{i} i是一个指针,遍历所有可能的n-gram, freq ⁡ ( c i , … , i + n − 1 ) \operatorname{freq}(\boldsymbol{c}{\boldsymbol{i}, \ldots, \boldsymbol{i}+n-1}) freq(ci,,i+n1)表示该n-gram在机器翻译输出中出现的次数, freq ⁡ ( r j , … , j + n − 1 ) \operatorname{freq}(\boldsymbol{r}{\boldsymbol{j}, \ldots, \boldsymbol{j}+n-1}) freq(rj,,j+n1)表示该n-gram在参考翻译中出现的次数。该公式用于计算BLEU指标中的n-gram匹配数量。

然后,计算精确度 p n p_n pn可以使用以下公式:

p n = ∑ i : c i , … , i + n − 1 ∈ c min ⁡ ( freq ⁡ ( c i , … , i + n − 1 ) , max ⁡ j = 1 m freq ⁡ ( r j , … , j + n − 1 ) ) ∑ i : c i , … , i + n − 1 ∈ c freq ⁡ ( c i , … , i + n − 1 ) p_n = \frac{\sum_{\boldsymbol{i}:\boldsymbol{c}{\boldsymbol{i}, \ldots, \boldsymbol{i}+n-1}\in \boldsymbol{c}} \min(\operatorname{freq}(\boldsymbol{c}{\boldsymbol{i}, \ldots, \boldsymbol{i}+n-1}), \max_{\boldsymbol{j}=1}^{m} \operatorname{freq}(\boldsymbol{r}{\boldsymbol{j}, \ldots, \boldsymbol{j}+n-1}))}{\sum{\boldsymbol{i}:\boldsymbol{c}{\boldsymbol{i}, \ldots, \boldsymbol{i}+n-1}\in \boldsymbol{c}} \operatorname{freq}(\boldsymbol{c}{\boldsymbol{i}, \ldots, \boldsymbol{i}+n-1})} pn=i:ci,,i+n1cfreq(ci,,i+n1)i:ci,,i+n1cmin(freq(ci,,i+n1),maxj=1mfreq(rj,,j+n1))

其中分子表示预测序列和参考翻译的n-gram匹配的总次数,分母表示预测序列中n-gram的总数。

换句话说, p n p_n pn是两个数量的比值: 第一个是预测序列与标签序列中匹配的元语法的数量, 第二个是预测序列中元语法的数量的比率。

实现

def bleu(pred_seq, label_seq, k):  #@save
    """计算BLEU"""
    pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
    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

测评

engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
    translation, attention_weight_seq = predict_seq2seq(
        net, eng, src_vocab, tgt_vocab, num_steps, device)
    print(f'{
      
      eng} => {
      
      translation}, bleu {
      
      bleu(translation, fra, k=2):.3f}')

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_51957239/article/details/129542045