本文是参考https://blog.csdn.net/raelum/article/details/125654501进行学习的,所以流程上会比较相似
step 1:环境的准备
导入本文中所有需要的包,具体如下:
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader
import math
import string
import matplotlib.pyplot as plt
from tkinter import _flatten
from collections import Counter
本文使用的数据集为http://www.manythings.org/anki/中可以下载的英法数据集。
原数据集的内容如下:
从上图中可以看到每个数据之间使用\t进行分隔,同时还包含了CC-BY开头的无用数据,因此,我们首先需要对数据集进行清洗。
step 2: 数据清理
step 2.1: 数据集清洗
具体可以参考以下的步骤:
- 通过open打开文件后使用readline逐行读取
- 通过[:-1]的方法分隔开无用数据CC-BY…
- 最后再通过list.append的方法读取每一行的数据
file_origin = open('./fra.txt','r',encoding='utf-8')
lines = file_origin.readlines()
content = []
for line in lines:
line_new = "\t".join(line.strip().split('\t')[:-1])
content.append(line_new)
输出前五行的方法和具体数据如下:
print(content[:5])
在数据集中,还包含着一些无用的数据和信息,其中需要剔除的:\u200b、\xad,还有一些特殊空格:\u2009、\u202f 和 \xa0 需要替换为普通空格,具体实现方法如下:
- 新建special_chars列表 [‘\u200b’,‘\xad’,‘\u2009’, ‘\u202f’, ‘\xa0’],在这里,特地把\u200b、\xad放在0和1的位置。因此,使用enumerate进行遍历时,当j > 1时,替换成空格;当j<1时直接剔除。
- 同时将还需要将文本全部小写化
- 如果单词和标点符号中间没有空格,还需要在之间插入空格
def data_cleaning(contents):
for i in range(len(contents)):
# 删除无用字符
special_chars = ['\u200b','\xad','\u2009', '\u202f', '\xa0']
for j , char in enumerate(special_chars):
contents[i].replace(char,' ' if j > 1 else '')
# 全部换为小写
contents[i].lower()
contents[i] = ''.join([
' ' + char if j > 0 and char in ',.!?' and content[i][j - 1] != ' '
else char
for j, char in enumerate(contents[i]) ])
return contents
在这里进行一个简单测试,并输出前10条数据
cleaned_content = data_cleaning(content)
for i in range(10):
print(cleaned_content[i])
具体结果如下:
step 2.2: tokenize处理
由于在content中,我们使用‘\t’将目标词元和源词元进行分隔,所以在这里需要额外多一步
处理的步骤如下:
- 分别建立两个list列表,将目标词元和原词元分别进行存储
- 按行进行遍历,并使用split(‘\t’)进行分隔
- 由于在这里并不是中文类型的数据,所以直接用split(’ ')分隔空格的方式就能得到
def tokenize(cleaned_content):
# 将目标词元和源词元分别进行存储
src_tokens , tgt_tokens = [], []
for line in cleaned_content:
pair = line.split('\t')
src_tokens.append(pair[0].split(' '))
tgt_tokens.append(pair[1].split(' '))
return src_tokens, tgt_tokens
输出src_tokens和tgt_tokens的分隔结果进行测试
print(src_tokens[:5])
print(tgt_tokens[:5])
得到的结果如下:
step 2.3:建立Vocab词表
为了建立单词和索引的映射,在这里我们需要新建Vocab类进行词表建立。
step 2.3.1: 词表的__init__函数
传入的参数:词元tokens,词元记录的最小频率min_freq
在建立词表之前,首先需要设置特殊词元 {unk:0, pad:1,bos:2,eos: 3}
在设置好特殊词元后,进行如下操作进行初始化:
- 使用_flatten把tokens都转换为一维的数据
- 使用Counter函数计算每个token出现的频次
- 用sort对出现频率进行排序(如果出现的频次大于min_freq)
- update更新字典
- 通过遍历的方法更新idx2token字典
def __init__(self,tokens,min_freq=2):
self.tokens = tokens # 传入的tokens是二维列表
self.min_freq = min_freq
# 设置特殊词元
self.token2idx = {'<unk>':0, '<pad>':1,'<bos>':2,'<eos>': 3}
# 先用_flatten把数据压成一维的
# 再使用Counter计算每个token出现的频次
# 用sort对出现频次进行一个排序
self.token2idx.update({
token: idx + 4
for idx,(token,freq) in enumerate(
sorted(Counter(_flatten(self.tokens)).items(), key=lambda x: x[1], reverse=True))
if freq >= self.min_freq
})
self.idx2token = {idx:token for token,idx in self.token2idx.items()}
step 2.3.2:词表的索引函数
传入的参数 tokens_or_indices:用于判断传入的类型
- 判断是不是str和int类型=》单个索引
- 判断是不是list和tuple类型=》多个索引
- 如果都不是=》raise TypeError
- 单个索引=》找idx->找不到就返回0, 找token-> 找不到就返回unk
- 多个索引=》拆开进行单个索引
def __getitem__(self, tokens_or_indices):
# 单个索引情形
# isinstance用于判断
if isinstance(tokens_or_indices,(str,int)):
if isinstance(tokens_or_indices,str):
return self.token2idx.get(tokens_or_indices,0) #找不到就返回数字0
else:
return self.idx2token.get(tokens_or_indices,'<unk>') #找不到就返回<unk>标签
# 多个索引类型
elif isinstance(tokens_or_indices,(list,tuple)):
return [self.__getitem__(item) for item in tokens_or_indices]
else:
raise TypeError
完整的Vocab类如下:
class Vocab:
# 设定最低记录的频率
def __init__(self,tokens,min_freq=2):
self.tokens = tokens # 传入的tokens是二维列表
self.min_freq = min_freq
# 设置特殊词元
self.token2idx = {'<unk>':0, '<pad>':1,'<bos>':2,'<eos>': 3}
# 先用_flatten把数据压成一维的
# 再使用Counter计算每个token出现的频次
# 用sort对出现频次进行一个排序
self.token2idx.update({
token: idx + 4
for idx,(token,freq) in enumerate(
sorted(Counter(_flatten(self.tokens)).items(), key=lambda x: x[1], reverse=True))
if freq >= self.min_freq
})
self.idx2token = {idx:token for token,idx in self.token2idx.items()}
def __getitem__(self, tokens_or_indices):
# 单个索引情形
# isinstance用于判断
if isinstance(tokens_or_indices,(str,int)):
if isinstance(tokens_or_indices,str):
return self.token2idx.get(tokens_or_indices,0) #找不到就返回数字0
else:
return self.idx2token.get(tokens_or_indices,'<unk>') #找不到就返回<unk>标签
# 多个索引类型
elif isinstance(tokens_or_indices,(list,tuple)):
return [self.__getitem__(item) for item in tokens_or_indices]
else:
raise TypeError
def __len__(self):
return len(self.idx2token)
在这里进行一下词表的测试
src_vocab, tgt_vocab = Vocab(src_tokens,min_freq=2), Vocab(tgt_tokens,min_freq=2)
print(len(src_vocab))
print(len(tgt_vocab))
结果如下:
step 2.4 句子的填充操作
送给 nn.Embedding 层的数据通常是词元在词表中的索引,并且是批量送入的,形状为 (batch_size, seq_len)
而 src_tokens 中的数据都是以词元的形式存在并且句子不等长,因此我们需要做些处理以让其能够批量加载。
将词元转化为索引非常简单,这里我们需要关注的是如何让句子等长。通常是设定一个长度,超过这个长度的句子进行截断,不到这个长度的句子用pad标签进行填充。
具体的代码比较简单
def truncate_pad(line, seq_len):
# 该函数针对单个句子进行处理
# 传入的句子是词元形式
if len(line) > seq_len:
return line[:seq_len]
else:
return line + ['<pad>'] * (seq_len - len(line))
可以写一些小例子进行测试
sentence = src_tokens[2000]
print(sentence)
print(truncate_pad(sentence, 10))
print(truncate_pad(sentence, 2))
具体结果如下:
step 2.5 句子的结束标识符填充
我们需要在 src_tokens 和 tgt_tokens 中的所有句子的末尾添加 eos以代表句子的结束,然后再将它们处理成等长的形式,之后将其中的词元转化为其在词表中的索引,最后以张量的形式返回。
def build_data(tokens, vocab, seq_len):
return torch.tensor([vocab[truncate_pad(line + ['<eos>'],seq_len)] for line in tokens])
step 2.6 数据的参数设置
# 参数设置
TRAIN_SIZE = 190000
TEST_SIZE = 4000
BATCH_SIZE = 4
SEQ_LEN = 45
# 将tokens转化成张量
src_data, tgt_data = build_data(src_tokens, src_vocab, SEQ_LEN), build_data(tgt_tokens, tgt_vocab, SEQ_LEN)
# 打乱数据以方便分割
indices = torch.randperm(len(src_data)) # 这样能够保证打乱后,句子是一一对应的关系
src_data, tgt_data = src_data[indices], tgt_data[indices]
# 划分出训练集和测试集(总数据量为194513)
src_train_data, src_test_data = src_data[:TRAIN_SIZE], src_data[-TEST_SIZE:]
tgt_train_data, tgt_test_data = tgt_data[:TRAIN_SIZE], tgt_data[-TEST_SIZE:]
train_data = TensorDataset(src_train_data, tgt_train_data)
test_data = TensorDataset(src_test_data, tgt_test_data)
# 设置DataLoader
train_loader = DataLoader(train_data, batch_size=BATCH_SIZE, shuffle=True)
test_loader = DataLoader(test_data, batch_size=1)
step 3: Seq2Seq模型的建立
step 3.1: encoder部分
step 3.1.1 初始化__init__函数
传入的参数为vocab_size,emb_size, hidden_size,num_layers,dropout=0
分别用于初始化Embedding层和GRU模块
def __init__(self,vocab_size,emb_size, hidden_size,num_layers,dropout=0):
super().__init__()
# 首先我们需要为 embedding 层指定 padding_idx,这样 <pad> 词元不会对梯度有任何贡献。
# 为什么是1=>因为 "<pad>:1" 所以设置为1
self.embedding = nn.Embedding(vocab_size,emb_size,padding_idx=1)
self.rnn = nn.GRU(emb_size,hidden_size,num_layers=num_layers,dropout=dropout)
step 3.1.2 forward函数
这里分步骤描述一下
- encoder_inputs的初始大小为(batch_size, seq_len)
- 经过embedding之后就变成了(batch_size, seq_len, emb_size) -> 可以理解为分析为多维度的
- 最后还需要转换维度变成(seq_len, batch_size, emb_size)
变换一下位置的具体原因:
对于这个input tensor,GRU是这样的定义的 如果输入的tensor只有两个维度: (sequence_length,input_size)
如果输入的tensor有三个维度: (sequence_length, batch_size,
input_size)
def forward(self, encoder_inputs):
# encoder_inputs的初始大小为(batch_size, seq_len)
# 经过embedding之后就变成了(batch_size, seq_len, emb_size)
# 最后还要变成(seq_len, batch_size, emb_size)
# 变换一下位置
# 具体的原因:
# 对于这个input tensor,GRU是这样的定义的
# 如果输入的tensor只有两个维度: (sequence_length, input_size)
# 如果输入的tensor有三个维度: (sequence_length, batch_size, input_size)
encoder_inputs = self.embedding(encoder_inputs).permute(1,0,2)
output, h_n = self.rnn(encoder_inputs)
# 这里的h_n是隐藏层的状态
# 形状为(num_layers,batch_size,hidden_size)
# 最后一个时刻最后一个隐层的输出的隐状态即为上下文向量,即h_n[-1]
# 其形状为 (batch_size, hidden_size)
return h_n
下面给一下完整的encoder部分的代码
class Seq2SeqEncoder(nn.Module):
def __init__(self,vocab_size,emb_size, hidden_size,num_layers,dropout=0):
super().__init__()
# 首先我们需要为 embedding 层指定 padding_idx,这样 <pad> 词元不会对梯度有任何贡献。
# 为什么是1=>因为 "<pad>:1" 所以设置为1
self.embedding = nn.Embedding(vocab_size,emb_size,padding_idx=1)
self.rnn = nn.GRU(emb_size,hidden_size,num_layers=num_layers,dropout=dropout)
def forward(self, encoder_inputs):
# encoder_inputs的初始大小为(batch_size, seq_len)
# 经过embedding之后就变成了(batch_size, seq_len, emb_size)
# 最后还要变成(seq_len, batch_size, emb_size)
# 变换一下位置
# 具体的原因:
# 对于这个input tensor,GRU是这样的定义的
# 如果输入的tensor只有两个维度: (sequence_length, input_size)
# 如果输入的tensor有三个维度: (sequence_length, batch_size, input_size)
encoder_inputs = self.embedding(encoder_inputs).permute(1,0,2)
output, h_n = self.rnn(encoder_inputs)
# 这里的h_n是隐藏层的状态
# 形状为(num_layers,batch_size,hidden_size)
# 最后一个时刻最后一个隐层的输出的隐状态即为上下文向量,即h_n[-1],其形状为 (batch_size, hidden_size
return h_n
Step 3.2: decoder部分
Step 3.2.1:初始化__init__函数
这里与encoder比较相似,但是比较不同的是添加了线性层,以及GRU的维度发生了改变
之所以是emb_size + hidden_size是因为我们在每个时间步需要将当前的输入和decoder输出的上下文向量拼在一起
具体代码如下:
def __init__(self,vocab_size,emb_size, hidden_size,num_layers,dropout=0):
super().__init__()
self.embedding = nn.Embedding(vocab_size,emb_size,padding_idx=1)
# 之所以是emb_size + hidden_size是因为我们在每个时间步需要将当前的输入和编码器输出的上下文向量拼在一起
self.rnn = nn.GRU(emb_size + hidden_size, hidden_size,num_layers=num_layers,dropout=dropout)
self.fc = nn.Linear(hidden_size,vocab_size)
Step 3.2.2: forward函数
下面是对代码的一些解释:
- decoder_inputs为目标序列偏移一位的结果
- decoder_inputs的初始形状(batch_size,seq_len)
- decoder_inputs的形状变化: (batch_size, seq_len) -> (batch_size, seq_len, embed_size) =》embedding层
(batch_size, seq_len, embed_size) -> (seq_len, batch_size, embed_size) =》变换位置才能输入到GRU中- 这里的seq_len为45,因为前面设定了序列的固定长度为45
def forward(self, decoder_inputs, encoder_states):
decoder_inputs = self.embedding(decoder_inputs).permute(1,0,2)
下面是对代码的一些解释:
- encoder_states 为最后一刻所有隐藏层的状态
context = encoder_states[-1]
context = context.repeat(decoder_inputs.shape[0],1,1)
output, h_n = self.rnn(torch.cat((decoder_inputs,context),-1), encoder_states)
encoder_states 为最后一刻所有隐藏层的状态,所以需要[-1]就能取到最后一个隐藏层的状态
context的形状为(batch_size,hidden_size),需要复制成(seq_len,batch_size,hidden_size)
repeat函数
torch.tensor.repeat()函数可以对张量进行重复扩充
当参数有三个时:(通道数的重复倍数,行的重复倍数,列的重复倍数),1表示不重复。
输入一维张量,参数为三个(b,m,n),即表示先在列上面进行重复n次,再在行上面重复m次,最后在通道上面重复b次,输出张量为三维
给个小例子
a = torch.randn(3)
a,a.repeat(3,1,1)
输出结果如下:
tensor([ 0.8093, 0.8625, -1.1996])
tensor([[[ 0.8093, 0.8625, -1.1996]],
[[ 0.8093, 0.8625, -1.1996]],
[[ 0.8093, 0.8625, -1.1996]]])
encoder在最后一个时刻的最后一个隐藏层的输出用作上下文向量,所以需要进行torch.cat操作将decoder_inputs和context进行拼接
又因为编码器在最后一个时刻的输出用作解码器的初始隐状态,所以将encoder_states和decoder的输入拼接起来作为 RNN 的输入
context在这里的形状为(batch_size,hidden_size),但由于要与decoder的输入进行拼接,所以需要复制成(seq_len,batch_size,hidden_size)
output, h_n = self.rnn(torch.cat((decoder_inputs,context),-1), encoder_states)
context = context.repeat(decoder_inputs.shape[0],1,1)
在这里用一个小例子解释一下torch.cat(-1)的作用,从下面这个小例子可以看出是在最后一个维度上进行拼接
经过拼接后,输入的维度为(seq_len, batch_size,embed_size + hidden_size)
output, h_n = self.rnn(torch.cat((decoder_inputs,context),-1), encoder_states)
最终output输出的结果为 (seq_len, batch_size, hidden_size)
接下来将output输入线性层(激活层),logits作为输出的维度为(seq_len, batch_size, vocab_size)
logits = self.fc(output)
class Seq2SeqDecoder(nn.Module):
def __init__(self,vocab_size,emb_size, hidden_size,num_layers,dropout=0):
super().__init__()
self.embedding = nn.Embedding(vocab_size,emb_size,padding_idx=1)
# 之所以是emb_size + hidden_size是因为我们在每个时间步需要将当前的输入和编码器输出的上下文向量拼在一起
self.rnn = nn.GRU(emb_size + hidden_size, hidden_size,num_layers=num_layers,dropout=dropout)
self.fc = nn.Linear(hidden_size,vocab_size)
def forward(self, decoder_inputs, encoder_states):
# decoder_inputs为目标序列偏移一位的结果
# decoder_inputs的初始形状(batch_size,seq_len)
# decoder_inputs (batch_size, seq_len) -> (batch_size, seq_len, embed_size) -> (seq_len, batch_size, embed_size)
decoder_inputs = self.embedding(decoder_inputs).permute(1,0,2)
# 这里的seq_len是45,因为MAX_LEN为45,所以维度都被扩充到了45
# 可以理解为每一个词元都被分析成(batch_size,embed_size)个数据
print(decoder_inputs.size())
print(decoder_inputs[0].size())
print(decoder_inputs)
# encoder_states 为最后一刻所有隐藏层的状态
# 但最后一个layer的输出才是我们要的目标状态
context = encoder_states[-1]
# context的形状为(batch_size,hidden_size),需要复制成(seq_len,batch_size,hidden_size)
context = context.repeat(decoder_inputs.shape[0],1,1)
# 这里就变成了(seq_len,batch_size,embed_size+ hidden_size)
# 只在最后一个维度添加
output, h_n = self.rnn(torch.cat((decoder_inputs,context),-1), encoder_states)
# output最后出来的结果是(seq_len,batch_size,hidden_size)
# logits 的形状为 (seq_len, batch_size, vocab_size)
logits = self.fc(output)
return logits, h_n
Step 3.3: Seq2Seq模型的拼接
由于我们在上文已经实现了Seq2Seq模型的encoder和decoder部分,所以在这里直接将两个模块拼接起来就可以完成模型的构建。
class Seq2SeqModel(nn.Module):
def __init__(self,encoder,decoder):
super().__init__()
self.encoder = encoder
self.decoder = decoder
def forward(self, encoder_inputs, decoder_inputs):
return self.decoder(decoder_inputs,self.encoder(encoder_inputs))
step 4:模型的训练
step 4.1 模型参数的设定
这里主要进行一些参数的设定
LR = 0.001
EPOCHS = 50
device = 'cuda' if torch.cuda.is_available() else 'cpu'
encoder = Seq2SeqEncoder(len(src_vocab), len(src_vocab), 256, num_layers=2, dropout=0.1)
decoder = Seq2SeqDecoder(len(tgt_vocab), len(tgt_vocab), 256, num_layers=2, dropout=0.1)
net = Seq2SeqModel(encoder, decoder)
net.to(device)
# 注意需要指定ignore_index
# index = 1 是为了去除掉<pad>的干扰
criterion = nn.CrossEntropyLoss(reduction='none', ignore_index=1)
optimizer = torch.optim.Adam(net.parameters(), lr=LR)
step 4.2 train函数的定义
传入的参数:train_loader, model, criterion, optimizer, num_epochs
这里的train_loader的定义为DataLoader(train_data, batch_size=BATCH_SIZE, shuffle=True)
在训练阶段,我们不采用上一个时间步的输出作为下一个时间步的输入,而是将目标序列偏移一位作为输入,这被称为 Teacher-forcing。具体而言,设目标序列为(为简便起见不考虑 padding)
decoder_inputs 本来的数据可以表示为[w1,w2,⋯,wT,eos]
但是这里采用了Teacher-forcing的处理方法
所以将其偏移一位并在序列起始处加上 bos :[bos,w1,w2,⋯,wT]
具体的操作为:
- 先将bos复制成decoder_targets的第一个维度的大小,也就是batch_size的大小
- 再使用torch.cat的方法将eos这一标签去除再按按channel进行拼接(外层不变,内层拼接)
代码如下:
bos_column = torch.tensor([tgt_vocab['<bos>']] * decoder_targets.shape[0]).reshape(-1,1).to(device)
decoder_inputs = torch.cat((bos_column,decoder_targets[:,:-1]),dim=1)
具体的可以看下面的例子,当完成tgt_vocab[‘bos’] * decoder_targets.shape[0]操作后,torch.size变成[101]
但为了拼接需要reshape成(-1,1)的形状
在下面的代码中,pred的形状为decoder中的输出logits的形状 (seq_len, batch_size, vocab_size)
decoder_targets的形状为(batch_size, seq_len)
为了满足citerion的输入条件,所以需要调整为(batch_size,vocab_size, seq_len)
最终输出all_loss的size为(batch_size, seq_len)
pred, _ = model(encoder_inputs, decoder_inputs)
all_loss = criterion(pred.permute(1, 2, 0), decoder_targets)
loss = all_loss.mean()
最后再进行反向传播操作
optimizer.zero_grad() # 将梯度初始化为0
loss.backward() # 反向传播求梯度
optimizer.step() # 更新所以参数
完整代码如下:
def train(train_loader, model, criterion, optimizer, num_epochs):
train_loss = []
model.train()
for epoch in range(num_epochs):
for batch_idx, (encoder_inputs,decoder_targets) in enumerate(train_loader):
encoder_inputs, decoder_targets = encoder_inputs.to(device), decoder_targets.to(device)
# 偏移一位作为decoder的输入
bos_column = torch.tensor([tgt_vocab['<bos>']] * decoder_targets.shape[0]).reshape(-1,1).to(device)
decoder_inputs = torch.cat((bos_column,decoder_targets[:,:-1]),dim=1)
# pred的形状为 (seq_len, batch_size, vocab_size)
pred, _ = model(encoder_inputs, decoder_inputs)
# decoder_targets 的形状为 (batch_size, seq_len),我们需要改变pred的形状以保证它能够正确输入
# all_loss 的形状为 (batch_size, seq_len),其中的每个元素都代表了一个词元的损失
all_loss = criterion(pred.permute(1, 2, 0), decoder_targets)
# 每个序列的损失是其所有词元的损失的平均,每个batch的损失是其所有序列的损失的平均
# 因此等价于每个batch里所有词元的损失的平均
loss = all_loss.mean()
# 反向传播
optimizer.zero_grad() # 将梯度初始化为0
loss.backward() # 反向传播求梯度
optimizer.step() # 更新所以参数
train_loss.append(loss.item())
# 每隔50个batch输出一次
if (batch_idx + 1) % 50 == 0:
print(
f'[Epoch {epoch + 1}] [{(batch_idx + 1) * len(encoder_inputs)}/{len(train_loader.dataset)}] loss: {loss:.4f}'
)
print()
return train_loss
train函数的具体运行方法如下:
train_loss = train(train_loader, net, criterion, optimizer, EPOCHS)
torch.save(net.state_dict(), 'seq2seq_params.pt')
plt.plot(train_loss)
plt.ylabel('train loss')
plt.show()
step 5:模型的测试
由于我们在前文的test_loader中将batch_size设为1
所以在这里需要一步一步进行输出(需要reshape成(1,1)的形状)
decoder_inputs = torch.tensor(pred_seq[-1]).reshape(1,1).to(device)
在经过模型的处理后从中取出预测张量中数值最高的(最有可能的idx),并输入到list中
如果为结束标识符eos,则break退出循环
next_token_idx = pred.squeeze().argmax().item()
if next_token_idx == tgt_vocab['<eos>']:
break
pred_seq.append(next_token_idx)
由于tgt_seq的形状为(1,seq_len),所以需要通过squeeze()函数进行降维,转化成(seq_len,)的形状并转换成list
tgt_seq = tgt_seq.squeeze().tolist()
最后,再通过vocab列表进行idx查找的,生成出最后的预测句子
pred_seq = tgt_vocab[pred_seq[1:]]
# 因为tgt_seq的形状为(1, seq_len),我们需要将其转化成(seq_len, )的形状
if tgt_vocab['<eos>'] in tgt_seq:
eos_idx = tgt_seq.index(tgt_vocab['<eos>'])
tgt_seq = tgt_vocab[tgt_seq[:eos_idx]]
else:
tgt_seq = tgt_vocab[tgt_seq]
def evaluate(test_loader, model):
bleu_scores = []
translation_results = []
model.eval()
# 因为batch_size为1,所以每次取出来都是单个句子
for src_seq , tgt_seq in test_loader:
encoder_inputs = src_seq.to(device)
h_n = model.encoder(encoder_inputs)
pred_seq = [tgt_vocab['<bos>']]
for _ in range(SEQ_LEN):
# 因为batch_size=1,且需要一步一步输出
decoder_inputs = torch.tensor(pred_seq[-1]).reshape(1,1).to(device)
pred, h_n = model.decoder(decoder_inputs,h_n)
# 取出预测张量中最大的值
next_token_idx = pred.squeeze().argmax().item()
if next_token_idx == tgt_vocab['<eos>']:
break
pred_seq.append(next_token_idx)
# 去掉开头的<bos>
pred_seq = tgt_vocab[pred_seq[1:]]
# 因为tgt_seq的形状为(1, seq_len),我们需要将其转化成(seq_len, )的形状
tgt_seq = tgt_seq.squeeze().tolist()
# 需要注意在<eos>之前截断
if tgt_vocab['<eos>'] in tgt_seq:
eos_idx = tgt_seq.index(tgt_vocab['<eos>'])
tgt_seq = tgt_vocab[tgt_seq[:eos_idx]]
else:
tgt_seq = tgt_vocab[tgt_seq]
translation_results.append((' '.join(tgt_seq), ' '.join(pred_seq)))
bleu_scores.append(bleu(tgt_seq, pred_seq, k=2))
return bleu_scores, translation_results
具体的测试代码如下:
net.load_state_dict(torch.load('seq2seq_params.pt'))
bleu_scores = evaluate(test_loader, net)
plt.bar(range(len(bleu_scores)), bleu_scores)
plt.ylabel('BLEU Score')
plt.show()
完整的代码
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader
import math
import string
import matplotlib.pyplot as plt
from tkinter import _flatten
from collections import Counter
file_origin = open('./fra.txt','r',encoding='utf-8')
lines = file_origin.readlines()
content = []
for line in lines:
line_new = "\t".join(line.strip().split('\t')[:-1])
content.append(line_new)
# print(content[:5])
#需要剔除的:\u200b、\xad,还有一些特殊空格:\u2009、\u202f 和 \xa0 需要替换为普通空格
def data_cleaning(contents):
for i in range(len(contents)):
# 删除无用字符
special_chars = ['\u200b','\xad','\u2009', '\u202f', '\xa0']
for j , char in enumerate(special_chars):
contents[i].replace(char,' ' if j > 1 else '')
# 全部换为小写
contents[i].lower()
contents[i] = ''.join([
' ' + char if j > 0 and char in ',.!?' and content[i][j - 1] != ' '
else char
for j, char in enumerate(contents[i]) ])
return contents
# cleaned_content = data_cleaning(content)
# for i in range(10):
# print(cleaned_content[i])
def tokenize(cleaned_content):
# 将目标词元和源词元分别进行存储
src_tokens , tgt_tokens = [], []
for line in cleaned_content:
pair = line.split('\t')
src_tokens.append(pair[0].split(' '))
tgt_tokens.append(pair[1].split(' '))
return src_tokens, tgt_tokens
src_tokens, tgt_tokens = tokenize(data_cleaning(content))
# print(src_tokens[:5])
# print(tgt_tokens[:5])
# 建立词表
class Vocab:
# 设定最低记录的频率
def __init__(self,tokens,min_freq=2):
self.tokens = tokens # 传入的tokens是二维列表
self.min_freq = min_freq
# 设置特殊词元
self.token2idx = {'<unk>':0, '<pad>':1,'<bos>':2,'<eos>': 3}
# 先用_flatten把数据压成一维的
# 再使用Counter计算每个token出现的频次
# 用sort对出现频次进行一个排序
self.token2idx.update({
token: idx + 4
for idx,(token,freq) in enumerate(
sorted(Counter(_flatten(self.tokens)).items(), key=lambda x: x[1], reverse=True))
if freq >= self.min_freq
})
self.idx2token = {idx:token for token,idx in self.token2idx.items()}
def __getitem__(self, tokens_or_indices):
# 单个索引情形
# isinstance用于判断
if isinstance(tokens_or_indices,(str,int)):
if isinstance(tokens_or_indices,str):
return self.token2idx.get(tokens_or_indices,0) #找不到就返回数字0
else:
return self.idx2token.get(tokens_or_indices,'<unk>') #找不到就返回<unk>标签
# 多个索引类型
elif isinstance(tokens_or_indices,(list,tuple)):
return [self.__getitem__(item) for item in tokens_or_indices]
else:
raise TypeError
def __len__(self):
return len(self.idx2token)
src_vocab, tgt_vocab = Vocab(src_tokens,min_freq=2), Vocab(tgt_tokens,min_freq=2)
print(len(src_vocab))
# 11170
print(len(tgt_vocab))
# print( torch.tensor([tgt_vocab['<bos>']] * 101))
# print( torch.tensor([tgt_vocab['<bos>']] * 101).size())
# # print( torch.tensor([tgt_vocab['<bos>']] * 101).reshape(-1,1))
# print( torch.tensor([tgt_vocab['<bos>']] * 101).reshape(-1,1).size())
# 19565
print(src_vocab.token2idx) # 仅展示前10行
# 数据加载
# 送给 nn.Embedding 层的数据通常是词元在词表中的索引,并且是批量送入的,形状为 (batch_size, seq_len)
# 而 src_tokens 中的数据都是以词元的形式存在并且句子不等长,因此我们需要做些处理以让其能够批量加载。
# 将词元转化为索引非常简单,这里我们需要关注的是如何让句子等长。通常是设定一个长度,超过这个长度的句子进行截断,不到这个长度的句子用 <pad> 进行填充。
def truncate_pad(line, seq_len):
# 该函数针对单个句子进行处理
# 传入的句子是词元形式
if len(line) > seq_len:
return line[:seq_len]
else:
return line + ['<pad>'] * (seq_len - len(line))
# sentence = src_tokens[2000]
# print(sentence)
# # ['i', 'made', 'tea', '.']
# print(truncate_pad(sentence, 10))
# # ['i', 'made', 'tea', '.', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>']
# print(truncate_pad(sentence, 2))
# # ['i', 'made']
# 接下来
# 我们需要在 src_tokens 和 tgt_tokens 中的所有句子的末尾添加 <eos> 以代表句子的结束
# 然后再将它们处理成等长的形式,之后将其中的词元转化为其在词表中的索引
# 最后以张量的形式返回。
def build_data(tokens, vocab, seq_len):
return torch.tensor([vocab[truncate_pad(line + ['<eos>'],seq_len)] for line in tokens])
# 参数设置
TRAIN_SIZE = 190000
TEST_SIZE = 4000
BATCH_SIZE = 4
SEQ_LEN = 45
# 将tokens转化成张量
src_data, tgt_data = build_data(src_tokens, src_vocab, SEQ_LEN), build_data(tgt_tokens, tgt_vocab, SEQ_LEN)
# 打乱数据以方便分割
indices = torch.randperm(len(src_data)) # 这样能够保证打乱后,句子是一一对应的关系
src_data, tgt_data = src_data[indices], tgt_data[indices]
# 划分出训练集和测试集(总数据量为194513)
src_train_data, src_test_data = src_data[:TRAIN_SIZE], src_data[-TEST_SIZE:]
tgt_train_data, tgt_test_data = tgt_data[:TRAIN_SIZE], tgt_data[-TEST_SIZE:]
train_data = TensorDataset(src_train_data, tgt_train_data)
test_data = TensorDataset(src_test_data, tgt_test_data)
# 设置DataLoader
train_loader = DataLoader(train_data, batch_size=BATCH_SIZE, shuffle=True)
test_loader = DataLoader(test_data, batch_size=1)
class Seq2SeqEncoder(nn.Module):
def __init__(self,vocab_size,emb_size, hidden_size,num_layers,dropout=0):
super().__init__()
# 首先我们需要为 embedding 层指定 padding_idx,这样 <pad> 词元不会对梯度有任何贡献。
# 为什么是1=>因为 "<pad>:1" 所以设置为1
self.embedding = nn.Embedding(vocab_size,emb_size,padding_idx=1)
self.rnn = nn.GRU(emb_size,hidden_size,num_layers=num_layers,dropout=dropout)
def forward(self, encoder_inputs):
# encoder_inputs的初始大小为(batch_size, seq_len)
# 经过embedding之后就变成了(batch_size, seq_len, emb_size)
# 最后还要变成(seq_len, batch_size, emb_size)
# 变换一下位置
# 具体的原因:
# 对于这个input tensor,GRU是这样的定义的
# 如果输入的tensor只有两个维度: (sequence_length, input_size)
# 如果输入的tensor有三个维度: (sequence_length, batch_size, input_size)
encoder_inputs = self.embedding(encoder_inputs).permute(1,0,2)
output, h_n = self.rnn(encoder_inputs)
# 这里的h_n是隐藏层的状态
# 形状为(num_layers,batch_size,hidden_size)
# 最后一个时刻最后一个隐层的输出的隐状态即为上下文向量,即h_n[-1],其形状为 (batch_size, hidden_size
return h_n
class Seq2SeqDecoder(nn.Module):
def __init__(self,vocab_size,emb_size, hidden_size,num_layers,dropout=0):
super().__init__()
self.embedding = nn.Embedding(vocab_size,emb_size,padding_idx=1)
# 之所以是emb_size + hidden_size是因为我们在每个时间步需要将当前的输入和编码器输出的上下文向量拼在一起
self.rnn = nn.GRU(emb_size + hidden_size, hidden_size,num_layers=num_layers,dropout=dropout)
self.fc = nn.Linear(hidden_size,vocab_size)
def forward(self, decoder_inputs, encoder_states):
# decoder_inputs为目标序列偏移一位的结果
# decoder_inputs的初始形状(batch_size,seq_len)
# decoder_inputs (batch_size, seq_len) -> (batch_size, seq_len, embed_size) -> (seq_len, batch_size, embed_size)
decoder_inputs = self.embedding(decoder_inputs).permute(1,0,2)
# 这里的seq_len是45,因为MAX_LEN为45,所以维度都被扩充到了45
# 可以理解为每一个词元都被分析成(batch_size,embed_size)个数据
print(decoder_inputs.size())
print(decoder_inputs[0].size())
print(decoder_inputs)
# encoder_states 为最后一刻所有隐藏层的状态
# 但最后一个layer的输出才是我们要的目标状态
context = encoder_states[-1]
# context的形状为(batch_size,hidden_size),需要复制成(seq_len,batch_size,hidden_size)
context = context.repeat(decoder_inputs.shape[0],1,1)
# 这里就变成了(seq_len,batch_size,embed_size+ hidden_size)
# 只在最后一个维度添加
output, h_n = self.rnn(torch.cat((decoder_inputs,context),-1), encoder_states)
# output最后出来的结果是(seq_len,batch_size,hidden_size)
# logits 的形状为 (seq_len, batch_size, vocab_size)
logits = self.fc(output)
return logits, h_n
class Seq2SeqModel(nn.Module):
def __init__(self,encoder,decoder):
super().__init__()
self.encoder = encoder
self.decoder = decoder
def forward(self, encoder_inputs, decoder_inputs):
return self.decoder(decoder_inputs,self.encoder(encoder_inputs))
LR = 0.001
EPOCHS = 50
device = 'cuda' if torch.cuda.is_available() else 'cpu'
encoder = Seq2SeqEncoder(len(src_vocab), len(src_vocab), 256, num_layers=2, dropout=0.1)
decoder = Seq2SeqDecoder(len(tgt_vocab), len(tgt_vocab), 256, num_layers=2, dropout=0.1)
net = Seq2SeqModel(encoder, decoder)
net.to(device)
# 注意需要指定ignore_index
# index = 1 是为了去除掉<pad>的干扰
criterion = nn.CrossEntropyLoss(reduction='none', ignore_index=1)
optimizer = torch.optim.Adam(net.parameters(), lr=LR)
def train(train_loader, model, criterion, optimizer, num_epochs):
train_loss = []
model.train()
for epoch in range(num_epochs):
for batch_idx, (encoder_inputs,decoder_targets) in enumerate(train_loader):
encoder_inputs, decoder_targets = encoder_inputs.to(device), decoder_targets.to(device)
# 偏移一位作为decoder的输入
bos_column = torch.tensor([tgt_vocab['<bos>']] * decoder_targets.shape[0]).reshape(-1,1).to(device)
decoder_inputs = torch.cat((bos_column,decoder_targets[:,:-1]),dim=1)
# pred的形状为 (seq_len, batch_size, vocab_size)
pred, _ = model(encoder_inputs, decoder_inputs)
# decoder_targets 的形状为 (batch_size, seq_len),我们需要改变pred的形状以保证它能够正确输入
# all_loss 的形状为 (batch_size, seq_len),其中的每个元素都代表了一个词元的损失
all_loss = criterion(pred.permute(1, 2, 0), decoder_targets)
# 每个序列的损失是其所有词元的损失的平均,每个batch的损失是其所有序列的损失的平均
# 因此等价于每个batch里所有词元的损失的平均
loss = all_loss.mean()
# 反向传播
optimizer.zero_grad() # 将梯度初始化为0
loss.backward() # 反向传播求梯度
optimizer.step() # 更新所以参数
train_loss.append(loss.item())
# 每隔50个batch输出一次
if (batch_idx + 1) % 50 == 0:
print(
f'[Epoch {epoch + 1}] [{(batch_idx + 1) * len(encoder_inputs)}/{len(train_loader.dataset)}] loss: {loss:.4f}'
)
print()
return train_loss
train_loss = train(train_loader, net, criterion, optimizer, EPOCHS)
torch.save(net.state_dict(), 'seq2seq_params.pt')
plt.plot(train_loss)
plt.ylabel('train loss')
plt.show()
def evaluate(test_loader, model):
bleu_scores = []
translation_results = []
model.eval()
# 因为batch_size为1,所以每次取出来都是单个句子
for src_seq , tgt_seq in test_loader:
encoder_inputs = src_seq.to(device)
h_n = model.encoder(encoder_inputs)
pred_seq = [tgt_vocab['<bos>']]
for _ in range(SEQ_LEN):
# 因为batch_size=1,且需要一步一步输出
decoder_inputs = torch.tensor(pred_seq[-1]).reshape(1,1).to(device)
pred, h_n = model.decoder(decoder_inputs,h_n)
# 取出预测张量中最大的值
next_token_idx = pred.squeeze().argmax().item()
if next_token_idx == tgt_vocab['<eos>']:
break
pred_seq.append(next_token_idx)
# 去掉开头的<bos>
pred_seq = tgt_vocab[pred_seq[1:]]
# 因为tgt_seq的形状为(1, seq_len),我们需要将其转化成(seq_len, )的形状
tgt_seq = tgt_seq.squeeze().tolist()
# 需要注意在<eos>之前截断
if tgt_vocab['<eos>'] in tgt_seq:
eos_idx = tgt_seq.index(tgt_vocab['<eos>'])
tgt_seq = tgt_vocab[tgt_seq[:eos_idx]]
else:
tgt_seq = tgt_vocab[tgt_seq]
translation_results.append((' '.join(tgt_seq), ' '.join(pred_seq)))
bleu_scores.append(bleu(tgt_seq, pred_seq, k=2))
return bleu_scores, translation_results
net.load_state_dict(torch.load('seq2seq_params.pt'))
bleu_scores = evaluate(test_loader, net)
plt.bar(range(len(bleu_scores)), bleu_scores)
plt.ylabel('BLEU Score')
plt.show()
def bleu(label, pred, k=4):
score = math.exp(min(0, 1 - len(label) / len(pred)))
for n in range(1, k + 1):
hashtable = Counter([' '.join(label[i:i + n]) for i in range(len(label) - n + 1)])
num_matches = 0
for i in range(len(pred) - n + 1):
ngram = ' '.join(pred[i:i + n])
if ngram in hashtable and hashtable[ngram] > 0:
num_matches += 1
hashtable[ngram] -= 1
score *= math.pow(num_matches / (len(pred) - n + 1), math.pow(0.5, n))
return score