本文主要分析循環神經網絡(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-1 和 X__t 通過 sigmoid 層,輸出 0 到 1 的值,表示須要保留或者丟棄多少上個細胞狀態的信息 C__t-1 ,下面是輸入門:
輸入門決定給細胞狀態添加多少信息,分爲兩個步驟,首先經過 h__t-1 和 X__t 通過 sigmoid 層獲得的值決定更新哪些信息,而後經過 h__t-1 和 X__t 通過 tanh 層獲得候選的細胞狀態,最後上個步驟的值做用於候選細胞狀態獲得輸入門的輸出值。而後這個值會添加進原細胞狀態中,更新爲新的細胞狀態 C__t ,以下所示:
最後是輸出門,以下所示:
首先也是經過 h__t-1 和 X__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__t 和 C__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)
複製代碼
訓練環境
訓練時推薦使用 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(…)