貌離神合的RNN與ODE:花式RNN簡介

PaperWeekly發表於2018-06-29

作者丨蘇劍林

單位丨廣州火焰資訊科技有限公司

研究方向丨NLP,神經網路

個人主頁丨kexue.fm

本來筆者已經決心不玩 RNN 了,但是在上個星期思考時忽然意識到 RNN 實際上對應了 ODE(常微分方程)的數值解法,這為我一直以來想做的事情——用深度學習來解決一些純數學問題——提供了思路。事實上這是一個頗為有趣和有用的結果,遂介紹一翻。順便地,本文也涉及到了自己動手編寫 RNN 的內容,所以本文也可以作為編寫自定義的 RNN 層的一個簡單教程

注:本文並非前段時間的熱點“神經 ODE [1]”的介紹(但有一定的聯絡)。

RNN基本

什麼是RNN? 

眾所周知,RNN 是“迴圈神經網路(Recurrent Neural Network)”,跟 CNN 不同,RNN 可以說是一類模型的總稱,而並非單個模型。簡單來講,只要是輸入向量序列 (x1,x2,…,xT),輸出另外一個向量序列 (y1,y2,…,yT),並且滿足如下遞迴關係的模型,都可以稱為 RNN。

貌離神合的RNN與ODE:花式RNN簡介

也正因為如此,原始的樸素 RNN,還有改進的如 GRU、LSTM、SRU 等模型,我們都稱為 RNN,因為它們都可以作為上式的一個特例。還有一些看上去與 RNN 沒關的內容,比如前不久介紹的 CRF 的分母的計算,實際上也是一個簡單的 RNN。

說白了,RNN 其實就是遞迴計算

自己編寫RNN

這裡我們先介紹如何用 Keras 簡單快捷地編寫自定義的 RNN。 

事實上,不管在 Keras 還是純 tensorflow 中,要自定義自己的 RNN 都不算複雜。在 Keras 中,只要寫出每一步的遞迴函式;而在 tensorflow 中,則稍微複雜一點,需要將每一步的遞迴函式封裝為一個 RNNCell 類。

下面介紹用 Keras 實現最基本的一個 RNN:

貌離神合的RNN與ODE:花式RNN簡介

程式碼非常簡單:

#! -*- coding: utf-8- -*-

from keras.layers import Layer
import keras.backend as K


class My_RNN(Layer):

    def __init__(self, output_dim, **kwargs):
        self.output_dim = output_dim # 輸出維度
        super(My_RNN, self).__init__(**kwargs)

    def build(self, input_shape): # 定義可訓練引數
        self.kernel1 = self.add_weight(name='kernel1',
                                      shape=(self.output_dim, self.output_dim),
                                      initializer='glorot_normal',
                                      trainable=True)
        self.kernel2 = self.add_weight(name='kernel2',
                                      shape=(input_shape[-1], self.output_dim),
                                      initializer='glorot_normal',
                                      trainable=True)
        self.bias = self.add_weight(name='kernel',
                                      shape=(self.output_dim,),
                                      initializer='glorot_normal',
                                      trainable=True)

    def step_do(self, step_in, states): # 定義每一步的迭代
        step_out = K.tanh(K.dot(states[0], self.kernel1) +
                          K.dot(step_in, self.kernel2) +
                          self.bias)
        return step_out, [step_out]

    def call(self, inputs): # 定義正式執行的函式
        init_states = [K.zeros((K.shape(inputs)[0],
                                self.output_dim)
                              )] # 定義初始態(全零)
        outputs = K.rnn(self.step_do, inputs, init_states) # 迴圈執行step_do函式
        return outputs[0] # outputs是一個tuple,outputs[0]為最後時刻的輸出,
                          # outputs[1]為整個輸出的時間序列,output[2]是一個list,
                          # 是中間的隱藏狀態。

    def compute_output_shape(self, input_shape):
        return (input_shape[0], self.output_dim)


可以看到,雖然程式碼行數不少,但大部分都只是固定格式的語句,真正定義 RNN 的,是 step_do 這個函式,這個函式接受兩個輸入:step_in 和 states。其中 step_in 是一個 (batch_size, input_dim) 的張量,代表當前時刻的樣本 xt,而 states 是一個 list,代表 yt−1 及一些中間變數。

