からっぽのしょこ

読んだら書く!書いたら読む!同じ事は二度調べ(たく)ない

7.3.2:Decoderクラス【ゼロつく2のノート(実装)】

はじめに

 『ゼロから作るDeep Learning 2――自然言語処理編』の初学者向け【実装】攻略ノートです。『ゼロつく2』学習の補助となるように適宜解説を加えています。本と一緒に読んでください。

 本の内容を1つずつ確認しながらゆっくりと組んでいきます。

 この記事は、7.3.2項「Decoderクラス」の内容です。seq2seqの出力側のRNNであるDecoderの処理を解説して、Pythonで実装します。

【前節の内容】

www.anarchive-beta.com

【他の節の内容】

www.anarchive-beta.com

【この節の内容】

7.3.2 Decoderクラス

 出力側のRNNであるDecoder(デコーダー)を実装します。Decoderは、Encoderから受け取った隠れ状態(エンコードされた情報)を別の形式の時系列データにデコードします。

# 7.3.2項で利用するライブラリ
import numpy as np


 Decoderの実装には、RNNで用いるレイヤを利用します。そのため、各レイヤのクラス定義を再実行するか、次の方法で実装済みのクラスを読み込む必要があります。各レイヤのクラスは、「common」フォルダ内の「time_layers.py」ファイルに実装されています。各レイヤについては、5.4.2.1項6.3.1.項5.4.2.2項の記事を参照してください。

# 実装済みクラスの読み込み用の設定
import sys
sys.path.append('C://Users//「ユーザー名」//Documents//・・・//deep-learning-from-scratch-2-master')

# 実装済みのレイヤを読み込み
from common.time_layers import TimeEmbedding # 5.4.2.1項
from common.time_layers import TimeLSTM # 6.3.1項
from common.time_layers import TimeAffine # 5.4.2.2項

 「deep-learning-from-scratch-2-master」フォルダにパスを設定しておく必要があります。

・処理の確認

 図7-8・7-9を参考にして、Decoderで行う処理を確認していきます。

・ネットワークの設定

 まずは、RNNを構築します。

 データとパラメータの形状に関する値を設定して、入力データを簡易的に作成します。時系列サイズ$T$(とたぶん$D$も)は、Encoderと同じ値である必要はありません。

# データとパラメータの形状に関する値を指定
N = 6 # バッチサイズ
T = 4 # 時系列サイズ
V = 13 # 単語の種類数
D = 15 # 単語ベクトル(Embedレイヤの中間層)のサイズ
H = 10 # 隠れ状態(LSTMレイヤの中間層)のサイズ

# (簡易的に)入力データを作成
xs = np.random.randint(low=0, high=V, size=(N, T))
print(xs)
print(xs.shape)
[[12  5  3  8]
 [ 9 12  6  4]
 [ 5  1  4  3]
 [ 3  1 11  5]
 [ 0  2  3 12]
 [12  1  0  7]]
(6, 4)

 Decoderの入力データは、Time Embedレイヤの入力$\mathbf{xs} = (x_{0,0}, \cdots, x_{N-1,T-1})$です。$n$番目のバッチデータの時刻$t$の入力データ$x_{n,t}$は、単語の種類(語彙)を示す単語IDです。単語IDは、語彙数を$V$として0から$V-1$の整数です。
 足し算データセットの場合は、文字の種類を示す文字IDであり$V = 13$です。また、Decoder側の時系列サイズは(答の列数5から1を引いた(詳細は7.3.3項にて))$T = 4$です。


 各レイヤの重みとバイアスの初期値をランダムに生成します。

# Time Embedレイヤのパラメータを初期化
embed_W = (np.random.randn(V, D) * 0.01)

# Time LSTMレイヤのパラメータを初期化
lstm_Wx = (np.random.randn(D, 4 * H) / np.sqrt(D))
lstm_Wh = (np.random.randn(H, 4 * H) / np.sqrt(H))
lstm_b = np.zeros(4 * H)

