“深度揭秘:机器翻译”

一.引言

     1.定义与背景

     2.事实或案例

二. 基础理论与实验概述

  • 技术发展历程

  • 神经机器翻译的工作原理

  • 实验操作流程

三.基于Transformer实现机器翻译(日译中)

1.提前准备好下列库

2.获取平行数据集 

3.准备分词器

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

5.创建DataLoader对象以在训练期间进行迭代 

6.序列到序列Transformer 

7.开始训练

8.使用训练好的模型尝试翻译日语句子

四.总结

一.引言

     1.定义与背景

背景

机器翻译的概念最早可追溯至20世纪中叶,大约在1950年代,随着计算机技术的初步发展,美国和苏联的科学家开始探索使用计算机进行自动翻译的可能性。早期的尝试主要依赖于基于规则的方法,即通过编写详尽的语言规则和词汇对应表来指导翻译过程。尽管这些早期系统标志着机器翻译研究的开端,但受限于当时计算机的处理能力和对语言复杂性的理解不足,翻译质量并不理想,难以满足实际应用需求。

定义

机器翻译(Machine Translation, MT)是一种利用计算机程序自动将一种自然语言的文本转换为另一种自然语言的技术。作为自然语言处理(Natural Language Processing, NLP)领域的一个关键分支,机器翻译旨在克服不同语言间的交流障碍,加速并扩大全球范围内的信息流通与文化交流。

     2.事实或案例

跨语言信息检索:维基百科等平台利用机器翻译技术,使非英语内容更容易被全球用户访问。通过机器翻译,用户即便不懂原文语言,也能大致理解页面内容,促进了知识的跨国界传播。

有道翻译的高质量输出:在一次比较测试中,有道翻译因其在处理创意类文本(如广告文案)翻译时展现的相对较高质量和贴合度而脱颖而出。尽管机器翻译在高度创造性内容上的表现通常不如人工翻译,但有道翻译通过优化算法,较好地保持了原文的创意意图和风格,这表明在特定领域内,机器翻译技术正在不断逼近人工翻译的水平。

这些案例证明,机器翻译技术正深刻地影响着全球通信、商业、媒体、教育等多个领域,不仅提高了效率,还促进了文化的交流与理解。

             

二. 基础理论与实验概述

技术发展历程

规则基础机器翻译:20世纪50至70年代,研究侧重于建立复杂的语言规则体系,这种方法需要语言学家手动编码每种语言的语法规则和例外情况。

统计机器翻译:从20世纪90年代开始,统计方法逐渐兴起,该方法利用大量双语语料库来学习翻译概率,提高了翻译的流畅性和准确性。

神经机器翻译:进入21世纪后,特别是2010年代以来,随着深度学习技术的突破,神经网络尤其是序列到序列(Sequence-to-Sequence)模型的应用,使得机器翻译取得了显著进步。神经机器翻译通过端到端的学习,能够捕捉更深层次的语言结构和上下文依赖,显著提升了翻译质量。

神经机器翻译的工作原理

神经机器翻译(Neural Machine Translation, NMT)是一种基于深度学习技术的自动翻译方法,其核心在于使用人工神经网络直接建模从源语言到目标语言的翻译过程。以下是神经机器翻译的主要工作原理:

  1. 数据准备:收集并整理大量源语言和目标语言对应的平行语料库,这是训练NMT模型的基础。

  2. 编码器操作:负责将源语言文本转换为一种连续的向量表示,这一过程捕获了源语言句子的语义和句法信息。编码器通常采用循环神经网络(RNN)、长短时记忆网络(LSTM)、门控循环单元(GRU)或更现代的Transformer架构。这些模型能够处理不同长度的输入序列,并通过隐藏状态保留之前的信息。

  3. 中间表示:编码完成后,源文本被转化成一个高维向量,这个向量被称为上下文向量或编码向量,它试图封装整个句子的意义。

  4. 注意力机制:这一机制解决了长距离依赖问题,使解码器能够在生成每个目标词时,直接关注源句子中相关的部分,而不是依赖于编码器提供的固定长度向量。自注意力(Self-Attention)是Transformer模型中的关键特性,它允许模型在处理输入序列的每个位置时,都能够考虑到序列中的所有其他位置,提高了对序列信息的处理效率和准确性。

  5. 解码器生成:接收到编码器产生的向量后,解码器逐步生成目标语言的翻译文本。解码过程同样可以使用RNN、LSTM、GRU或Transformer,它从编码器得到的向量开始,逐词预测目标语言序列。解码过程中,注意力机制(Attention Mechanism)经常被应用,它允许解码器在生成每个目标语言词时,灵活地“关注”源语言序列的不同部分,这有助于模型更好地理解和传达源语言的含义。

  6. 端到端学习:与传统的基于规则或统计的翻译系统不同,NMT模型是端到端的,意味着从输入文本到输出翻译的整个流程都在一个模型中完成,无需分阶段处理。模型通过反向传播和梯度下降等优化算法,在大量平行语料库(包含源语言和目标语言对应句子的集合)上进行训练,学习到最优的参数配置。

  7. 束搜索优化:为了提高翻译质量,解码时采用束搜索策略探索可能的翻译路径,选取最可能正确的翻译序列作为输出。

