簡單明朗的 RNN 寫詩教程

段小輝發表於2021-01-26

簡單明朗的 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]獲得詩的內容。

下述程式碼實現了兩個功能:

  1. 獲得符合要求的詩:(len(poetry)-1) % 6,每一首五言詩,包括“,。”一共有\(6*n\) 個字,同時每一首詩是以 "\n" 結尾的,因為我們(len(poetry)-1)%6==0則就代表符合要求。同時五言詩的第6個字元是","——> 使用poetrys儲存。
  2. 獲得詩中出現的字元。——>使用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

參考

  1. RNN模型與NLP應用(6/9):Text Generation (自動文字生成)
  2. Poems_generator_Keras
  3. 深度學習框架PyTorch:入門與實踐
  4. 用Keras實現RNN+LSTM的模型自動編寫古詩
  5. Keras-SimpleRNN
  6. Keras-fit_generator
  7. 資料探勘入門系列教程(十一)之keras入門使用以及構建DNN網路識別MNIST
  8. Keras 如何使用fit和fit_generator

相關文章