簡單明朗的 RNN 寫詩教程
本來想做一個標題黨的,取了一個史上最簡單的 RNN 寫詩教程這標題,但是後來想了想,這TM不就是標題黨嗎?怎麼活成了自己最討厭的模樣??後來就改成了這個標題。
在上篇部落格網路流量預測入門(一)之RNN 介紹中,介紹了RNN的原理,而在這篇部落格中,將介紹如何使用keras構建RNN,然後自動寫詩。
專案地址:Github:https://github.com/xiaohuiduan/rnn_chinese_poetry
資料集介紹
既然是寫詩,當然得有資料集,不過還好有大神已經將資料集準備好了,具體資料集的來源已不可知,因為網上基本上都是使用這個資料集。(如果有人知道,可以在評論區指出,然後我再新增上)
資料集地址:Github,資料集部分資料如下所示:
在資料集中,每一行都是一首唐詩,其中,詩的題目和內容以 ":" 分開,每一首詩都有題目,但是不一定有內容(也就是說內容可能為空)。其中,詩內容中的標點符號都是全形符號。有一些詩五言詩,不過也有一些詩不是五言的。當然,我們只考慮五言詩(大概有27k首)。
程式碼思路
輸入 and 輸出
首先我們得先弄清我們要幹什麼,然後才能更好得寫程式碼。如標題所示,目的是使用RNN寫詩,那麼必然有輸入和輸出。那麼問題來了,RNN的輸入是什麼,輸出是什麼?
我們希望rnn能夠寫詩,那麼怎麼寫呢?我們這樣定義如下的方式:
RNN接受 6個字元(5個字+一個標點符號),然後輸出下一個字元。至於怎麼生成一首完整的詩詞,等到後面討論。
RNN當然不能夠直接接受 "床前明月光," 這個中文的輸入,我們要對其進行 Encode,變成數字,然後才能夠輸入到RNN網路中。同理,RNN輸出的肯定也不是一箇中文字元,我們也要對其進行Decode 才能將輸出變成一箇中文字元。
怎麼進行Encode,有一個很簡單的方法,那就是進行one-hot編碼,對於每一個字(包括標點符號在內)我們都進行onehot編碼,這樣就可以了。但實際上,這個這樣會有一點小問題。在資料集中,所有符合條件的詩,大概由近 7,000 個字元組成,如果對每一個字都進行onehot編碼的話,就會消耗大量的記憶體,同時也會加大計算的複雜度。
因此,我們定義如下:只對前出現頻率最多的 2999 個字元進行 one-hot 編碼,對於剩下的字,用 “ ”(空格字元)代替。這樣一共只需要對3000個字元進行one-hot編碼就?了(2999個字元+一個空格字元)。
訓練集構建
在前面我們定義了RNN的輸入和輸出,同時也有詩的資料集, 那麼我們構建訓練集呢?參考RNN模型與NLP應用(6/9):Text Generation (自動文字生成)
具體步驟如下圖所示:我們將一句詩可以進行如下切分。然後將切分得到的資料進行one-hot編碼,然後進行訓練即可。(這樣看來,每一首詩可以生成很多的資料集)
生成一首完整的詩
前面我們討論了關於網路的輸入和輸出,以及資料集的構建,那麼,假如我們有一個已經訓練好的模型,如何來產生一首詩的?
生成一首完整的詩的流程如下所示,與訓練的操作有點類似,只不過會將RNN的輸出重新當作RNN的輸入。(以此來產生符合字數要求的詩)
經過上述的操作,大家實際上可以嘗試的寫一些程式碼了,基本上不會有很大的問題。接下來,我將講一講具體怎麼實現。
程式碼實現
首先定義一些配置:
- DISALLOWED_WORDS:如果在詩中出現了DISALLOWED_WORDS,則捨棄這首詩。
# 詩data的地址
poetry_data_path = "./data/poetry.txt"
# 如果詩詞中出現這些詞,則將詩捨棄
DISALLOWED_WORDS = ['(', ')', '(', ')', '__', '《', '》', '【', '】', '[', ']']
# 取3000個字作詩,其中包括空格字元
WORD_NUM = 3000
# 將出現少的字使用空格代替
UNKONW_CHAR = " "
# 根據前6個字預測下一個字,比如說根據“寒隨窮律變,”預測“春”
TRAIN_NUM = 6
讀取檔案
針對於資料集,我們有如下的要求:
- 必須是五言詩(不過下面的程式碼無法完全保證是五言詩),同時至少要有兩句詩
- 不能出現上文中定義的DISALLOWED_WORDS
前面我們說了,每一首詩必有題目和內容(內容可以為空),其中,題目和內容以 ":"(半形)分開,因此,我們可以通過 line.split(":")[1]
獲得詩的內容。
下述程式碼實現了兩個功能:
- 獲得符合要求的詩:
(len(poetry)-1) % 6
,每一首五言詩,包括“,。”一共有\(6*n\) 個字,同時每一首詩是以 "\n" 結尾的,因為我們(len(poetry)-1)%6==0
則就代表符合要求。同時五言詩的第6個字元是","——> 使用poetrys
儲存。 - 獲得詩中出現的字元。——>使用
all_word
儲存。
# 儲存詩詞
poetrys = []
# 儲存在詩詞中出現的字
all_word = []
with open(poetry_data_path,encoding="utf-8") as f:
for line in f:
# 獲得詩的內容
poetry = line.split(":")[1].replace(" ","")
flag = True
# 如果在句子中出現'(', ')', '(', ')', '__', '《', '》', '【', '】', '[', ']'則捨棄
for dis_word in DISALLOWED_WORDS:
if dis_word in poetry:
flag = False
break
# 只需要5言的詩(兩句詩包括標點符號就是12個字),假如少於兩句詩則捨棄
if len(poetry) < 12 or poetry[5] != ',' or (len(poetry)-1) % 6 != 0:
flag = False
if flag:
# 統計出現的詞
for word in poetry:
all_word.append(word)
poetrys.append(poetry)
統計字數
前面我們說過,在資料集中,所有符合條件的詩,大概由近 7,000 個字組成,如果對每一個字都進行one-hot編碼的話,就會浪費大量的記憶體,加大計算的複雜度。解決方法可以這樣做:
使用Counter對字數進行統計,然後根據出現的次數進行排序,最後得到出現頻率最多的2999個字。
from collections import Counter
# 對字數進行統計
counter = Counter(all_word)
# 根據出現的次數,進行從大到小的排序
word_count = sorted(counter.items(),key=lambda x : -x[1])
most_num_word,_ = zip(*word_count)
# 取前2999個字,然後在最後加上" "
use_words = most_num_word[:WORD_NUM - 1] + (UNKONW_CHAR,)
構建word 與 id的對映
我們需要對word進行onehot編碼,怎麼編呢?很簡單,每一個word對應一個id,然後對這個id進行one-hot編碼就行了。因此我們需要構建word到id的對映。
舉個例子:如果一共只有3個字“唐”,“宋”,“明”,然後我們可以構建如下的對映:
"唐" ——> 0 ;"宋"——>1;"明"——>2;進行one-hot編碼後,則就變成了:
- 唐:[1,0,0]
- 宋:[0,1,0]
- 明:[0,0,1]
構建word與id的對映是必須的,經過如下簡單的程式碼,便構成了對映。
# word 到 id的對映 {',': 0,'。': 1,'\n': 2,'不': 3,'人': 4,'山': 5,……}
word_id_dict = {word:index for index,word in enumerate(use_words)}
# id 到 word的對映 {0: ',',1: '。',2: '\n',3: '不',4: '人',5: '山',……}
id_word_dict = {index:word for index,word in enumerate(use_words)}
轉成one-hot程式碼
下面定義兩個函式:
-
word_to_one_hot將一個字轉成one-hot 形式
-
phrase_to_one_hot 將一個句子轉成one-hot形式
import numpy as np
def word_to_one_hot(word):
"""將一個字轉成onehot形式
:param word: [一個字]
:type word: [str]
"""
one_hot_word = np.zeros(WORD_NUM)
# 假如字是生僻字,則變成空格
if word not in word_id_dict.keys():
word = UNKONW_CHAR
index = word_id_dict[word]
one_hot_word[index] = 1
return one_hot_word
def phrase_to_one_hot(phrase):
"""將一個句子轉成onehot
:param phrase: [一個句子]
:type poetry: [str]
"""
one_hot_phrase = []
for word in phrase:
one_hot_phrase.append(word_to_one_hot(word))
return one_hot_phrase
隨機打亂資料
np.random.shuffle(poetrys)
構建訓練集
然後我們需要進行如下操作,根據詩構建資料集(one-hot編碼之前的資料集)。
構建資料集的時候我們需要注意一件事情,需要區分不同的詩(因為我們總不可能用A的詩去預測B的詩噻,hhh)。每一首詩都是以 "\n" 結尾的,因此,當迴圈到"\n"時,就代表對於這首詩,我們已經構建好資料集了(上圖中的X_Data【用X_train_word
表示】,Y_Data【用Y_train_word
表示】)。
X_train_word = []
Y_train_word = []
for poetry in poetrys:
for i in range(len(poetry)):
X = poetry[i:i+TRAIN_NUM]
Y = poetry[i+TRAIN_NUM]
if "\n" not in X and "\n" not in Y:
X_train_word.append(X)
Y_train_word.append(Y)
else:
break
在沒有打亂順序的情況下,部分結果如下所示:
構建模型
使用的框架:
- keras:2.4.3:如果想使用我訓練好的模型,請保持版本一致。如果自己訓練的話,就無所謂了。
模型圖如下所示,模型結構參考Poems_generator_Keras,關於SimpleRNN的介紹可以參考Keras-SimpleRNN,關於如何使用keras構建神經網路可以參考資料探勘入門系列教程(十一)之keras入門使用以及構建DNN網路識別MNIST。
在前面說了,RNN模型輸入的是一個 6個字元 的句子,因此經過one-hot編碼後就會變成shape為(6,3000)的陣列,而輸出為一個字元,對應one-hot編碼的shape為(3000)。
程式碼如下所示:
import keras
from keras.callbacks import LambdaCallback,ModelCheckpoint
from keras.models import Input, Model
from keras.layers import Dropout, Dense,SimpleRNN
from keras.optimizers import Adam
from keras.utils import plot_model
def build_model():
print('building model')
# 輸入的dimension
input_tensor = Input(shape=(TRAIN_NUM,WORD_NUM))
rnn = SimpleRNN(512,return_sequences=True)(input_tensor)
dropout = Dropout(0.6)(rnn)
rnn = SimpleRNN(256)(dropout)
dropout = Dropout(0.6)(rnn)
dense = Dense(WORD_NUM, activation='softmax')(dropout)
model = Model(inputs=input_tensor, outputs=dense)
optimizer = Adam(lr=0.001)
model.compile(loss='categorical_crossentropy', optimizer=optimizer, metrics=['accuracy'])
model.summary()
# 畫出模型圖
# plot_model(model, to_file='model.png', show_shapes=True, expand_nested=True, dpi=500)
return model
對於SimpleRNN,如果return_sequences=True
,則代表其返回如下:
如果return_sequences=False
(預設),則代表返回如下所示:
這個模型中,套了兩層RNN。
model = build_model()
批載入資料
這次資料集比較大,一共有\(1559196\)份資料,一般來說沒有這麼大記憶體將其所有的資料一次性全部轉成one-hot形式。
因此,我們可以這樣做:在訓練的時候才開始載入資料,每一次只需要載入batch_size的資料,然後只需要將batch_size 大小的資料轉成one-hot形式,然後進行訓練。在這種情況下,只需要將batch-size的資料轉成one-hot,可以大大減小記憶體消耗。
so,使用keras訓練的時候,不能使用fit(因為fit需要一次將資料集全部放入RAM中),而應該使用fit_generator,關於其使用推薦看看:Keras 如何使用fit和fit_generator
import math
def get_batch(batch_size = 32):
"""源源不斷產生產生one-hot編碼的訓練資料
:param batch_size: [一次產生訓練資料的大小], defaults to 32
:type batch_size: int, optional
:yield: [返回X(np.array(X_train_batch))和Y(np.array(Y_train_batch))]
:rtype: [X.shape為(batch_size, 6, 3000) , Y.shape資料的shape(batch_size, 3000)]
"""
# 確定每輪有多少個batch
steps = math.ceil(len(X_train_word) / batch_size)
while True:
for i in range(steps):
X_train_batch = []
Y_train_batch = []
X_batch_datas = X_train_word[i*batch_size:(i+1)*batch_size]
Y_batch_datas = Y_train_word[i*batch_size:(i+1)*batch_size]
for x,y in zip(X_batch_datas,Y_batch_datas):
X_train_batch.append(phrase_to_one_hot(x))
Y_train_batch.append(word_to_one_hot(y))
yield np.array(X_train_batch),np.array(Y_train_batch)
訓練的過程中生成詩句
在訓練的過程中,可以每經過一定數量的epoch生成一首詩,生成詩的操作如下:
在訓練的過程中,呼叫generate_sample_result
,即可產生五言詩,然後將生成的詩寫入到out/out.txt
中。
def predict_next(x):
""" 根據X預測下一個字元
:param x: [輸入資料]
:type x: [x的shape為(1,TRAIN_NUM,WORD_NUM)]
:return: [最大概率字元的索引,有可能為為2999,也就是預測的字元可能為“ ”]
:rtype: [int]
"""
predict_y = model.predict(x)[0]
# 獲得最大概率的索引
index = np.argmax(predict_y)
return index
def generate_sample_result(epoch, logs):
"""生成五言詩
:param epoch: [目前模型訓練的epoch]
:type epoch: [int]
:param logs: [模型訓練日誌]
:type logs: [list]
"""
# 每個epoch都產生輸出
if epoch % 1 == 0:
# 根據“一朝春夏改,”生成詩
predict_sen = "一朝春夏改,"
predict_data = predict_sen
# 生成的4句五言詩(4 * 6 = 24)
while len(predict_sen) < 24:
X_data = np.array(phrase_to_one_hot(predict_data)).reshape(1,TRAIN_NUM,WORD_NUM)
# 根據6個字元預測下一個字元
y = predict_next(X_data)
predict_sen = predict_sen+ id_word_dict[y]
# “寒隨窮律變,” ——> “隨窮律變,春”
predict_data = predict_data[1:]+id_word_dict[y]
# 將資料寫入檔案
with open('out/out.txt', 'a',encoding='utf-8') as f:
f.write(write_data+'\n')
開始訓練
在訓練的時候,每隔一個epoch,都會將模型進行儲存,每個epoch完成的時候,都會呼叫generate_sample_result
生成詩。
batch_size = 2048
model.fit_generator(
generator=get_batch(batch_size),
verbose=True,
steps_per_epoch=math.ceil(len(X_train_word) / batch_size),
epochs=1000000,
callbacks=[
ModelCheckpoint("poetry_model.hdf5",verbose=1,monitor='val_loss',period=1),
# 每次完成一個epoch會呼叫generate_sample_result產生五言詩
LambdaCallback(on_epoch_end=generate_sample_result)
]
)
因為我的電腦就是一個mx250小水管,我就放在kaggle上面跑了,畢竟白嫖它不香嗎?如果實在想自己跑,但是有沒有比較好的GPU,可以嘗試將len(X_train_word)
改成其他的數,比如說“100000”。要在如下的兩個地方改,這樣的話,很快就可以出訓練的結果。(這樣會導致訓練的時候無法覆蓋整個資料集。)
詩詞生成
我在Github中提供了訓練好的模型(注意keras版本是2.4.3),在 test.ipynb 中提供瞭如何載入模型然後生成詩句的方法,在這裡就不贅述了。
最後簡單的展示一下生成的結果(實際上模型訓練的效果並不是很好,?):
部落格園牛逼,州心青山人。雨水不三在,花去不相河。
總結
在這篇部落格中,詳細介紹瞭如何使用keras模型構建一個RNN模型,然後使用其來自動生成五言詩。實際上,在個人看來,程式碼不是難題,最難的應該是思路,如果構建一個清晰明朗的思路,才是能夠寫好程式碼的前提。
專案地址:Github