本博客以《PyTorch自然语言处理入门与实战》第九章的Seq2seq模型处理英译中翻译任务作为基础,给出自己的理解及代码的详细注释。
在数据预处理中,用到了train.tags.zh-en.en和train.tags.zh-en.zh两个文件,第一个文件包含英译中任务的英文内容,第二个文件包含英译中任务的中文内容(数据集下载链接)。
两个文件来自IWSLT 2015数据集(提供了一些TED演讲的多种语言和英语之间的翻译)。
# step1: 从文件所包含的众多信息中筛选出演讲内容
fen = open('train.tags.zh-en.en', encoding='utf8') # 演讲稿的英文内容
fzh = open('train.tags.zh-en.zh', encoding='utf8') # 演讲稿的中文内容
en_zh = []
while True:
lz = fzh.readline() # 每次执行依次读取中文文件中的一行内容
le = fen.readline() # 每次执行依次读取英文文件中的一行内容
# 判断是否读完文件
if not lz:
assert not le # 如果读完,两个文件的结果都应该是空行 not lz == not le == True
break
lz, le = lz.strip(), le.strip() # 返回删除首尾空白字符(换行符也能删除)的字符串副本
# 筛选出需要的演讲内容部分
if lz.startswith('' ):
assert le.startswith('' )
lz = fzh.readline()
le = fen.readline()
# 关键词部分
assert lz.startswith('' )
assert le.startswith('' )
lz = fzh.readline()
le = fen.readline()
# 演讲人部分
assert lz.startswith('' )
assert le.startswith('' )
lz = fzh.readline()
le = fen.readline()
# 演讲 ID
assert lz.startswith('' )
assert le.startswith('' )
lz = fzh.readline()
le = fen.readline()
# 标题部分
assert lz.startswith('' )
assert le.startswith('' )
lz = fzh.readline()
le = fen.readline()
# 描述部分
assert lz.startswith('' )
assert le.startswith('' )
else: # 演讲内容部分
if not lz:
assert not le
break
# step2: 定位到演讲内容部分后,进行分词
# 对于中文内容, 我们把每个字当成一个词,因此list(lz)就实现分词
# 对于英文内容,我们把每个单词当成一个词,因此用空格字符“ ”进行分词
new_le = []
for w in le.split(' '): # 英文内容按照空格字符进行分词
# 按照空格进行分词后,某些单词后面会跟着标点符号 "." 和 “,”
w = w.replace('.', '').replace(',', '') # 去掉跟单词连着的标点符号
w = w.lower() # 统一单词大小写
if w:
new_le.append(w)
en_zh.append([new_le, list(lz)])
# step3 分别统计中英文内容数据中出现的词的数量
from tqdm import tqdm # 利用进度条直观展示处理进度
en_words = set() # 初始化集合对象 自动去重
zh_words = set()
for s in tqdm(en_zh):
for w in s[0]: # 统计英文
w = w.replace('.', '').replace(',', '').lower()
if w:
en_words.add(w)
for w in s[1]: # 统计中文
if w:
zh_words.add(w)
# step4 将集合对象转换为列表对象后,添加三个标识符'', '', ''
# sos ---> start of sentence 句子开头 索引:0
# eos ---> end of sentence 句子结尾 索引:1
# pad ---> 填充标识符 索引:2
en_wl = ['' , '' , '' ] + list(en_words)
zh_wl = ['' , '' , '' ] + list(zh_words)
pad_id = 2
# step5 利用字典对象存储词和索引的对应关系
en2id = {}
zh2id = {}
for i, w in enumerate(en_wl): # 遍历枚举类型对象实现此功能
en2id[w] = i
for i, w in enumerate(zh_wl):
zh2id[w] = i
使用80%数据作为训练集,20%数据作为测试集,代码如下:
import random
random.shuffle(en_zh) # 随机打乱全部数据
train_num = len(en_zh) * 0.8
train_set = en_zh[:train_num] # 8成用于训练
dev_set = en_zh[train_num:] # 2成用于测试
利用torch.utils.data.dataset
和torch.utils.data.DataLoader
创建训练集和测试集以及它们的数据加载器
import torch
batch_size = 16
data_workers = 0 # 子进程数
class MyDataSet(torch.utils.data.Dataset):
def __init__(self, examples):
self.examples = examples
def __len__(self):
return len(self.examples)
def __getitem__(self, index):
example = self.examples[index]
s1 = example[0]
s2 = example[1]
l1 = len(s1)
l2 = len(s2)
return s1, l1, s2, l2, index # 英文句子 英文句子长度 中文句子 中文句子长度 当前数据在数据集中的索引
# batch_size = 16 是全局变量
def the_collate_fn(batch):
src = [[0] * batch_size] # src ---> source 缩写 该任务中 源句子指的是英文句子 # 每个样本的开头都是0(起始标识符的编码)
tar = [[0] * batch_size] # tar ---> target 缩写 目标句子指的是中文句子
src_max_l = 0 # 初始化英文句子最大长度 方便计算需要填充的个数
for b in batch: # 每个batch的数据有五个信息 分别是: 英文句子 英文句子长度 中文句子 中文句子长度 当前数据在数据集中的索引
src_max_l = max(src_max_l, b[1]) # b[1] 即英文句子的长度
tar_max_l = 0
for b in batch:
tar_max_l = max(tar_max_l, b[3]) # b[3] 即中文句子的长度
for i in range(src_max_l):
l = []
for x in batch:
if i < x[1]:
l.append(en2id[x[0][i]])
else:
l.append(pad_id) # 如果句子长度小于最大句子长度,进行填充
src.append(l)
# l记录的是每个句子的第 i 个词 有多少个句子? batch size个,因此len(l) == batch_size == 句子的数量
# src记录的是每个 l 总共多少个l? src_max_l个,因此len(src) == src_max_l == 句子的最大长度
# len(src) == 句子的最大长度 len(src[0]) == 句子的数量
# [len(src), len(src[0])] ==> [src len, batch size]
for i in range(tar_max_l): # 注释参考上面
l = []
for x in batch:
if i < x[3]:
l.append(zh2id[x[2][i]])
else:
l.append(pad_id) # 如果句子长度小于最大句子长度,进行填充
tar.append(l)
indexs = [b[4] for b in batch] # b[4] 记录的是 当前数据在数据集中的索引
src.append([1] * batch_size) # 终止标识符的编码为1 所以src和tar在句子的最后把终止符加上
tar.append([1] * batch_size)
s1 = torch.LongTensor(src)
s2 = torch.LongTensor(tar)
return s1, s2, indexs
# 构建训练集
train_dataset = MyDataSet(train_set)
dev_dataset = MyDataSet(dev_set)
# 定义训练集数据加载器和验证集数据加载器
train_data_loader = torch.utils.data.DataLoader(
train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=data_workers,
collate_fn=the_collate_fn,
)
dev_data_loader = torch.utils.data.DataLoader(
dev_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=data_workers,
collate_fn=the_collate_fn,
)
代码如下:
import torch.nn as nn
class Encoder(nn.Module):
def __init__(self, input_dim, emb_dim, hid_dim, n_layers, dropout):
super().__init__()
self.hid_dim = hid_dim
self.n_layers = n_layers
self.embedding = nn.Embedding(input_dim, emb_dim) # 词嵌入
self.rnn = nn.LSTM(emb_dim, hid_dim, n_layers, dropout=dropout)
self.dropout = nn.Dropout(dropout)
def forward(self, src):
# src = (src len, batch size)
embedded = self.dropout(self.embedding(src))
# embedded = (src len, batch size, emb dim)
outputs, (hidden, cell) = self.rnn(embedded)
# outputs = (src len, batch size, hid dim * n directions)
# hidden = (n layers * n directions, batch size, hid dim)
# cell = (n layers * n directions, batch size, hid dim)
# rnn的输出总是来自顶部的隐藏层
return hidden, cell
class Decoder(nn.Module):
def __init__(self, output_dim, emb_dim, hid_dim, n_layers, dropout):
super().__init__()
self.output_dim = output_dim
self.hid_dim = hid_dim
self.n_layers = n_layers
self.embedding = nn.Embedding(output_dim, emb_dim)
self.rnn = nn.LSTM(emb_dim, hid_dim, n_layers, dropout=dropout)
self.fc_out = nn.Linear(hid_dim, output_dim)
self.dropout = nn.Dropout(dropout)
def forward(self, input, hidden, cell):
# 各输入的形状
# input = (batch size)
# hidden = (n layers * n directions, batch size, hid dim)
# cell = (n layers * n directions, batch size, hid dim)
# LSTM是单向的 ==> n directions == 1
# hidden = (n layers, batch size, hid dim)
# cell = (n layers, batch size, hid dim)
input = input.unsqueeze(0) # (batch size) --> [1, batch size)
embedded = self.dropout(self.embedding(input)) # (1, batch size, emb dim)
output, (hidden, cell) = self.rnn(embedded, (hidden, cell))
# LSTM理论上的输出形状
# output = (seq len, batch size, hid dim * n directions)
# hidden = (n layers * n directions, batch size, hid dim)
# cell = (n layers * n directions, batch size, hid dim)
# 解码器中的序列长度 seq len == 1
# 解码器的LSTM是单向的 n directions == 1 则实际上
# output = (1, batch size, hid dim)
# hidden = (n layers, batch size, hid dim)
# cell = (n layers, batch size, hid dim)
prediction = self.fc_out(output.squeeze(0))
# prediction = (batch size, output dim)
return prediction, hidden, cell
class Seq2Seq(nn.Module):
def __init__(self, input_word_count, output_word_count, encode_dim, decode_dim, hidden_dim, n_layers,
encode_dropout, decode_dropout, device):
"""
:param input_word_count: 英文词表的长度 34737
:param output_word_count: 中文词表的长度 4015
:param encode_dim: 编码器的词嵌入维度
:param decode_dim: 解码器的词嵌入维度
:param hidden_dim: LSTM的隐藏层维度
:param n_layers: 采用n层LSTM
:param encode_dropout: 编码器的dropout概率
:param decode_dropout: 编码器的dropout概率
:param device: cuda / cpu
"""
super().__init__()
self.encoder = Encoder(input_word_count, encode_dim, hidden_dim, n_layers, encode_dropout)
self.decoder = Decoder(output_word_count, decode_dim, hidden_dim, n_layers, decode_dropout)
self.device = device
def forward(self, src, trg, teacher_forcing_ratio=0.5):
# src = (src len, batch size)
# trg = (trg len, batch size)
# teacher_forcing_ratio 定义使用Teacher Forcing的比例
# 例如 if teacher_forcing_ratio is 0.75 we use ground-truth inputs 75% of the time
batch_size = trg.shape[1]
trg_len = trg.shape[0]
trg_vocab_size = self.decoder.output_dim # 实际上就是中文词表的长度
# 初始化保存解码器输出的Tensor
outputs = torch.zeros(trg_len, batch_size, trg_vocab_size).to(self.device)
# 编码器的隐藏层输出将作为i解码器的第一个隐藏层输入
hidden, cell = self.encoder(src)
# 解码器的第一个输入应该是起始标识符
input = trg[0, :] # 取trg的第“0”行所有列 “0”指的是索引
# 从the_collate_fn函数中可以看出trg的第“0”行全是0,也就是起始标识符对应的ID
for t in range(1, trg_len): # 从 trg的第"1"行开始遍历
# 解码器的输入包括:起始标识符的词嵌入input; 编码器输出的 hidden and cell states
# 解码器的输出包括:输出张量(predictions) and new hidden and cell states
output, hidden, cell = self.decoder(input, hidden, cell)
# 保存每次预测结果于outputs
# outputs (trg_len, batch_size, trg_vocab_size)
# output (batch size, trg_vocab_size)
outputs[t] = output
# 随机决定是否使用Teacher Forcing
teacher_force = random.random() < teacher_forcing_ratio
# output (batch size, trg_vocab_size) 沿dim=1取最大值索引
top1 = output.argmax(dim=1) # (batch size, )
# if teacher forcing, 以真实值作为下一个输入 否则 使用预测值
input = trg[t] if teacher_force else top1
return outputs
source_word_count = len(en_wl) # 英文词表的长度 34737
target_word_count = len(zh_wl) # 中文词表的长度 4015
encode_dim = 256 # 编码器的词嵌入维度
decode_dim = 256 # 解码器的词嵌入维度
hidden_dim = 512 # LSTM的隐藏层维度
n_layers = 2 # 采用n层LSTM
encode_dropout = 0.5 # 编码器的dropout概率
decode_dropout = 0.5 # 编码器的dropout概率
device = torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu') # GPU可用 用GPU
# Seq2Seq模型实例化
model = Seq2Seq(source_word_count, target_word_count, encode_dim, decode_dim, hidden_dim, n_layers, encode_dropout,
decode_dropout, device).to(device)
# 初始化模型参数
def init_weights(m):
for name, param in m.named_parameters():
nn.init.uniform_(param.data, -0.08, 0.08)
model.apply(init_weights)
# 统计Seq2Seq模型中可训练的参数个数
def count_parameters(model):
return sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f'The model has {count_parameters(model):,} trainable parameters')
import torch.optim as optim
# 定义优化器
optimizer = optim.Adam(model.parameters())
# 定义损失函数
criterion = nn.CrossEntropyLoss(ignore_index=pad_id).to(device) # 忽略填充标识符的索引
# 训练策略
def train(model, iterator, optimizer, criterion, clip):
model.train() # 切换到训练模式
epoch_loss = 0
for i, batch in enumerate(iterator):
src = batch[0].to(device)
trg = batch[1].to(device)
optimizer.zero_grad() # 梯度清零
output = model(src, trg) # 前向传播
# trg = [trg len, batch size]
# output = [trg len, batch size, output dim]
output_dim = output.shape[-1]
output = output[1:].view(-1, output_dim)
trg = trg[1:].view(-1)
# trg = [(trg len - 1) * batch size]
# output = [(trg len - 1) * batch size, output dim]
loss = criterion(output, trg) # 计算损失
loss.backward() # 反向传播
torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
optimizer.step() # 更新参数
epoch_loss += loss.item()
return epoch_loss / len(iterator)
# 验证策略
def evaluate(model, iterator, criterion):
model.eval() # 切换到验证模式
epoch_loss = 0
with torch.no_grad(): # 不计算梯度
for i, batch in enumerate(iterator):
src = batch[0].to(device)
trg = batch[1].to(device)
output = model(src, trg, teacher_forcing_ratio=0) # 验证时禁用Teacher Forcing
# trg = [trg len, batch size]
# output = [trg len, batch size, output dim]
output_dim = output.shape[-1]
output = output[1:].view(-1, output_dim)
trg = trg[1:].view(-1)
# trg = [(trg len - 1) * batch size]
# output = [(trg len - 1) * batch size, output dim]
loss = criterion(output, trg)
epoch_loss += loss.item()
return epoch_loss / len(iterator)
# 记录每个epoch的用时
def epoch_time(start_time, end_time):
elapsed_time = end_time - start_time
elapsed_mins = int(elapsed_time / 60)
elapsed_secs = int(elapsed_time - (elapsed_mins * 60))
return elapsed_mins, elapsed_secs
import math
import time
N_EPOCHS = 10 # 训练轮次
CLIP = 1
best_valid_loss = float('inf')
# 开始训练
for epoch in range(N_EPOCHS):
start_time = time.time()
train_loss = train(model, train_data_loader, optimizer, criterion, CLIP)
# 每训练一个轮次,测试一次
valid_loss = evaluate(model, dev_data_loader, criterion)
end_time = time.time()
epoch_mins, epoch_secs = epoch_time(start_time, end_time)
if valid_loss < best_valid_loss: # 保存最优模型(验证loss阶段性最低时)
best_valid_loss = valid_loss
torch.save(model.state_dict(), 'best_model.pth')
# 打印相关指标
print(f'Epoch: {epoch + 1:02} | Time: {epoch_mins}m {epoch_secs}s')
print(f'\tTrain Loss: {train_loss:.3f} | Train PPL: {math.exp(train_loss):7.3f}')
print(f'\t Val. Loss: {valid_loss:.3f} | Val. PPL: {math.exp(valid_loss):7.3f}')