TensorFlow2 學習——RNN生成古詩詞

TensorFlow2 學習——RNN生成古詩詞

0. 前言

  • 利用循環神經網絡RNN可以做各種連續性數據的預測,其中生成古詩詞是一件非常有趣的事,特此分享我的學習經驗
  • 先來幾首藏頭詩吧 ^_^
    寧靜致遠
    寧隨古峯一里鄉,靜在門林滿樹通。致有舊人身自住,遠花不似水花中。
    
    風起雲湧
    風山一夕月,起落鳥紛紛。雲散生何處,湧深千尺村。
    
    春夏秋冬
    春來空樹柳微時,夏火遙愁獨寂寥。秋上北陵村未苦,冬來寒向入樓僧。
    
  • 另外,我的實現參考了這篇博客,非常感謝這位博主的無私奉獻!^_^

1. 導包

  • 代碼
    import math
    import re
    import numpy as np
    import tensorflow as tf
    from collections import Counter
    
  • 版本信息
    • Python 3.7.6
    • Tensorflow 2.1.0
    • Anaconda conda 4.8.3

2. 數據預處理

2.1 原始數據

  • 原始數據(百度網盤: poetry.txt 提取碼: b2pp)
  • 內容示例如下
    過老子廟:仙居懷聖德,靈廟肅神心。草合人蹤斷,塵濃鳥跡深。流沙丹竈沒,關路紫煙沉。獨傷千載後,空餘松柏林。
    途次陝州:境出三秦外,途分二陝中。山川入虞虢,風俗限西東。樹古棠陰在,耕餘讓畔空。鳴笳從此去,行見洛陽宮。
    野次喜雪:拂曙闢行宮,寒皋野望通。每雲低遠岫,飛雪舞長空。賦象恆依物,縈迴屢逐風。爲知勤恤意,先此示年豐。
    送賀知章歸四明:遺榮期入道,辭老竟抽簪。豈不惜賢達,其如高尚心。寰中得祕要,方外散幽襟。獨有青門餞,羣僚悵別深。
    軒遊宮十五夜:行邁離秦國,巡方赴洛師。路逢三五夜,春色暗中期。關外長河轉,宮中淑氣遲。歌鐘對明月,不減舊遊時。
    
  • 我們的原始數據poetry.txt中,每一行是一首詩,按":"符號分隔爲詩的標題、內容,其中還有逗號、句號。

