人工知能に関する断創録

このブログでは人工知能のさまざまな分野について調査したことをまとめています(更新停止: 2019年12月31日)

PyTorch (16) 文字レベルRNNで人名の分類

前回からずいぶん時間が空いてしまった (^^;) 今回からテキストや音声など系列データを扱う手法に進んでいこうと思っています。

最初のお題として文字レベルのRecurrent Neural Network (RNN) を試しました。PyTorchチュートリアルの Classifying Names with a Character-Level RNN です。

このチュートリアルは、人名から国籍を推定するというタスクです。データとして数千の人名を18の国籍に分類したデータが提供されています。たとえば、Hintonさんだったらスコットランド人、Schmidhuberさんだったらドイツ人、Moriさんだったら日本人という感じ。

RNNなどの系列データを扱うニューラルネットでは、系列の要素として文字、単語、n-gramなどいろいろ取りえますが、今回は文字です。つまり、人名を文字の系列データとみなします。

このチュートリアルは、PyTorchの nn.RNN モジュールを使わずにスクラッチでシンプルなRNNを実装していますが、それに加えて、nn.RNNnn.LSTM を使った実装も試してみました!

最近、Jupyter NotebookをGoogleのクラウド上で提供しているGoogle Colabにハマっていて、今回もそれで実験しています。このサービスは連続12時間までならGPUも無料で使えるのでこのブログで取り扱っている内容くらいなら十分使えます。Googleアカウントを持っていれば誰でも使えるのでおすすめ!

180323-character-level-rnn.ipynb - Google ドライブ

あと今回から記事にコード載せて解説するスタイル止めようと思います。元記事の説明の方が詳しいし、面倒くさくなったので(ボソ)。最近は、Deep Learning流行ってるせいですごく詳しく説明した記事もたくさんありますしね。

日付みるとわかるように3/23に実験してブログ書くの面倒くさくなって1ヶ月放置してました (^^;)

RNNの構造の違い

このチュートリアルで気になったのはRNNの構造です。

f:id:aidiary:20180421101654p:plain

出力された隠れ状態を入力に戻すというのは一般的なRNNだと思うのですが、inputとhiddenをマージしてから次の隠れ状態や出力を求める というのがちょっと違和感を感じました。

nn.RNN の実装だと下のように入力は i2h()(w_ihとb_ih)で処理して、前の隠れ状態は h2h()(w_hhとb_hh)で処理してそれを足し算して次の隠れ状態を求めるというのが一般的な実装だと思ってたので(出力はさらに h2o() が入る)。

w_ihとw_hhの行列を一つにまとめただけで理論的には同じことかな?

f:id:aidiary:20180421123035p:plain http://pytorch.org/docs/stable/nn.html#torch.nn.RNN

今回のチュートリアルに合わせてテンソルサイズなどを図示してみました。

f:id:aidiary:20180421122308p:plain

系列データの分類を行う場合は、系列を全部入れ終わったあとに最後の出力のみを使うmany to one型が一般的です。

f:id:aidiary:20180421231716p:plain:w128 The Unreasonable Effectiveness of Recurrent Neural Networks

nn.RNNを使った実装

PyTorchのモジュール nn.RNN を使うと下のようになります。nn.RNN を使うと系列の各要素を順番に処理するforループを自分で書く必要がなくなるのですごく楽になる!

class SimpleRNN(nn.Module):
    
    def __init__(self, input_size, hidden_size, output_size):
        super(SimpleRNN, self).__init__()
        
        self.hidden_size = hidden_size
        self.rnn = nn.RNN(input_size, hidden_size, num_layers=1)
        self.out = nn.Linear(hidden_size, output_size)
        self.softmax = nn.LogSoftmax(dim=2)  # outは3Dtensorになるのでdim=2
    
    def forward(self, input, hidden):
        # nn.RNNは系列をまとめて処理できる
        # outputは系列の各要素を入れたときの出力
        # hiddenは最後の隠れ状態(=最後の出力) output[-1] == hidden
        output, hidden = self.rnn(input, hidden)
        
        # RNNの出力がoutput_sizeになるようにLinearに通す
        output = self.out(output)

        # 活性化関数
        output = self.softmax(output)

        return output, hidden
    
    def initHidden(self):
        # 最初に入力する隠れ状態を初期化
        # (num_layers, batch, hidden_size)
        return Variable(torch.zeros(1, 1, self.hidden_size))

今回はミニバッチサイズ1としています。つまり、系列(人名)は1つずつRNNに入力しています。系列長(人名の長さ)が異なった複数の系列データをミニバッチとしてまとめてRNNに入力する方法は調査中!また取り上げたい。

f:id:aidiary:20180421103155p:plain

nn.LSTM を使った実装

LSTMの場合は、内部状態としてhに加えてcというのが増えるだけであとは大体同じ。 (h, c) の渡し方は人によって書き方が違うけどどうやるのがよいだろう?タプルでまとめるのがよいのかな・・・

class SimpleLSTM(nn.Module):
    
    def __init__(self, input_size, hidden_size, output_size):
        super(SimpleLSTM, self).__init__()
        
        self.hidden_size = hidden_size
        self.lstm = nn.LSTM(input_size, hidden_size, num_layers=1)
        self.out = nn.Linear(hidden_size, output_size)
        self.softmax = nn.LogSoftmax(dim=2)  # outは3Dtensorになるのでdim=2
    
    def forward(self, input, h, c):
        # nn.RNNは系列をまとめて処理できる
        # outputは系列の各要素を入れたときの出力
        # hiddenは最後の隠れ状態(=最後の出力) output[-1] == hidden[0]
        output, (h, c) = self.lstm(input, (h, c))
        
        # RNNの出力がoutput_sizeになるようにLinearに通す
        output = self.out(output)

        # 活性化関数
        output = self.softmax(output)

        return output, (h, c)
    
    def initHidden(self):
        # 最初に入力する隠れ状態を初期化
        # LSTMの場合は (h, c) と2つある
        # (num_layers, batch, hidden_size)
        h = Variable(torch.zeros(1, 1, self.hidden_size))
        c = Variable(torch.zeros(1, 1, self.hidden_size))
        if cuda:
            h = h.cuda()
            c = c.cuda()
        return (h, c)

LSTMは学習が難しいみたいでSGDだとあまりうまくいかなかった。Adamに変えたらRNNよりlossが減ったみたい。ただこれは訓練lossなので過学習してるかも(笑)

f:id:aidiary:20180421103401p:plain

f:id:aidiary:20180421111255p:plain

ためしに自分の名前で予測してみました。

> Mori
(-0.11) Japanese
(-2.37) Italian
(-5.35) Russian

> Koichiro
(-0.13) Japanese
(-3.53) Italian
(-3.61) English

Moriは訓練データに入ってるけどKoichiroは入っていません。どちらも正解していた!

参考文献