实验操作流程

     1 .读取和预处理数据

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

!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  # 提供数据处理工具,如DataLoader

# 添加自定义模块的路径,以便能够导入d2lzh_pytorch(此行可能根据你的环境需要调整或删除)
# import sys
# sys.path.append("..")
import d2lzh_pytorch as d2l  # 自定义的深度学习实用函数库

# 定义特殊词汇:填充符、开始符和结束符
PAD, BOS, EOS = '<pad>', '<bos>', '<eos>'

# 设置CUDA可见设备,这里设置为使用第0号GPU
os.environ["CUDA_VISIBLE_DEVICES"] = "0"

# 创建设备对象,优先使用GPU,如果不可用则使用CPU
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

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

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

# 将一个序列中所有的词记录在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)

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

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)

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

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

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

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

class Encoder(nn.Module):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 drop_prob=0, **kwargs):
        super(Encoder, self).__init__(**kwargs)
        # 定义嵌入层,将词汇表中的每个单词映射到embed_size维度的向量
        self.embedding = nn.Embedding(vocab_size, embed_size)
        # 定义GRU网络,使用给定的隐藏单元数量和层数,dropout用于防止过拟合
        self.rnn = nn.GRU(input_size=embed_size, hidden_size=num_hiddens, 
                          num_layers=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

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

# 实例化Encoder类,设定词汇表大小为10,嵌入维度为8,隐藏单元数量为16,以及2层GRU结构
encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)

# 准备一个形状为(4, 7)的输入张量,这里使用全零张量作为示例输入
input_sequence = torch.zeros((4, 7))

# 调用编码器的forward方法,传入示例输入和初始状态(由begin_state方法生成)
output, state = encoder(input_sequence, encoder.begin_state())

# 输出编码器的输出和状态的形状
output.shape, state.shape

3.注意力机制

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

def attention_model(input_size, attention_size):
    # 创建一个顺序模型容器
    model = nn.Sequential(
        # 第一层是一个线性变换,将输入向量的维度从input_size转换到attention_size
        nn.Linear(input_size, attention_size, bias=False),
        # 使用tanh激活函数对线性变换的结果进行非线性转换
        nn.Tanh(),
        # 第二层线性变换,将经过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)  # 返回背景变量

给个例子,编码器的时间步数为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))

# 调用注意力模型的前向传播函数,传入编码器状态和解码器状态
# 注意:这里假设存在一个名为attention_forward的函数,用于执行注意力模型的前向传播
attention_weights = attention_forward(model, enc_states, dec_state)

# 输出注意力权重的形状
attention_weights.shape

4.含注意力机制的解码器 

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

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