2.2 數據預處理

  • 首先,因爲我們想訓練的是寫詩的內容,因此等下訓練的時候只需要詩的內容即可。
  • 另外,我們的數據中可能存在部分符號的問題,例如中英文符號混用、每行存在多個冒號、數據中存在其他符號等問題,因此我們需要對數據進行清洗。
    # 數據路徑
    DATA_PATH = './datasets/poetry.txt'
    # 單行詩最大長度
    MAX_LEN = 64
    # 禁用的字符,擁有以下符號的詩將被忽略
    DISALLOWED_WORDS = ['(', ')', '(', ')', '__', '《', '》', '【', '】', '[', ']']
    	
    # 一首詩(一行)對應一個列表的元素
    poetry = []
    
    # 按行讀取數據 poetry.txt
    with open(DATA_PATH, 'r', encoding='utf-8') as f:
        lines = f.readlines()
    # 遍歷處理每一條數據    
    for line in lines:
        # 利用正則表達式拆分標題和內容
        fields = re.split(r"[::]", line)
        # 跳過異常數據
        if len(fields) != 2:
            continue
        # 得到詩詞內容(後面不需要標題)
        content = fields[1]
        # 跳過內容過長的詩詞
        if len(content) > MAX_LEN - 2:
            continue
        # 跳過存在禁用符的詩詞
        if any(word in content for word in DISALLOWED_WORDS):
            continue
            
        poetry.append(content.replace('\n', '')) # 最後要記得刪除換行符
    
  • 接着,我們來打印幾首處理後的詩看看
    for i in range(0, 5):
        print(poetry[i])
    
    繫馬宮槐老,持杯店菊黃。故交今不見,流恨滿川光。
    世間何事不潸然,得失人情命不延。適向蔡家廳上飲,回頭已見一千年。
    只領千餘騎,長驅磧邑間。雲州多警急,雪夜度關山。石響鈴聲遠,天寒弓力慳。秦樓休悵望,不日凱歌還。
    今日花前飲,甘心醉數杯。但愁花有語,不爲老人開。
    秋來吟更苦,半咽半隨風。禪客心應亂,愁人耳願聾。雨晴煙樹裏,日晚古城中。遠思應難盡,誰當與我同。
    
  • 現在,我們需要對詩句進行分詞,不過考慮到爲了最後生成的詩的長度的整齊性,以及便利性,我們在這裏按單個字符進行拆分。(你也可以使用專業的分詞工具,例如jieba、hanlp等)
  • 並且,我們還需要統計一下詞頻,刪除掉出現次數較低的詞
    # 最小詞頻
    MIN_WORD_FREQUENCY = 8
    
    # 統計詞頻,利用Counter可以直接按單個字符進行統計詞頻
    counter = Counter()
    for line in poetry:
        counter.update(line)
    # 過濾掉低詞頻的詞
    tokens = [token for token, count in counter.items() if count >= MIN_WORD_FREQUENCY]
    
  • 看看我們的詞頻統計結果如何
    i = 0
    for token, count in counter.items():
        if i >= 5:
            break;
        print(token, "->",count)
        i += 1
    
    寒 -> 2627
    隨 -> 1039
    窮 -> 487
    律 -> 119
    變 -> 286
    
  • 除此之外,還有幾個點需要我們考慮
    • 需要用2個符號分別表示一首詩的起始點、結束點。這樣我們的神經網絡才能由訓練得知什麼時候寫完一首詩。
    • 需要一個字符來代表所有未知的字符。因爲我們的數據去除了低頻詞,並且我們的文本不可能包含全世界所有的字符,因此需要一個字符來表示未知字符。
    • 需要一個字符來填充詩詞,以保證詩詞的長度統一。因爲單個批次內訓練的數據特徵長度必須一致。
  • 因此,我們需要設置幾個特殊字符
    # 補上特殊詞標記:填充字符標記、未知詞標記、開始標記、結束標記
    tokens = ["[PAD]", "[NONE]", "[START]", "[END]"] + tokens
    
  • 最後,我們需要對生成的所有詞進行編號,方便後面進行轉碼
    # 映射: 詞 -> 編號
    word_idx = {}
    # 映射: 編號 -> 詞
    idx_word = {}
    for idx, word in enumerate(tokens):
        word_idx[word] = idx
        idx_word[idx] = word
    
  • 注意:因爲後面我們要構建一個Tokenizer,在其內部實現該結構,此處的代碼可以不用管

2.3 構建Tokenizer

  • 構建一個Tokenizer,用於實現編號與詞之間、編號列表與詞列表之間的轉換
  • 其代碼如下
    class Tokenizer:
        """
        分詞器
        """
    
        def __init__(self, tokens):
            # 詞彙表大小
            self.dict_size = len(tokens)
            # 生成映射關係
            self.token_id = {} # 映射: 詞 -> 編號
            self.id_token = {} # 映射: 編號 -> 詞
            for idx, word in enumerate(tokens):
                self.token_id[word] = idx
                self.id_token[idx] = word
            
            # 各個特殊標記的編號id,方便其他地方使用
            self.start_id = self.token_id["[START]"]
            self.end_id = self.token_id["[END]"]
            self.none_id = self.token_id["[NONE]"]
            self.pad_id = self.token_id["[PAD]"]
    
        def id_to_token(self, token_id):
            """
            編號 -> 詞
            """
            return self.id_token.get(token_id)
    
        def token_to_id(self, token):
            """
            詞 -> 編號
            """
            return self.token_id.get(token, self.none_id)
    
        def encode(self, tokens):
            """
            詞列表 -> [START]編號 + 編號列表 + [END]編號
            """
            token_ids = [self.start_id, ] # 起始標記
            # 遍歷,詞轉編號
            for token in tokens:
                token_ids.append(self.token_to_id(token))
            token_ids.append(self.end_id) # 結束標記
            return token_ids
    
        def decode(self, token_ids):
            """
            編號列表 -> 詞列表(去掉起始、結束標記)
            """
            # 起始、結束標記
            flag_tokens = {"[START]", "[END]"}
            
            tokens = []
            for idx in token_ids:
                token = self.id_to_token(idx)
                # 跳過起始、結束標記
                if token not in flag_tokens:
                    tokens.append(token)
            return tokens
    
  • 初始化 Tokenizer
    tokenizer = Tokenizer(tokens)
    