特別要注意的是,states 是一個張量的 list,而不是單個張量,這是因為在遞迴過程中可能要同時傳遞多箇中間變數,而不僅僅是 yt−1 一個,比如 LSTM 就需要有兩個態張量。最後 step_do 要返回 yt 和新的 states,這是 step_do 這步的函式的編寫規範。 

而 K.rnn 這個函式,接受三個基本引數(還有其他引數,請自行看官方文件),其中第一個引數就是剛才寫好的 step_do 函式,第二個引數則是輸入的時間序列,第三個是初始態,跟前面說的 states 一致,所以很自然 init_states 也是一個張量的 list,預設情況下我們會選擇全零初始化。

ODE基本

什麼是ODE?

ODE 就是“常微分方程(Ordinary Differential Equation)”,這裡指的是一般的常微分方程組:

貌離神合的RNN與ODE:花式RNN簡介

研究 ODE 的領域通常也直接稱為“動力學”、“動力系統”,這是因為牛頓力學通常也就只是一組 ODE 而已。

ODE可以產生非常豐富的函式。比如 e^t 其實就是 x˙=x 的解,sint 和 cost 都是 x¨+x=0 的解(初始條件不同)。事實上,我記得確實有一些教程是直接通過微分方程 x˙=x 來定義 e^t 函式的。除了這些初等函式,很多我們能叫得上名字但不知道是什麼鬼的特殊函式,都是通過 ODE 匯出來的,比如超幾何函式、勒讓德函式、貝塞爾函式...

總之,ODE 能產生並且已經產生了各種各樣千奇百怪的函式~

數值解ODE 

能精確求出解析解的 ODE 其實是非常少的,所以很多時候我們都需要數值解法。 

ODE 的數值解已經是一門非常成熟的學科了,這裡我們也不多做介紹,僅引入最基本的由數學家尤拉提出來的迭代公式:

貌離神合的RNN與ODE:花式RNN簡介

這裡的 h 是步長。尤拉的解法來源很簡單,就是用:

貌離神合的RNN與ODE:花式RNN簡介

來近似導數項 x˙(t)。只要給定初始條件 x(0),我們就可以根據 (4) 一步步迭代算出每個時間點的結果。

ODE與RNN 

ODE也是RNN

大家仔細對比 (4) 和 (1),發現有什麼聯絡了嗎?

在 (1) 中,t 是一個整數變數,在 (4) 中,t 是一個浮點變數,除此之外,(4) 跟 (1) 貌似就沒有什麼明顯的區別了。事實上,在 (4) 中我們可以以 h 為時間單位,記 t=nh,那麼 (4) 變成了:

貌離神合的RNN與ODE:花式RNN簡介

可以看到現在 (6) 中的時間變數 n 也是整數了。這樣一來,我們就知道:ODE 的尤拉解法 (4) 實際上就是 RNN 的一個特例罷了。這裡我們也許可以間接明白為什麼 RNN 的擬合能力如此之強了(尤其是對於時間序列資料),我們看到 ODE 可以產生很多複雜的函式,而 ODE 只不過是 RNN 的一個特例罷了,所以 RNN 也就可以產生更為複雜的函式了。 

用RNN解ODE 

於是,我們就可以寫一個 RNN 來解 ODE 了,比如《兩生物種群競爭模型》[2] 中的例子:

貌離神合的RNN與ODE:花式RNN簡介

我們可以寫出:

#! -*- coding: utf-8- -*-

from keras.layers import Layer
import keras.backend as K


class ODE_RNN(Layer):

    def __init__(self, steps, h, **kwargs):
        self.steps = steps
        self.h = h
        super(ODE_RNN, self).__init__(**kwargs)

    def step_do(self, step_in, states): # 定義每一步的迭代
        x = states[0]
        r1,r2,a1,a2,iN1,iN2 = 0.1,0.3,0.0001,0.0002,0.002,0.003
        _1 = r1 * x[:,0] * (1 - iN1 * x[:,0]) - a1 * x[:,0] * x[:,1]
        _2 = r2 * x[:,1] * (1 - iN2 * x[:,1]) - a2 * x[:,0] * x[:,1]
        _1 = K.expand_dims(_1, 1)
        _2 = K.expand_dims(_2, 1)
        _ = K.concatenate([_1, _2], 1)
        step_out = x + self.h * _
        return step_out, [step_out]

    def call(self, inputs): # 這裡的inputs就是初始條件
        init_states = [inputs]
        zeros = K.zeros((K.shape(inputs)[0],
                         self.steps,
                         K.shape(inputs)[1])) # 迭代過程用不著外部輸入,所以
                                              # 指定一個全零輸入,只為形式上的傳入
        outputs = K.rnn(self.step_do, zeros, init_states) # 迴圈執行step_do函式
        return outputs[1] # 這次我們輸出整個結果序列

    def compute_output_shape(self, input_shape):
        return (input_shape[0], self.steps, input_shape[1])