# Time Affineレイヤのパラメータを初期化
affine_W = (np.random.randn(H, V) / np.sqrt(H))
affine_b = np.zeros(V)

 各パラメータの形状については各レイヤの記事を、初期値の設定については1巻の6.2節を参照してください。

 作成したパラメータを渡して、各レイヤのインスタンスを作成します。

# Time Embedレイヤのインスタンスを生成
embed_layer = TimeEmbedding(embed_W)

# Time Embedレイヤのインスタンスを生成
lstm_layer = TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=True)

# Time Embedレイヤのインスタンスを生成
affine_layer = TimeAffine(affine_W, affine_b)


 Encoderの0から数えて$T$番目(最後の時刻)の隠れ状態$\mathbf{h}_{T-1} = (h_{0,0}^{(T-1)}, \cdots, h_{N-1,H-1}^{(T-1)})$が、Decoderの0番目のLSTMレイヤに入力します(この$T$はEncoder側の時系列サイズです)。

 TimeLSTMクラスのset_state()メソッドに$\mathbf{h}_{T-1}$を渡します。ここでは、$\mathbf{h}_{T-1}$を簡易的に作成します。

# (簡易的に)Encoderからの入力を作成
h = np.random.randn(N, H)
print(np.round(h, 2))
print(h.shape)

# Encoderの隠れ状態を入力
lstm_layer.set_state(h)
[[-0.73  2.02 -1.75 -0.7   0.85 -0.24 -0.29 -0.12  0.21  0.24]
 [ 1.04 -0.19 -0.21  0.33 -0.49  1.89 -0.21  0.57  1.49 -1.4 ]
 [-0.71 -0.03  0.42 -1.8   1.58  0.3   0.84  0.2  -1.08 -0.32]
 [ 1.35  1.2  -0.85 -0.18 -1.81  1.32  1.08 -1.55 -0.59 -1.08]
 [ 0.94  0.5   0.1  -0.   -0.84  0.3  -2.24 -0.5  -0.71  0.83]
 [ 0.08  1.82  1.06  0.72 -2.98 -1.59  0.59  1.42  0.03  1.48]]
(6, 10)

 $t$番目のLSTMレイヤでは、「$t$番目の隠れ状態$\mathbf{h}_t = (h_{0,0}^{(t)}, \cdots, h_{N-1,H-1}^{(t)})$」の計算に、「1つ前の隠れ状態$\mathbf{h}_{t-1} = (h_{0,0}^{(t-1)}, \cdots, h_{N-1,H-1}^{(t-1)})$」を使うのでした。同様に「Decoderの0番目の隠れ状態$\mathbf{h}_0$」の計算において、(1つ前の隠れ状態のように)「Encoderの隠れ状態$\mathbf{h}_{T-1}$」を使います。

 TimeLSTMクラスのインスタンス内では、T個のLSTMレイヤが作成されます。各LSTMレイヤで計算される隠れ状態は、TimeLSTMのインスタンス変数hとして保存されます。set_state()メソッドによって、隠れ状態(インスタンス変数)hを書き換えられます。

 以上でRNNを構築できました。次は順伝播の処理を確認します。

・順伝播の計算

 各レイヤの順伝播を計算します。

# Time Embedレイヤの順伝播を計算
out = embed_layer.forward(xs)
print(out.shape)

# Time LSTMレイヤの順伝播を計算
out = lstm_layer.forward(out)
print(out.shape)

# Time Affineレイヤの順伝播を計算
score = affine_layer.forward(out)
print(np.round(score[:, 0, :], 3))
print(score.shape)
(6, 4, 15)
(6, 4, 10)
[[ 0.068  0.014 -0.048 -0.252  0.063 -0.31  -0.041  0.077  0.013  0.073
   0.161 -0.092  0.183]
 [-0.002 -0.123 -0.064  0.046 -0.246 -0.105 -0.023  0.027 -0.208 -0.053
  -0.251 -0.041 -0.011]
 [-0.125  0.1   -0.071  0.089 -0.036  0.135 -0.102  0.089 -0.149 -0.009
  -0.    -0.042 -0.092]
 [ 0.044 -0.384 -0.054  0.026 -0.163 -0.227  0.185 -0.002 -0.06  -0.115
  -0.244  0.173  0.055]
 [ 0.172 -0.003 -0.198 -0.02   0.07  -0.048  0.057  0.076  0.355 -0.037
   0.215  0.157  0.132]
 [ 0.238  0.386  0.036  0.11   0.141  0.261 -0.103 -0.088  0.553  0.064
   0.253 -0.084  0.019]]
