文章目錄
前言
這是對torch官網seq2seq教程的翻譯和筆記,記錄下來方便以後查看。
正文
這是關於“從頭開始處理NLP”的第三個也是最後一個教程,其中我們編寫了自己的類和函數來預處理數據,以完成我們的NLP建模任務。我們希望在您完成本教程之後,您能在緊隨本教程之後的三個教程中繼續學習torchtext如何處理大部分的預處理。
在這個項目中,我們將教一個神經網絡從法語翻譯成英語。
[KEY: > input, = target, < output]
> il est en train de peindre un tableau .
= he is painting a picture .
< he is painting a picture .
> pourquoi ne pas essayer ce vin delicieux ?
= why not try that delicious wine ?
< why not try that delicious wine ?
> elle n est pas poete mais romanciere .
= she is not a poet but a novelist .
< she not not a poet but a novelist .
> vous etes trop maigre .
= you re too skinny .
< you re all alone .
這是由簡單但強大的序列到序列網絡的思想實現的,兩個遞歸神經網絡一起工作,將一個序列轉換爲另一個序列。編碼器網絡將輸入序列壓縮成矢量,解碼網絡將矢量展開成新的序列。
在這個項目中,我們將教一個神經網絡從法語翻譯成英語。
爲了改進這個模型,我們將使用一種注意機制,它讓譯碼器學會在輸入序列的特定範圍內集中注意力。
Requirements
from __future__ import unicode_literals, print_function, division
from io import open
import unicodedata
import string
import re
import random
import torch
import torch.nn as nn
from torch import optim
import torch.nn.functional as F
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
這裏有些其實沒必要,比如第一行,另外本文沒有if __name__ == '__main__':
作爲程序入口,因此代碼還可能組織的更好。
Loading data files
這裏主要是介紹數據,這對後面自己調整模型做其他任務有很大幫助,因爲輸入的格式也比較重要。
這個項目的數據是一套成千上萬的英法翻譯對。
Open Data Stack Exchange上的這個問題指引我打開翻譯網站https://tatoeba.org/,可以在https://tatoeba.org/eng/downloads下載,更好的是,有人做了額外工作——將語言對分離進單個文本文件:https://www.manythings.org/anki/
英語到法語的對太大,無法包括在repo裏,所以在繼續之前先下載。該文件是一個以製表符分隔的翻譯對列表:data/eng-fra.txt
I am cold. J'ai froid.
與字符級RNN教程中使用的字符編碼類似,我們將把一種語言中的每個單詞表示爲獨熱編碼,換種方式說是除單個1(在單詞的索引處)之外的一個0的巨大向量。與一種語言中可能存在的幾十個字符相比,其中的單詞要多得多,因此編碼向量要大得多。然而,我們將稍微作弊一下,將數據修剪爲每種語言只使用幾千個單詞。
我們需要爲每個單詞建立一個唯一的索引,以便以後作爲網絡的輸入和目標。爲了跟蹤所有這些,我們將使用一個助手類,它有word→index()和index→word()字典,以及用於以後替換稀有單詞的每個單詞的計數。Lang word2index index2word word2count
SOS_token = 0
EOS_token = 1
class Lang:
def __init__(self, name):
self.name = name
self.word2index = {}
self.word2count = {}
self.index2word = {0: "SOS", 1: "EOS"}
self.n_words = 2 # Count SOS and EOS
def addSentence(self, sentence):
for word in sentence.split(' '):
self.addWord(word)
def addWord(self, word):
if word not in self.word2index:
self.word2index[word] = self.n_words
self.word2count[word] = 1
self.index2word[self.n_words] = word
self.n_words += 1
else:
self.word2count[word] += 1
這些文件都是Unicode格式的,爲了簡化,我們將把Unicode字符轉換爲ASCII,使所有字符都小寫,並去除大多數標點符號。
# Turn a Unicode string to plain ASCII, thanks to
# https://stackoverflow.com/a/518232/2809427
def unicodeToAscii(s):
return ''.join(
c for c in unicodedata.normalize('NFD', s)
if unicodedata.category(c) != 'Mn'
)
# Lowercase, trim, and remove non-letter characters
def normalizeString(s):
s = unicodeToAscii(s.lower().strip())
s = re.sub(r"([.!?])", r" \1", s)
s = re.sub(r"[^a-zA-Z.!?]+", r" ", s)
return s
爲了讀取數據文件,我們將文件分割成行,然後將行分割成對。所有的文件都是英語→其他語言,所以如果我們想從其他語言轉換爲英語,我添加了標誌來反轉對
def readLangs(lang1, lang2, reverse=False):
print("Reading lines...")
# Read the file and split into lines
lines = open('data/%s-%s.txt' % (lang1, lang2), encoding='utf-8').\
read().strip().split('\n')
# Split every line into pairs and normalize
pairs = [[normalizeString(s) for s in l.split('\t')] for l in lines]
# Reverse pairs, make Lang instances
if reverse:
pairs = [list(reversed(p)) for p in pairs]
input_lang = Lang(lang2)
output_lang = Lang(lang1)
else:
input_lang = Lang(lang1)
output_lang = Lang(lang2)
return input_lang, output_lang, pairs
因爲有很多例句,而我們想快速訓練一些東西,所以我們將把數據集精簡爲相對簡短的句子。這裏的最大長度是10個單詞(包括結束標點符號),我們將過濾到可以翻譯成“I am”或“He is”等形式的句子(考慮到之前替換的撇號)。
MAX_LENGTH = 10
eng_prefixes = (
"i am ", "i m ",
"he is", "he s ",
"she is", "she s ",
"you are", "you re ",
"we are", "we re ",
"they are", "they re "
)
def filterPair(p):
return len(p[0].split(' ')) < MAX_LENGTH and \
len(p[1].split(' ')) < MAX_LENGTH and \
p[1].startswith(eng_prefixes)
def filterPairs(pairs):
return [pair for pair in pairs if filterPair(pair)]
準備數據的整個過程是:
- 讀取文本文件並拆分爲行,將行拆分爲對
- 規範文本,過濾長度和內容
- 將句子成對地組成單詞列表
def prepareData(lang1, lang2, reverse=False):
input_lang, output_lang, pairs = readLangs(lang1, lang2, reverse)
print("Read %s sentence pairs" % len(pairs))
pairs = filterPairs(pairs)
print("Trimmed to %s sentence pairs" % len(pairs))
print("Counting words...")
for pair in pairs:
input_lang.addSentence(pair[0])
output_lang.addSentence(pair[1])
print("Counted words:")
print(input_lang.name, input_lang.n_words)
print(output_lang.name, output_lang.n_words)
return input_lang, output_lang, pairs
input_lang, output_lang, pairs = prepareData('eng', 'fra', True)
print(random.choice(pairs))
輸出是:
Reading lines...
Read 135842 sentence pairs
Trimmed to 10599 sentence pairs
Counting words...
Counted words:
fra 4345
eng 2803
['j en suis contente .', 'i m happy with that .']
可以看到 pair 其實是一個list。
The Seq2Seq Model
遞歸神經網絡(RNN)是一種對序列進行操作並將其自身的輸出作爲後續步驟的輸入的網絡。
序列到序列網絡,或seq2seq網絡,或編碼器解碼器網絡,是由兩個稱爲編碼器和解碼器的RNNs組成的模型。編碼器讀取輸入序列並輸出單個向量,譯碼器讀取該向量以產生輸出序列。
與單個RNN的序列預測不同,seq2seq模型將我們從序列長度和順序中解放出來,這使得它非常適合在兩種語言之間進行轉換。
想想這句話“Je ne suis pas le chat noir”(我不是黑貓)。輸入句中的大部分詞直接翻譯到輸出句中,但順序略有不同,如“chat noir”和“black cat”。由於“ne/pas”結構(法語結構),輸入句中也多了一個單詞。直接從輸入的單詞序列中產生正確的翻譯是很困難的。
使用seq2seq模型,編碼器創建一個單個向量,在理想情況下,它將輸入序列的“含義”編碼爲單個向量——句子的某個N維空間中的一個點。
The Encoder
seq2seq網絡的編碼器是一個RNN,它從輸入句子中爲每個單詞輸出一些值。對於每個輸入字,編碼器輸出一個向量和一個隱藏狀態,並對下一個輸入字使用隱藏狀態。
class EncoderRNN(nn.Module):
def __init__(self, input_size, hidden_size):
super(EncoderRNN, self).__init__()
self.hidden_size = hidden_size
self.embedding = nn.Embedding(input_size, hidden_size)
self.gru = nn.GRU(hidden_size, hidden_size)
def forward(self, input, hidden):
embedded = self.embedding(input).view(1, 1, -1)
output = embedded
output, hidden = self.gru(output, hidden)
return output, hidden
def initHidden(self):
return torch.zeros(1, 1, self.hidden_size, device=device)
The Decoder
解碼器是另一個RNN,它接受編碼器的輸出向量並輸出一系列單詞來創建翻譯
Simple Decoder
class DecoderRNN(nn.Module):
def __init__(self, hidden_size, output_size):
super(DecoderRNN, self).__init__()
self.hidden_size = hidden_size
self.embedding = nn.Embedding(output_size, hidden_size)
self.gru = nn.GRU(hidden_size, hidden_size)
self.out = nn.Linear(hidden_size, output_size)
self.softmax = nn.LogSoftmax(dim=1)
def forward(self, input, hidden):
output = self.embedding(input).view(1, 1, -1)
output = F.relu(output)
output, hidden = self.gru(output, hidden)
output = self.softmax(self.out(output[0]))
return output, hidden
def initHidden(self):
return torch.zeros(1, 1, self.hidden_size, device=device)
我鼓勵你訓練並觀察這個模型的結果,但爲了節省空間,我們將直接走向金牌並引入注意機制。
Attention Decoder
如果只有上下文context向量在編碼器和解碼器之間傳遞,那麼這個向量就承擔了對整個句子進行編碼的負擔。
注意力允許解碼器網絡“聚焦”於編碼器輸出的不同部分,以處理解碼器自己輸出的每一步。首先,我們計算一組注意力權重。這些將與編碼器輸出向量相乘,以創建一個加權組合。結果(在代碼中調用)應該包含有關輸入序列的特定部分的信息,從而幫助解碼器選擇正確的輸出單詞.attn_applied
通過另一個前饋層,使用譯碼器的輸入和隱藏狀態作爲輸入,計算注意權值。因爲在訓練數據中有各種大小的句子,爲了實際創建和訓練這個層,我們必須選擇它可以應用的最大句子長度(輸入長度,對於編碼器輸出)。最大長度的句子將使用所有的注意力權重,而較短的句子將只使用前幾個注意力權重.attn
class AttnDecoderRNN(nn.Module):
def __init__(self, hidden_size, output_size, dropout_p=0.1, max_length=MAX_LENGTH):
super(AttnDecoderRNN, self).__init__()
self.hidden_size = hidden_size
self.output_size = output_size
self.dropout_p = dropout_p
self.max_length = max_length
self.embedding = nn.Embedding(self.output_size, self.hidden_size)
self.attn = nn.Linear(self.hidden_size * 2, self.max_length)
self.attn_combine = nn.Linear(self.hidden_size * 2, self.hidden_size)
self.dropout = nn.Dropout(self.dropout_p)
self.gru = nn.GRU(self.hidden_size, self.hidden_size)
self.out = nn.Linear(self.hidden_size, self.output_size)
def forward(self, input, hidden, encoder_outputs):
embedded = self.embedding(input).view(1, 1, -1)
embedded = self.dropout(embedded)
attn_weights = F.softmax(
self.attn(torch.cat((embedded[0], hidden[0]), 1)), dim=1)
attn_applied = torch.bmm(attn_weights.unsqueeze(0),
encoder_outputs.unsqueeze(0))
output = torch.cat((embedded[0], attn_applied[0]), 1)
output = self.attn_combine(output).unsqueeze(0)
output = F.relu(output)
output, hidden = self.gru(output, hidden)
output = F.log_softmax(self.out(output[0]), dim=1)
return output, hidden, attn_weights
def initHidden(self):
return torch.zeros(1, 1, self.hidden_size, device=device)
Training
Preparing Training Data
爲了訓練,對於每一對我們需要一個輸入張量(輸入句子中的單詞索引)和目標張量(目標句子中的單詞索引)。在創建這些向量時,我們將把EOS標記附加到兩個序列中。
def indexesFromSentence(lang, sentence):
return [lang.word2index[word] for word in sentence.split(' ')]
def tensorFromSentence(lang, sentence):
indexes = indexesFromSentence(lang, sentence)
indexes.append(EOS_token)
return torch.tensor(indexes, dtype=torch.long, device=device).view(-1, 1)
def tensorsFromPair(pair):
input_tensor = tensorFromSentence(input_lang, pair[0])
target_tensor = tensorFromSentence(output_lang, pair[1])
return (input_tensor, target_tensor)
Training the Model
爲了訓練,我們通過編碼器運行輸入語句,並跟蹤每一個輸出和最新的隱藏狀態。然後將作爲解碼器的第一個輸入,將編碼器的最後一個隱藏狀態作爲其第一個隱藏狀態。
“教師強迫”的概念是使用真正的目標輸出作爲下一個輸入,而不是使用解碼器的猜測作爲下一個輸入。使用教師強迫使其收斂更快,但當訓練好的網絡被利用時,可能會表現出不穩定性。
你可以觀察到教師強迫網絡的輸出與連貫閱讀語法但偏離正確的翻譯——直覺它已經學會表徵輸出語法和一旦老師告訴它最初幾個字可以理解含義,但卻沒有很好地學習瞭如何創建這個句子的翻譯。
因爲PyTorch的autograd給了我們自由,我們可以用一個簡單的if語句隨機選擇是否使用教師強制。更多地使用它。teacher_forcing_ratio
teacher_forcing_ratio = 0.5
def train(input_tensor, target_tensor, encoder, decoder, encoder_optimizer, decoder_optimizer, criterion, max_length=MAX_LENGTH):
encoder_hidden = encoder.initHidden()
encoder_optimizer.zero_grad()
decoder_optimizer.zero_grad()
input_length = input_tensor.size(0)
target_length = target_tensor.size(0)
encoder_outputs = torch.zeros(max_length, encoder.hidden_size, device=device)
loss = 0
for ei in range(input_length):
encoder_output, encoder_hidden = encoder(
input_tensor[ei], encoder_hidden)
encoder_outputs[ei] = encoder_output[0, 0]
decoder_input = torch.tensor([[SOS_token]], device=device)
decoder_hidden = encoder_hidden
use_teacher_forcing = True if random.random() < teacher_forcing_ratio else False
if use_teacher_forcing:
# Teacher forcing: Feed the target as the next input
for di in range(target_length):
decoder_output, decoder_hidden, decoder_attention = decoder(
decoder_input, decoder_hidden, encoder_outputs)
loss += criterion(decoder_output, target_tensor[di])
decoder_input = target_tensor[di] # Teacher forcing
else:
# Without teacher forcing: use its own predictions as the next input
for di in range(target_length):
decoder_output, decoder_hidden, decoder_attention = decoder(
decoder_input, decoder_hidden, encoder_outputs)
topv, topi = decoder_output.topk(1)
decoder_input = topi.squeeze().detach() # detach from history as input
loss += criterion(decoder_output, target_tensor[di])
if decoder_input.item() == EOS_token:
break
loss.backward()
encoder_optimizer.step()
decoder_optimizer.step()
return loss.item() / target_length
這是一個輔助函數,給定當前時間和進度%,用於打印經過的時間和估計剩餘時間。
import time
import math
def asMinutes(s):
m = math.floor(s / 60)
s -= m * 60
return '%dm %ds' % (m, s)
def timeSince(since, percent):
now = time.time()
s = now - since
es = s / (percent)
rs = es - s
return '%s (- %s)' % (asMinutes(s), asMinutes(rs))
整個培訓過程是這樣的:
- 啓動一個計時器
- 初始化優化器和標準
- 創建一組訓練對
- 開始繪製空損失數組
然後多次調用,偶爾打印進度(示例的百分比、目前的時間、估計時間)和平均損失
def trainIters(encoder, decoder, n_iters, print_every=1000, plot_every=100, learning_rate=0.01):
start = time.time()
plot_losses = []
print_loss_total = 0 # Reset every print_every
plot_loss_total = 0 # Reset every plot_every
encoder_optimizer = optim.SGD(encoder.parameters(), lr=learning_rate)
decoder_optimizer = optim.SGD(decoder.parameters(), lr=learning_rate)
training_pairs = [tensorsFromPair(random.choice(pairs))
for i in range(n_iters)]
criterion = nn.NLLLoss()
for iter in range(1, n_iters + 1):
training_pair = training_pairs[iter - 1]
input_tensor = training_pair[0]
target_tensor = training_pair[1]
loss = train(input_tensor, target_tensor, encoder,
decoder, encoder_optimizer, decoder_optimizer, criterion)
print_loss_total += loss
plot_loss_total += loss
if iter % print_every == 0:
print_loss_avg = print_loss_total / print_every
print_loss_total = 0
print('%s (%d %d%%) %.4f' % (timeSince(start, iter / n_iters),
iter, iter / n_iters * 100, print_loss_avg))
if iter % plot_every == 0:
plot_loss_avg = plot_loss_total / plot_every
plot_losses.append(plot_loss_avg)
plot_loss_total = 0
showPlot(plot_losses)
Plotting results
使用訓練時保存的損失值數組matplotlib進行繪圖。plot_loss
import matplotlib.pyplot as plt
plt.switch_backend('agg')
import matplotlib.ticker as ticker
import numpy as np
def showPlot(points):
plt.figure()
fig, ax = plt.subplots()
# this locator puts ticks at regular intervals
loc = ticker.MultipleLocator(base=0.2)
ax.yaxis.set_major_locator(loc)
plt.plot(points)
Evaluation
評估基本上和訓練是一樣的,但是沒有目標,所以我們只是在每一步把解碼器的預測反饋給它自己。每當它預測到一個單詞時,我們就把它添加到輸出字符串中,如果它預測到了EOS標記,我們就停在那裏。我們還存儲解碼器的注意力輸出,以便稍後顯示。
def evaluate(encoder, decoder, sentence, max_length=MAX_LENGTH):
with torch.no_grad():
input_tensor = tensorFromSentence(input_lang, sentence)
input_length = input_tensor.size()[0]
encoder_hidden = encoder.initHidden()
encoder_outputs = torch.zeros(max_length, encoder.hidden_size, device=device)
for ei in range(input_length):
encoder_output, encoder_hidden = encoder(input_tensor[ei],
encoder_hidden)
encoder_outputs[ei] += encoder_output[0, 0]
decoder_input = torch.tensor([[SOS_token]], device=device) # SOS
decoder_hidden = encoder_hidden
decoded_words = []
decoder_attentions = torch.zeros(max_length, max_length)
for di in range(max_length):
decoder_output, decoder_hidden, decoder_attention = decoder(
decoder_input, decoder_hidden, encoder_outputs)
decoder_attentions[di] = decoder_attention.data
topv, topi = decoder_output.data.topk(1)
if topi.item() == EOS_token:
decoded_words.append('<EOS>')
break
else:
decoded_words.append(output_lang.index2word[topi.item()])
decoder_input = topi.squeeze().detach()
return decoded_words, decoder_attentions[:di + 1]
我們可以從訓練集中隨機評估句子,並打印出輸入、目標和輸出,做出一些主觀的質量判斷:
def evaluateRandomly(encoder, decoder, n=10):
for i in range(n):
pair = random.choice(pairs)
print('>', pair[0])
print('=', pair[1])
output_words, attentions = evaluate(encoder, decoder, pair[0])
output_sentence = ' '.join(output_words)
print('<', output_sentence)
print('')
Training and Evaluating
有了所有這些輔助函數(看起來是額外的工作,但它使運行多個實驗變得更容易),我們實際上可以初始化一個網絡並開始訓練。
記住,輸入的句子是經過嚴格過濾的。對於這個小數據集,我們可以使用相對較小的256個隱藏節點的網絡和單一的GRU層。在MacBook CPU上運行大約40分鐘後,我們將得到一些合理的結果。
hidden_size = 256
encoder1 = EncoderRNN(input_lang.n_words, hidden_size).to(device)
attn_decoder1 = AttnDecoderRNN(hidden_size, output_lang.n_words, dropout_p=0.1).to(device)
trainIters(encoder1, attn_decoder1, 75000, print_every=5000)