理解循環網絡及其在移動端的趣味應用:AI詩人

本文主要分析循環神經網絡(RNN)的大體原理及其 TensorFlow 實現,文章後半部分詳細介紹循環網絡結合移動端的一次趣味實踐:AI詩人(別忘記點個Star哦)。java

閱讀更多文章 -> 博客連接python

以前介紹的神經網絡包括卷積網絡,HED 網絡等網絡結構都是從輸入層到隱藏層再到輸出層,每次輸入對應輸出,輸入、輸出之間是無關聯的。這些網絡都沒法提取時間序列的相關特徵和上小文語義的相關特徵,循環神經網絡即是爲了解決這類問題而生的。git

循環神經網絡輸入的是一個序列,輸出的也是序列,固然也能夠視場景而定輸出一個值。訓練的時候能夠學習到關於序列的特徵,主要運用於語音識別、語言模型、機器翻譯以及時序分析等方面。不只僅侷限於這些常見的應用場景,更多時候能夠結合本身的實際需求尋找合適的網絡,以前看到過有電商平臺使用循環網絡作用戶的意圖預測,用戶在客戶端上每次點擊的內容做爲一個序列,通過訓練能預測出該用戶後續是否有購買意圖,對此有興趣的能夠點擊該內容的原文。下面開始介紹循環神經網絡,對此不敢興趣的能夠直接調至實踐部分。github

理解循環網絡

對於循環神經網絡,一個很是重要的概念就是時刻,會針對每一時刻的輸入結合當前時刻的狀態造成一個輸出,而且更新當前狀態,以下左圖所視,循環神經網絡的主體 A 的輸入除了來自輸入層 X t ,還有一個循環的邊來提供當前時刻的狀態,並輸出一個值 h t 。同時 A 的狀態會從當前步傳遞到下一步。理論上,循環神經網絡能夠看作同一個神經網絡結構的無限複製,出於優化考慮,現實中會將循環體展開獲得下圖右邊的部分。api

對於最簡單的循環網絡,主體部分 A 能夠是一個容許兩個輸入,兩個輸出全鏈接網絡,示意圖以下:數組

固然實際上並不會直接使用如此簡單的全鏈接層做爲循環的主體,這個例子是爲了讀者更好的理解循環網絡,以及循環網絡是同一個神經網絡結構的無限複製這個概念。markdown

標準的循環網絡有個顯而易見的問題:長期依賴(long-term dependencies)。實際問題中,有些時刻只須要依賴短時間內的上下文信息,而有些時刻須要遠期的上下文信息, 長短時記憶網絡(long short term memory, LSTM)的設計就是爲了解決這個問題。怎麼解決呢?LSTM 是一種擁有三個「門」結構的特殊網絡結構,它的內部有 4 個網絡層(標準的循環網絡只有一個網絡層):網絡

其中的門結構能夠有選擇性的影響網絡中每一時刻的狀態,由結構圖也能夠看出,所謂的門結構就是使用 sigmoid 層和一個點乘的操做,sigmoid 做爲激活函數的全鏈接層會輸出一個 0 到 1 之間的數值,用來控制信息經過的量,爲 1 表示信息所有經過,爲 0 時信息均不能經過。session

上文所說的三個門指的是忘記門,輸入門和輸出門。另外還有一個關鍵點是細胞狀態(cell state),貫穿始終,而且鏈接三個門結構。其中忘記門以下圖所示:app

忘記門決定了細胞狀態須要丟棄哪些信息,經過輸入 h__t-1X__t 通過 sigmoid 層,輸出 0 到 1 的值,表示須要保留或者丟棄多少上個細胞狀態的信息 C__t-1 ,下面是輸入門:

輸入門決定給細胞狀態添加多少信息,分爲兩個步驟,首先經過 h__t-1X__t 通過 sigmoid 層獲得的值決定更新哪些信息,而後經過 h__t-1X__t 通過 tanh 層獲得候選的細胞狀態,最後上個步驟的值做用於候選細胞狀態獲得輸入門的輸出值。而後這個值會添加進原細胞狀態中,更新爲新的細胞狀態 C__t ,以下所示:

