一、环境配置
python 3.10
torch==2.1.0
二、项目简介
本次项目旨在实现一次简单的机器翻译,用一个简单的含有注意力机制的模型和一个非常小的法语英语对照文本作为数据集来完成整个流程。
机器翻译,又称为自动翻译,简称“机翻、机译”,是利用计算机将一种自然语言(源语言)转换为另一种自然语言(目标语言)的过程。它是计算语言学的一个分支,是人工智能的终极目标之一,具有重要的科学研究价值。
三、数据预处理
!tar -xf d2lzh_pytorch.tar
# 导入必要的库
import collections # 用于计数和其他集合操作
import os # 用于操作系统相关的操作,如设置环境变量
import io # 用于处理输入输出操作
import math # 用于数学操作
import torch # PyTorch 库,用于深度学习
from torch import nn # 从 PyTorch 中导入神经网络模块
import torch.nn.functional as F # 从 PyTorch 中导入功能函数,如激活函数
import torchtext.vocab as Vocab # 从 torchtext 中导入词汇表模块
import torch.utils.data as Data # 从 PyTorch 中导入数据加载模块
# 添加模块路径
# sys.path.append("..")
import d2lzh_pytorch as d2l
# 定义一些特殊的标记,用于处理文本数据
PAD, BOS, EOS = '<pad>', '<bos>', '<eos>'
# 设置环境变量,指定使用的 GPU 设备
os.environ["CUDA_VISIBLE_DEVICES"] = "0"
# 根据是否有可用的 GPU,选择设备(GPU 或 CPU)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 打印 PyTorch 版本和当前使用的设备
print(torch.__version__, device)
将我们所需要的资源解压出来,导入必要的库,这里我使用了gpu跑代码,如果也打算用gpu跑,先检查自己的CUDA版本,下载与CUDA对应的pytorch。
# 将一个序列中所有的词记录在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_tokens, out_tokens, in_seqs, out_seqs = [], [], [], []
# 初始化四个空列表,分别存储输入和输出的词元(tokens)和序列(sequences)
with io.open('fr-en-small.txt') as f:
lines = f.readlines()
# 打开文件 'fr-en-small.txt' 并读取所有行到列表中
for line in lines:
in_seq, out_seq = line.rstrip().split('\t')
# 使用\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
# 如果输入或输出序列的长度超过 max_seq_len - 1(考虑到EOS符号),则跳过此对序列
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)
# 返回输入词汇表、输出词汇表和包含输入输出数据的TensorDataset
我们在这里使用一个很小的法语—英语数据集。在这个数据集里,每一行是一对法语句子和它对应的英语句子,中间使用`'\t'`隔开。在读取数据时,我们在句末附上“<eos>;”符号,并可能通过添加“<pad>;”符号使每个序列的长度均为`max_seq_len`。我们为法语词和英语词分别创建词典。法语词的索引和英语词的索引相互独立。
以下是数据集内容
max_seq_len = 7
in_vocab, out_vocab, dataset = read_data(max_seq_len)
dataset[0]
将最大序列设置为7,我们查看读取到的第一个样本
包括了法语的索引和英语索引
四、构建模型
在此部分,我们将构建一个简单的带有注意力机制的编码器--解码器架构。
编码器(Encoder):“将现实问题转化为数学问题”
编码器负责处理输入序列,将其转换成一个固定长度的内部表示形式(通常称为上下文向量或特征向量)。这个内部表示形式捕捉了输入序列的关键信息。在处理过程中,编码器逐步压缩输入信息,目的是将所有必要信息编码到一个抽象的表示中。
解码器(Decoder):“求解数学问题,并转化为现实世界的解决方案”
解码器的任务是将编码器输出的内部表示转换为目标序列。解码器逐步生成输出序列,每一步都可能依赖于前一步的输出以及从编码器传递过来的上下文信息。在生成过程中,解码器逐渐解开编码器压缩的信息,将其转化为有意义的输出。
注意力机制可以通俗的理解为从关注全部到关注重点。在注意力机制前,一个词的生成会根据整个序列,但有了注意力机制,就只根据最相关的几个。
这里只是简述,并不完全正确,感兴趣的读者可以研读相关论文与文章。
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)
# 定义GRU层,输入维度是embed_size,隐藏层单元数是num_hiddens,层数是num_layers,丢弃概率是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)
# 将嵌入层的输出和初始状态传入GRU层,返回GRU的输出和新的状态
def begin_state(self):
return None
# 初始化状态为None
这里我们定义了一个编码器
# 定义Encoder实例,参数分别是词汇表大小、嵌入维度、隐藏层单元数和层数
encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
# 创建一个形状为(4, 7)的输入张量,全为零。表示批量大小为4,序列长度为7
inputs = torch.zeros((4, 7))
# 获取编码器初始状态
state = encoder.begin_state()
# 将输入张量和初始状态传入编码器
output, state = encoder(inputs, state)
# 输出的形状和状态的形状
output.shape, state.shape
我们来创建一个批量大小为4、时间步数为7的小批量序列输入。设门控循环单元的隐藏层个数为2,隐藏单元个数为16。编码器对该输入执行前向计算后返回的输出形状为(时间步数, 批量大小, 隐藏单元个数)。门控循环单元在最终时间步的多层隐藏状态的形状为(隐藏层个数, 批量大小, 隐藏单元个数)。对于门控循环单元来说,`state`就是一个元素,即隐藏状态;如果使用长短期记忆,`state`是一个元组,包含两个元素即隐藏状态和记忆细胞。
import torch.nn as nn
def attention_model(input_size, attention_size):
# 定义一个简单的注意力模型
model = nn.Sequential(
nn.Linear(input_size, attention_size, bias=False), # 输入到注意力层的线性变换,无偏置
nn.Tanh(), # Tanh激活函数
nn.Linear(attention_size, 1, bias=False) # 输出注意力权重的线性变换,无偏置
)
return model
我们定义了一个简单的注意力模型,将输入连结后通过含单隐藏层的多层感知机变换,使用tanh()作为激活函数
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
# 创建一个注意力模型,假设 attention_model 函数接受两个参数:输入的隐藏层单元数和某个超参数(这里是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 函数,传入模型、编码器状态和解码器状态,获取输出的形状
output_shape = attention_forward(model, enc_states, dec_state).shape
# 打印输出的形状
print(output_shape)
在上面的例子中,编码器的时间步数为10,批量大小为4,编码器和解码器的隐藏单元个数均为8。注意力机制返回一个小批量的背景向量,每个背景向量的长度等于编码器的隐藏单元个数。因此输出的形状为(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层,输入的尺寸是注意力输出的背景向量c和实际输入嵌入后的向量的拼接结果
# 所以输入尺寸是 num_hiddens + embed_size,GRU输出的隐藏状态维度是 num_hiddens
# num_layers 表示GRU的层数,drop_prob 是dropout的概率
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: 当前输入的词索引,形状为 (batch, )
state: GRU的隐藏状态,形状为 (num_layers, batch, num_hiddens)
enc_states: 编码器的隐藏状态,形状为 (seq_len, batch, num_hiddens)
"""
# 使用注意力机制计算背景向量c,形状为 (batch, num_hiddens)
c = attention_forward(self.attention, enc_states, state[-1])
# 将当前输入词索引转换为词向量,并与背景向量在特征维度上进行拼接
# 拼接后的形状为 (batch, num_hiddens + embed_size)
input_and_c = torch.cat((self.embedding(cur_input), c), dim=1)
# 为输入和背景向量的拼接结果增加时间步维度,时间步个数为1,形状变为 (1, batch, num_hiddens + embed_size)
output, state = self.rnn(input_and_c.unsqueeze(0), state)
# 移除时间步维度,形状变为 (batch, num_hiddens)
# 通过全连接层,将GRU的输出映射到词汇表大小的向量,形状为 (batch, vocab_size)
output = self.out(output).squeeze(dim=0)
return output, state
def begin_state(self, enc_state):
"""
初始化解码器的隐藏状态
参数:
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
我们先实现`batch_loss`函数计算一个小批量的损失。解码器在最初时间步的输入是特殊字符`BOS`。之后,解码器在某时间步的输入为样本输出序列在上一时间步的词,即强制教学。我们在这里使用掩码变量避免填充项对损失函数计算的影响。
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
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)
设定好超参数后我们就可以开始训练了
六、翻译实现
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)
# 将词元转换为对应的索引,并转换为张量,形状为 (1, max_seq_len)
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)
# 解码器的初始输入为BOS(开始符)的索引
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())]
# 如果输出的是EOS(结束符),则停止解码
if pred_token == 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)
可以看到结果还是不错的。
对于一个翻译的正确性,模型效果的评估,我们使用BLEU来实现
def bleu(pred_tokens, label_tokens, k):
len_pred, len_label = len(pred_tokens), len(label_tokens)
# 计算BLEU的BP (Brevity Penalty) 惩罚因子
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)
# 构建参考序列的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
# 更新得分
score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
return score
def score(input_seq, label_seq, k):
# 使用 encoder 和 decoder 将输入序列翻译成目标序列
pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)
# 将参考序列拆分成词元
label_tokens = label_seq.split(' ')
# 计算预测序列和参考序列之间的BLEU得分,并打印结果
print('bleu %.3f, predict: %s' % (bleu(pred_tokens, label_tokens, k),
' '.join(pred_tokens)))
如果预测完全正确,score=1
七、总结
本次实验我们实现了一个简单的机器翻译的所有流程,并且简单讲解了一下带有注意力机制的编码器--解码器框架。最后使用了BLEU来评估模型预测的效果。希望我的文章对你有效果,请让我们继续学习。