「寫作沒有規定。有時它來得容易而且完美;有時就像在巖石上鑽孔,然後用炸藥把它炸開一樣。」—歐內斯特·海明威
本博客的目的是解釋如何通過實現基於LSTMs的強大體系結構來構建文本生成的端到端模型。
博客分為以下幾個部分:
介紹文本預處理序列生成模型體系結構訓練階段文本生成完整代碼請訪問:https://github.com/FernandoLpz/Text-Generation-BiLSTM-PyTorch
介紹
多年來,人們提出了各種各樣的建議來建模自然語言,但這是怎麼回事呢?「建模自然語言」指的是什麼?我們可以認為「建模自然語言」是指對構成語言的語義和語法進行推理,本質上是這樣,但它更進一步。
目前,自然語言處理(NLP)領域通過不同的方法和技術處理不同的任務,即對語言進行推理、理解和建模。
自然語言處理(NLP)領域在過去的十年裡發展非常迅速。許多模型都從不同的角度提出了解決不同NLP任務的方法。同樣,最受歡迎的模型中的共同點是實施基於深度學習的模型。
如前所述,NLP領域解決了大量的問題,特別是在本博客中,我們將通過使用基於深度學習的模型來解決文本生成問題,例如循環神經網絡LSTM和Bi-LSTM。同樣,我們將使用當今最複雜的框架之一來開發深度學習模型,特別是我們將使用PyTorch的LSTMCell類來開發。
問題陳述
給定一個文本,神經網絡將通過字符序列來學習給定文本的語義和句法。隨後,將隨機抽取一系列字符,並預測下一個字符。
文本預處理
首先,我們需要一個我們要處理的文本。有不同的資源可以在純文本中找到不同的文本,我建議你看看Gutenberg項目(https://www.gutenberg.org/).。
在這個例子中,我將使用George Bird Grinnell的《Jack Among the Indians》這本書,你可以在這裡找到:https://www.gutenberg.org/cache/epub/46205/pg46205.txt。所以,第一章的第一行是:
The train rushed down the hill, with a long shrieking whistle, and then began to go more and more slowly. Thomas had brushed Jack off and thanked him for the coin that he put in his hand, and with the bag in one hand and the stool in the other now went out onto the platform and down the steps, Jack closely following.如你所見,文本包含大寫、小寫、換行符、標點符號等。建議你將文本調整為一種形式,使我們能夠以更好的方式處理它,這主要降低我們將要開發的模型的複雜性。
我們要把每個字符轉換成它的小寫形式。另外,建議將文本作為一個字符列表來處理,也就是說,我們將使用一個字符列表,而不是使用「字符串」。將文本作為字符序列的目的是為了更好地處理生成的序列,這些序列將提供給模型(我們將在下一節中詳細介紹)。
代碼段1-預處理
def read_dataset(file): letters = ['a','b','c','d','e','f','g','h','i','j','k','l','m', 'n','o','p','q','r','s','t','u','v','w','x','y','z',' '] # 打開原始文件 with open(file, 'r') as f: raw_text = f.readlines() # 將每一行轉換為小寫 raw_text = [line.lower() for line in raw_text] # 創建一個包含整個文本的字符串 text_string = '' for line in raw_text: text_string += line.strip() # 。創建一個字符數組 text = list() for char in text_string: text.append(char) # 去掉所有的符號,只保留字母 text = [char for char in text if char in letters] return text如我們所見,在第2行我們定義了要使用的字符,所有其他符號都將被丟棄,我們只保留「空白」符號。
在第6行和第10行中,我們讀取原始文件並將其轉換為小寫形式。
在第14行和第19行的循環中,我們創建了一個代表整本書的字符串,並生成了一個字符列表。在第23行中,我們通過只保留第2行定義的字母來過濾文本列表。
因此,一旦文本被加載和預處理,例如:
text = "The train rushed down the hill."可以得到這樣的字符列表:
text = ['t','h','e',' ','t','r','a','i','n',' ','r','u','s','h','e','d',' ','d','o','w','n',' ','t','h','e',' ','h','i','l','l']我們已經有了全文作為字符列表。眾所周知,我們不能將原始字符直接引入神經網絡,我們需要一個數值表示,因此,我們需要將每個字符轉換成一個數值表示。為此,我們將創建一個字典來幫助我們保存等價的「字符索引」和「索引字符」。
代碼段2-字典創建
def create_dictionary(text): char_to_idx = dict() idx_to_char = dict() idx = 0 for char in text: if char not in char_to_idx.keys(): # 構建字典 char_to_idx[char] = idx idx_to_char[idx] = char idx += 1 return char_to_idx, idx_to_char我們可以注意到,在第11行和第12行創建了「char-index」和index-char」字典。
到目前為止,我們已經演示了如何加載文本並以字符列表的形式保存它,我們還創建了兩個字典來幫助我們對每個字符進行編碼和解碼。
序列生成
序列生成的方式完全取決於我們要實現的模型類型。如前所述,我們將使用LSTM類型的循環神經網絡,它按順序接收數據(時間步長)。
對於我們的模型,我們需要形成一個給定長度的序列,我們稱之為「窗口」,其中要預測的字符(目標)將是窗口旁邊的字符。每個序列將由窗口中包含的字符組成。要形成一個序列,窗口一次向右得到一個字符。要預測的字符始終是窗口後面的字符。我們可以在圖中清楚地看到這個過程。
在本例中,窗口的大小為4,這意味著它將包含4個字符。目標是作者在窗口圖像右邊的第一個字符
到目前為止,我們已經看到了如何以一種簡單的方式生成字符序列。現在我們需要將每個字符轉換為其各自的數字格式,為此,我們將使用預處理階段生成的字典。這個過程可以在下圖可視化。
很好,現在我們知道了如何使用一個一次滑動一個字符的窗口來生成字符序列,以及如何將字符轉換為數字格式,下面的代碼片段顯示了所描述的過程。
代碼段3-序列生成
def build_sequences(text, char_to_idx, window): x = list() y = list() for i in range(len(text)): try: # 從文本中獲取字符窗口 # 將其轉換為其idx表示 sequence = text[i:i+window] sequence = [char_to_idx[char] for char in sequence] #得到target # 轉換到它的idx表示 target = text[i+window] target = char_to_idx[target] # 保存sequence和target x.append(sequence) y.append(target) except: pass x = np.array(x) y = np.array(y) return x, y太棒了,現在我們知道如何預處理原始文本,如何將其轉換為字符列表,以及如何以數字格式生成序列。現在我們來看看最有趣的部分,模型架構。
模型架構
正如你已經在這篇博客的標題中讀到的,我們將使用Bi-LSTM循環神經網絡和標準LSTM。本質上,我們使用這種類型的神經網絡,因為它在處理順序數據時具有巨大的潛力,例如文本類型的數據。同樣,也有大量的文章提到使用基於循環神經網絡的體系結構(例如RNN、LSTM、GRU、Bi-LSTM等)進行文本建模,特別是文本生成[1,2]。
所提出的神經網絡結構由一個嵌入層、一個雙LSTM層和一個LSTM層組成。緊接著,後一個LSTM連接到一個線性層。
方法
該方法包括將每個字符序列傳遞到嵌入層,這將為構成序列的每個元素生成向量形式的表示,因此我們將形成一個嵌入字符序列。隨後,嵌入字符序列的每個元素將被傳遞到Bi-LSTM層。隨後,將生成構成雙LSTM(前向LSTM和後向LSTM)的LSTM的每個輸出的串聯。緊接著,每個前向+後向串聯的向量將被傳遞到LSTM層,最後一個隱藏狀態將從該層傳遞給線性層。最後一個線性層將有一個Softmax函數作為激活函數,以表示每個字符的概率。下圖顯示了所描述的方法。
到目前為止,我們已經解釋了文本生成模型的體系結構以及實現的方法。現在我們需要知道如何使用PyTorch框架來實現所有這些,但是首先,我想簡單地解釋一下bilstm和LSTM是如何協同工作的,以便稍後了解如何在代碼中實現這一點,那麼讓我們看看bilstm網絡是如何工作的。
Bi-LSTM和LSTM
標準LSTM和Bi-LSTM的關鍵區別在於Bi-LSTM由2個LSTM組成,通常稱為「正向LSTM」和「反向LSTM」。基本上,正向LSTM以原始順序接收序列,而反向LSTM接收序列。隨後,根據要執行的操作,兩個LSTMs的每個時間步的每個隱藏狀態都可以連接起來,或者只對兩個LSTMs的最後一個狀態進行操作。在所提出的模型中,我們建議在每個時間步加入兩個隱藏狀態。
很好,現在我們了解了Bi-LSTM和LSTM之間的關鍵區別。回到我們正在開發的示例中,下圖表示每個字符序列在通過模型時的演變。
太好了,一旦Bi-LSTM和LSTM之間的交互都很清楚,讓我們看看我們是如何在代碼中僅使用PyTorch框架中的LSTMcell來實現的。
那麼,首先讓我們了解一下如何構造TextGenerator類的構造函數,讓我們看看下面的代碼片段:
代碼段4-文本生成器類的構造函數
class TextGenerator(nn.ModuleList): def __init__(self, args, vocab_size): super(TextGenerator, self).__init__() self.batch_size = args.batch_size self.hidden_dim = args.hidden_dim self.input_size = vocab_size self.num_classes = vocab_size self.sequence_len = args.window # Dropout self.dropout = nn.Dropout(0.25) # Embedding 層 self.embedding = nn.Embedding(self.input_size, self.hidden_dim, padding_idx=0) # Bi-LSTM # 正向和反向 self.lstm_cell_forward = nn.LSTMCell(self.hidden_dim, self.hidden_dim) self.lstm_cell_backward = nn.LSTMCell(self.hidden_dim, self.hidden_dim) # LSTM 層 self.lstm_cell = nn.LSTMCell(self.hidden_dim * 2, self.hidden_dim * 2) # Linear 層 self.linear = nn.Linear(self.hidden_dim * 2, self.num_classes)如我們所見,從第6行到第10行,我們定義了用於初始化神經網絡每一層的參數。需要指出的是,input_size等於詞彙表的大小(也就是說,我們的字典在預處理過程中生成的元素的數量)。同樣,要預測的類的數量也與詞彙表的大小相同,序列長度表示窗口的大小。
另一方面,在第20行和第21行中,我們定義了組成Bi-LSTM的兩個LSTMCells(向前和向後)。在第24行中,我們定義了LSTMCell,它將與Bi-LSTM的輸出一起饋送。值得一提的是,隱藏狀態的大小是Bi-LSTM的兩倍,這是因為Bi-LSTM的輸出是串聯的。稍後在第27行定義線性層,稍後將由softmax函數過濾。
一旦定義了構造函數,我們需要為每個LSTM創建包含單元狀態和隱藏狀態的張量。因此,我們按如下方式進行:
代碼片段5-權重初始化
# Bi-LSTM# hs = [batch_size x hidden_size]# cs = [batch_size x hidden_size]hs_forward = torch.zeros(x.size(0), self.hidden_dim)cs_forward = torch.zeros(x.size(0), self.hidden_dim)hs_backward = torch.zeros(x.size(0), self.hidden_dim)cs_backward = torch.zeros(x.size(0), self.hidden_dim)# LSTM# hs = [batch_size x (hidden_size * 2)]# cs = [batch_size x (hidden_size * 2)]hs_lstm = torch.zeros(x.size(0), self.hidden_dim * 2)cs_lstm = torch.zeros(x.size(0), self.hidden_dim * 2)# 權重初始化torch.nn.init.kaiming_normal_(hs_forward)torch.nn.init.kaiming_normal_(cs_forward)torch.nn.init.kaiming_normal_(hs_backward)torch.nn.init.kaiming_normal_(cs_backward)torch.nn.init.kaiming_normal_(hs_lstm)torch.nn.init.kaiming_normal_(cs_lstm)一旦定義了包含隱藏狀態和單元狀態的張量,是時候展示整個體系結構的組裝是如何完成的.
首先,讓我們看一下下面的代碼片段:
代碼片段6-BiLSTM+LSTM+線性層
# 從 idx 到 embeddingout = self.embedding(x)# 為LSTM準備shapeout = out.view(self.sequence_len, x.size(0), -1)forward = []backward = []# 解開Bi-LSTM# 正向for i in range(self.sequence_len): hs_forward, cs_forward = self.lstm_cell_forward(out[i], (hs_forward, cs_forward)) hs_forward = self.dropout(hs_forward) cs_forward = self.dropout(cs_forward) forward.append(hs_forward) # 反向for i in reversed(range(self.sequence_len)): hs_backward, cs_backward = self.lstm_cell_backward(out[i], (hs_backward, cs_backward)) hs_backward = self.dropout(hs_backward) cs_backward = self.dropout(cs_backward) backward.append(hs_backward) # LSTMfor fwd, bwd in zip(forward, backward): input_tensor = torch.cat((fwd, bwd), 1) hs_lstm, cs_lstm = self.lstm_cell(input_tensor, (hs_lstm, cs_lstm))# 最後一個隱藏狀態通過線性層out = self.linear(hs_lstm)為了更好地理解,我們將用一些定義的值來解釋程序,這樣我們就可以理解每個張量是如何從一個層傳遞到另一個層的。所以假設我們有:
batch_size = 64hidden_size = 128sequence_len = 100num_classes = 27所以x輸入張量將有一個形狀:
# torch.Size([batch_size, sequence_len])x : torch.Size([64, 100])然後,在第2行中,x張量通過嵌入層傳遞,因此輸出將具有一個大小:
# torch.Size([batch_size, sequence_len, hidden_size])x_embedded : torch.Size([64, 100, 128])需要注意的是,在第5行中,我們正在reshape x_embedded 張量。這是因為我們需要將序列長度作為第一維,本質上是因為在Bi-LSTM中,我們將迭代每個序列,因此重塑後的張量將具有一個形狀:
# torch.Size([sequence_len, batch_size, hidden_size])x_embedded_reshaped : torch.Size([100, 64, 128])緊接著,在第7行和第8行定義了forward 和backward 列表。在那裡我們將存儲Bi-LSTM的隱藏狀態。
所以是時候給Bi-LSTM輸入數據了。首先,在第12行中,我們在向前LSTM上迭代,我們還保存每個時間步的隱藏狀態(hs_forward)。在第19行中,我們迭代向後的LSTM,同時保存每個時間步的隱藏狀態(hs_backward)。你可以注意到循環是以相同的順序執行的,不同之處在於它是以相反的形式讀取的。每個隱藏狀態將具有以下形狀:
# hs_forward : torch.Size([batch_size, hidden_size])hs_forward : torch.Size([64, 128])# hs_backward : torch.Size([batch_size, hidden_size])hs_backward: torch.Size([64, 128])很好,現在讓我們看看如何為最新的LSTM層提供數據。為此,我們使用forward 和backward 列表。在第26行中,我們遍歷與第27行級聯的forward 和backward 對應的每個隱藏狀態。需要注意的是,通過連接兩個隱藏狀態,張量的維數將增加2倍,即張量將具有以下形狀:
# input_tesor : torch.Size([bathc_size, hidden_size * 2])input_tensor : torch.Size([64, 256])最後,LSTM將返回大小為的隱藏狀態:
# last_hidden_state: torch.Size([batch_size, num_classes])last_hidden_state: torch.Size([64, 27])最後,LSTM的最後一個隱藏狀態將通過一個線性層,如第31行所示。因此,完整的forward函數顯示在下面的代碼片段中:
代碼片段7-正向函數
def forward(self, x): # Bi-LSTM # hs = [batch_size x hidden_size] # cs = [batch_size x hidden_size] hs_forward = torch.zeros(x.size(0), self.hidden_dim) cs_forward = torch.zeros(x.size(0), self.hidden_dim) hs_backward = torch.zeros(x.size(0), self.hidden_dim) cs_backward = torch.zeros(x.size(0), self.hidden_dim) # LSTM # hs = [batch_size x (hidden_size * 2)] # cs = [batch_size x (hidden_size * 2)] hs_lstm = torch.zeros(x.size(0), self.hidden_dim * 2) cs_lstm = torch.zeros(x.size(0), self.hidden_dim * 2) # 權重初始化 torch.nn.init.kaiming_normal_(hs_forward) torch.nn.init.kaiming_normal_(cs_forward) torch.nn.init.kaiming_normal_(hs_backward) torch.nn.init.kaiming_normal_(cs_backward) torch.nn.init.kaiming_normal_(hs_lstm) torch.nn.init.kaiming_normal_(cs_lstm) # 從 idx 到 embedding out = self.embedding(x) # 為LSTM準備shape out = out.view(self.sequence_len, x.size(0), -1) forward = [] backward = [] # 解開Bi-LSTM # 正向 for i in range(self.sequence_len): hs_forward, cs_forward = self.lstm_cell_forward(out[i], (hs_forward, cs_forward)) hs_forward = self.dropout(hs_forward) cs_forward = self.dropout(cs_forward) forward.append(hs_forward) # 反向 for i in reversed(range(self.sequence_len)): hs_backward, cs_backward = self.lstm_cell_backward(out[i], (hs_backward, cs_backward)) hs_backward = self.dropout(hs_backward) cs_backward = self.dropout(cs_backward) backward.append(hs_backward) # LSTM for fwd, bwd in zip(forward, backward): input_tensor = torch.cat((fwd, bwd), 1) hs_lstm, cs_lstm = self.lstm_cell(input_tensor, (hs_lstm, cs_lstm)) # 最後一個隱藏狀態通過線性層 out = self.linear(hs_lstm) return out到目前為止,我們已經知道如何使用PyTorch中的LSTMCell來組裝神經網絡。現在是時候看看我們如何進行訓練階段了,所以讓我們繼續下一節。
訓練階段
太好了,我們來訓練了。為了執行訓練,我們需要初始化模型和優化器,稍後我們需要為每個epoch 和每個mini-batch,所以讓我們開始吧!
代碼片段8-訓練階段
def train(self, args): # 模型初始化 model = TextGenerator(args, self.vocab_size) # 優化器初始化 optimizer = optim.RMSprop(model.parameters(), lr=self.learning_rate) # 定義batch數 num_batches = int(len(self.sequences) / self.batch_size) # 訓練模型 model.train() # 訓練階段 for epoch in range(self.num_epochs): # Mini batches for i in range(num_batches): # Batch 定義 try: x_batch = self.sequences[i * self.batch_size : (i + 1) * self.batch_size] y_batch = self.targets[i * self.batch_size : (i + 1) * self.batch_size] except: x_batch = self.sequences[i * self.batch_size :] y_batch = self.targets[i * self.batch_size :] # 轉換 numpy array 為 torch tensors x = torch.from_numpy(x_batch).type(torch.LongTensor) y = torch.from_numpy(y_batch).type(torch.LongTensor) # 輸入數據 y_pred = model(x) # loss計算 loss = F.cross_entropy(y_pred, y.squeeze()) # 清除梯度 optimizer.zero_grad() # 反向傳播 loss.backward() # 更新參數 optimizer.step() print("Epoch: %d , loss: %.5f " % (epoch, loss.item()))一旦模型被訓練,我們將需要保存神經網絡的權重,以便以後使用它們來生成文本。為此我們有兩種選擇,第一種是定義一個固定的時間段,然後保存權重,第二個是確定一個停止函數,以獲得模型的最佳版本。在這個特殊情況下,我們將選擇第一個選項。在對模型進行一定次數的訓練後,我們將權重保存如下:
代碼段9-權重保存
# 保存權重torch.save(model.state_dict(), 'weights/textGenerator_model.pt')到目前為止,我們已經看到了如何訓練文本生成器和如何保存權重,現在我們將進入這個博客的最後一部分,文本生成!
文本生成
我們已經到了博客的最後一部分,文本生成。為此,我們需要做兩件事:第一件事是加載訓練好的權重,第二件事是從序列集合中隨機抽取一個樣本作為模式,開始生成下一個字符。下面我們來看看下面的代碼片段:
代碼片段10-文本生成器
def generator(model, sequences, idx_to_char, n_chars): # 評估模式 model.eval() # 定義softmax函數 softmax = nn.Softmax(dim=1) # 從序列集合中隨機選取索引 start = np.random.randint(0, len(sequences)-1) # 給定隨機的idx來定義模式 pattern = sequences[start] # 利用字典,它輸出了Pattern print("\nPattern: \n") print(''.join([idx_to_char[value] for value in pattern]), "\"") # 在full_prediction中,我們將保存完整的預測 full_prediction = pattern.copy() # 預測開始,它將被預測為一個給定的字符長度 for i in range(n_chars): # 轉換為tensor pattern = torch.from_numpy(pattern).type(torch.LongTensor) pattern = pattern.view(1,-1) # 預測 prediction = model(pattern) # 將softmax函數應用於預測張量 prediction = softmax(prediction) # 預測張量被轉換成一個numpy數組 prediction = prediction.squeeze().detach().numpy() # 取概率最大的idx arg_max = np.argmax(prediction) # 將當前張量轉換為numpy數組 pattern = pattern.squeeze().detach().numpy() # 窗口向右1個字符 pattern = pattern[1:] # 新pattern是由「舊」pattern+預測的字符組成的 pattern = np.append(pattern, arg_max) # 保存完整的預測 full_prediction = np.append(full_prediction, arg_max) print("Prediction: \n") print(''.join([idx_to_char[value] for value in full_prediction]), "\"")因此,通過在以下特徵下訓練模型:
window : 100epochs : 50hidden_dim : 128batch_size : 128learning_rate : 0.001我們可以生成以下內容:
Seed:one of the prairie swellswhich gave a little wider view than most of them jack saw quite close to thePrediction:one of the prairie swellswhich gave a little wider view than most of them jack saw quite close to the wnd banngessejang boffff we outheaedd we band r hes tller a reacarof t t alethe ngothered uhe th wengaco ack fof ace ca e s alee bin cacotee tharss th band fofoutod we we ins sange trre anca y w farer we sewigalfetwher d e we n s shed pack wngaingh tthe we the we javes t supun f the har man bllle s ng ou y anghe ond we nd ba a she t t anthendwe wn me anom ly tceaig t i isesw arawns t d ks wao thalac tharr jad d anongive where the awe w we he is ma mie cack seat sesant sns t imes hethof riges we he d ooushe he hang out f t thu inong bll llveco we see s the he haa is s igg merin ishe d t san wack owhe o or th we sbe se we we inange t ts wan br seyomanthe harntho thengn th me ny we ke in acor offff of wan s arghe we t angorro the wand be thing a sth t tha alelllll willllsse of s wed w brstougof bage orore he anthesww were ofawe ce qur the he sbaing tthe bytondece nd t llllifsffo acke o t in ir me hedlff scewant pi t bri pi owasem the awh thorathas th we hed ofainginictoplid we me正如我們看到的,生成的文本可能沒有任何意義,但是有一些單詞和短語似乎形成了一個想法,例如:
we, band, pack, the, man, where, he, hang, out, be, thing, me, were恭喜,我們已經到了博客的結尾!
結論
在本博客中,我們展示了如何使用PyTorch的LSTMCell建立一個用於文本生成的端到端模型,並實現了基於循環神經網絡LSTM和Bi-LSTM的體系結構。
值得注意的是,建議的文本生成模型可以通過不同的方式進行改進。一些建議的想法是增加要訓練的文本語料庫的大小,增加epoch以及每個LSTM的隱藏層大小。另一方面,我們可以考慮一個基於卷積LSTM的有趣的架構。
參考引用
[1] LSTM vs. GRU vs. Bidirectional RNN for script generation(https://arxiv.org/pdf/1908.04332.pdf)
[2] The survey: Text generation models in deep learning(https://www.sciencedirect.com/science/article/pii/S1319157820303360)