最後是輸出門,以下所示:

首先也是經過 h__t-1X__t 通過 sigmoid 層獲得 0 到 1 的值決定哪些信息須要輸出,該值做用於新的細胞狀態 C__t 通過 tanh 層最終獲得輸出值。以上就是長短時記憶網絡的主要結構。因爲 TensorFlow 爲咱們封裝了內部結構,因此實現起一個循環網絡的單元很是簡單:

tf.nn.rnn_cell.LSTMCell(num_unit)
複製代碼

固然這只是一個循環單元,具體的使用在後續的應用文章中在介紹。其中參數 num_unit 表示網絡的寬度,也是輸出向量的寬度。好比在接下來介紹的自動寫詩網絡,輸入向量大小爲 [num_seqs, batch_size],LSTM 網絡的輸出爲 [num_seqs, batch_size, num_unit],接下來介紹 LSTM 網絡的移動端應用。

循環網絡的移動端實踐

AI 詩人:github.com/pqpo/AIPoet別忘記點個 Star 哦!) 是我開源的一個趣味應用,無須聯網隨時隨地寫藏頭詩、意境詩。使用 TensorFlow 實現的長短時記憶循環神經網絡,針對五萬多首唐詩進行訓練,總共訓練了 10w+,其中準確率達到了 70%+,而且將訓練模型移植到 Android 客戶端中,固然你也能夠將訓練出的模型移植到任意平臺。應用截圖以下:

一、數據預處理

開始以前先介紹一下該模型大體的實現過程,首先準備詩歌訓練集,好比: 「仙人浩歌望我來,應攀玉樹長相待。」,接下來是將詩歌處理成訓練數據,訓練數據通常須要有輸入和用於計算損失的輸出,
輸入:[仙人浩歌望我來,應攀玉樹長相待]
輸出:[人浩歌望我來,應攀玉樹長相待。]

看出區別了嗎?這樣就是一對訓練數據集,而且達到了預測下一個字的目的。

因爲詩歌長短不一,爲了提升運算效率,能夠固定數組長度,過長的截取,不夠的頭部填充 0。爲了讓模型知道詩歌的開始和結束,定義了開始和結束符:<START>、<EOP>,這時候的訓練集是這樣的:
輸入:[000000<START>仙人浩歌望我來,應攀玉樹長相待]
輸出:[000000人浩歌望我來,應攀玉樹長相待。<EOP>]

另外因爲漢字不利於後續的數據處理與分析,故將漢字轉化成數字,只須要統計一下有多少漢字,而後各自分配一個數字保證各自不重複便可,好比:

{
    "word2ix": {
        "憁": 4330, 
        "耀": 5499, 
        "枅": 6543, 
        "涉": 1324, 
        "談": 2482, 
        ...
    }
}
複製代碼

爲了方便後續訓練,再也不每次都預處理數據,將數據以二進制格式保存,下次直接使用二進制文件便可:

# 保存成二進制文件
np.savez_compressed(opt.pickle_path,
                   data=pad_data,
                   word2ix=word2ix,
                   ix2word=ix2word)
複製代碼

以上部分的詳細代碼查看:github.com/pqpo/AIPoet…

感謝 chinese-poetry 提供的詩歌數據

二、網絡實現

下面是 TensorFlow 實現的網絡部分,代碼很少就所有貼出來了:

