我們先來看一首詩饲鄙。
深宮有奇物凄诞,璞玉冠何有。
度歲忽如何忍级,遐齡復何欲帆谍。
學來玉階上,仰望金閨籍轴咱。
習協(xié)萬壑間汛蝙,高高萬象逼。
這是一首藏頭詩朴肺,每句詩的第一個字連起來就是“深度學習”窖剑。想必你也猜到了,這首詩就是使用深度學習寫的戈稿!本章我們將學習一些自然語言處理的基本概念西土,并嘗試自己動手,用RNN實現(xiàn)自動寫詩鞍盗。
9.1 自然語言處理的基礎知識
自然語言處理(Natural Language Processing翠储,NLP)是人工智能和語言學領域的分支學科。自然語言處理是一個很寬泛的學科橡疼,涉及機器翻譯、句法分析庐舟、信息檢索等諸多研究方向欣除。由于篇幅的限制,本章重點講解自然語言處理中的兩個基本概念:詞向量(Word Vector)和循環(huán)神經(jīng)網(wǎng)絡(Recurrent Neural Network挪略,RNN)历帚。
9.1.1 詞向量
自然語言處理主要研究語言信息,語言(詞杠娱、句子挽牢、篇章等)屬于人類認知過程中產(chǎn)生的高層認知抽象實體,而語音和圖像屬于較低層的原始輸入信號摊求。語音禽拔、圖像數(shù)據(jù)表達不需要特殊的編碼,并且有天生的順序性和關聯(lián)性,近似的數(shù)字會被認為是近似的特征睹栖。正如圖像是由像素組成硫惕,語言是由詞或字組成,可以把語言轉換為詞或字表示的集合野来。
然而恼除,不同于像素的大小天生具有色彩信息,詞的數(shù)值大小很難表征詞的含義曼氛。最初豁辉,人們?yōu)榱朔奖悖捎肙ne-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]
這種詞的表示方法十分簡單弹澎,也很容易實現(xiàn),解決了分類器難以處理屬性(Categorical)數(shù)據(jù)的問題努咐。它的缺點也很明顯:冗余太多苦蒿、無法體現(xiàn)詞與詞之間的關系∩裕可以看到佩迟,這10個詞的表示,彼此之間都是相互正交的竿屹,即任意兩個詞之間都不相關报强,并且任何兩個詞之間的距離也都是一樣的。同時拱燃,隨著詞數(shù)的增加秉溉,One-Hot向量的維度也會急劇增長,如果有3000個不同的詞碗誉,那么每個One-Hot詞向量都是3000維召嘶,而且只有一個位置為1,其余位置都是0,哮缺。雖然One-Hot編碼格式在傳統(tǒng)任務上表現(xiàn)出色弄跌,但是由于詞的維度太高,應用在深度學習上時尝苇,常常出現(xiàn)維度災難铛只,所以在深度學習中一般采用詞向量的表示形式埠胖。
詞向量(Word Vector),也被稱為詞嵌入(Word Embedding)格仲,并沒有嚴格統(tǒng)一的定義押袍。從概念上講,它是指把一個維數(shù)為所有詞的數(shù)量的高維空間(幾萬個字凯肋,幾十萬個詞)嵌入一個維度低得多的連續(xù)向量空間(通常是128或256維)中谊惭,每個單詞或詞組被映射為實數(shù)域上的向量。
詞向量有專門的訓練方法侮东,這里不會細講圈盔,感興趣的讀者可以學習斯坦福的CS224系列課程(包括CS224D和CS224N)。在本章的學習中悄雅,讀者只需要知道詞向量最重要的特征是相似詞的詞向量距離相近驱敲。每個詞的詞向量維度都是固定的,每一維都是連續(xù)的數(shù)宽闲。舉個例子:如果我們用二維的詞向量表示十個詞:足球众眨、比賽、教練容诬、隊伍娩梨、褲子、長褲览徒、上衣和編織狈定、折疊、拉习蓬,那么可視化出來的結果如下所示纽什。可以看出躲叼,同類的詞(足球相關的詞芦缰、衣服相關的詞、以及動詞)彼此聚集枫慷,相互之間的距離比較近饺藤。
可見,用詞向量表示的詞流礁,不僅所用維度會變少(由10維變成2維),其中也會包含更合理的語義信息罗丰。除了相鄰詞距離更近之外神帅,詞向量還有不少有趣的特征,如下圖所示萌抵。虛線的兩端分別是男性詞和女性詞找御,例如叔叔和阿姨元镀、兄弟和姐妹、男人和女人霎桅、先生和女士栖疑。可以看出滔驶,虛線的方向和長度都差不多遇革,因此可以認為vector(國王) - vector(女王) ≈ vector(男人) - vector(女人),換一種寫法就是vector(國王) - vector(男人) ≈ vector(女王) - vector(女人)揭糕,即國王可以看成男性君主萝快,女王可以看成女性君主,國王減去男性著角,只剩下君主的特征揪漩;女王減去女性,也只剩下君主的特征吏口,所以這二者相似奄容。
英文一般是用一個向量表示一個詞,也有使用一個向量表示一個字母的情況产徊。中文同樣也有一個詞或者一個字的詞向量表示昂勒,與英文采用空格來區(qū)分詞不同,中文的詞與詞之間沒有間隔囚痴,因此如果采用基于詞的詞向量表示叁怪,需要先進行中文分詞。
這里只對詞向量做一個概括性的介紹深滚,讓讀者對詞向量有一個直觀的認知奕谭。讀者只需要掌握詞向量技術用向量表征詞,相似詞之間的向量距離近痴荐。至于如何訓練詞向量血柳,如何評估詞向量等內容,這里不做介紹生兆,感興趣的讀者可以參看斯坦福大學的相關課程难捌。
在PyTorch中,針對詞向量有一個專門的層nn.Embedding鸦难,用來實現(xiàn)詞與詞向量的映射根吁。nn.Embedding具有一個權重,形狀是(num_words合蔽,embedding_dim)击敌,例如對上述例子中的10個詞,每個詞用2維向量表征拴事,對應的權重就是一個10 * 2的矩陣沃斤。Embedding的輸入形狀是N * W圣蝎,N是batch size,W是序列的長度衡瓶,輸出的形狀是N * W * embedding_dim徘公。輸入必須是LongTensor,F(xiàn)loatTensor必須通過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,這里應該注意區(qū)分筛武,除非特殊說明缝其,我們所遇到的絕大多數(shù)RNN都是指前者。在用深度學習解決NLP問題時徘六,RNN幾乎是必不可少的工具内边。假設我們現(xiàn)在已經(jīng)有每個詞的詞向量表示,那么我們將如何獲得這些詞所組成的句子的含義呢待锈?我們無法單純地分析一個詞漠其,因此每一個詞都依賴于前一個詞,單純地看某一個詞無法獲得句子的信息竿音。RNN則可以很好地解決這個問題和屎,通過每次利用之前詞的狀態(tài)(hidden state)和當前詞相結合計算新的狀態(tài)。
RNN的網(wǎng)絡結構圖如下所示春瞬。
-
:輸入詞的序列(共有
個詞)柴信,每個詞都是一個向量,通常用詞向量表示宽气。
-
:隱藏元(共
個)随常,每個隱藏元都由之前的詞計算得到,所以可以認為包含之前所有詞的信息萄涯。
代表初始信息绪氛,一般采用全0的向量進行初始化。
-
:轉換函數(shù)涝影,根據(jù)當前輸入
和前一個隱藏元的狀態(tài)
钞楼,計算新的隱藏元狀態(tài)
“懒眨可以認為
包含前
個詞的信息询件,即
,由
利用
和
計算得到的
唆樊,可以認為是包含前
個詞的信息宛琅。需要注意的是,每一次計算
都用同一個
逗旁。
一般是一個矩陣乘法運算嘿辟。
RNN最后會輸出所有隱藏元的信息,一般只使用最后一個隱藏元的信息片效,可以認為它包含了整個句子的信息红伦。
上圖所示的RNN結構通常被稱為Vanilla RNN,易于實現(xiàn)淀衣,并且簡單直觀昙读,但卻具有嚴重的梯度消失和梯度爆炸問題,難以訓練膨桥。目前在深度學習中普遍使用的是一種被稱為LSTM的RNN結構蛮浑。LSTM的全稱是Long Short Term Memory Networks,即長短期記憶網(wǎng)絡只嚣,其結構如下圖所示沮稚,它的結構與Vanilla RNN類似,也是通過不斷利用之前的狀態(tài)和當前的輸入來計算新的狀態(tài)册舞。但其函數(shù)更復雜蕴掏,除了隱藏元狀態(tài)(hidden state
),還有cell state
调鲸。每個LSTM單元的輸出有兩個盛杰,一個是下面的
(
同時被創(chuàng)建分支引到上面去),一個是上面的
线得。
的存在能很好地抑制梯度消失和梯度爆炸等問題饶唤。關于RNN和LSTM的介紹,可以參考colah的博客:Understanding LSTM Networks贯钩。
LSTM很好地解決了訓練RNN過程中出現(xiàn)的各種問題募狂,在幾乎各類問題中都要展現(xiàn)出好于Vanilla RNN的表現(xiàn)。在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的層數(shù)無關祸穷,只與序列長度有關,而hn和cn則相反勺三。
除了LSTM雷滚,PyTorch中還有LSTMCell。LSTM是對一個LSTM層的抽象吗坚,可以看成是由多個LSTMCell組成祈远。而使用LSTMCell則可以進行更精細化的操作呆万。LSTM還有一種變體稱為GRU(Gated Recurrent Unit),相較于LSTM车份,GRU的速度更快谋减,效果也接近。在某些對速度要求十分嚴格的場景可以使用GRU作為LSTM的替代品扫沼。
9.2 CharRNN
CharRNN的作者Andrej Karpathy現(xiàn)任特斯拉AI主管出爹,也曾是最優(yōu)的深度學習課程CS231n的主講人。關于CharRNN缎除,Andrej Karpathy有一篇論文《Visualizing and understanding recurrent networks》發(fā)表于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可以看成一個分類問題:根據(jù)當前字符骤坐,預測下一個字符绪杏。對于英文字母來說,文本中用到的總共不超過128個字符(假設就是128個字符)纽绍,所以預測問題就可以改成128分類問題:將每一個隱藏元的輸出蕾久,輸入到一個全連接層,計算輸出屬于128個字符的概率拌夏,計算交叉熵損失即可僧著。
總結成一句話:CharRNN通過利用當前字的隱藏元狀態(tài)預測下一個字,把生成問題變成了分類問題障簿。
訓練完成之后盹愚,我們就可以利用網(wǎng)絡進行文本生成來寫詩。生成的步驟如下圖所示站故。
- 首先輸入一個起始的字符(一般用<START>標識)液荸,計算輸出屬于每個字符的概率仇冯。
- 選擇概率最大的一個字符作為輸出。
- 將上一步的輸出作為輸入,繼續(xù)輸入到網(wǎng)絡中笔喉,計算輸出屬于每個字符的概率。
- 一直重復這個過程誊爹。
- 最后將所有字符拼接組合在一起芬首,就得到最后的生成結果。
CharRNN還有一些不夠嚴謹之處吮成,例如它使用One-Hot的形式表示詞橱乱,而不是使用詞向量;使用RNN而不是LSTM赁豆。在本次實驗中仅醇,我們將對這些進行改進,并利用常用的中文語料庫進行訓練魔种。
9.3 用PyTorch實現(xiàn)CharRNN
本章所有源碼及數(shù)據(jù)百度網(wǎng)盤下載析二,提取碼:vqid。
本次實驗采用的數(shù)據(jù)是來自GitHub上中文詩詞愛好者收集的5萬多首唐詩原文。原始文件是Json文件和Sqlite數(shù)據(jù)庫的存儲格式叶摄。筆者在此基礎上做了兩個修改:
- 繁體中文改成簡體中文:原始數(shù)據(jù)是繁體中文的属韧,雖然詩詞更有韻味,但是對于習慣了簡體中文的讀者來說可能還是有點別扭蛤吓。
- 把所有的數(shù)據(jù)進行截斷和補齊成一樣的長度:由于不同詩歌的長度不一樣宵喂,不易拼接成一個batch,因此需要將它們處理成一樣的長度会傲。
最后為了方便讀者復現(xiàn)實驗锅棕,筆者對原始數(shù)據(jù)進行了處理,并提供了一個numpy的壓縮包tang.npz淌山,里面包含三個對象裸燎。
- data:(57580,125)的numpy數(shù)組,總共有57580首詩歌泼疑,每首詩歌長度為125個字符(不足125補空格德绿,超過125的丟棄)。
- word2ix:每個詞和它對應的序號退渗,例如“春”這個詞對應的序號是1000移稳。
- ix2word:每個序號和它對應的詞,例如序號1000對應著“春”這個詞会油。
其中data對詩歌的處理步驟如下个粱。
- 以《靜夜思》這首詩為例,先轉成list钞啸,并在前面和后面加上起始符<START>和終止符<EOP>几蜻,變成:
['<START>',
'床','前','明','月','光',',',
'疑','是','地','上','霜','体斩。',
'舉','頭','望','明','月','梭稚,',
'低','頭','思','故','鄉(xiāng)','。',
'<EOP>']
- 對于長度達不到125個字符的詩歌絮吵,在前面補上空格(用</s>表示)弧烤,直到長度達到125,變成如下格式:
['</s>','</s>','</s>',......,
'<START>',
'床','前','明','月','光','蹬敲,',
'疑','是','地','上','霜','暇昂。',
'舉','頭','望','明','月',',',
'低','頭','思','故','鄉(xiāng)','伴嗡。',
'<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數(shù)組。
將numpy的數(shù)據(jù)還原成詩歌的例子如下:
import numpy as np
# 加載數(shù)據(jù)
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>
數(shù)據(jù)處理完后佛南,再來看看本次實驗的文件組織架構:
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數(shù)據(jù)。
- data.py:對原始的唐詩文本進行預處理绵载,如果直接使用tang.npz埂陆,則不需要對json的數(shù)據(jù)進行處理。
程序中主要的配置選項和命令行參數(shù)如下:
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中主要有以下三個函數(shù):
- _parseRawData:解析原始的json數(shù)據(jù)鹃栽,提取成list。
- pad_sequences:將不同長度的數(shù)據(jù)截斷或補齊成一樣的長度躯畴。
- get_data:給主程序調用的接口民鼓。如果二進制文件存在,則直接讀取二進制的numpy文件蓬抄;否則讀取文本文件進行處理丰嘉,并將處理結果保存成二進制文件。
二進制文件tang.npz已在本書附帶代碼中提供嚷缭,讀者可以不必下載原始的json文件饮亏,直接加載處理好的二進制文件即可。
data.py中的get_data函數(shù)的代碼如下:
def get_data(opt):
"""
@param opt 配置選項 Config對象
@return word2ix: dict,每個字對應的序號,形如u'月'->100
@return ix2word: dict,每個序號對應的字克滴,形如'100'->u'月'
@return data: numpy數(shù)組逼争,每一行是一首詩對應的字的下標
"""
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>"]
# 將每首詩歌保存的內容由‘字’變成‘數(shù)’
# 形如[春,江,花,月,夜]變成[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的訓練函數(shù)train中就可以這么使用數(shù)據(jù):
# 獲取數(shù)據(jù)
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實現(xiàn)為一個Dataset對象着帽,但是它還是可以利用DataLoader進行多線程加載杂伟。這是因為data作為一個Tensor對象,自身已經(jīng)實現(xiàn)了getitem和len方法仍翰。其中赫粥,data.getitem(0)等價于data[0],len(data)返回data.size(0)予借,這種運行方式被稱為鴨子類型(Duck Typing)越平,是一種動態(tài)類型的風格。在這種風格中灵迫,一個對象有效的語義秦叛,不是由繼承自特定的類或實現(xiàn)特定的接口決定,而是由當前方法和屬性的集合決定瀑粥。這個概念的名字來源于James Whitcomb Riley提出的鴨子測試挣跋,“鴨子測試”可以這樣描述:“當看到一只鳥走起來像鴨子、游起來像鴨子狞换、叫起來也像鴨子避咆,那么這只鳥就可以被稱為鴨子”。同理修噪,當一個對象可以向Dataset對象一樣提供getitem和len方法時查库,它就可以被稱為Dataset。
另外需要注意的是黄琼,這種直接把所有的數(shù)據(jù)全部加載到內存的做法膨报,在某些情況下會比較占內存,但是速度會有很大的提升适荣,因為它避免了頻繁的硬盤讀寫现柠,減少了I/O等待,在實驗中如果數(shù)據(jù)量足夠小弛矛,可以酌情選擇把數(shù)據(jù)全部預處理成二進制的文件全部加載到內存中够吩。
模型構建的代碼保存在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
總體而言,輸入的字詞序號經(jīng)過nn.Embedding得到相應的詞向量表示丈氓,然后利用兩層的LSTM提取詞的所有隱藏元的信息周循,再利用隱藏元的信息進行分類强法,判斷輸出屬于每一個詞的概率。這里使用LSTM而不是LSTMCell是為了簡化代碼湾笛。當輸入的序列長度為1時饮怯,LSTM實現(xiàn)的功能與LSTMCell一樣。需要注意的是嚎研,這里輸入(input)的數(shù)據(jù)形狀是(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)
# 獲取數(shù)據(jù)
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))
這里需要注意的是數(shù)據(jù),以“床前明月光”這句詩為例系忙,輸入是“床前明月”蛹疯,預測的目標是“前明月光”:
- 輸入“床”的時候捺弦,網(wǎng)絡預測的下一個字的目標是“前”苦始。
- 輸入“前”的時候柠贤,網(wǎng)絡預測的下一個字的目標是“明”餐弱。
- 輸入“明”的時候驮瞧,網(wǎng)絡預測的下一個字的目標是“月”。
- 輸入“月”的時候蒜埋,網(wǎng)絡預測的下一個字的目標是“光”。
- ……
這種錯位的方式胎挎,通過data_[:-1,:]和data_[1:,:]實現(xiàn)芽卿。前者包含從第0個詞直到最后一個詞(不包含)筷转,后者是第一個詞到結尾(包括最后一個詞)袭蝗。由于是分類問題啤咽,因此我們使用交叉熵損失作為評估函數(shù)。
接著我們來看看如何用訓練好的模型寫詩,第一種是給定詩歌的開頭幾個字接著寫詩歌。實現(xiàn)如下:
def generate(model, start_words, ix2word, word2ix, prefix_words=None):
"""
給定幾個詞轨香,根據(jù)這幾個詞接著生成一首完整的詩歌
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
這種生成方式是根據(jù)給定部分文字督赤,然后接著完成詩歌余下的部分,生成的步驟如下:
- 首先利用給定的文字“床前明月光”尉桩,計算隱藏元盛卡,并預測下一個詞(預測的結果是“,”)筑凫。
- 將上一步計算的隱藏元和輸出(“滑沧,”)作為新的輸入,繼續(xù)預測新的輸出和計算隱藏元巍实。
- 將上一步計算的隱藏元和輸出作為新的輸入滓技,繼續(xù)預測新的輸出和計算隱藏元。
- ……
這里還有一個選項是prefix_word棚潦,可以用來控制生成的詩歌的意境和長短令漂。比如以“床前明月光”作為start_words輸入,在不指定prefix_words時丸边,生成的詩歌如下:
床前明月光叠必,朗朗秋風清。
昨夜雨后人妹窖,一身一招迎纬朝。
何必在天末,安得佐戎庭骄呼。
豈伊不可越共苛,所以為我情。
在指定prefix_words為“狂沙將軍戰(zhàn)燕然蜓萄,大漠孤煙黃河騎隅茎。”的情況下嫉沽,生成的詩歌如下(明顯帶有邊塞氣息辟犀,而且由五言古詩變成了七言古詩):
床前明月光照耀,城下射蛟沙漠漠绸硕。
父子號犬不可親堂竟,劍門弟子何紛紛。
胡笳一聲下馬來臣咖,關城繚繞天河去跃捣。
戰(zhàn)士忠州十二紀漱牵,后賢美人不敢攀夺蛇。
還可以生成藏頭詩,實現(xiàn)的方式如下:
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 # 用來指示已經(jīng)生成了多少句藏頭詩
# 上一個詞
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:
# 如果生成的詩歌已經(jīng)包含全部藏頭的詞夷狰,則結束
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)上一步預測的字作為輸入,繼續(xù)預測下一個字书劝。
(3)重復第二步进倍,直到輸出的字是“」憾裕”或者“猾昆!”,說明一句詩結束了洞斯,可以繼續(xù)輸入下一句藏頭的字毡庆,跳到第一步。
(4)重復上述步驟烙如,直到所有藏頭的字都輸入完畢么抗。
上述兩種生成詩歌的方法還需要提供命令行接口,實現(xiàn)方式如下:
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='大漠孤煙直,長河落日圓认臊。' # 意境和格律
藏頭詩“深度學習”的結果如下:
深林無外物圃庭,長嘯似神仙。
度石無人跡失晴,青冥似水年剧腻。
學馴疑有匠,澁尺不成冤涂屁。
習坎無遺跡书在,幽居不得仙。
生成的很多詩歌都是高質量的拆又,有些甚至已經(jīng)學會了簡單的對偶和押韻儒旬。例如:
落帆迷舊里,望月到西州帖族。
浩蕩江南岸栈源,高情江海鷗。
風帆隨雁吹竖般,江月照旌樓甚垦。
泛泛揚州客,停舟泛水鷗涣雕。
很有意思的是艰亮,如果生成的詩歌長度足夠長,會發(fā)現(xiàn)生成的詩歌意境會慢慢改變挣郭,以至于和最開始的毫無關系迄埃。例如:
大漠孤煙照高閣,夾城飛鞚連天闕兑障。
青絲不語不知音侄非,一曲繁華空繞山伶棒。
昔年曾作江南客,今日相逢不相識彩库。
今年花落花滿園,妾心不似君不同先蒋。
回頭舞馬邯鄲陌骇钦,回頭笑語歌聲鬧。
夫君欲問不相見竞漾,今日相看不相見眯搭。
君不見君心斷斷腸,莫言此地情何必业岁?
桃花陌陌不堪惜鳞仙,君恩不似春光色。
一開始是邊塞詩笔时,然后變成了羈旅懷人棍好,最后變成了閨怨詩。
意境允耿、格式和韻腳等信息都保存于隱藏元之中借笙,隨著輸入的不斷變化,隱藏元保存的信息也在不斷變化较锡,有些信息及時經(jīng)過了很長的時間依舊可以保存下來(比如詩歌的長短业稼,五言還是七言),而有些信息隨著輸入的變化也發(fā)生較大的改變蚂蕴。在本程序中低散,我們使用prefix_words就是為了網(wǎng)絡能夠利用給定的輸入初始化隱藏元的狀態(tài)。事實上骡楼,隱藏元的每一個數(shù)都控制著生成詩歌的某一部分屬性熔号,感興趣的讀者可以嘗試調整隱藏元的數(shù)值,觀察生成的詩歌有什么變化鸟整。
總體上跨嘉,程序生成的詩歌效果還不錯,字詞之間的組合也比較有意境吃嘿,但是詩歌卻反一個一以貫之的主題祠乃,讀者很難從一首詩歌中得到一個主旨。這是因為隨著詩歌長度的增加兑燥,即使是LSTM也不可避免地忘記幾十個字之前的輸入亮瓷。另外一個比較突出的問題就是,生成的詩歌中經(jīng)常出現(xiàn)重復的詞降瞳,這在傳統(tǒng)的詩歌創(chuàng)作中應該是極力避免的現(xiàn)象嘱支,而在程序生成的詩歌中卻常常出現(xiàn)蚓胸。
本章介紹了自然語言處理中的一些基本概念,并帶領讀者實現(xiàn)了一個能夠生成古詩的小程序除师。程序從唐詩中學習沛膳,并模仿古人寫出了不少優(yōu)美的詩句。