(6, 4, 13)

 Time Embedレイヤの出力は、単語ベクトル(の集合)$\mathbf{xs} = (x_{0,0,0}, \cdots, x_{N-1,T-1,D-1})$です。
 Time LSTMレイヤの出力は、隠れ状態(の集合)$\mathbf{hs} = (h_{0,0,0}, \cdots, h_{N-1,T-1,H-1})$です。
 Time Affineレイヤの出力は、スコア(の集合)$\mathbf{ys} = (y_{0,0,0}, \cdots, y_{N-1,T-1,V-1})$です。

 スコア$\mathbf{ys}$をTime Softmax with Lossレイヤに入力します。Softmaxレイヤによって正規化されたデータを$\mathbf{ys}$で表すこともあります。

 以上が順伝播の処理です。続いて、逆伝播の処理を確認します。

・逆伝播の計算

 Time Softmax with LossレイヤからTime Affineレイヤに、$\mathbf{ys}$の勾配$\frac{\partial L}{\partial \mathbf{ys}} = \Bigl( \frac{\partial L}{\partial y_{0,0,0}}, \cdots, \frac{\partial L}{\partial y_{N-1,T-1,V-1}} \Bigr)$が入力します。

 ここでは$\frac{\partial L}{\partial \mathbf{ys}}$を簡易的に作成して、各レイヤの逆伝播を逆順に計算します。

# (簡易的に)逆伝播の入力データを作成
dscore = np.ones((N, T, V))
print(dscore.shape)

# Time Affineレイヤの逆伝播を計算
dout = affine_layer.backward(dscore)
print(dout.shape)

# Time LSTMレイヤの逆伝播を計算
dout = lstm_layer.backward(dout)
print(dout.shape)

# EncoderのT-1番目の隠れ状態の勾配をEncoderに出力
dh = lstm_layer.dh
print(np.round(dh, 3))
print(dh.shape)

# Time Embedレイヤの逆伝播を計算
dout = embed_layer.backward(dout)
print(dout)
(6, 4, 13)
(6, 4, 10)
(6, 4, 15)
[[-0.058  0.031  0.091  0.119 -0.057 -0.102 -0.032 -0.043 -0.173 -0.053]
 [-0.033  0.596  0.177 -0.135  0.251 -0.335 -0.437  0.201 -0.136  0.004]
 [ 0.038  0.247  0.234  0.255  0.1   -0.522 -0.161  0.196 -0.093 -0.28 ]
 [-0.135  0.405  0.314  0.063 -0.036 -0.271 -0.641 -0.122 -0.392  0.175]
 [-0.041  0.248  0.195  0.245 -0.204 -0.356 -0.113  0.316  0.143 -0.416]
 [ 0.415  0.124  0.112 -0.276 -0.442 -0.629 -0.358  0.419 -0.093 -0.719]]
(6, 10)
None

 Time Affineレイヤの出力は、隠れ状態の勾配$\frac{\partial L}{\partial \mathbf{hs}} = \Bigl( \frac{\partial L}{\partial h_{0,0,0}}, \cdots, \frac{\partial L}{\partial h_{N-1,T-1,H-1}} \Bigr)$です。
 Time LSTMレイヤの出力は、単語ベクトルの勾配$\frac{\partial L}{\partial \mathbf{xs}} = \Bigl( \frac{\partial L}{\partial x_{0,0,0}}, \cdots, \frac{\partial L}{\partial x_{N-1,T-1,D-1}} \Bigr)$です。同時に、Encoderの$T-1$番目の隠れ状態$\mathbf{h}_{T-1}$の勾配(Decoderの0番目のLSTMレイヤの出力)$\frac{\partial L}{\partial \mathbf{h}_{T-1}} = \Bigl( \frac{\partial L}{\partial h_{0,0}^{(T-1)}}, \cdots, \frac{\partial L}{\partial h_{N-1,H-1}^{(T-1)}} \Bigr)$も計算されます。
 Time Embedレイヤより先はないので、Noneを返します。入力データの勾配は返しません。

 $\frac{\partial L}{\partial \mathbf{h}_{T-1}}$をEncoderの$T-1$番目のLSTMレイヤに入力します。

 以上が学習時に行う処理です。

