我們先來看一首詩。
深宮有奇物,璞玉冠何有。
度歲忽如何,遐齡複何欲。
學來玉階上,仰望金閨籍。
習協萬壑間,高高萬象逼。
這是一首藏頭詩,每句詩的第一個字連起來就是“深度學習”。想必你也猜到了,這首詩就是使用深度學習寫的!本章我們将學習一些自然語言處理的基本概念,并嘗試自己動手,用RNN實作自動寫詩。
9.1 自然語言處理的基礎知識
自然語言處理(Natural Language Processing,NLP)是人工智能和語言學領域的分支學科。自然語言處理是一個很寬泛的學科,涉及機器翻譯、句法分析、資訊檢索等諸多研究方向。由于篇幅的限制,本章重點講解自然語言進行中的兩個基本概念:詞向量(Word Vector)和循環神經網絡(Recurrent Neural Network,RNN)。
9.1.1 詞向量
自然語言處理主要研究語言資訊,語言(詞、句子、篇章等)屬于人類認知過程中産生的高層認知抽象實體,而語音和圖像屬于較低層的原始輸入信号。語音、圖像資料表達不需要特殊的編碼,并且有天生的順序性和關聯性,近似的數字會被認為是近似的特征。正如圖像是由像素組成,語言是由詞或字組成,可以把語言轉換為詞或字表示的集合。
然而,不同于像素的大小天生具有色彩資訊,詞的數值大小很難表征詞的含義。最初,人們為了友善,采用One-Hot編碼格式。以一個隻有10個不同詞的語料庫為例(這裡隻是舉個例子,一般中文語料庫的字平均在8000 ~ 50000,而詞則在幾十萬左右),我們可以用一個10維的向量表示每個詞,該向量在詞下标位置的值為1,而其他全部為0。示例如下:
第1個詞:[1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
第2個詞:[0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
第3個詞:[0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
……
第10個詞:[0, 0, 0, 0, 0, 0, 0, 0, 0, 1]
這種詞的表示方法十分簡單,也很容易實作,解決了分類器難以處理屬性(Categorical)資料的問題。它的缺點也很明顯:備援太多、無法展現詞與詞之間的關系。可以看到,這10個詞的表示,彼此之間都是互相正交的,即任意兩個詞之間都不相關,并且任何兩個詞之間的距離也都是一樣的。同時,随着詞數的增加,One-Hot向量的次元也會急劇增長,如果有3000個不同的詞,那麼每個One-Hot詞向量都是3000維,而且隻有一個位置為1,其餘位置都是0,。雖然One-Hot編碼格式在傳統任務上表現出色,但是由于詞的次元太高,應用在深度學習上時,常常出現次元災難,是以在深度學習中一般采用詞向量的表示形式。
詞向量(Word Vector),也被稱為詞嵌入(Word Embedding),并沒有嚴格統一的定義。從概念上講,它是指把一個維數為所有詞的數量的高維空間(幾萬個字,幾十萬個詞)嵌入一個次元低得多的連續向量空間(通常是128或256維)中,每個單詞或詞組被映射為實數域上的向量。
詞向量有專門的訓練方法,這裡不會細講,感興趣的讀者可以學習斯坦福的CS224系列課程(包括CS224D和CS224N)。在本章的學習中,讀者隻需要知道詞向量最重要的特征是相似詞的詞向量距離相近。每個詞的詞向量次元都是固定的,每一維都是連續的數。舉個例子:如果我們用二維的詞向量表示十個詞:足球、比賽、教練、隊伍、褲子、長褲、上衣和編織、折疊、拉,那麼可視化出來的結果如下所示。可以看出,同類的詞(足球相關的詞、衣服相關的詞、以及動詞)彼此聚集,互相之間的距離比較近。
可見,用詞向量表示的詞,不僅所用次元會變少(由10維變成2維),其中也會包含更合理的語義資訊。除了相鄰詞距離更近之外,詞向量還有不少有趣的特征,如下圖所示。虛線的兩端分别是男性詞和女性詞,例如叔叔和阿姨、兄弟和姐妹、男人和女人、先生和女士。可以看出,虛線的方向和長度都差不多,是以可以認為vector(國王) - vector(女王) ≈ vector(男人) - vector(女人),換一種寫法就是vector(國王) - vector(男人) ≈ vector(女王) - vector(女人),即國王可以看成男性君主,女王可以看成女性君主,國王減去男性,隻剩下君主的特征;女王減去女性,也隻剩下君主的特征,是以這二者相似。
英文一般是用一個向量表示一個詞,也有使用一個向量表示一個字母的情況。中文同樣也有一個詞或者一個字的詞向量表示,與英文采用空格來區分詞不同,中文的詞與詞之間沒有間隔,是以如果采用基于詞的詞向量表示,需要先進行中文分詞。
這裡隻對詞向量做一個概括性的介紹,讓讀者對詞向量有一個直覺的認知。讀者隻需要掌握詞向量技術用向量表征詞,相似詞之間的向量距離近。至于如何訓練詞向量,如何評估詞向量等内容,這裡不做介紹,感興趣的讀者可以參看斯坦福大學的相關課程。
在PyTorch中,針對詞向量有一個專門的層nn.Embedding,用來實作詞與詞向量的映射。nn.Embedding具有一個權重,形狀是(num_words,embedding_dim),例如對上述例子中的10個詞,每個詞用2維向量表征,對應的權重就是一個10 * 2的矩陣。Embedding的輸入形狀是N * W,N是batch size,W是序列的長度,輸出的形狀是N * W * embedding_dim。輸入必須是LongTensor,FloatTensor必須通過tensor.long()方法轉成LongTensor。舉例如下:
#coding:utf8
import torch as t
from torch import nn
embedding = t.nn.Embedding(10, 2) # 10個詞,每個詞用2維詞向量表示
input = t.arange(0, 6).view(3, 2).long() # 3個句子,每個句子有2個詞
input = t.autograd.Variable(input)
output = embedding(input)
print(output.size())
print(embedding.weight.size())
輸出是:
(3L, 2L, 2L)
(10L, 2L)
需要注意的是,Embedding的權重也是可以訓練的,既可以采用随機初始化,也可以采用預訓練好的詞向量初始化。
9.1.2 RNN
RNN的全稱是Recurrent Neural Network,在深度學習中還有一個Recursive Neural Network也被稱為RNN,這裡應該注意區分,除非特殊說明,我們所遇到的絕大多數RNN都是指前者。在用深度學習解決NLP問題時,RNN幾乎是必不可少的工具。假設我們現在已經有每個詞的詞向量表示,那麼我們将如何獲得這些詞所組成的句子的含義呢?我們無法單純地分析一個詞,是以每一個詞都依賴于前一個詞,單純地看某一個詞無法獲得句子的資訊。RNN則可以很好地解決這個問題,通過每次利用之前詞的狀态(hidden state)和目前詞相結合計算新的狀态。
RNN的網絡結構圖如下所示。
- x 1 , x 2 , x 3 , . . . , x T x_1,x_2,x_3,...,x_T x1,x2,x3,...,xT:輸入詞的序列(共有 T T T個詞),每個詞都是一個向量,通常用詞向量表示。
- h 0 , h 1 , h 2 , h 3 , . . . , h T h_0,h_1,h_2,h_3,...,h_T h0,h1,h2,h3,...,hT:隐藏元(共 T + 1 T+1 T+1個),每個隐藏元都由之前的詞計算得到,是以可以認為包含之前所有詞的資訊。 h 0 h_0 h0代表初始資訊,一般采用全0的向量進行初始化。
- f W f_W fW:轉換函數,根據目前輸入 x t x_t xt和前一個隐藏元的狀态 h t − 1 h_{t-1} ht−1,計算新的隐藏元狀态 h t h_t ht。可以認為 h t − 1 h_{t-1} ht−1包含前 t − 1 t-1 t−1個詞的資訊,即 x 1 , x 2 , . . . , x t − 1 x_1,x_2,...,x_{t-1} x1,x2,...,xt−1,由 f W f_W fW利用 h t − 1 h_{t-1} ht−1和 x t x_t xt計算得到的 h t h_t ht,可以認為是包含前 t t t個詞的資訊。需要注意的是,每一次計算 h t h_t ht都用同一個 f W f_W fW。 f W f_W fW一般是一個矩陣乘法運算。
RNN最後會輸出所有隐藏元的資訊,一般隻使用最後一個隐藏元的資訊,可以認為它包含了整個句子的資訊。
上圖所示的RNN結構通常被稱為Vanilla RNN,易于實作,并且簡單直覺,但卻具有嚴重的梯度消失和梯度爆炸問題,難以訓練。目前在深度學習中普遍使用的是一種被稱為LSTM的RNN結構。LSTM的全稱是Long Short Term Memory Networks,即長短期記憶網絡,其結構如下圖所示,它的結構與Vanilla RNN類似,也是通過不斷利用之前的狀态和目前的輸入來計算新的狀态。但其 f W f_W fW函數更複雜,除了隐藏元狀态(hidden state h h h),還有cell state c c c。每個LSTM單元的輸出有兩個,一個是下面的 h t h_t ht( h t h_t ht同時被建立分支引到上面去),一個是上面的 c t c_t ct。 c t c_t ct的存在能很好地抑制梯度消失和梯度爆炸等問題。關于RNN和LSTM的介紹,可以參考colah的部落格:Understanding LSTM Networks。
LSTM很好地解決了訓練RNN過程中出現的各種問題,在幾乎各類問題中都要展現出好于Vanilla RNN的表現。在PyTorch中使用LSTM的例子如下。
import torch as t
from torch import nn
from torch.autograd import Variable
# 輸入詞用10維詞向量表示
# 隐藏元用20維向量表示
# 兩層的LSTM
rnn = nn.LSTM(10,20,2)
# 輸入每句話有5個詞
# 每個詞由10維的詞向量表示
# 總共有3句話(batch-size)
input = Variable(t.randn(5,3,10))
# 隐藏元(hidden state和cell state)的初始值
# 形狀(num_layers,batch_size,hidden_size)
h0 = Variable(t.zeros(2,3,20))
c0 = Variable(t.zeros(2,3,20))
# output是最後一層所有隐藏元的值
# hn和cn是所有層(這裡有2層)的最後一個隐藏元的值
output,(hn,cn) = rnn(input,(h0,c0))
print(output.size())
print(hn.size())
print(cn.size())
輸出如下:
torch.Size([5, 3, 20])
torch.Size([2, 3, 20])
torch.Size([2, 3, 20])
注意:output的形狀與LSTM的層數無關,隻與序列長度有關,而hn和cn則相反。
除了LSTM,PyTorch中還有LSTMCell。LSTM是對一個LSTM層的抽象,可以看成是由多個LSTMCell組成。而使用LSTMCell則可以進行更精細化的操作。LSTM還有一種變體稱為GRU(Gated Recurrent Unit),相較于LSTM,GRU的速度更快,效果也接近。在某些對速度要求十分嚴格的場景可以使用GRU作為LSTM的替代品。
9.2 CharRNN
CharRNN的作者Andrej Karpathy現任特斯拉AI主管,也曾是最優的深度學習課程CS231n的主講人。關于CharRNN,Andrej Karpathy有一篇論文《Visualizing and understanding recurrent networks》發表于ICLR2016,同時還有一篇相當精彩的部落格The Unreasonable Effectiveness of Recurrent Neural Networks介紹了不可思議的CharRNN。
CharRNN從海量文本中學習英文字母(注意,是字母,不是英語單詞)的組合,并能夠自動生成相對應的文本。例如作者用莎士比亞的劇集訓練CharRNN,最後得到一個能夠模仿莎士比亞寫劇的程式,生成的莎劇劇本如下:
PANDARUS:
Alas, I think he shall be come approached and the day
When little srain would be attain’d into being never fed,
And who is but a chain and subjects of his death,
I should not sleep.
Second Senator:
They are away this miseries, produced upon my soul,
Breaking and strongly should be buried, when I perish
The earth and thoughts of many states.
DUKE VINCENTIO:
Well, your wit is in the care of side and that.
Second Lord:
They would be ruled after this chamber, and
my fair nues begun out of the fact, to be conveyed,
Whose noble souls I’ll have the heart of the wars.
Clown:
Come, sir, I will make did behold your worship.
VIOLA:
I’ll drink it.
作者還做了許多十分有趣的實驗,例如模仿Linux的源代碼寫程式,模仿開源的教科書的LaTeX源碼寫程式等。
CharRNN的原理十分簡單,它分為訓練和生成兩部分。訓練的時候如下所示。
例如,莎士比亞劇本中有
hello world
這句話,可以把它轉化成分類任務。RNN的輸入是
hello world
,對于RNN的每一個隐藏元的輸出,都接一個全連接配接層用來預測下一個字,即:
- 第一個隐藏元,輸入
,包含h
的資訊,預測輸出h
;e
- 第二個隐藏元,輸入
,包含e
的資訊,預測輸出he
;l
- 第三個隐藏元,輸入
,包含l
的資訊,預測輸出hel
;l
- 第四個隐藏元,輸入
,包含l
的資訊,預測輸出hell
;o
- 等等。
如上所述,CharRNN可以看成一個分類問題:根據目前字元,預測下一個字元。對于英文字母來說,文本中用到的總共不超過128個字元(假設就是128個字元),是以預測問題就可以改成128分類問題:将每一個隐藏元的輸出,輸入到一個全連接配接層,計算輸出屬于128個字元的機率,計算交叉熵損失即可。
總結成一句話:CharRNN通過利用目前字的隐藏元狀态預測下一個字,把生成問題變成了分類問題。
訓練完成之後,我們就可以利用網絡進行文本生成來寫詩。生成的步驟如下圖所示。
- 首先輸入一個起始的字元(一般用辨別),計算輸出屬于每個字元的機率。
- 選擇機率最大的一個字元作為輸出。
- 将上一步的輸出作為輸入,繼續輸入到網絡中,計算輸出屬于每個字元的機率。
- 一直重複這個過程。
- 最後将所有字元拼接組合在一起,就得到最後的生成結果。
CharRNN還有一些不夠嚴謹之處,例如它使用One-Hot的形式表示詞,而不是使用詞向量;使用RNN而不是LSTM。在本次實驗中,我們将對這些進行改進,并利用常用的中文語料庫進行訓練。
9.3 用PyTorch實作CharRNN
本章所有源碼及資料百度網盤下載下傳,提取碼:vqid。
本次實驗采用的資料是來自GitHub上中文詩詞愛好者收集的5萬多首唐詩原文。原始檔案是Json檔案和Sqlite資料庫的存儲格式。筆者在此基礎上做了兩個修改:
- 繁體中文改成簡體中文:原始資料是繁體中文的,雖然詩詞更有韻味,但是對于習慣了簡體中文的讀者來說可能還是有點别扭。
- 把所有的資料進行截斷和補齊成一樣的長度:由于不同詩歌的長度不一樣,不易拼接成一個batch,是以需要将它們處理成一樣的長度。
最後為了友善讀者複現實驗,筆者對原始資料進行了處理,并提供了一個numpy的壓縮包tang.npz,裡面包含三個對象。
- data:(57580,125)的numpy數組,總共有57580首詩歌,每首詩歌長度為125個字元(不足125補空格,超過125的丢棄)。
- word2ix:每個詞和它對應的序号,例如“春”這個詞對應的序号是1000。
- ix2word:每個序号和它對應的詞,例如序号1000對應着“春”這個詞。
其中data對詩歌的處理步驟如下。
- 以《靜夜思》這首詩為例,先轉成list,并在前面和後面加上起始符和終止符,變成:
['<START>',
'床','前','明','月','光',',',
'疑','是','地','上','霜','。',
'舉','頭','望','明','月',',',
'低','頭','思','故','鄉','。',
'<EOP>']
- 對于長度達不到125個字元的詩歌,在前面補上空格(用表示),直到長度達到125,變成如下格式:
['</s>','</s>','</s>',......,
'<START>',
'床','前','明','月','光',',',
'疑','是','地','上','霜','。',
'舉','頭','望','明','月',',',
'低','頭','思','故','鄉','。',
'<EOP>']
對于長度超過125個字元的詩歌《春江花月夜》,把結尾的詞截斷,變成如下格式:
['<START>',
'春','江','潮','水','連','海','平',',','海','上','明','月','共','潮','生','。',
……,
'江','水','流','春','去','欲','盡',',','江','潭','落','月','複','西','斜','。',
'斜','月','沉','沉','藏','海','霧',',','碣','石',
'<END>']
- 将每個字都轉成對應的序号,例如“春”轉換成1000,變成如下格式,每個list的長度都是125。
[12,1000,959,......,127,285,1000,695,50,622,545,299,3,
906,155,236,828,61,635,87,262,704,957,23,68,912,200,
539,819,494,398,296,94,905,871,34,818,766,58,881,469,
22,385,696]
- 将序号list轉成numpy數組。
将numpy的資料還原成詩歌的例子如下:
import numpy as np
# 加載資料
datas = np.load('tang.npz', allow_pickle=True)
data = datas['data']
ix2word = datas['ix2word'].item()
# 檢視第一首詩歌
poem = data[0]
# 詞序号轉成對應的漢字
poem_txt = [ix2word[ii] for ii in poem]
print(''.join(poem_txt))
輸出如下:
</s></s></s></s></s></s></s></s></s></s></s></s></s></s></s>
</s></s></s></s></s></s></s></s></s></s></s></s></s></s></s>
</s></s></s></s></s></s></s></s></s></s></s></s></s></s></s>
</s></s></s></s></s></s></s></s></s></s></s></s></s></s></s>
</s></s></s></s></s></s></s></s></s></s></s></s></s></s></s>
<START>
度門能不訪,冒雪屢西東。
已想人如玉,遙憐馬似骢。
乍迷金谷路,稍變上陽宮。
還比相思意,紛紛正滿空。
<EOP>
資料處理完後,再來看看本次實驗的檔案組織架構:
checkpoints/
data.py
main.py
model.py
README.md
requirements.txt
tang.npz
utils.py
其中幾個比較重要的檔案如下:
- main.py:包含程式配置、訓練和生成。
- model.py:模型定義。
- utils.py:可視化工具visdom的封裝。
- tang.npz:将5萬多首唐詩預處理成numpy資料。
- data.py:對原始的唐詩文本進行預處理,如果直接使用tang.npz,則不需要對json的資料進行處理。
程式中主要的配置選項和指令行參數如下:
class Config(object):
data_path = 'data/' # 詩歌的文本檔案存放路徑
pickle_path = 'tang.npz' # 預處理好的二進制檔案
author = None # 隻學習某位作者的詩歌
constrain = None # 長度限制
category = 'poet.tang' # 類别,唐詩還是宋詩歌(poet.song)
lr = 1e-3
weight_decay = 1e-4
use_gpu = True
epoch = 20
batch_size = 128
maxlen = 125 # 超過這個長度的之後字被丢棄,小于這個長度的在前面補空格
plot_every = 20 # 每20個batch 可視化一次
# use_env = True # 是否使用visodm
env = 'poetry' # visdom env
max_gen_len = 200 # 生成詩歌最長長度
debug_file = 'debug/debug.txt'
model_path = None # 預訓練模型路徑
prefix_words = '細雨魚兒出,微風燕子斜。' # 不是詩歌的組成部分,用來控制生成詩歌的意境
start_words = '閑雲潭影日悠悠' # 詩歌開始
acrostic = False # 是否是藏頭詩
model_prefix = 'checkpoints/tang' # 模型儲存路徑
在data.py中主要有以下三個函數:
- _parseRawData:解析原始的json資料,提取成list。
- pad_sequences:将不同長度的資料截斷或補齊成一樣的長度。
- get_data:給主程式調用的接口。如果二進制檔案存在,則直接讀取二進制的numpy檔案;否則讀取文本檔案進行處理,并将處理結果儲存成二進制檔案。
二進制檔案tang.npz已在本書附帶代碼中提供,讀者可以不必下載下傳原始的json檔案,直接加載處理好的二進制檔案即可。
data.py中的get_data函數的代碼如下:
def get_data(opt):
"""
@param opt 配置選項 Config對象
@return word2ix: dict,每個字對應的序号,形如u'月'->100
@return ix2word: dict,每個序号對應的字,形如'100'->u'月'
@return data: numpy數組,每一行是一首詩對應的字的下标
"""
if os.path.exists(opt.pickle_path):
data = np.load(opt.pickle_path)
data, word2ix, ix2word = data['data'], data['word2ix'].item(), data['ix2word'].item()
return data, word2ix, ix2word
# 如果沒有處理好的二進制檔案,則處理原始的json檔案
data = _parseRawData(opt.author, opt.constrain, opt.data_path, opt.category)
words = {_word for _sentence in data for _word in _sentence}
word2ix = {_word: _ix for _ix, _word in enumerate(words)}
word2ix['<EOP>'] = len(word2ix) # 終止辨別符
word2ix['<START>'] = len(word2ix) # 起始辨別符
word2ix['</s>'] = len(word2ix) # 空格
ix2word = {_ix: _word for _word, _ix in list(word2ix.items())}
# 為每首詩歌加上起始符和終止符
for i in range(len(data)):
data[i] = ["<START>"] + list(data[i]) + ["<EOP>"]
# 将每首詩歌儲存的内容由‘字’變成‘數’
# 形如[春,江,花,月,夜]變成[1,2,3,4,5]
new_data = [[word2ix[_word] for _word in _sentence]
for _sentence in data]
# 詩歌長度不夠opt.maxlen的在前面補空格,超過的,删除末尾的
pad_data = pad_sequences(new_data,
maxlen=opt.maxlen,
padding='pre',
truncating='post',
value=len(word2ix) - 1)
# 儲存成二進制檔案
np.savez_compressed(opt.pickle_path,
data=pad_data,
word2ix=word2ix,
ix2word=ix2word)
return pad_data, word2ix, ix2word
這樣在main.py的訓練函數train中就可以這麼使用資料:
# 擷取資料
data, word2ix, ix2word = get_data(opt)
data = t.from_numpy(data)
dataloader = t.utils.data.DataLoader(data,
batch_size=opt.batch_size,
shuffle=True,
num_workers=1)
注意,我們這裡沒有将data實作為一個Dataset對象,但是它還是可以利用DataLoader進行多線程加載。這是因為data作為一個Tensor對象,自身已經實作了__getitem__和__len__方法。其中,data.getitem(0)等價于data[0],len(data)傳回data.size(0),這種運作方式被稱為鴨子類型(Duck Typing),是一種動态類型的風格。在這種風格中,一個對象有效的語義,不是由繼承自特定的類或實作特定的接口決定,而是由目前方法和屬性的集合決定。這個概念的名字來源于James Whitcomb Riley提出的鴨子測試,“鴨子測試”可以這樣描述:“當看到一隻鳥走起來像鴨子、遊起來像鴨子、叫起來也像鴨子,那麼這隻鳥就可以被稱為鴨子”。同理,當一個對象可以向Dataset對象一樣提供__getitem__和__len__方法時,它就可以被稱為Dataset。
另外需要注意的是,這種直接把所有的資料全部加載到記憶體的做法,在某些情況下會比較占記憶體,但是速度會有很大的提升,因為它避免了頻繁的硬碟讀寫,減少了I/O等待,在實驗中如果資料量足夠小,可以酌情選擇把資料全部預處理成二進制的檔案全部加載到記憶體中。
模型建構的代碼儲存在model.py中:
# coding:utf8
import torch
import torch.nn as nn
import torch.nn.functional as F
class PoetryModel(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim):
super(PoetryModel, self).__init__()
self.hidden_dim = hidden_dim
self.embeddings = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, self.hidden_dim, num_layers=2)
self.linear1 = nn.Linear(self.hidden_dim, vocab_size)
def forward(self, input, hidden=None):
seq_len, batch_size = input.size()
if hidden is None:
# h_0 = 0.01*torch.Tensor(2, batch_size, self.hidden_dim).normal_().cuda()
# c_0 = 0.01*torch.Tensor(2, batch_size, self.hidden_dim).normal_().cuda()
h_0 = input.data.new(2, batch_size, self.hidden_dim).fill_(0).float()
c_0 = input.data.new(2, batch_size, self.hidden_dim).fill_(0).float()
else:
h_0, c_0 = hidden
# size: (seq_len,batch_size,embeding_dim)
embeds = self.embeddings(input)
# output size: (seq_len,batch_size,hidden_dim)
output, hidden = self.lstm(embeds, (h_0, c_0))
# size: (seq_len*batch_size,vocab_size)
output = self.linear1(output.view(seq_len * batch_size, -1))
return output, hidden
總體而言,輸入的字詞序号經過nn.Embedding得到相應的詞向量表示,然後利用兩層的LSTM提取詞的所有隐藏元的資訊,再利用隐藏元的資訊進行分類,判斷輸出屬于每一個詞的機率。這裡使用LSTM而不是LSTMCell是為了簡化代碼。當輸入的序列長度為1時,LSTM實作的功能與LSTMCell一樣。需要注意的是,這裡輸入(input)的資料形狀是(seq_len,batch_size),如果輸入的尺寸是(batch_size,seq_len),需要在輸入LSTM之前進行轉置操作(variable.transpose)。
訓練相關的代碼儲存于main.py中,總體而言比較簡單,訓練過程和第6章提到的貓和狗二分類問題比較相似,都是分類問題。
def train(**kwargs):
for k, v in kwargs.items():
setattr(opt, k, v)
opt.device = t.device('cuda') if opt.use_gpu else t.device('cpu')
device = opt.device
vis = Visualizer(env=opt.env)
# 擷取資料
data, word2ix, ix2word = get_data(opt)
data = t.from_numpy(data)
dataloader = t.utils.data.DataLoader(data,
batch_size=opt.batch_size,
shuffle=True,
num_workers=1)
# 模型定義
model = PoetryModel(len(word2ix), 128, 256)
optimizer = t.optim.Adam(model.parameters(), lr=opt.lr)
criterion = nn.CrossEntropyLoss()
if opt.model_path:
model.load_state_dict(t.load(opt.model_path))
model.to(device)
loss_meter = meter.AverageValueMeter()
for epoch in range(opt.epoch):
loss_meter.reset()
for ii, data_ in tqdm.tqdm(enumerate(dataloader)):
# 訓練
data_ = data_.long().transpose(1, 0).contiguous()
data_ = data_.to(device)
optimizer.zero_grad()
input_, target = data_[:-1, :], data_[1:, :]
output, _ = model(input_)
loss = criterion(output, target.view(-1))
loss.backward()
optimizer.step()
loss_meter.add(loss.item())
# 可視化
if (1 + ii) % opt.plot_every == 0:
if os.path.exists(opt.debug_file):
ipdb.set_trace()
vis.plot('loss', loss_meter.value()[0])
# 詩歌原文
poetrys = [[ix2word[_word] for _word in data_[:, _iii].tolist()]
for _iii in range(data_.shape[1])][:16]
vis.text('</br>'.join([''.join(poetry) for poetry in poetrys]), win=u'origin_poem')
gen_poetries = []
# 分别以這幾個字作為詩歌的第一個字,生成8首詩
for word in list(u'春江花月夜涼如水'):
gen_poetry = ''.join(generate(model, word, ix2word, word2ix))
gen_poetries.append(gen_poetry)
vis.text('</br>'.join([''.join(poetry) for poetry in gen_poetries]), win=u'gen_poem')
t.save(model.state_dict(), '%s_%s.pth' % (opt.model_prefix, epoch))
這裡需要注意的是資料,以“床前明月光”這句詩為例,輸入是“床前明月”,預測的目标是“前明月光”:
- 輸入“床”的時候,網絡預測的下一個字的目标是“前”。
- 輸入“前”的時候,網絡預測的下一個字的目标是“明”。
- 輸入“明”的時候,網絡預測的下一個字的目标是“月”。
- 輸入“月”的時候,網絡預測的下一個字的目标是“光”。
- ……
這種錯位的方式,通過data_[:-1,:]和data_[1:,:]實作。前者包含從第0個詞直到最後一個詞(不包含),後者是第一個詞到結尾(包括最後一個詞)。由于是分類問題,是以我們使用交叉熵損失作為評估函數。
接着我們來看看如何用訓練好的模型寫詩,第一種是給定詩歌的開頭幾個字接着寫詩歌。實作如下:
def generate(model, start_words, ix2word, word2ix, prefix_words=None):
"""
給定幾個詞,根據這幾個詞接着生成一首完整的詩歌
start_words:u'春江潮水連海平'
比如start_words 為 春江潮水連海平,可以生成:
"""
results = list(start_words)
start_word_len = len(start_words)
# 手動設定第一個詞為<START>
input = t.Tensor([word2ix['<START>']]).view(1, 1).long()
if opt.use_gpu: input = input.cuda()
hidden = None
if prefix_words:
for word in prefix_words:
output, hidden = model(input, hidden)
input = input.data.new([word2ix[word]]).view(1, 1)
for i in range(opt.max_gen_len):
output, hidden = model(input, hidden)
if i < start_word_len:
w = results[i]
input = input.data.new([word2ix[w]]).view(1, 1)
else:
top_index = output.data[0].topk(1)[1][0].item()
w = ix2word[top_index]
results.append(w)
input = input.data.new([top_index]).view(1, 1)
if w == '<EOP>':
del results[-1]
break
return results
這種生成方式是根據給定部分文字,然後接着完成詩歌餘下的部分,生成的步驟如下:
- 首先利用給定的文字“床前明月光”,計算隐藏元,并預測下一個詞(預測的結果是“,”)。
- 将上一步計算的隐藏元和輸出(“,”)作為新的輸入,繼續預測新的輸出和計算隐藏元。
- 将上一步計算的隐藏元和輸出作為新的輸入,繼續預測新的輸出和計算隐藏元。
- ……
這裡還有一個選項是prefix_word,可以用來控制生成的詩歌的意境和長短。比如以“床前明月光”作為start_words輸入,在不指定prefix_words時,生成的詩歌如下:
床前明月光,朗朗秋風清。
昨夜雨後人,一身一招迎。
何必在天末,安得佐戎庭。
豈伊不可越,是以為我情。
在指定prefix_words為“狂沙将軍戰燕然,大漠孤煙黃河騎。”的情況下,生成的詩歌如下(明顯帶有邊塞氣息,而且由五言古詩變成了七言古詩):
床前明月光照耀,城下射蛟沙漠漠。
父子号犬不可親,劍門弟子何紛紛。
胡笳一聲下馬來,關城缭繞天河去。
戰士忠州十二紀,後賢美人不敢攀。
還可以生成藏頭詩,實作的方式如下:
def gen_acrostic(model, start_words, ix2word, word2ix, prefix_words=None):
"""
生成藏頭詩
start_words : u'深度學習'
生成:
深木通中嶽,青苔半日脂。
度山分地險,逆浪到南巴。
學道兵猶毒,當時燕不移。
習根通古岸,開鏡出清羸。
"""
results = []
start_word_len = len(start_words)
input = (t.Tensor([word2ix['<START>']]).view(1, 1).long())
if opt.use_gpu: input = input.cuda()
hidden = None
index = 0 # 用來訓示已經生成了多少句藏頭詩
# 上一個詞
pre_word = '<START>'
if prefix_words:
for word in prefix_words:
output, hidden = model(input, hidden)
input = (input.data.new([word2ix[word]])).view(1, 1)
for i in range(opt.max_gen_len):
output, hidden = model(input, hidden)
top_index = output.data[0].topk(1)[1][0].item()
w = ix2word[top_index]
if (pre_word in {u'。', u'!', '<START>'}):
# 如果遇到句号,藏頭的詞送進去生成
if index == start_word_len:
# 如果生成的詩歌已經包含全部藏頭的詞,則結束
break
else:
# 把藏頭的詞作為輸入送入模型
w = start_words[index]
index += 1
input = (input.data.new([word2ix[w]])).view(1, 1)
else:
# 否則的話,把上一次預測是詞作為下一個詞輸入
input = (input.data.new([word2ix[w]])).view(1, 1)
results.append(w)
pre_word = w
return results
生成藏頭詩的步驟如下:
(1)輸入藏頭的字,開始預測下一個字。
(2)上一步預測的字作為輸入,繼續預測下一個字。
(3)重複第二步,直到輸出的字是“。”或者“!”,說明一句詩結束了,可以繼續輸入下一句藏頭的字,跳到第一步。
(4)重複上述步驟,直到所有藏頭的字都輸入完畢。
上述兩種生成詩歌的方法還需要提供指令行接口,實作方式如下:
def gen(**kwargs):
"""
提供指令行接口,用以生成相應的詩
"""
for k, v in kwargs.items():
setattr(opt, k, v)
data, word2ix, ix2word = get_data(opt)
model = PoetryModel(len(word2ix), 128, 256);
map_location = lambda s, l: s
state_dict = t.load(opt.model_path, map_location=map_location)
model.load_state_dict(state_dict)
if opt.use_gpu:
model.cuda()
# python2和python3 字元串相容
if sys.version_info.major == 3:
if opt.start_words.isprintable():
start_words = opt.start_words
prefix_words = opt.prefix_words if opt.prefix_words else None
else:
start_words = opt.start_words.encode('ascii', 'surrogateescape').decode('utf8')
prefix_words = opt.prefix_words.encode('ascii', 'surrogateescape').decode(
'utf8') if opt.prefix_words else None
else:
start_words = opt.start_words.decode('utf8')
prefix_words = opt.prefix_words.decode('utf8') if opt.prefix_words else None
start_words = start_words.replace(',', u',') \
.replace('.', u'。') \
.replace('?', u'?')
gen_poetry = gen_acrostic if opt.acrostic else generate
result = gen_poetry(model, start_words, ix2word, word2ix, prefix_words)
print(''.join(result))
9.4 實驗結果分析
訓練的指令如下:
python main.py train \
--plot-every=150 \
--batch-size=128 \
--pickle-path='tang.npz' \
--lr=1e-3 \
--env='poetry3' \
--epoch=50
訓練過程如下:
生成一首詩(指定開頭、指定意境和格律):
python main.py gen
--model-path='checkpoints/tang_49.pth'
--start-words='孤帆遠影碧空盡,'
--prefix-words='朝辭白帝彩雲間,千裡江陵一日還。'
生成的詩歌如下:
孤帆遠影碧空盡,萬裡風波入楚山。
綠岸風波搖浪浪,綠楊風起撲船灣。
煙含楚甸悲風遠,風送漁舟夜夜閑。
月色不知何處在,江花猶在落花間。
風生水檻風波急,浪入江山浪蹙閑。
莫道江湖無一事,今年一別一雙攀。
人間幾度千年別,日暮無窮白雪還。
莫道長安無所負,不知何事更相關。
生成一首藏頭詩(指定藏頭,指定意境格律):
python main.py gen \
--model-path='checkpoints/tang_49.pth' \ # 指定模型
--acrostic=True \ # True:藏頭詩
--start-words='深度學習' \ # 藏頭内容
--prefix-words='大漠孤煙直,長河落日圓。' # 意境和格律
藏頭詩“深度學習”的結果如下:
深林無外物,長嘯似神仙。
度石無人迹,青冥似水年。
學馴疑有匠,澁尺不成冤。
習坎無遺迹,幽居不得仙。
生成的很多詩歌都是高品質的,有些甚至已經學會了簡單的對偶和押韻。例如:
落帆迷舊裡,望月到西州。
浩蕩江南岸,高情江海鷗。
風帆随雁吹,江月照旌樓。
泛泛揚州客,停舟泛水鷗。
很有意思的是,如果生成的詩歌長度足夠長,會發現生成的詩歌意境會慢慢改變,以至于和最開始的毫無關系。例如:
大漠孤煙照高閣,夾城飛鞚連天阙。
青絲不語不知音,一曲繁華空繞山。
昔年曾作江南客,今日相逢不相識。
今年花落花滿園,妾心不似君不同。
回頭舞馬邯鄲陌,回頭笑語歌聲鬧。
夫君欲問不相見,今日相看不相見。
君不見君心斷斷腸,莫言此地情何必?
桃花陌陌不堪惜,君恩不似春光色。
一開始是邊塞詩,然後變成了羁旅懷人,最後變成了閨怨詩。
意境、格式和韻腳等資訊都儲存于隐藏元之中,随着輸入的不斷變化,隐藏元儲存的資訊也在不斷變化,有些資訊及時經過了很長的時間依舊可以儲存下來(比如詩歌的長短,五言還是七言),而有些資訊随着輸入的變化也發生較大的改變。在本程式中,我們使用prefix_words就是為了網絡能夠利用給定的輸入初始化隐藏元的狀态。事實上,隐藏元的每一個數都控制着生成詩歌的某一部分屬性,感興趣的讀者可以嘗試調整隐藏元的數值,觀察生成的詩歌有什麼變化。
總體上,程式生成的詩歌效果還不錯,字詞之間的組合也比較有意境,但是詩歌卻反一個一以貫之的主題,讀者很難從一首詩歌中得到一個主旨。這是因為随着詩歌長度的增加,即使是LSTM也不可避免地忘記幾十個字之前的輸入。另外一個比較突出的問題就是,生成的詩歌中經常出現重複的詞,這在傳統的詩歌創作中應該是極力避免的現象,而在程式生成的詩歌中卻常常出現。
本章介紹了自然語言進行中的一些基本概念,并帶領讀者實作了一個能夠生成古詩的小程式。程式從唐詩中學習,并模仿古人寫出了不少優美的詩句。