自動微分・誤差逆伝播の理論と応用③:RNN(Recurrent Neural Network)

自動微分(Automatic Differentiation)は大規模なニューラルネットワークであるDeepLearningの学習における誤差逆伝播などに用いられる手法です。当記事ではリカレントニューラルネットワーク(RNN; Recurrent Neural Network)の実装について取り扱いました。
作成にあたっては「ゼロから作るDeep Learning②」の第$5$章「リカレントニューラルネットワーク」の内容を主に参照しました。

・用語/公式解説
https://www.hello-statisticians.com/explain-terms

RNNモジュールの実装

概要

RNNにおける基本的な再帰処理は下記のような数式で表すことができる。
$$
\large
\begin{align}
\mathbf{h}_{t} &= \tanh{(\mathbf{h}_{t-1} W_{h} + \mathbf{x}_{t} W_{x} + \mathbf{b})} \\
\mathbf{h}_{t-1}^{\mathrm{T}}, \, \mathbf{h}_{t}^{\mathrm{T}} & \in \mathbb{R}^{H}, \, W_{h} \in \mathbb{R}^{H \times H}, \, \mathbf{x}_{t}^{\mathrm{T}} \in \mathbb{R}^{D}, \, W_{x} \in \mathbb{R}^{D \times H}, \, \mathbf{b}^{\mathrm{T}} \in \mathbb{R}^{H}
\end{align}
$$

上記はバッチサイズが$N=1$の場合の表記である。同様にバッチサイズ$N$の場合は下記のような数式で再帰処理を表せる。
$$
\large
\begin{align}
\mathbf{h}_{t} &= \tanh{(\mathbf{h}_{t-1} W_{h} + \mathbf{x}_{t} W_{x} + \mathbf{b})} \\
\mathbf{h}_{t-1}, \, \mathbf{h}_{t} & \in \mathbb{R}^{N \times H}, \, W_{h} \in \mathbb{R}^{H \times H}, \, \mathbf{x}_{t} \in \mathbb{R}^{N \times D}, \, W_{x} \in \mathbb{R}^{D \times H}, \, \mathbf{b} \in \mathbb{R}^{N \times H}
\end{align}
$$

上記の微分はAffine変換の微分と同様に取り扱えば良い。

$\tanh{(x)}$の定義と微分

ハイパボリックタンジェント関数$\tanh{(x)}$は下記のように定義される。
$$
\large
\begin{align}
\tanh{(x)} = \frac{e^{x} – e^{-x}}{e^{x} + e^{-x}}
\end{align}
$$

上記の$x$に関する微分は分数関数の微分の公式に基づいて下記のように得られる。
$$
\large
\begin{align}
(\tanh{(x)})’ &= \frac{(e^{x} + e^{-x})^{2} – (e^{x} – e^{-x})^{2}}{(e^{x} + e^{-x})^{2}} \\
&= 1 – \frac{(e^{x} – e^{-x})^{2}}{(e^{x} + e^{-x})^{2}} \\
&= 1 – \tanh{(x)}
\end{align}
$$

実装と使用例

class RNN:
    def __init__(self, Wx, Wh, b):
        self.params = [Wx, Wh, b]
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
        self.cache = None

    def forward(self, x, h_prev):
        Wx, Wh, b = self.params
        t = np.dot(h_prev, Wh) + np.dot(x, Wx) + b
        h_next = np.tanh(t)
        self.cache = (x, h_prev, h_next)
        return h_next

    def backward(self, dh_next):
        Wx, Wh, b = self.params
        x, h_prev, h_next = self.cache
        
        dt = dh_next * (1. - h_next**2)
        db = np.sum(dt, axis=0)
        dWh = np.dot(h_prev.T, dt)
        dh_prev = np.dot(dt, Wh.T)
        dWx = np.dot(x.T, dt)
        dx = np.dot(dt, Wx.T)

        self.grads[0][...] = dWx
        self.grads[1][...] = dWh
        self.grads[2][...] = db

        return dx, dh_prev

順伝播の計算

下記のようにRNNクラスを用いることで順伝播の計算を行うことができる。

Wx = np.array([[-2., 1., 3.], [2., -3., 1.]])
Wh = np.array([[1., 2., -5.], [2., -5., 7.], [1., 5., 2]])
b = np.array([-2., -2., -5.])

x = np.array([[1., 2.]])
h_prev = np.array([[2., 1., 2.]])
dh_next = np.array([1., 1., 1.])

rnn = RNN(Wx, Wh, b)
h_next = rnn.forward(x, h_prev)

print(h_next)

・実行結果

[[0.99998771 0.96402758 0.76159416]]