・文章の生成

 最後に、文章生成の処理を確認します。7.1節のときは、求めた確率分布に従い「確率的に」単語を生成しました。7.3節では、確率分布の最大値に従い「決定的に」単語を生成します。

 Encoderに1つ(バッチサイズ$N = 1$)のデータ$xs = (x_{0,0}, \cdots, x_{0,T-1})$を入力した場合を想定します。これは足し算データセットでいうと、1つの式(例えば$71+118$)を文字IDで表したものです。

 順伝播のときと同様に、$xs$をエンコードした情報$\mathbf{h}_{T-1} = (h_{0,0}^{(T-1)}, \cdots, h_{0,H-1}^{(T-1)})$がDecoderに入力します。

 Encoderの隠れ状態$\mathbf{h}_{T-1}$を簡易的に作成して、TimeLSTMクラスのset_state()メソッドに渡してインスタンス変数に保存します。

# (簡易的に)Encoderからの入力を作成
h = np.random.randn(1, H)
print(np.round(h, 2))
print(h.shape)

# Encoderの隠れ状態を入力
lstm_layer.set_state(h)
[[ 0.27  0.39 -0.91  0.66  0.78  0.4   0.44 -1.47  0.47  0.23]]
(1, 10)

 データ数(バッチサイズ)が1の場合でも、2次元配列である必要があります。

 文章生成では、時刻0のデータ$x_{0,0}$のみをDecoderに入力します。これは7.1節で、最初の単語を指定したの同じです。足し算データセットの場合は、区切り文字_の文字IDのみを入力します。

 指定した単語に連ねて生成する単語数を指定します。足し算データセットの場合は、x_testt_testの列数5から区切り文字を除いた4です。

# 最初に入力する単語のIDを指定
start_id = 6

# サンプリングする単語数を指定
sample_size = 4

 ちなみに、区切り文字の文字IDは6です。

 指定した個数の文字ID(単語ID)をサンプリングしてsampledに格納していきます。sampledが、エンコードされた足し算の式の情報$\mathbf{h}_{T-1}$から求めた解答になります。$\mathbf{h}_{T-1}$の他には情報がない区切り文字しか与えていないため、足し算の式(時系列データ)を足し算の答(時系列データ)に変換したことになります。

 この例では7.1節の文章生成とは違い、最初に入力する文字(単語)は区切り文字であり不要な情報です。そこで、start_idsampledの初期値には含めません。またサンプリングしない単語も指定しないため、while文ではなくfor文で指定した回数繰り返します。

 足し算の答を得ることを想定すると、生成するデータ(文字)に確率的な揺らぎは必要なく、一番可能性の高いデータ(文字)を選びたいところです。そこで、確率が最大の文字IDを選ぶことにします。
 スコアを正規化しても大小関係は変わらないのでした。つまりスコアが最大の要素が、確率が最大の要素です。よって、スコアが最大の文字を次の文字として選びます。

 scoreから値が最大の要素のインデックスをnp.argmax()で調べて、そのインデックスをサンプリングした文字IDとします。

# 単語IDの受け皿を初期化
sampled = []

# 最初に入力する単語IDを設定
sample_id = start_id