class Decoder(nn.Module):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 attention_size, drop_prob=0):
        super(Decoder, self).__init__()
        # 单词嵌入层,将词汇表中的单词ID转换成固定维度的向量表示
        self.embedding = nn.Embedding(vocab_size, embed_size)
        
        # 注意力模型,用于计算注意力权重
        # 输入向量的维度设置为两倍的隐藏单元数(编码器的双向GRU输出)
        self.attention = attention_model(2*num_hiddens, attention_size)
        
        # GRU层,用于解码过程
        # 输入包括注意力模型输出的上下文向量c和嵌入层输出的单词嵌入向量
        # 因此,GRU的输入维度为 num_hiddens + embed_size
        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)
        """
        # 使用注意力机制计算背景向量
        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

5.训练模型 

我们先实现batch_loss函数计算一个小批量的损失。解码器在最初时间步的输入是特殊字符BOS。之后,解码器在某时间步的输入为样本输出序列在上一时间步的词,即强制教学。此外,同10.3节(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)
    # 初始化解码器的隐藏状态
    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):
    # 初始化编码器和解码器的Adam优化器
    enc_optimizer = torch.optim.Adam(encoder.parameters(), lr=lr)
    dec_optimizer = torch.optim.Adam(decoder.parameters(), lr=lr)

    # 定义交叉熵损失函数,reduction设为'none'意味着不执行平均操作
    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
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)

6.预测不定长的序列 (贪婪搜索)

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_size为1
    
    # 初始化编码器的状态
    enc_state = encoder.begin_state()
    
    # 编码器前向传播
    enc_output, enc_state = encoder(enc_input, enc_state)  # 编码器输出和状态
    
    # 解码器初始化输入和状态
    dec_input = torch.tensor([out_vocab.stoi[BOS]])  # 初始输入为开始标记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  # 返回生成的标记序列

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

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

7.评价翻译结果 

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

具体来说,设词数为𝑛

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

分别为标签序列和预测序列的词数,那么,BLEU的定义为

其中𝑘是我们希望匹配的子序列的最大词数。可以看到当预测序列和标签序列完全一致时,BLEU为1。因为匹配较长子序列比匹配较短子序列更难,BLEU对匹配较长子序列的精度赋予了更大权重。例如,当𝑝𝑛固定在0.5时,随着𝑛的增大,0.51/2≈0.7,0.51/4≈0.84,0.51/8≈0.92,0.51/16≈0.96。另外,模型预测较短序列往往会得到较高𝑝𝑛值。因此,上式中连乘项前面的系数是为了惩罚较短的输出而设的。举个例子,当𝑘=2时,假设标签序列为𝐴、𝐵、𝐶、𝐷、𝐸、𝐹,而预测序列为𝐴、𝐵。虽然𝑝1=𝑝2=1,但惩罚系数exp(1−6/2)≈0.14,因此BLEU也接近0.14。

下面来实现BLEU的计算。

# 导入必要的库
import math
import collections

# 定义计算BLEU分数的函数
def bleu(pred_tokens, label_tokens, k):
    # 计算预测序列和标签序列的长度
    len_pred, len_label = len(pred_tokens), len(label_tokens)
    
    # 如果预测序列比标签序列短,则计算一个惩罚因子,否则为1
    score = math.exp(min(0, 1 - len_label / len_pred))
    
    # 对于每一个n-gram大小(从1到k)
    for n in range(1, k + 1):
        # 初始化匹配的数量和标签序列中每个n-gram的出现次数
        num_matches, label_subs = 0, collections.defaultdict(int)
        
        # 遍历标签序列,统计每个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):
            # 如果当前n-gram在标签序列中存在且尚未被匹配,则增加匹配数量并减少该n-gram的计数
            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):
    # 使用encoder和decoder对输入序列input_seq进行翻译,获得预测的词列表pred_tokens
    pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)
    
    # 将标签序列label_seq按空格分割成词列表label_tokens
    label_tokens = label_seq.split(' ')
    
    # 调用bleu函数计算预测序列pred_tokens与标签序列label_tokens之间的BLEU分数
    # 参数k表示最大n-gram的大小
    bleu_score = bleu(pred_tokens, label_tokens, k)
    
    # 打印BLEU分数和预测的词序列
    print('bleu %.3f, predict: %s' % (bleu_score, ' '.join(pred_tokens)))

预测正确则分数为1。

score('ils regardent .', 'they are watching .', k=2)

 三.基于Transformer实现机器翻译(日译中)

1.提前准备好下列库

# 导入必要的库
import math
import torchtext
import torch
import torch.nn as nn
from torch import Tensor
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader
from collections import Counter
from torchtext.vocab import Vocab
from torch.nn import TransformerEncoder, TransformerDecoder, TransformerEncoderLayer, TransformerDecoderLayer
import io
import time
import pandas as pd
import numpy as np
import pickle
import tqdm
import sentencepiece as spm
# 设置随机种子以确保实验的可重复性
torch.manual_seed(0)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# print(torch.cuda.get_device_name(0)) ## 如果你有GPU,请在你自己的电脑上尝试运行这一套代码
device

2.获取平行数据集 

在本实验之中中,我们将使用从JParaCrawl获取的日英平行数据集![http://www.kecl.ntt.co.jp/icl/lirg/jparacrawl],该数据集被称为“由NTT创建的最大的公开可用的英日平行语料库。它是通过大规模爬取网页并自动对齐平行句子来创建的。”您也可以在这里查看相关论文。

请注意,JParaCrawl的使用受特定条款约束,仅限于研究目的,涉及信息分析,包括但不限于复制和分配。但是,不允许将其用于包括销售基于此数据训练的翻译机在内的商业用途。使用前需同意其提供的使用条款。

# 使用 pandas 库从指定路径读取一个 CSV 文件
# 文件路径为 './zh-ja/zh-ja.bicleaner05.txt'
# 分隔符设置为制表符('\t'),使用 Python 引擎进行解析
# 由于文件没有列标题,header 设置为 None,表示不使用第一行作为列名
df = pd.read_csv('./zh-ja/zh-ja.bicleaner05.txt', sep='\\t', engine='python', header=None)
trainen = df[2].values.tolist()#[:10000]
trainja = df[3].values.tolist()#[:10000]

在导入所有日语及其对应的英语条目后,我删除了数据集中最后一项数据,因为它存在缺失值。总计,trainentrainja中的句子总数为5,973,071条。然而,为了学习目的,通常建议先对数据进行抽样,并确保一切按预期工作,之后再一次性使用所有数据,这样可以节省时间。

以下是数据集中包含的一个句子示例。

print(trainen[500])
print(trainja[500])

我们也可以使用不同的平行数据集来配合这篇文章进行学习,只要确保我们能够将数据处理成上面显示的两个字符串列表,分别包含日语句子和英语句子即可。 

3.准备分词器 

不像英语或其他字母语言,日语句子中没有空格来分隔单词。我们可以使用JParaCrawl提供的分词器,它们是基于SentencePiece为日语和英语分别创建的。你可以访问JParaCrawl网站下载这些分词器,或者点击这里进行访问。

# 初始化两个 SentencePiece 分词器,分别用于英语和日语的文本处理。
en_tokenizer = spm.SentencePieceProcessor(model_file='enja_spm_models/spm.en.nopretok.model')
ja_tokenizer = spm.SentencePieceProcessor(model_file='enja_spm_models/spm.ja.nopretok.model')

分词器加载后,你可以通过运行以下代码来测试它们。

en_tokenizer.encode("All residents aged 20 to 59 years who live in Japan must enroll in public pension system.", out_type='str')
ja_tokenizer.encode("年金 日本に住んでいる20歳~60歳の全ての人は、公的年金制度に加入しなければなりません。", out_type='str')

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

利用分词器和原始句子,我们接下来构建从TorchText导入的词汇表(Vocab)对象。此过程可能需要几秒到几分钟不等,具体取决于数据集的大小及计算能力。不同的分词器也会影响构建词汇表所需的时间,我尝试了几种其他的日语文本分词器,但SentencePiece似乎工作得既好又快速,对我来说足够了。

# 定义构建词汇表的函数
def build_vocab(sentences, tokenizer):
    # 创建一个Counter对象来统计词频
    counter = Counter()
    # 遍历所有的句子
    for sentence in sentences:
        # 使用tokenizer将句子编码为token列表,并将类型转换为str
        tokens = tokenizer.encode(sentence, out_type=str)
        # 更新Counter对象,统计token频率
        counter.update(tokens)
    # 创建并返回词汇表,其中包含一些特殊标记如<unk>, <pad>, <bos>, <eos>
    return Vocab(counter, specials=['<unk>', '<pad>', '<bos>', '<eos>'])

# 使用上述函数创建日语词汇表
ja_vocab = build_vocab(trainja, ja_tokenizer)

# 使用上述函数创建英语词汇表
en_vocab = build_vocab(trainen, en_tokenizer)

在我们拥有词汇表对象之后,就可以使用词汇表(vocab)和分词器对象来为训练数据构建张量(tensors)了。

def data_process(ja, en):
    data = []  # 初始化一个空列表,用于存储处理后的数据
    # 遍历日语和英语句子的配对
    for (raw_ja, raw_en) in zip(ja, en):
        # 对日语文本进行预处理:
        #   1. 使用ja_tokenizer将日语文本raw_ja编码为token列表,并去掉末尾的换行符("\n")
        #   2. 将token列表转换为它们在ja_vocab中的索引
        #   3. 使用torch.tensor将索引列表转换为长整型张量
        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列表中
        data.append((ja_tensor_, en_tensor_))
    
    # 返回处理后的数据列表
    return data

# 使用上述函数处理训练数据
train_data = data_process(trainja, trainen)

5.创建DataLoader对象以在训练期间进行迭代 

在此处,我将BATCH_SIZE设置为16,以防止“cuda内存不足”,但这取决于多种因素,如机器内存容量、数据大小等,因此请根据需要自由调整批次大小(注意:PyTorch教程在使用Multi30k德英数据集时将批次大小设置为128。)

BATCH_SIZE = 8
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)

6.序列到序列Transformer 

Transformer是一种序列到序列(Seq2Seq)模型,它在《注意力就是你所需要的》这篇论文中被引入,用于解决机器翻译任务。Transformer模型包含一个编码器和一个解码器块,每个块中都有固定数量的层。

编码器通过一系列多头注意力(Multi-head Attention)和前馈网络(Feed Forward Network)层传播输入序列来处理它。编码器的输出,即所谓的记忆,连同目标张量一起被传给解码器。编码器和解码器采用端到端的方式进行训练,并使用教师强制(teacher forcing)技术。

在训练过程中,我们需要一个后续词掩码来阻止模型在做出预测时查看未来的词。我们还需要掩码来隐藏源语言和目标语言的填充标记。下面,我们定义一个函数来同时处理这两个问题。

我们定义了模型的参数并实例化了模型。同时,我们也定义了损失函数,即交叉熵损失,并指定了用于训练的优化器。

数据迭代器每次产生一对原始字符串。我们需要将这些字符串对转换为可以被我们之前定义的Seq2Seq网络处理的批次张量。我们定义了一个collate函数,将一批原始字符串转换为可以直接输入到模型中的批次张量。

现在我们有了训练模型的所有要素。让我们开始训练!从定义训练循环到在验证集上评估模型,我们逐步进行。最后,我们还定义了一个函数,用贪心算法生成输出序列,以及一个实际的函数,将输入句子翻译为目标语言。

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


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)
        self.transformer_encoder = TransformerEncoder(encoder_layer, num_layers=num_encoder_layers)
        # 创建解码器层
        decoder_layer = TransformerDecoderLayer(d_model=emb_size, nhead=NHEAD,
                                                dim_feedforward=dim_feedforward)
        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)

文本中的令牌通过使用令牌嵌入来表示。为了引入词序的概念,会在令牌嵌入中加入位置编码。

class PositionalEncoding(nn.Module):
    def __init__(self, emb_size: int, dropout, 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))

        # 应用正弦和余弦函数计算位置编码
        pos_embedding[:, 0::2] = torch.sin(pos * den)
        pos_embedding[:, 1::2] = torch.cos(pos * den)

        # 添加一个新的维度,使位置编码可以与嵌入向量相加
        pos_embedding = pos_embedding.unsqueeze(-2)

        # 注册位置编码为持久缓冲区,这样它不会被优化器更新
        self.register_buffer('pos_embedding', pos_embedding)

        # Dropout层
        self.dropout = nn.Dropout(dropout)
    def forward(self, token_embedding: Tensor):
        return self.dropout(token_embedding +
                            self.pos_embedding[:token_embedding.size(0),:])

class TokenEmbedding(nn.Module):
    def __init__(self, vocab_size: int, emb_size):
        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)

我们创建了一个后续词掩码(subsequent word mask),用以阻止目标词关注其后面的词,即在预测时避免看到未来信息。同时,我们还为遮蔽源语言和目标语言中的填充标记创建了掩码。这样做是为了确保模型在处理序列数据时能够正确地区分有效内容与填充内容,从而提高训练效率和翻译准确性。

def generate_square_subsequent_mask(sz):
    mask = (torch.triu(torch.ones((sz, sz), device=device)) == 1).transpose(0, 1)
    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)
# 检查源序列中哪些位置是填充符(PAD_IDX),并将其转置,以便于后续操作
  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

果想要看到训练的效果请使用你自己的带GPU的电脑运行这一套代码。

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           # 训练轮数
# 初始化Transformer模型
transformer = Seq2SeqTransformer(NUM_ENCODER_LAYERS, NUM_DECODER_LAYERS,
                                 EMB_SIZE, SRC_VOCAB_SIZE, TGT_VOCAB_SIZE, 
                                 FFN_HID_DIM)  # SRC_VOCAB_SIZE, TGT_VOCAB_SIZE为源语言和目标语言的词汇表大小

# 参数初始化,使用Xavier初始化方法对模型参数进行初始化,有利于训练稳定性和收敛速度
for p in transformer.parameters():
    if p.dim() > 1:  # 只对多维(非偏置项)参数进行初始化
        nn.init.xavier_uniform_(p)

# 将模型移至计算设备(如GPU)
transformer = transformer.to(device)

# 定义损失函数,忽略PAD_IDX位置的损失,通常PAD_IDX用于填充序列到相同长度
loss_fn = torch.nn.CrossEntropyLoss(ignore_index=PAD_IDX)

# 定义优化器,使用Adam优化算法,设置学习率、beta值和epsilon值
optimizer = torch.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, tgt = src.to(device), tgt.to(device)  # 将数据移至计算设备
        tgt_input = tgt[:-1, :]  # 目标序列的输入,去掉最后一个token作为预测的目标

        # 创建掩码,用于自注意力机制和序列填充处理
        src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)

        # 通过模型得到预测logits
        logits = model(src, tgt_input, src_mask, tgt_mask, src_padding_mask, tgt_padding_mask, src_padding_mask)

        # 梯度清零
        optimizer.zero_grad()

        # 计算损失,仅考虑除了第一个token外的所有token
        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    # 初始化验证阶段的损失总和
    for idx, (src, tgt) in enumerate(valid_iter):  # 遍历验证数据迭代器
        src, tgt = src.to(device), 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)

7.开始训练 

最后,在准备了所有必需的类和函数之后,我们准备好训练我们的模型了。不言而喻,完成训练所需的时间会因多种因素而有很大差异,包括计算能力、参数设置以及数据集的大小。

当我使用JParaCrawl完整句子列表训练模型时,每种语言大约有590万条句子,仅使用一块NVIDIA GeForce RTX 3070 GPU,每个周期就需要大约5个小时。

下面是训练的代码:

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"))

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>", "")
translate(transformer, "HSコード 8515 はんだ付け用、ろう付け用又は溶接用の機器(電気式(電気加熱ガス式を含む。)", ja_vocab, en_vocab, ja_tokenizer)
trainen.pop(5)
trainja.pop(5)

 四.总结

神经机器翻译技术(NMT)作为现代自动翻译领域的前沿方法,其核心优势与未来展望可总结为以下几点:

  1. 深度学习驱动的精准翻译:NMT利用深度学习模型,特别是Transformer架构,通过端到端训练捕捉语言的深层结构和语境依赖,显著提升了翻译的准确度与自然流畅度。

  2. 注意力机制的创新应用:引入注意力机制使得模型能在翻译过程中灵活聚焦源文本的不同部分,这一创新极大增强了长句和复杂结构的翻译质量。

  3. 大规模数据的高效利用:NMT模型得益于互联网时代海量双语数据的训练,能更好地理解和适应多种语言风格及领域特定术语,拓宽了其应用范围。

  4. 实时性和个性化服务:随着计算能力的提升,NMT能够实现实时翻译,为用户提供即时交流解决方案,并有望通过持续学习用户的偏好进一步优化个性化翻译体验。

  5. 跨语言信息访问的促进:NMT降低了语言障碍,促进了全球信息的流通与共享,对国际交流、教育、科研等领域产生了深远影响。

  6. 持续的技术进步与挑战:尽管已取得显著成就,NMT仍面临如稀有语言资源不足、文化背景理解、以及如何在低资源条件下保持高性能等挑战,推动着研究者不断探索更高效的训练方法、模型架构及跨模态翻译等前沿方向。

  • 30
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值