上記では順伝播の計算を行ったが、計算の途中経過が正しいことは下記より確認できる。

t = np.dot(h_prev, Wh) + np.dot(x, Wx) + b
h_next = np.tanh(t)

print(np.dot(h_prev, Wh))
print(np.dot(x, Wx))
print(b)
print(t)
print(h_next)

・実行結果

[[6. 9. 1.]]
[[ 2. -5.  5.]]
[-2. -2. -5.]
[[6. 2. 1.]]
[[0.99998771 0.96402758 0.76159416]]

上記の計算は下記の数式の計算と同様である。
t = np.dot(h_prev, Wh) + np.dot(x, Wx) + bの計算
$$
\begin{align}
& H_{t-1} W_h + X W_x + B \\
&= \left( \begin{array}{ccc} 2 & 1 & 2 \end{array} \right)\left( \begin{array}{ccc} 1 & 2 & -5 \\ 2 & -5 & 7 \\ 1 & 5 & 2 \end{array} \right) + \left( \begin{array}{cc} 1 & 2 \end{array} \right)\left( \begin{array}{ccc} -2 & 1 & 3 \\ 2 & -3 & 1 \end{array} \right) + \left( \begin{array}{ccc} -2 & -2 & 5 \end{array} \right) \\
&= \left( \begin{array}{ccc} 6 & 9 & 1 \end{array} \right) + \left( \begin{array}{ccc} 2 & -5 & 5 \end{array} \right) + \left( \begin{array}{ccc} -2 & -2 & -5 \end{array} \right) \\
&= \left( \begin{array}{ccc} 6 & 2 & 1 \end{array} \right)
\end{align}
$$

h_next = np.tanh(t)の計算
$$
\large
\begin{align}
\tanh{(6)} &= \frac{e^{6}-e^{-6}}{e^{6}+e^{-6}} = 0.999 \cdots \\
\tanh{(2)} &= \frac{e^{2}-e^{-2}}{e^{2}+e^{-2}} = 0.964 \cdots \\
\tanh{(1)} &= \frac{e^{1}-e^{-1}}{e^{1}+e^{-1}} = 0.761 \cdots
\end{align}
$$

RNN全体の実装

実装

class TimeRNN:
    def __init__(self, Wx, Wh, b):
        self.params = [Wx, Wh, b]
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
        self.layers = None
        self.h, self.dh = None, None

    def forward(self, xs):
        Wx, Wh, b = self.params
        N, T, D = xs.shape
        D, H = Wx.shape
        
        self.layers = []
        hs = np.empty((N, T, H), dtype="f")
        
        if self.h is None:
            self.h = np.zeros((N, H), dtype="f")

        for t in range(T):
            layer = RNN(*self.params)
            self.h = layer.forward(xs[:, t, :], self.h)
            hs[:, t, :] = self.h
            self.layers.append(layer)

        return hs

使用例

RNN全体の実装は基本的には前節のRNNクラスを複数回用いて構築を行う。以下、バッチ数が$N=2$、時系列の数が$T=2$、各入力ベクトルの次元が$D=3$、隠れ層の次元が$H=2$の場合について確認を行う。まず、入力に対応する配列を下記のように定義する。

xs = np.array([[[1., 2., 1.], [2., 1., 1.]], [[1., -2., 1.], [2., -1., 1.]]])
print(xs.shape)  # (N, T, D)

・実行結果

(2, 2, 3)

また、パラメータの$W_x, W_h, \mathbf{b}$はRNNクラスの各インスタンスで共有するので、下記のように定義できる。

Wx = np.array([[1., 1.], [2., -1], [-1., 1.]])
Wh = np.array([[1., -1.], [-1., 2.]])
b = np.array([[-1., 1.]]).repeat(2, axis=0)

print(Wx.shape)  # (D, H)
print(Wh.shape)  # (H, H)
print(b.shape)   # (N, H)

・実行結果

(3, 2)
(2, 2)
(2, 2)

このとき、TimeRNNクラスを用いて下記のようにRNNの順伝播を計算することができる。

rnns = TimeRNN(Wx, Wh, b)
hs = rnns.forward(xs)

print(xs.shape)
print(hs.shape)
print(hs[:, 1, :])

・実行結果

(2, 2, 3)
(2, 2, 2)
[[ 0.97729546  0.9982775 ]
 [-0.99932903  0.99999976]]

上記の結果は下記の実行結果に一致する。

h1 = np.tanh(np.dot(xs[:, 0, :], Wx) + b)
h2 = np.tanh(np.dot(h1, Wh) + np.dot(xs[:, 1, :], Wx) + b)
print(h2)

・実行結果

[[ 0.97729548  0.99827751]
 [-0.99932906  0.99999977]]