Модели «последовательность к последовательности», также называемые моделями «кодер-декодер», представляют собой семейство моделей, в которых обычно обучаются две рекуррентные нейронные сети. Первая RNN, кодер, обучается получать входной текст и последовательно кодировать его. Вторая RNN, декодер, получает закодированную последовательность и выполняет преобразование текста. Этот уникальный метод совместного обучения двух RNN был представлен Чо и др. в https://arxiv.org/pdf/1406.1078v3.pdfand и мгновенно завоевал популярность в задачах NLP, где вход и выход — это пары явных текстов, таких как перевод и резюмирование.
В следующем руководстве мы рассмотрим, как создавать и обучать модели Seq2Seq в PyTorch для англо-немецкого перевода.
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
import matplotlib.pyplot as plt
Мы используем набор данных Multi30k, популярный набор данных для переводов с и на многие языки. Для наших целей мы используем набор данных переводов с английского на немецкий:
Перед работой с PyTorch обязательно настройте устройство. Код ниже выбирает GPU, если он доступен.
In [5]:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
device
Out[5]:
device(type='cuda')
Данные находятся в файлах txt, поэтому мы используем стандартный метод Python open.
In [6]:
with open(train_path_en) as en_raw_train:
en_parsed_train = en_raw_train.readlines()
with open(train_path_de) as de_raw_train:
de_parsed_train = de_raw_train.readlines()
with open(test_path_en) as en_raw_test:
en_parsed_test = en_raw_test.readlines()
with open(test_path_de) as de_raw_test:
de_parsed_test = de_raw_test.readlines()
Количество экземпляров в наших тренировочном и тестовом наборах совпадает с указанным в репозитории Github набора данных.
Ниже мы приводим 5 англо-немецких примеров. Данные предварительно обработаны и полутокинизированы (достаточно разделения пробелами).
In [8]:
for i in range(5):
print("English: {} \n German: {} \n".format(en_parsed_train[i].strip(), de_parsed_train[i].strip()))
English: two young , white males are outside near many bushes .
German: zwei junge weiße männer sind im freien in der nähe vieler büsche .
English: several men in hard hats are operating a giant pulley system .
German: mehrere männer mit schutzhelmen bedienen ein antriebsradsystem .
English: a little girl climbing into a wooden playhouse .
German: ein kleines mädchen klettert in ein spielhaus aus holz .
English: a man in a blue shirt is standing on a ladder cleaning a window .
German: ein mann in einem blauen hemd steht auf einer leiter und putzt ein fenster .
English: two men are at the stove preparing food .
German: zwei männer stehen am herd und bereiten essen zu .
Токенизация
Создание токенизированной версии для всех наборов путем разбивки каждого предложения:
In [9]:
en_train = [sent.strip().split(" ") for sent in en_parsed_train]
en_test = [sent.strip().split(" ") for sent in en_parsed_test]
de_train = [sent.strip().split(" ") for sent in de_parsed_train]
de_test = [sent.strip().split(" ") for sent in de_parsed_test]
Поскольку в этом уроке мы используем два языка, мы создадим два отдельных словаря:
In [10]:
en_index2word = ["<PAD>", "<SOS>", "<EOS>"]
de_index2word = ["<PAD>", "<SOS>", "<EOS>"]
for ds in [en_train, en_test]:
for sent in ds:
for token in sent:
if token not in en_index2word:
en_index2word.append(token)
for ds in [de_train, de_test]:
for sent in ds:
for token in sent:
if token not in de_index2word:
de_index2word.append(token)
Использование словарей index2word для создания обратных отображений (word2index):
In [11]:
en_word2index = {token: idx for idx, token in enumerate(en_index2word)}
de_word2index = {token: idx for idx, token in enumerate(de_index2word)}
Убедитесь, что сопоставления выполнены правильно для обоих словарей:
In [12]:
en_index2word[20]
Out[12]:
'a'
In [13]:
en_word2index["a"]
Out[13]:
20
In [14]:
de_index2word[20]
Out[14]:
'ein'
In [15]:
de_word2index["ein"]
Out[15]:
20
В отличие от работы с твитами, мы не можем просто предположить конкретную максимальную длину последовательности. Чтобы получить точную оценку, мы вычислили среднюю длину обоих языков в обучающих наборах.
In [16]:
en_lengths = sum([len(sent) for sent in en_train])/len(en_train)
de_lengths = sum([len(sent) for sent in de_train])/len(de_train)
In [17]:
en_lengths
Out[17]:
13.018448275862069
In [18]:
de_lengths
Out[18]:
12.438137931034483
Средняя длина экземпляров на английском языке составляет ~13 слов, а на немецком ~12 слов. Мы можем предположить, что длина большинства экземпляров не превышает 20 слов, и использовать это значение в качестве верхней границы для заполнения и усечения.
In [231]:
seq_length = 20
In [232]:
def encode_and_pad(vocab, sent, max_length):
sos = [vocab["<SOS>"]]
eos = [vocab["<EOS>"]]
pad = [vocab["<PAD>"]]
if len(sent) < max_length - 2: # -2 for SOS and EOS
n_pads = max_length - 2 - len(sent)
encoded = [vocab[w] for w in sent]
return sos + encoded + eos + pad * n_pads
else: # sent is longer than max_length; truncating
encoded = [vocab[w] for w in sent]
truncated = encoded[:max_length - 2]
return sos + truncated + eos
Создание токенизированных наборов фиксированного размера:
In [233]:
en_train_encoded = [encode_and_pad(en_word2index, sent, seq_length) for sent in en_train]
en_test_encoded = [encode_and_pad(en_word2index, sent, seq_length) for sent in en_test]
de_train_encoded = [encode_and_pad(de_word2index, sent, seq_length) for sent in de_train]
de_test_encoded = [encode_and_pad(de_word2index, sent, seq_length) for sent in de_test]
Наконец, для подготовки данных мы создаем необходимые PyTorch Datasets и DataLoaders:
Gated Recurrent Unit (GRU) — это RNN, которая более эффективна, чем LSTM, в обращении с памятью и имеет очень похожую производительность. Мы используем GRU в качестве базовой модели как для кодера, так и для декодера.
In [235]:
class EncoderRNN(nn.Module):
def __init__(self, input_size, hidden_size):
super(EncoderRNN, self).__init__()
self.hidden_size = hidden_size
# Embedding layer
self.embedding = nn.Embedding(input_size, hidden_size, padding_idx=0)
# GRU layer. The input and output are both of the same size
# since embedding size = hidden size in this example
self.gru = nn.GRU(hidden_size, hidden_size, batch_first=True)
def forward(self, input, hidden):
# The inputs are first transformed into embeddings
embedded = self.embedding(input)
output = embedded
# As in any RNN, the new input and the previous hidden states are fed
# into the model at each time step
output, hidden = self.gru(output, hidden)
return output, hidden
def initHidden(self):
# This method is used to create the innitial hidden states for the encoder
return torch.zeros(1, batch_size, self.hidden_size)
Декодер GRU
In [236]:
class DecoderRNN(nn.Module):
def __init__(self, hidden_size, output_size):
super(DecoderRNN, self).__init__()
self.hidden_size = hidden_size
# Embedding layer
self.embedding = nn.Embedding(output_size, hidden_size, padding_idx=0)
# The GRU layer
self.gru = nn.GRU(hidden_size, hidden_size)
# Fully-connected layer for scores
self.out = nn.Linear(hidden_size, output_size)
# Applying Softmax to the scores
self.softmax = nn.LogSoftmax(dim=1)
def forward(self, input, hidden):
# Feeding input through embedding layer
output = self.embedding(input)
# Applying an activation function (ReLu)
output = F.relu(output)
# Feeding input and previous hidden state
output, hidden = self.gru(output, hidden)
# Outputting scores from the final time-step
output = self.softmax(self.out(output[0]))
return output, hidden
# We do not need an .initHidden() method for the decoder since the
# encoder output will act as input in the first decoder time-step
Настройка и обучение
In [237]:
hidden_size = 128
Инициализация кодера и декодера и отправка на устройство.
При обучении моделей Seq2Seq требуется 2 оптимизатора, один для кодера и один для декодера. Они обучаются одновременно с каждой партией.
In [241]:
criterion = nn.CrossEntropyLoss()
enc_optimizer = torch.optim.Adam(encoder.parameters(), lr = 3e-3)
dec_optimizer = torch.optim.Adam(decoder.parameters(), lr = 3e-3)
In [242]:
losses = []
In [243]:
input_length = target_length = seq_length
SOS = en_word2index["<SOS>"]
EOS = en_word2index["<EOS>"]
epochs = 15
for epoch in range(epochs):
for idx, batch in enumerate(train_dl):
# Creating initial hidden states for the encoder
encoder_hidden = encoder.initHidden()
# Sending to device
encoder_hidden = encoder_hidden.to(device)
# Assigning the input and sending to device
input_tensor = batch[0].to(device)
# Assigning the output and sending to device
target_tensor = batch[1].to(device)
# Clearing gradients
enc_optimizer.zero_grad()
dec_optimizer.zero_grad()
# Enabling gradient calculation
with torch.set_grad_enabled(True):
# Feeding batch into encoder
encoder_output, encoder_hidden = encoder(input_tensor, encoder_hidden)
# This is a placeholder tensor for decoder outputs. We send it to device as well
dec_result = torch.zeros(target_length, batch_size, len(de_index2word)).to(device)
# Creating a batch of SOS tokens which will all be fed to the decoder
decoder_input = target_tensor[:, 0].unsqueeze(dim=0).to(device)
# Creating initial hidden states of the decoder by copying encoder hidden states
decoder_hidden = encoder_hidden
# For each time-step in decoding:
for i in range(1, target_length):
# Feed input and previous hidden states
decoder_output, decoder_hidden = decoder(decoder_input, decoder_hidden)
# Finding the best scoring word
best = decoder_output.argmax(1)
# Assigning next input as current best word
decoder_input = best.unsqueeze(dim=0)
# Creating an entry in the placeholder output tensor
dec_result[i] = decoder_output
# Creating scores and targets for loss calculation
scores = dec_result.transpose(1, 0)[1:].reshape(-1, dec_result.shape[2])
targets = target_tensor[1:].reshape(-1)
# Calculating loss
loss = criterion(scores, targets)
# Performing backprop and clipping excess gradients
loss.backward()
torch.nn.utils.clip_grad_norm_(encoder.parameters(), max_norm=1)
torch.nn.utils.clip_grad_norm_(decoder.parameters(), max_norm=1)
enc_optimizer.step()
dec_optimizer.step()
# Keeping track of loss
losses.append(loss.item())
if idx % 100 == 0:
print(idx, sum(losses)/len(losses))
Мы видим, что потери постоянно уменьшаются по мере обучения, что означает, что модель правильно обучается задаче.
In [244]:
plt.plot(losses)
Out[244]:
[<matplotlib.lines.Line2D at 0x7f7b54873790>]
Тестирование на примере предложения:
In [252]:
test_sentence = "the men are walking in the streets ."
# Tokenizing, Encoding, transforming to Tensor
test_sentence = torch.tensor(encode_and_pad(en_word2index, test_sentence.split(), seq_length)).unsqueeze(dim=0)
Вы можете использовать Google Translate для проверки перевода, если вы не знаете немецкого языка. Кроме того, экспериментируйте с различными примерами предложений, чтобы проверить поведение модели в разных ситуациях.
В этом руководстве мы использовали популярный набор данных переводов (Multi30k) для обучения модели GRU Seq2Seq для задачи перевода. Затухающие потери показали четкую кривую обучения, а финальный тест показал точные переводы.