from keras.models import Sequential
import numpy as np
import matplotlib.pyplot as plt


steps,h = 1000,0.1

M = Sequential()
M.add(ODE_RNN(steps, h, input_shape=(2,)))
M.summary()

# 直接前向傳播就輸出解了
result = M.predict(np.array([[100, 150]]))[0] # 以[100, 150]為初始條件進行演算
times = np.arange(1, steps+1) * h

# 繪圖
plt.plot(times, result[:,0])
plt.plot(times, result[:,1])
plt.savefig('test.png')

整個過程很容易理解,只不過有兩點需要指出一下。首先,由於方程組 (7) 只有兩維,而且不容易寫成矩陣運算,因此我在 step_do 函式中是直接逐位操作的(程式碼中的 x[:,0],x[:,1]),如果方程本身維度較高,而且能寫成矩陣運算,那麼直接利用矩陣運算寫會更加高效;然後,我們可以看到,寫完整個模型之後,直接 predict 就輸出結果了,不需要“訓練”。

貌離神合的RNN與ODE:花式RNN簡介

▲ RNN解兩物種的競爭模型

反推ODE引數

前一節的介紹也就是說,RNN 的前向傳播跟 ODE 的尤拉解法是對應的,那麼反向傳播又對應什麼呢?

在實際問題中,有一類問題稱為“模型推斷”,它是在已知實驗資料的基礎上,猜測這批資料符合的模型(機理推斷)。這類問題的做法大概分兩步,第一步是猜測模型的形式,第二步是確定模型的引數。假定這批資料可以由一個 ODE 描述,並且這個 ODE 的形式已經知道了,那麼就需要估計裡邊的引數

如果能夠用公式完全解出這個 ODE,那麼這就只是一個非常簡單的迴歸問題罷了。但前面已經說過,多數 ODE 都沒有公式解,所以數值方法就必須了。這其實就是 ODE 對應的 RNN 的反向傳播所要做的事情:前向傳播就是解 ODE(RNN 的預測過程),反向傳播自然就是推斷 ODE 的引數了(RNN 的訓練過程)。這是一個非常有趣的事實:ODE 的引數推斷是一個被研究得很充分的課題,然而在深度學習這裡,只是 RNN 的一個最基本的應用罷了

我們把剛才的例子的微分方程的解資料儲存下來,然後只取幾個點,看看能不能反推原來的微分方程出來,解資料為:

貌離神合的RNN與ODE:花式RNN簡介

假設就已知這有限的點資料,然後假定方程 (7) 的形式,求方程的各個引數。我們修改一下前面的程式碼:

#! -*- coding: utf-8- -*-

from keras.layers import Layer
import keras.backend as K


def my_init(shape, dtype=None): # 需要定義好初始化,這相當於需要實驗估計引數的量級
    return K.variable([0.1, 0.1, 0.001, 0.001, 0.001, 0.001])


class ODE_RNN(Layer):

    def __init__(self, steps, h, **kwargs):
        self.steps = steps
        self.h = h
        super(ODE_RNN, self).__init__(**kwargs)

    def build(self, input_shape): # 將原來的引數設為可訓練的引數
        self.kernel = self.add_weight(name='kernel', 
                                      shape=(6,),
                                      initializer=my_init,
                                      trainable=True)
    def step_do(self, step_in, states): # 定義每一步的迭代
        x = states[0]
        r1,r2,a1,a2,iN1,iN2 = (self.kernel[0], self.kernel[1],
                               self.kernel[2], self.kernel[3],
                               self.kernel[4], self.kernel[5])
        _1 = r1 * x[:,0] * (1 - iN1 * x[:,0]) - a1 * x[:,0] * x[:,1]
        _2 = r2 * x[:,1] * (1 - iN2 * x[:,1]) - a2 * x[:,0] * x[:,1]
        _1 = K.expand_dims(_1, 1)
        _2 = K.expand_dims(_2, 1)
        _ = K.concatenate([_1, _2], 1)
        step_out = x + self.h * K.clip(_, -1e5, 1e5) # 防止梯度爆炸
        return step_out, [step_out]

    def call(self, inputs): # 這裡的inputs就是初始條件
        init_states = [inputs]
        zeros = K.zeros((K.shape(inputs)[0],
                         self.steps,
                         K.shape(inputs)[1])) # 迭代過程用不著外部輸入,所以
                                              # 指定一個全零輸入,只為形式上的傳入
        outputs = K.rnn(self.step_do, zeros, init_states) # 迴圈執行step_do函式
        return outputs[1] # 這次我們輸出整個結果序列

    def compute_output_shape(self, input_shape):
        return (input_shape[0], self.steps, input_shape[1])