# 文章を生成
for _ in range(sample_size):
    # 入力用に2次元配列に変換
    x = np.array(sample_id).reshape((1, 1))
    
    # スコアを計算
    out = embed_layer.forward(x)
    out = lstm_layer.forward(out)
    score = affine_layer.forward(out)
    
    # スコアが最大の単語IDを取得
    sample_id = np.argmax(score.flatten()) # 入力データを更新
    sampled.append(int(sample_id)) # サンプルを保存


 サンプリングされたデータを確認しましょう。

# サンプリングされた単語IDを確認
print(sampled)

# 文章を整形
print(''.join([id_to_char[c_id] for c_id in sampled]))
[6, 7, 7, 7]
_999

 これが変換後の時系列データです。id_to_charは、7.2.4項の足し算データセットを読み込む必要があります。ここでは学習を行っていないので、結果はデタラメです。

 以上がDecoderで行う処理です。実際に足し算データセットを利用した処理は次項で行います。

・実装

 処理の確認ができたので、Decoderをクラスとして実装します。

# デコーダーの定義
class Decoder:
    # 初期化メソッド
    def __init__(self, vocab_size, wordvec_size, hidden_size):
        # 変数の形状に関する値を取得
        V, D, H = vocab_size, wordvec_size, hidden_size
        
        # パラメータを初期化
        embed_W = (np.random.randn(V, D) * 0.01).astype('f')
        lstm_Wx = (np.random.randn(D, 4 * H) / np.sqrt(D)).astype('f')
        lstm_Wh = (np.random.randn(H, 4 * H) / np.sqrt(H)).astype('f')
        lstm_b = np.zeros(4 * H).astype('f')
        affine_W = (np.random.randn(H, V) / np.sqrt(H)).astype('f')
        affine_b = np.zeros(V).astype('f')
        
        # レイヤを生成
        self.embed = TimeEmbedding(embed_W)
        self.lstm = TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=True)
        self.affine = TimeAffine(affine_W, affine_b)
        
        # パラメータと勾配をリストに格納
        self.params = [] # パラメータ
        self.grads = []  # 勾配
        for layer in (self.embed, self.lstm, self.affine):
            self.params += layer.params
            self.grads += layer.grads
        
    # 順伝播メソッド
    def forward(self, xs, h):
        # Encoderの隠れ状態を入力
        self.lstm.set_state(h)
        
        # 各レイヤの順伝播を計算
        out = self.embed.forward(xs)
        out = self.lstm.forward(out)
        score = self.affine.forward(out)
        return score
    
    # 逆伝播メソッド
    def backward(self, dscore):
        # 各レイヤの逆伝播を逆順に計算
        dout = self.affine.backward(dscore)
        dout = self.lstm.backward(dout)
        dout = self.embed.backward(dout)
        
        # EncoderのT-1番目の隠れ状態の勾配をEncoderに出力
        dh = self.lstm.dh
        return dh
    
    # 文章生成メソッド
    def generate(self, h, start_id, sample_size):
        # 文字IDの受け皿を初期化
        sampled = []
        
        # 区切り文字のIDを設定
        sample_id = start_id
        
        # エンコードされた足し算の式を入力
        self.lstm.set_state(h)
        
        # 解答を生成
        for _ in range(sample_size):
            # 入力用に2次元配列に変換
            x = np.array(sample_id).reshape((1, 1))
            
            # スコアを計算
            out = self.embed.forward(x)
            out = self.lstm.forward(out)
            score = self.affine.forward(out)
            
            # スコアが最大の文字IDを取得
            sample_id = np.argmax(score.flatten()) # 入力データを更新
            sampled.append(int(sample_id)) # サンプルを保存
        
        return sampled

 TimeEmbedクラスの逆伝播メソッドbackward()Noneを返します。よって、Encoderの逆伝播メソッドbackward()Noneを返します。

 実装したクラスを試してみましょう。

 簡易的な入力データ$\mathbf{xs}$と、Decoderのインスタンスを作成します。

# (簡易的に)入力データを作成
xs = np.random.randint(low=0, high=V, size=(N, T))
print(xs)
print(xs.shape)