# [num_seqs, batch_size]
def char_rnn_net(inputs, num_classes, batch_size=128, is_training=True, num_layers=2, lstm_size=256, embedding_size=128):
    if not is_training:
        batch_size = 1
    with tf.name_scope('embedding'):
        embedding = tf.Variable(tf.truncated_normal(shape=[num_classes, embedding_size], stddev=0.1), name='embedding')
        lstm_inputs = tf.nn.embedding_lookup(embedding, inputs)
    with tf.namescope('lstm'):
        cell = tf.nn.rnn_cell.MultiRNNCell(
            [tf.nn.rnn_cell.LSTMCell(lstm_size, state_is_tuple=is_training) for _ in range(num_layers)],
            state_is_tuple=is_training)
        initial_state = cell.zero_state(batch_size, dtype=tf.float32)
        x_sequence = tf.unstack(lstm_inputs)
        lstm_outputs, hidden = tf.nn.static_rnn(cell, x_sequence, initial_state=initial_state)
        x = tf.reshape(lstm_outputs, [-1, lstm_size])
        output = tf.layers.dense(inputs=x, units=num_classes, activation=None)
        endpoints = {'output': output, 'hidden': hidden, 'initial_state': initial_state}
        return endpoints
複製代碼

首先須要注意的是輸入向量的大小爲 [num_seqs, batch_size],這是由於網絡中使用的是 tf.nn.static_rnn 而非 tf.nn.dynamic_rnn,因此在此以前生成批量訓練數據的時候作過一次轉置:

def generate_batch_data(input, batch_size):
    input_ = tf.convert_to_tensor(input)
    data_set = tf.data.Dataset.from_tensor_slices(input_).shuffle(10000).repeat().batch(batch_size)
    iterator = data_set.make_one_shot_iterator()
    batch_data = iterator.get_next()
    tensor = tf.transpose(batch_data)
    input_batch, target_batch = tensor[:-1, :], tensor[1:, :]
    return input_batch, target_batch
複製代碼

因爲咱們處理的中文數據,因此使用了 embedding 層,而不是簡單的進行 one-hot, 若是處理的是英文能夠考慮直接使用 one-hot 編碼,每一個單詞都是一個維度,而且彼此獨立。可是因爲中文字符較多,常見的也有3000多個,one-hot 以後會變得很稀疏,不利於神經網絡的學習。embedding 層可使用一個較低維的向量表示出全部漢字,而且經過訓練還能必定的表現出不一樣類別變量之間的關係。

通常爲了使網絡更加健壯適應更復雜的狀況,能夠疊加多個循環網絡單元:

cell = tf.nn.rnn_cell.MultiRNNCell(
            [tf.nn.rnn_cell.LSTMCell(lstm_size, state_is_tuple=is_training) for _ in range(num_layers)],
            state_is_tuple=is_training)
複製代碼

這裏疊加了 2 層,最終展開以後會以下圖所示:

tf.nn.static_rnn 相比較於 tf.nn.dynamic_rnn,前者至關因而後者展開的形式,調用 dynamic_rnn 不會將 rnn 展開,而是利用 tf.while_loop 這個 api 生成一個開源執行循環的圖,因此 static_rnn 生成圖的時間會略慢於 dynamic_rnn,運行速度上二者是否有差距還有待實驗驗證。這裏爲何使用 static_rnn 呢?緣由是 static_rnn 能夠更方便的轉換爲 TensorFlow Lite 模型,具體查看官方文檔:轉換 RNN 模型

使用 tf.nn.static_rnn 還有一個注意的地方是第二個參數是一個 tensor 數組,須要使用 tf.unstack 展開成 tensor 數組,x_sequence 是一個長度爲 num_seqs 的數組,數組內容是 Shape 爲 [batch_size, embedding_size] 的 Tensor 對象。

x_sequence = tf.unstack(lstm_inputs)
lstm_outputs, hidden = tf.nn.static_rnn(cell, x_sequence, initial_state=initial_state)
複製代碼

以上 static_rnn 的輸出分兩個部分 lstm_outputs 和 hidden,lstm_outputs 爲正式的輸出,而且是全部輸出的集合,長度爲 num_seqs 的數組,數組內的 Tensor 對象大小爲[embedding_size, lstm_size] , 也就是原理篇介紹的輸出門的輸出 h__t , 另外還輸出了一個 hidden,由於上述 LSTM 單元疊加了兩層,因此 static_rnn 輸出的第二個參數是個 tuple,分別表示兩個 LSTM 單元的隱藏層,咱們只看其中一個隱藏單元,從網絡結構圖上能夠看出隱藏層輸出由兩部分組成 h__tC__t ,它是一個 LSTMStateTuple 對象,裏面包含了兩個 Tensor 對象,大小均爲 [embedding_size, lstm_size],而且隱藏層中的 h__t 是和輸出層徹底一致的。