2.4 構建PoetryDataSet

  • 爲了方便後面按批次抽取數據訓練模型,因此我們還需要構建一個數據生成器。這樣TensorFlow在訓練模型時會之間從該數據生成器抽取數據。
  • 另外,我們抽取的原始數據還需要進行轉碼,才能餵給模型進行訓練,該部分也封裝在PoetryDataSet中
  • 其代碼如下
    class PoetryDataSet:
        """
        古詩數據集生成器
        """
    
        def __init__(self, data, tokenizer, batch_size):
            # 數據集
            self.data = data
            self.total_size = len(self.data)
            # 分詞器,用於詞轉編號
            self.tokenizer = tokenizer
            # 每批數據量
            self.batch_size = BATCH_SIZE
            # 每個epoch迭代的步數
            self.steps = int(math.floor(len(self.data) / self.batch_size))
        
        def pad_line(self, line, length, padding=None):
            """
            對齊單行數據
            """
            if padding is None:
                padding = self.tokenizer.pad_id
                
            padding_length = length - len(line)
            if padding_length > 0:
                return line + [padding] * padding_length
            else:
                return line[:length]
            
        def __len__(self):
            return self.steps
    
        def __iter__(self):
            # 打亂數據
            np.random.shuffle(self.data)
            # 迭代一個epoch,每次yield一個batch
            for start in range(0, self.total_size, self.batch_size):
                end = min(start + self.batch_size, self.total_size)
                data = self.data[start:end]
                
                max_length = max(map(len, data)) 
                
                batch_data = []
                for str_line in data:
                    # 對每一行詩詞進行編碼、並補齊padding
                    encode_line = self.tokenizer.encode(str_line)
                    pad_encode_line = self.pad_line(encode_line, max_length + 2) # 加2是因爲tokenizer.encode會添加START和END
                    batch_data.append(pad_encode_line)
    
                batch_data = np.array(batch_data)
                # yield 特徵、標籤
                yield batch_data[:, :-1], batch_data[:, 1:]
    
        def generator(self):
            while True:
                yield from self.__iter__()
    
  • 生成的特徵、標籤的示例如下(實際是編號,此處做了轉換)
    特徵:[START]我有辭鄉劍,玉鋒堪截雲。襄陽走馬客,意氣自生春。朝嫌劍花淨,暮嫌劍光冷。能持劍向人,不解持照身。[END][PAD][PAD][PAD]
    標籤:我有辭鄉劍,玉鋒堪截雲。襄陽走馬客,意氣自生春。朝嫌劍花淨,暮嫌劍光冷。能持劍向人,不解持照身。[END][PAD][PAD][PAD][PAD]
    
  • 初始化 PoetryDataSet
    BATCH_SIZE = 32
    dataset = PoetryDataSet(poetry, tokenizer, BATCH_SIZE)
    

3. 模型的構建與訓練