# Decoderのインスタンスを作成
decoder = Decoder(V, D, H)
[[10  0 10  6]
 [ 6  1  8  2]
 [ 7 12  1  3]
 [ 8  3  9  3]
 [ 7  0  3 12]
 [ 4  4 11  6]]
(6, 4)


 順伝播を計算します。

# (簡易的に)Encoderからの入力を作成
h = np.random.randn(N, H)
print(h.shape)

# 順伝播を計算
score = decoder.forward(xs, h)
print(np.round(score[0], 2))
print(score.shape)
(6, 10)
[[-0.1  -0.07 -0.07  0.1  -0.05 -0.02 -0.06 -0.02  0.14  0.14 -0.09 -0.05
  -0.01]
 [-0.04 -0.04 -0.08  0.1  -0.03 -0.   -0.02  0.01  0.15  0.08 -0.05 -0.05
  -0.02]
 [-0.02 -0.01 -0.06  0.06 -0.02  0.   -0.01  0.01  0.1   0.05 -0.04 -0.05
  -0.  ]
 [-0.02 -0.   -0.04  0.03 -0.01  0.   -0.01  0.01  0.06  0.03 -0.03 -0.03
   0.  ]]
(6, 4, 13)

 「Decoderの入力データ$\mathbf{xs}$」と「エンコードされたEncoderの入力データの情報$\mathbf{h}_{T-1}$」から、スコア$\mathbf{ys}$が得られました。$\mathbf{ys}$をTime Softmax with Lossレイヤに入力して損失$L$を求めます。

 続いて、逆伝播の入力(Time Softmax with Lossレイヤの出力)$\frac{\partial L}{\partial \mathbf{ys}}$を簡易的に作成して、逆伝播の計算を行います。

# (簡易的に)スコアの勾配を作成
dscore = np.ones((N, T, V))
print(dscore.shape)

# 逆伝播を計算
dh = decoder.backward(dscore)
print(np.round(dh, 3))
print(dh.shape)
(6, 4, 13)
[[-0.099 -0.037 -0.379  0.1   -0.047  0.099 -0.148  0.28  -0.128 -0.111]
 [-0.211 -0.117 -0.175 -0.25  -0.046 -0.264 -0.183  0.021 -0.015 -0.151]
 [-0.418 -0.737 -0.955  0.66   0.173  0.182 -0.054  0.612 -0.481 -0.504]
 [-0.415 -0.555 -1.215  0.699  0.15   0.197 -0.076  0.877 -0.568 -0.543]
 [-0.036  0.178  0.071 -0.236 -0.089  0.002 -0.277 -0.128 -0.159  0.152]
 [-0.139  0.125 -0.262 -0.138 -0.045  0.027 -0.238  0.147 -0.191  0.019]]
(6, 10)

 インスタンス内に各レイヤのパラメータの勾配が保存されます。確率的勾配降下法により、それぞれ勾配を用いてパラメータを更新します。
 また、エンコードされた入力の情報$\mathbf{h}_{T-1}$の勾配$\frac{\partial L}{\partial \mathbf{h}_{T-1}}$が得られました。これをEncoderに入力します。

 最後に、文章生成を行います。

# 最初に入力する単語のIDを指定
start_id = 6

# (簡易的に)Encoderからの入力を作成
h = np.random.randn(1, H)
print(h.shape)

# 文章を生成
sampled = decoder.generate(h, start_id, sample_size)
print(sampled)
print(''.join([id_to_char[c_id] for c_id in sampled]))
(1, 10)
[5, 6, 1, 1]
 _66

 学習を行っていないので、この結果はデタラメです。文字ID5(空白)なので、sampledの要素数と変換後の文字数が一致しないように見えるかもしれません。

 以上でEncoderとDecoderを実装できました。次項では、この2つのRNNを組み合わせてseq2seqを実装します。

参考文献

  • 斎藤康毅『ゼロから作るDeep Learning 2――自然言語処理編』オライリー・ジャパン,2018年.

おわりに

 前項とこの項をやったのなら次も一緒にやっちゃいましょう。

【次節の内容】

www.anarchive-beta.com