from keras.models import Sequential
from keras.optimizers import Adam
import numpy as np
import matplotlib.pyplot as plt


steps,h = 50, 1 # 用大步長,減少步數,削弱長時依賴,也加快推斷速度
series = {0: [100, 150],
          10: [165, 283],
          15: [197, 290],
          30: [280, 276],
          36: [305, 269],
          40: [318, 266],
          42: [324, 264]}

M = Sequential()
M.add(ODE_RNN(steps, h, input_shape=(2,)))
M.summary()

# 構建訓練樣本
# 其實就只有一個樣本序列,X為初始條件,Y為後續時間序列
X = np.array([series[0]])
Y = np.zeros((1, steps, 2))

for i,j in series.items():
    if i != 0:
        Y[0, int(i/h)-1] += series[i]

# 自定義loss
# 在訓練的時候,只考慮有資料的幾個時刻,沒有資料的時刻被忽略
def ode_loss(y_true, y_pred):
    T = K.sum(K.abs(y_true), 2, keepdims=True)
    T = K.cast(K.greater(T, 1e-3), 'float32')
    return K.sum(T * K.square(y_true - y_pred), [1, 2])

M.compile(loss=ode_loss,
          optimizer=Adam(1e-4))

M.fit(X, Y, epochs=10000) # 用低學習率訓練足夠多輪

# 用訓練出來的模型重新預測,繪圖,比較結果
result = M.predict(np.array([[100, 150]]))[0]
times = np.arange(1, steps+1) * h

plt.clf()
plt.plot(times, result[:,0], color='blue')
plt.plot(times, result[:,1], color='green')
plt.plot(series.keys(), [i[0] for i in series.values()], 'o', color='blue')
plt.plot(series.keys(), [i[1] for i in series.values()], 'o', color='green')
plt.savefig('test.png')

結果可以用一張圖來看:

貌離神合的RNN與ODE:花式RNN簡介

▲ RNN做ODE的引數估計效果

(散點:有限的實驗資料,曲線:估計出來的模型)

顯然結果是讓人滿意的。

又到總結

本文在一個一般的框架下介紹了 RNN 模型及其在 Keras 下的自定義寫法,然後揭示了 ODE 與 RNN 的聯絡。在此基礎上,介紹了用 RNN 直接求解 ODE 以及用 RNN 反推 ODE 引數的基本思路。

需要提醒讀者的是,在 RNN 模型的反向傳播中,要謹慎地做好初始化和截斷處理處理,並且選擇好學習率等,以防止梯度爆炸的出現(梯度消失只是優化得不夠好,梯度爆炸則是直接崩潰了,解決梯度爆炸問題尤為重要)。

總之,梯度消失和梯度爆炸在 RNN 中是一個很經典的困難,事實上,LSTM、GRU 等模型的引入,根本原因就是為了解決 RNN 的梯度消失問題,而梯度爆炸則是通過使用 tanh 或 sigmoid 啟用函式來解決的。

但是如果用 RNN 解決 ODE 的話,我們就沒有選擇啟用函式的權利了(啟用函式就是 ODE 的一部分),所以只能謹慎地做好初始化及其他處理。據說,只要謹慎做好初始化,普通 RNN 中用 relu 作為啟用函式都是無妨的。

相關連結

[1]. Tian Qi C, Yulia R, Jesse B, David D. Neural Ordinary Differential Equations. arXiv preprint arXiv:1806.07366, 2018.

[2]. 兩生物種群競爭模型

https://kexue.fm/archives/3120

相關文章