3.1 構建模型

  • 現在我們可以開始構建RNN模型了,因爲模型層與層之間是順序的,因此我們可以採用Sequential快速構建模型。
  • 模型如下 (不太懂LSTM?建議看看這堂課程)
    model = tf.keras.Sequential([
        # 詞嵌入層
        tf.keras.layers.Embedding(input_dim=tokenizer.dict_size, output_dim=150),
        # 第一個LSTM層
        tf.keras.layers.LSTM(150, dropout=0.5, return_sequences=True),
        # 第二個LSTM層
        tf.keras.layers.LSTM(150, dropout=0.5, return_sequences=True),
        # 利用TimeDistributed對每個時間步的輸出都做Dense操作(softmax激活)
        tf.keras.layers.TimeDistributed(tf.keras.layers.Dense(tokenizer.dict_size, activation='softmax')),
    ])
    
  • 模型總覽
    model.summary()
    
    Model: "sequential_2"
    _________________________________________________________________
    Layer (type)                 Output Shape              Param #   
    =================================================================
    embedding_2 (Embedding)      (None, None, 150)         515100    
    _________________________________________________________________
    lstm_4 (LSTM)                (None, None, 150)         180600    
    _________________________________________________________________
    lstm_5 (LSTM)                (None, None, 150)         180600    
    _________________________________________________________________
    time_distributed_2 (TimeDist (None, None, 3434)        518534    
    =================================================================
    Total params: 1,394,834
    Trainable params: 1,394,834
    Non-trainable params: 0
    _________________________________________________________________
    
  • 進行模型編譯(選擇優化器、損失函數)
    model.compile(
        optimizer=tf.keras.optimizers.Adam(), 
        loss=tf.keras.losses.sparse_categorical_crossentropy
    )
    
  • 注意:因爲我們的標籤是非one_hot形式的,因此需要選擇sparse_categorical_crossentropy 。當然你也可以利用tf.one_hot(標籤, size)進行轉換,然後使用categorical_crossentropy。

3.2 訓練模型

  • 開始訓練模型
    model.fit(
        dataset.generator(), 
        steps_per_epoch=dataset.steps, 
        epochs=10
    )
    
    Train for 767 steps
    Epoch 1/10
    767/767 [==============================] - 34s 44ms/step - loss: 4.8892
    Epoch 2/10
    767/767 [==============================] - 31s 41ms/step - loss: 4.2494
    Epoch 3/10
    767/767 [==============================] - 31s 40ms/step - loss: 4.1113
    Epoch 4/10
    767/767 [==============================] - 31s 40ms/step - loss: 3.9864
    Epoch 5/10
    767/767 [==============================] - 31s 40ms/step - loss: 3.8660
    Epoch 6/10
    767/767 [==============================] - 31s 40ms/step - loss: 3.7879
    Epoch 7/10
    767/767 [==============================] - 31s 40ms/step - loss: 3.7339
    Epoch 8/10
    767/767 [==============================] - 31s 40ms/step - loss: 3.6826
    Epoch 9/10
    767/767 [==============================] - 31s 40ms/step - loss: 3.6275
    Epoch 10/10
    767/767 [==============================] - 31s 40ms/step - loss: 3.5999
    

4. 預測

4.1 預測單個詞

  • 模型對於數據的預測結果是概率分佈
    # 需要先將詞轉爲編號
    token_ids = [tokenizer.token_to_id(word) for word in ["月", "光", "靜", "謐"]]
    # 進行預測
    result = model.predict([token_ids ,])
    print(result)
    
    [[[2.0809230e-04 9.3881181e-03 5.5695949e-07 ... 5.6030808e-06
       8.5241054e-06 2.0507096e-06]
      [7.6916285e-06 6.1246334e-03 1.8850582e-08 ... 4.8418292e-06
       2.8483141e-06 5.3288642e-07]
      [5.0856406e-06 3.1365673e-03 1.9067786e-08 ... 4.5156207e-06
       1.0479171e-05 9.7814757e-07]
      [7.1793047e-06 2.2729969e-02 2.0391434e-08 ... 2.0609916e-06
       2.2420336e-06 2.1413473e-06]]]
    
  • 每次預測其實是根據一個序列預測一個新的詞,我們需要詞的多樣化,因此可以按預測結果的概率分佈進行抽樣。代碼如下
    def predict(model, token_ids):
        """
        在概率值爲前100的詞中選取一個詞(按概率分佈的方式)
        :return: 一個詞的編號(不包含[PAD][NONE][START])
        """
        # 預測各個詞的概率分佈
        # -1 表示只要對最新的詞的預測
        # 3: 表示不要前面幾個標記符
        _probas = model.predict([token_ids, ])[0, -1, 3:]
        # 按概率降序,取前100
        p_args = _probas.argsort()[-100:][::-1] # 此時拿到的是索引
        p = _probas[p_args] # 根據索引找到具體的概率值
        p = p / sum(p) # 歸一
        # 按概率抽取一個
        target_index = np.random.choice(len(p), p=p)
        # 前面預測時刪除了前幾個標記符,因此編號要補上3位,纔是實際在tokenizer詞典中的編號
        return p_args[target_index] + 3
    
  • 我們隨便來對一個序列進行循環預測試試
    token_ids = tokenizer.encode("清風明月")[:-1]
    while len(token_ids) < 13:
        # 預測詞的編號
        target = predict(model, token_ids)
        # 保存結果
        token_ids.append(target)
        # 到達END
        if target == tokenizer.end_id: 
            break
            
    print("".join(tokenizer.decode(token_ids)))
    
    清風明月夜,晚色北堂殘。
    
  • 至此,基本的預測已經完成。後面只需要設置一些規則,就可以實現隨機生成一首詩、生成一首藏頭詩的功能

4.2 隨機生成一首詩、自動續寫詩詞

  • 代碼如下
    def generate_random_poem(tokenizer, model, text=""):
        """
        隨機生成一首詩
        :param tokenizer: 分詞器
        :param model: 古詩模型
        :param text: 古詩的起始字符串,默認爲空
        :return: 一首古詩的字符串
        """
        # 將初始字符串轉成token_ids,並去掉結束標記[END]
        token_ids = tokenizer.encode(text)[:-1]
        while len(token_ids) < MAX_LEN:
            # 預測詞的編號
            target = predict(model, token_ids)
            # 保存結果
            token_ids.append(target)
            # 到達END
            if target == tokenizer.end_id: 
                break
            
        return "".join(tokenizer.decode(token_ids))
    
  • 隨意測試幾次
    for i in range(5):
        print(generate_random_poem(tokenizer, model))
    
    江亭路斷暮,歸去見芳洲。惆悵門中去,心年少地深。夜期深木靜,水落夕陽深。秋去人南雨,悽頭望海中。
    洛陌江陽宮下樹,玉門宮夜似東雲。今更已長逢醉士,一明先語似相春。
    春山風半夜初歸,萬歲空聲去去過。自惜秦生猶送酒,何人無計不安稀。
    何處東陵路,無年已復還。曉鶯逢半急,潮望月雲稀。暗影通三度,煙沙水鳥深。當年相憶望,何處問漁家。
    清夜向陽閣,一風看北宮。雨分紅蕊草,紅杏藥茶行。野石翻山遠,猿晴不獨天。誰知一山下,飛首卻悠悠。
    
  • 給一首詩的開頭,讓它自己續寫
    print(generate_random_poem(tokenizer, model, "春眠不覺曉,"))
    print(generate_random_poem(tokenizer, model, "白日依山盡,"))
    print(generate_random_poem(tokenizer, model, "秦時明月漢時關,"))
    
    春眠不覺曉,坐住樹深空。風月飄猶曉,春多出水流。
    白日依山盡,相逢獨水聲。唯疑見心意,一老淚鳴歸。落晚南遊客,吟猿見柳寒。何堪看暮望,還見有軍情。
    秦時明月漢時關,慾望時恩不道心。莫憶舊鄉僧雁在,始堪曾在牡苓流。
    

4.2 生成一首藏頭詩

  • 代碼如下
    def generate_acrostic_poem(tokenizer, model, heads):
        """
        生成一首藏頭詩
        :param tokenizer: 分詞器
        :param model: 古詩模型
        :param heads: 藏頭詩的頭
        :return: 一首古詩的字符串
        """
        # token_ids,只包含[START]編號
        token_ids = [tokenizer.start_id, ]
        # 逗號和句號標記編號
        punctuation_ids = {tokenizer.token_to_id(","), tokenizer.token_to_id("。")}
        content = []
        # 爲每一個head生成一句詩
        for head in heads:
            content.append(head)
            # head轉爲編號id,放入列表,用於預測
            token_ids.append(tokenizer.token_to_id(head))
            # 開始生成一句詩
            target = -1;
            while target not in punctuation_ids: # 遇到逗號、句號,說明本句結束,開始下一句
                # 預測詞的編號
                target = predict(model, token_ids)
                # 因爲可能預測到END,所以加個判斷
                if target > 3:
                    # 保存結果到token_ids中,下一次預測還要用
                    token_ids.append(target)
                    content.append(tokenizer.id_to_token(target))
    
        return "".join(content)
    
  • 隨意測試幾次
    print(generate_acrostic_poem(tokenizer, model, heads="上善若水"))
    print(generate_acrostic_poem(tokenizer, model, heads="明月清風"))
    print(generate_acrostic_poem(tokenizer, model, heads="點個贊吧"))
    
    上亭清色望,善地半煙霞。若辨從秋日,水花清上清。
    明夕遠多盡,月生開雨明。清山看楚雪,風色水堂鍾。
    點閣風空雪,個枝時未開。贊君初合淚,吧石似春風。
    

4.3 如何生成一首押韻詩?

  • 看了前面生成隨機詩、藏頭詩的代碼,其實你應該知道我們對於生成的詩的每個詞是可以控制。
  • 那麼我們在選取每句最後一個字時,只需要換一個預測方法即可。
  • 之前我們使用predict是選取概率值爲前100的,現在你只需要從預測的概率分佈中過濾出與前面句式押韻的詞,然後從中隨機抽取一個字,即可生成押韻的詩句!^_^

5. 其他

  • 如果你需要在訓練時,每個epoch都打印一下訓練效果,或者想保存loss最小的模型,你可以在訓練時添加Callback,例如
    class ShowSaveCallback(tf.keras.callbacks.Callback):
    
        def __init__(self):
            super().__init__()
            # 給一個初始最大值
            self.loss = float("inf")
    
        def on_epoch_end(self, epoch, logs=None):
            # 保留損失最低的模型
            if logs['loss'] <= self.loss:
                self.loss = logs['loss']
                model.save("./rnn_model.h5")
            # 查看一下本次訓練的效果
            print()
            for i in range(5):
                print(generate_random_poem(tokenizer, model))
    
    # 開始訓練
    model.fit(
        dataset.generator(), 
        steps_per_epoch=dataset.steps, 
        epochs=10,
        callbacks=[ShowSaveCallback()]
    )
    
  • 加載訓練好的模型
    model = tf.keras.models.load_model("./rnn_model.h5")
    
    # 後面就可以繼續進行預測了
    
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章