最後通過一個全鏈接層轉換爲咱們想要的 Shape 就能夠輸出了:

x = tf.reshape(lstm_outputs, [-1, lstm_size])
output = tf.layers.dense(inputs=x, units=num_classes, activation=None)
複製代碼

三、模型訓練

訓練環境

  • 本地環境
    Python 版本:3.7.2
    TensorFlow 版本:1.13.2
  • 訓練環境
    Google 免費提供的平臺:Colaboratory

訓練時推薦使用 GPU 環境,在本身的 macbook 上訓練一輪須要 6 秒,若是沒有 GPU 環境,能夠無償使用 Colaboratory 的 GPU 平臺,訓練一輪縮短到 0.2 秒,提高了近 30 倍。

能夠將訓練腳本和預處理的二進制文件上傳至 Google Driver,而後新建 ipynb 文件,在設置中選擇 GPU 硬件加速,Python3 運行時類型。經過下面代碼加載 Google Driver 目錄:

from google.colab import drive
drive.mount('/content/gdrive')
import glob
glob.glob('gdrive/My Drive/poetry-gen/*')
複製代碼

運行以後會輸出掛載的目錄文件:

['gdrive/My Drive/poetry-gen/tang.npz',
 'gdrive/My Drive/poetry-gen/char_rnn_net.py',
 'gdrive/My Drive/poetry-gen/data_utils.py',
 'gdrive/My Drive/poetry-gen/freeze_model.py',
 'gdrive/My Drive/poetry-gen/sample.py',
 'gdrive/My Drive/poetry-gen/utils.py',
 'gdrive/My Drive/poetry-gen/config.py',
 'gdrive/My Drive/poetry-gen/train.py']
複製代碼

因爲文件路徑會和本地的不同,因此須要修改 config.py 中的相關文件路徑,而後就能夠執行腳本訓練了,命令以下:

!python "/content/gdrive/My Drive/poetry-gen/train.py"
複製代碼

訓練了 13w+ 次之後準確率達到了 70%+:

step: 134997/134997 loss: 1.5622 accuracy: 0.71 0.1806 sec/batch
複製代碼

四、模型驗證與導出

以後就可使用 sample.py 腳本驗證模型效果了:

data, word2ix, ix2word = get_data(Config)
result = generate(u'牀前明月光', word2ix, ix2word, prefix_words=u'郡邑浮前浦,波瀾動遠空。')
print(''.join(result))
## 牀前明月光,門下舊歡情。橘柚吳姬醉,蘭塘楚水行。煙霞隨水鳥,蘋藻逐仙英。藕花泳水聲,桂酒留春並。...

# 藏頭詩
result = gen_acrostic(u'牀前明月光', word2ix, ix2word, prefix_words=u'郡邑浮前浦,波瀾動遠空。')
print(''.join(result))
# 牀鋪金鉞肅,心耿白雲生。
# 前席臨清興,五原分睿情。
# 明時廓無事,薄俸頗如名。
# 月出南山色,雲昏北戶陰。
# 光陰方熠素,幽沚忽參驚。
複製代碼

若是效果依舊不滿意能夠考慮繼續訓練一段時間。而後就能夠導出爲 TensorFlow Lite 的模型了:

converter = tf.contrib.lite.TFLiteConverter.from_session(sess, [inputs, initial_state],[output_tensor, hidden])
# converter.post_training_quantize = True
tflite_model = converter.convert()
open(FLAGS.output_file, 'wb').write(tflite_model)
複製代碼

這裏並未開啓量化開關(post_training_quantize),量化以前模型大小爲 17.5M,量化以後模型大小爲7.8M,雖然量化以後模型大小縮小了一倍,可是寫詩效果也有所降低,因此使用了未量化的模型。須要注意的是導出以後模型的輸入輸出向量的類型和大小:

# 1.1 input shape is: (1, 1), name is: inputs:0, type is: <dtype: 'int32'>
# 1.2 input shape is: (1, 1024), name is: lstm/MultiRNNCellZeroState/MultiRNNCellZeroState/zeros:0, type is: <dtype: 'float32'>
# 2.1 output shape is: (1,), name is: outputs:0, type is: <dtype: 'int32'>
# 2.2 output shape is: (1, 1024), name is: lstm/rnn/rnn/multi_rnn_cell/concat:0, type is: <dtype: 'float32'>
複製代碼

輸入和輸出均有兩個,其中包括輸入的字符和輸出的字符,以及大小爲 (1, 1024) 的輸入輸出隱藏層,而且數值大小均爲 32 位,爲何這次的隱藏層是一個 Tensor 對象呢?以前說的是 tuple 啊,緣由是 Tensor 對象更方便數據的輸入,因此將隱藏層轉換成了 Tensor,只須要在構建網絡的時候設置一個值就行(state_is_tuple=True):

cell = tf.nn.rnn_cell.MultiRNNCell([tf.nn.rnn_cell.LSTMCell(lstm_size, state_is_tuple=True) for _ in range(num_layers)],state_is_tuple=True)
複製代碼

具體代碼位於:github.com/pqpo/AIPoet…

最後還要導出文字與數字的轉換文件以供移動端使用:github.com/pqpo/AIPoet…

五、移動端集成

到這一步咱們獲得了兩個文件,一個是模型文件,一個是 JSON 格式的數字文字轉換文件,保存到 assets 目錄中供 Android 程序讀取。核心代碼位於:github.com/pqpo/AIPoet…

須要特別注意的是輸入和輸出參數:

input = ByteBuffer.allocateDirect(Int.SIZE_BYTES)
input.order(ByteOrder.nativeOrder())
output = ByteBuffer.allocateDirect(Int.SIZE_BYTES)
output.order(ByteOrder.nativeOrder())
states = arrayOf(
    ByteBuffer.allocateDirect(HIDDEN_SIZE * Int.SIZE_BYTES),
    ByteBuffer.allocateDirect(HIDDEN_SIZE * Int.SIZE_BYTES)
 ).apply {
    this[0].order(ByteOrder.nativeOrder())
    this[1].order(ByteOrder.nativeOrder())
}
複製代碼

其中 Int.SIZE_BYTES 大小爲 4,正好是以前提到的 32 位數值大小(int32, float32)。input 表示輸入字符轉換爲數字的值,output 爲輸出的數字,通過轉換獲得字符。states 爲一個長度爲 2 的數組,裏面保存的是輸入和輸出的隱藏層,HIDDEN_SIZE 大小爲 1024,對應上述的 #1.2 與 #2.2。爲什麼要設計成一個數組而非寫死定義 input_state 和 output_state 呢?緣由是這個時刻的輸出狀態是下個時刻的輸入狀態,寫死定義以後須要進行一次數據的拷貝,具體邏輯參考以下代碼:

@Synchronized
@Throws(UnmappedWordException::class)
fun fetchNext(word: String): String {
    val wordIndex = convert.word2Index(word)
    if (wordIndex == -1) {
        throw UnmappedWordException(word)
    }
    val inputState = states[inputStateIndex]
    val outputState = states[1 - inputStateIndex]
    input.clear()
    input.putInt(convert.word2Index(word))
    inputState.rewind()
    output.clear()
    outputState.clear()
    inputs[0] = input
    inputs[1] = inputState
    outputs[0] = output
    outputs[1] = outputState
    tfLite.runForMultipleInputsOutputs(inputs, outputs)
    output.rewind()
    inputStateIndex = 1 - inputStateIndex
    val index = output.int
    return convert.index2Word(index)
 }
複製代碼

如此一來,經過重複調用 fetchNext 輸入一個文字,最終連成一首詩。須要注意的是第一個輸入的字符必須是 「<START>」,告訴模型開始做詩了,代碼參考 AiPoet.song(…)

相關文章
相關標籤/搜索