1.1.4 实战:日期转换
本小节我们将通过一个非常简单、直观的日期转换的例子,来更加深入地了解Seq2Seq结构的模型。我们需要实现的功能就是将中文的“年-月-日”格式的日期转换成英文的“day/month/year”(即DD/MM/YYYY)格式的日期,数据的区间是1950~2050年。当然,这个功能比较简单,直接通过一些规则就可以完成映射,为了增加难度,提供的中文日期采用“YY-MM-DD”的格式,即年份数字缺少前两位,模型需要推理转换的日期到底是20世纪还是21世纪。
1.加载数据
训练模型首先需要数据集,由于数据比较简单,可以直接通过规则进行创建。我们需要对每个数字、符号和单词创建一个唯一的索引,以便稍后用作编码器的输入转换和解码器的输出转换。我们可以通过两个字典word2index(word→index)和index2word(index→word)来实现,它们分别表示将词元转换为索引和将索引转换为词元。
python
class DateDataset(Dataset):
def __init__(self, n):
# 初始化两个空列表,用于存储中文和英文日期
self.date_cn=[]
self.date_en=[]
for _ in range(n):
#随机生成年、月和日
year=random.randint(1950, 2050)
month=random.randint(1, 12)
day=random.randint(1, 28) #假设最大为28日
date=datetime.date(year, month, day)
# 格式化日期并添加到对应的列表中
self.date_cn.append(date.strftime("%y-%m-%d"))
self.date_en.append(date.strftime("%d/%b/%Y"))
# 创建一个词汇集,包含0~9的数字、-、/和英文日期中的月份缩写
self.vocab=set([str(i) for i in range(0, 10)]+
["-", "/"]+[i.split("/")[1] for i in self.date_en])
# 创建一个词汇到索引的映射,其中<SOS>、<EOS>和<PAD>分别对应开始、结束和填充标记
self.word2index={v: i for i, v in enumerate(
sorted(list(self.vocab)), start=2)}
self.word2index["<SOS>"]=SOS_token
self.word2index["<EOS>"]=EOS_token
self.word2index["<PAD>"]=PAD_token
# 将开始、结束和填充标记添加到词汇集中
self.vocab.add("<SOS>")
self.vocab.add("<EOS>")
self.vocab.add("<PAD>")
# 创建一个索引到词汇的映射
self.index2word={i: v for v, i in self.word2index.items()}
# 初始化输入和目标列表
self.input, self.target=[], []
for cn, en in zip(self.date_cn, self.date_en):
# 将日期字符串转换为词汇索引列表,然后添加到输入和目标列表中
self.input.append([self.word2index[v] for v in cn])
self.target.append(
[self.word2index["<SOS>"], ]+
[self.word2index[v] for v in en[:3]]+
[self.word2index[en[3:6]]]+
[self.word2index[v] for v in en[6:]]+
[self.word2index["<EOS>"], ]
)
# 将输入和目标列表转换为NumPy数组
self.input, self.target=np.array(self.input), np.array(self.target)
def __len__(self):
# 返回数据集的长度,即输入的数量
return len(self.input)
def __getitem__(self, index):
# 返回给定索引的输入、目标和目标的长度
return self.input[index], self.target[index], len(self.target[index])
@property
def num_word(self):
# 返回词表的大小
return len(self.vocab)
这段代码定义了一个名为DateDataset的类,它是一个继承自torch.utils.data.Dataset的自定义数据集类。该数据集用于生成随机的日期数据,并进行数据预处理和编码。
在类的初始化方法中,首先定义了空的date_cn和date_en列表,分别用于存储“YY-MM-DD”类型的日期和“DD/MM/YYYY”类型的日期,然后根据指定的数据数量n生成n个随机的日期数据。每个日期数据都是随机生成的年、月、日,并使用strftime方法将其转换为指定的日期字符串格式并添加到date_cn和date_en列表中。
然后,根据生成的日期数据构建了一个词表(vocab),该词表包含所有在日期数据中出现的数字、分隔符(“-”和“/”)以及月份的缩写。之后,将词表中的每个词和对应的索引构建成字典(word2index和index2word),并添加特殊的标记词(<SOS>、<EOS>)和对应的索引值。
接下来,根据构建的字典,对date_cn和date_en中的每个日期数据进行编码处理。对于中文日期数据(date_cn),将每个字符根据字典转换为对应的索引值,并存储到input列表中。对于英文日期数据(date_en),首先转换为规定的格式,然后在前后拼接上<SOS>和<EOS>,并存储到target列表中。最后,将input和target转换为NumPy数组,并分别存储到self.input和self.target中。
该类还实现了__len__方法和__getitem__方法,它们分别用于返回数据集的长度和指定索引位置的数据样本。另外,该类还定义了一个名为num_word的属性方法,用于返回词表中的词汇数量。
我们可以通过以下语句大致了解该数据集,如图1-7所示。
图1-7 数据集样例示意
2.训练模型
数据准备完成后就可以开始训练模型了,如以下代码所示。
python
n_epochs=100
batch_size=32
MAX_LENGTH=11
hidden_size=128
learning_rate=0.001
dataloader=DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True)
encoder=EncoderRNN(dataset.num_word, hidden_size)
decoder=AttentionDecoderRNN(hidden_size, dataset.num_word)
encoder_optimizer=optim.Adam(encoder.parameters(), lr=learning_rate)
decoder_optimizer=optim.Adam(decoder.parameters(), lr=learning_rate)
criterion=nn.NLLLoss()
for i in range(n_epochs+1):
total_loss=0
for input_tensor, target_tensor, target_length in dataloader:
encoder_optimizer.zero_grad()
decoder_optimizer.zero_grad()
encoder_outputs, encoder_hidden=encoder(input_tensor)
decoder_outputs, _, _=decoder(encoder_outputs, encoder_hidden, target_tensor)
loss=criterion(
decoder_outputs.view(-1, decoder_outputs.size(-1)),
target_tensor.view(-1).long()
)
loss.backward()
encoder_optimizer.step()
decoder_optimizer.step()
total_loss+=loss.item()
total_loss/=len(dataloader)
if i % 10==0:
print(f"epoch: {i}, loss: {total_loss}")
首先,我们定义了一些超参数,包括训练的总轮数(n_epochs)、批量大小(batch_size)、最大序列长度(MAX_LENGTH)、隐藏层的维度(hidden_size)、学习率(learning_rate)等。接下来,通过DataLoader加载数据集(dataset),将数据集按照指定的批量大小进行划分,并打乱顺序(shuffle=True)。然后,创建了一个EncoderRNN对象和一个AttentionDecoderRNN对象,并分别使用Adam优化器对它们的参数进行优化。接着,定义了一个损失函数(nn.NLLLoss),用于计算模型输出和目标张量之间的负对数似然损失。
在训练循环中,使用一个外部循环控制训练轮数。在每一轮训练中,使用一个内部循环遍历数据集中的每个批量。首先,将优化器的梯度置为0,以避免累积梯度影响优化的效果。然后,将输入序列(input_tensor)输入Encoder模型,获取Encoder的输出和隐藏状态。再将Encoder的输出和隐藏状态以及目标序列(target_tensor)输入Decoder模型,获取Decoder的输出。计算模型输出和目标序列之间的损失,并执行反向传播和优化器参数更新的操作。累加每个批量的损失以得到总损失。
最后,在每一轮训练结束后,将总损失除以数据加载器(dataloader)中的批量数,得到平均损失。并且,每迭代10次输出一次当前轮数和平均损失。
3.评估
在开始评估模型之前,需要先将编码器和解码器模型设置为评估模式。评估过程与训练过程基本相同,但是没有目标输出,因此我们将解码器的预测结果反馈给自身进行下一步操作。每次预测出一个新的单词后,我们就将其添加到输出字符串中,如果预测到了EOS标记,就停止预测。
python
def evaluate(encoder, decoder, x):
encoder.eval()
decoder.eval()
encoder_outputs, encoder_hidden=encoder(th.tensor(np.array([x])))
start=th.ones(x.shape[0],1) # [n, 1]
start[:,0]=th.tensor(SOS_token).long()
decoder_outputs, _, _=decoder(encoder_outputs, encoder_hidden)
_, topi=decoder_outputs.topk(1)
decoded_ids=topi.squeeze()
decoded_words=[]
for idx in decoded_ids:
decoded_words.append(dataset.index2word[idx.item()])
return ''.join(decoded_words)
for i in range(5):
predict=evaluate(encoder, decoder, dataset[i][0])
print(f"input: {dataset.date_cn[i]}, target: {dataset.date_en[i]}, predict: {predict}")
在上述代码中,通过循环调用evaluate函数来进行预测,并输出预测结果。首先,令输入序列x通过Encoder模型,得到Encoder的输出和隐藏状态。接下来,创建一个初始输入start,其维度为[n, 1],并将其每个元素设置为SOS_token(起始标记)的索引。将Encoder的输出和隐藏状态以及start作为输入,通过Decoder模型得到Decoder的输出。
然后,从Decoder的输出中取出每个位置上的最大值索引(topi),构建出预测的序列(decoded_ids)。接着,根据词典(dataset.index2word)将每个索引转换为对应的词,并存储到decoded_words列表中。最后,将decoded_words中的词按顺序连接起来,得到最终的预测结果(predict)。
如图1-8所示,在主程序中,循环遍历5个数据样本。对于每个样本,调用evaluate函数进行预测,同时输出输入序列(dataset.date_cn[i])、目标序列(dataset.date_en[i])和预测结果(predict)。可以发现,模型不但能够正确地对日期进行转换,而且对于2011年11月28日,也没有将其错误地预测为1911年的日期。
图1-8 Seq2Seq的日期转换模型训练效果示意