人工知能とか犬とか

人工知能と犬に興味があります。しょぼしょぼ更新してゆきます。

PyTorchのSeq2Seqをミニバッチ化するときに気をつけたこと

概要

PyTorchチュートリアルに、英仏の機械翻訳モデルを作成するTranslation with a Sequence to Sequence Network and Attentionがあります。 このチュートリアルは、教師データを一つずつ与える形になっており、結構遅いのです。 なので、バッチでの学習ができるように修正を試みたところ、注意ポイントがいくつかあったのでまとめておきます。

RNNのバッチ学習の実装

RNNでバッチ学習を行う際に問題となるのが、入力されるデータ系列の長さがバッチ内で異なることです。 この問題には一般的に、バッチ内での長さを揃えるためのパディングと、パディングした部分が学習の邪魔にならないようにするマスキングを実装して対処する必要があります。

実装自体は割と簡単にできますが、きちんと実装しないと学習が全然進まなかったりするので注意が必要です。

パディング

パディング自体はそう難しい処理ではありませんが、ググったりフォーラムを参照したり、調べ始めるといろいろやり方があって混乱してしまいました。結果として2つに落ち着きました。

  • 元データに対して、パディングトークン(今回は0)を必要な長さになるまで追加する。
  • 元データを個別にTensorにし、pad_sequenceを使う。

大規模な学習を行う必要のない場合は、前者で良さそうです。

1つ目のやり方

こちらは説明の必要も無いくらい簡単にできます。 単純に足りない長さ分のパディングトークンを追加するだけです。

max_length = 10
seq = [1, 1, 1, 1, 1]
seq += [0] * (max_length - len(seq))
seq
[1, 1, 1, 1, 1, 0, 0, 0, 0, 0]

悩むのは、max_lengthをバッチ内での最大系列長にするか、グローバルな定数にしておくかぐらいでしょうか。

2つ目のやり方

こちらはpad_sequenceを使う方法ですが、シーケンスの長さが降順になるようにソートする必要があり、少々めんどくさいです。

a = torch.ones(2)
b = torch.ones(3)
c = torch.ones(4)
d = torch.ones(3)
e = torch.ones(1)

# 長さが降順になるようにソート
sorted_tensors = sorted([a, b, c, d, e], key=lambda x: x.shape[0], reverse=True)

# Padding
nn.utils.rnn.pad_sequence(sorted_tensors, batch_first=True)
tensor([[ 1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  0.],
        [ 1.,  1.,  1.,  0.],
        [ 1.,  1.,  0.,  0.],
        [ 1.,  0.,  0.,  0.]])

pad_sequenceは、すでにTensorとして用意されているデータをリストに格納し、パディング処理するという用途には向いていそうですが、これを単体で使うことはあまり無い気がしています。

公式ドキュメントのFAQにある、My recurrent network doesn’t work with data parallelismを読む限りでは、複数GPUや分散環境での学習時に必要な、PackedSequenceという仕組みを使うときに使うようです。単一の環境で行う場合は、無理に使う必要も無いでしょう。

マスキング

マスキングは、以下の2点が適切に行われる必要があるでしょう。

  • Embedding
  • Loss

Embedding

Embeddingでは、padding_idx引数を指定する必要があります。 これを指定することで、パディングされた部分の埋め込みをすべて0にすることができます。

num_input = 10
emb_size = 5

embedding = nn.Embedding(num_input, emb_size, padding_idx=0)
batch = torch.LongTensor([
    [1, 2, 3, 0, 0, 0, 0, 0, 0, 0],
    [1, 2, 3, 4, 5, 0, 0, 0, 0, 0]
])

embedding(batch)
tensor([[[ 0.6500,  0.1616, -1.1696, -0.0516, -0.9050],
         [-0.4270,  1.1525, -0.8994, -1.0899, -0.6576],
         [ 0.4006,  0.3189,  0.1728,  1.4344,  2.0811],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000]],

        [[ 0.6500,  0.1616, -1.1696, -0.0516, -0.9050],
         [-0.4270,  1.1525, -0.8994, -1.0899, -0.6576],
         [ 0.4006,  0.3189,  0.1728,  1.4344,  2.0811],
         [ 0.6779, -0.7535,  0.1944,  0.8275, -0.5984],
         [ 0.9328, -1.4141,  1.0738,  1.5253, -1.1572],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000]]])

Loss

Seq2Seqをミニバッチで行う場合、損失の計算を行う際にパディング部分を適切に処理しないと、損失の計算結果がで大きく変わってしまいます。これによって、損失を過大評価したり過小評価したりといったことが生じてしまうので、パディング部分をマスクして損失は計算する必要があります。

loss1 = nn.NLLLoss()
loss2 = nn.NLLLoss(ignore_index=0)  # ignore_indexを指定

pred = torch.rand(100, 5)
true = torch.randint(high=5, size=(100,), dtype=torch.long)

print(loss1(pred, true))
print(loss1(pred[true > 0], true[true > 0]))
print(loss2(pred, true))
tensor(-0.4701)
tensor(-0.4473)
tensor(-0.4473)

パディング部分をマスクした損失は、上記の例だとloss1(pred[true > 0], true[true > 0])で計算することも可能ですが、loss2のようにignore_indexを指定することでも実現できます。面倒なので、ignore_indexを指定したほうが良いでしょう。

その他

PyTorchに限らず、深層学習の実装を行う際は、層に期待されている入出力がどういうサイズのテンソルなのかを適切に把握しておく必要があります。PyTorchのドキュメントでは、引数にテンソルを取る場合のサイズや順序を明確に示しているので、ドキュメントをよく読みましょう。

僕が実装の過程で躓いたのは以下の2つくらいでした。

  • RNNのbatch_first引数:デフォルトでは入力も出力も(seq_len, batch, input_size)というサイズのTensorだが、これをTrueにすると(batch, seq_len, input_size)になる。
  • 内積計算:バッチに含まれているTensor同士の内積の計算は、torch.bmmで実現できる。 torch.transposeやsqueeze、unsqeezeなどと組み合わせてサイズの順番を適切にしてから計算する必要がある。

まとめ

PyTorchのチュートリアルも、Attention機構になってくると複雑になってきます。 パディングとマスキング周りは結構調べながら実装しました。テンソルのサイズは最初は混乱しますが、丁寧に一つ一つの処理を追い、transposeやsqueeze, unsqeezeを駆使しながら実装するのは、パズルのようで楽しい作業でもあります。

一応、今回実装したものをNotebookにしてあります。Decoderは、チュートリアルとは別のバージョンを一つ実装しています。Attentionっていろいろ種類がありますね。 いずれもろくにチューニングしていないので、精度的に良いという感じではありません。残念ながら、Attentionの結果も納得の行くものにはなっていません。

PyTorchにはfairseqのようなパッケージもあるので、こういうものを活用して、Seq2Seqは手軽に実装できるようになっておきたいです。

Word2Vecと多義性

概要

Word2Vecは、単語をベクトルとして表現する手法ですが、「ダウンタウン」のような語は、多義性を持っています。 事実、word2vecにおける「ダウンタウン」は「ウッチャンナンチャン」のようなお笑い芸人コンビよりも、「シーサイド」などの地理的な用語とのほうが類似度が高くなることがあります。 SVDを使って、ベクトル表現された「ダウンタウン」からのお笑い芸人要素を抽出してみたいのです。

準備

ダウンタウン」の多義性

ダウンタウンに類似している単語を、学習済みword2vecモデルを使って調べてみましょう。

from gensim.models.word2vec import Word2Vec
model_path = 'word2vec.gensim.model'
model = Word2Vec.load(model_path)

target_word = "ダウンタウン"
model.wv.most_similar(target_word, topn=10)

きっとこんな出力が得られるはずです。

[('ミッドタウン', 0.8537717461585999),
 ('中心街', 0.8283530473709106),
 ('アップタウン', 0.8175398111343384),
 ('ストリート', 0.8096662759780884),
 ('マンハッタン', 0.8070244193077087),
 ('セントラル・パーク', 0.7880061864852905),
 ('チャイナタウン', 0.7842386364936829),
 ('セントラルパーク', 0.7788770198822021),
 ('メインストリート', 0.772649884223938),
 ('リバーサイド', 0.7724398374557495)]

都市や町を表すような横文字の単語がたくさん出てきます。ダウンタウンはお笑いコンビであると同時に、「繁華街」を表す単語でもあります。 ちなみに、自分がぱっと思いつくお笑い芸人がどこに出てくるかというと、、、

owarai_words = ['タモリ', 'さんま', '明石家さんま', 'ビートたけし', 'サンドウィッチマン',
                'ウッチャンナンチャン', 'とんねるず', 'ナインティナイン', 'バナナマン']

for i, (word, score) in enumerate(model.wv.most_similar("ダウンタウン", topn=100000)):
    if word in owarai_words:
        print(i+1, word, score)
7737 ウッチャンナンチャン 0.37757110595703125
9201 とんねるず 0.36377257108688354
15844 ナインティナイン 0.3186175227165222
16347 サンドウィッチマン 0.31598663330078125
21048 バナナマン 0.29402637481689453
22260 タモリ 0.28919389843940735
27073 明石家さんま 0.27145278453826904
34131 ビートたけし 0.2494104504585266
45667 さんま 0.2202756255865097

なんと7737番目に「ウッチャンナンチャン」が出てきます。類似度も0.38と、そんなに高くありません。 この学習済みモデル内では、ダウンタウンはお笑い芸人というよりは、「繁華街」の意味が強いみたいです。

多義性を抽出する

ダウンタウンのベクトル表現の中に含まれているであろうお笑い芸人要素をなんとかして抽出してみましょう。以下のような戦略を取ります。

  1. とりあえず類似している単語を検索してそこそこの数を集める
  2. あつめた類似語の中で支配的なベクトル成分をSVDによって特定する
  3. SVDで得られた変換によって、元のword vectorから、支配的な成分を差し引く
  4. 差し引いたベクトルで検索をかけてみる

類似している単語を集める

とりあえず30単語くらい集めてみましょう。

model.wv.most_similar(positive=[target_word], topn=30)
[('ミッドタウン', 0.8537717461585999),
 ('中心街', 0.8283530473709106),
 ('アップタウン', 0.8175398111343384),
 ('ストリート', 0.8096662759780884),
 ('マンハッタン', 0.8070244193077087),
 ('セントラル・パーク', 0.7880061864852905),
 ('チャイナタウン', 0.7842386364936829),
 ('セントラルパーク', 0.7788770198822021),
 ('メインストリート', 0.772649884223938),
 ('リバーサイド', 0.7724398374557495),
 ('ベイエリア', 0.7706623077392578),
 ('ロウアー・マンハッタン', 0.7596601247787476),
 ('シュガーランド', 0.7591447234153748),
 ('アヴェニュー', 0.7567015290260315),
 ('ナッシュビル', 0.7514258623123169),
 ('アッパー・イースト・サイド', 0.7497652769088745),
 ('ヴィレッジ', 0.7473465204238892),
 ('タイムズスクエア', 0.7472085952758789),
 ('マンハッタン区', 0.7465125918388367),
 ('スコッツデール', 0.7446001172065735),
 ('大通り', 0.7414740920066833),
 ('ハイウェイ', 0.7391560077667236),
 ('ハイツ', 0.7379716634750366),
 ('ビーチ', 0.7375644445419312),
 ('イースト', 0.7367238998413086),
 ('タウン', 0.7340238094329834),
 ('メイン・ストリート', 0.7318717241287231),
 ('セントポール', 0.7312244176864624),
 ('アベニュー', 0.7290509939193726),
 ('ビレッジ', 0.727695643901825)]

トップ30の単語に含まれる強い成分を抽出してみましょう。

from sklearn.decomposition import TruncatedSVD
similar_words = [x[0] for x in model.wv.most_similar(positive=[target_word], topn=30)]
x = np.array([model.wv[target_word]] + [model.wv[word] for word in similar_words])

# SVDで最も強い成分を取り出す
svd = TruncatedSVD(n_components=1)
transformed_x = svd.fit_transform(x)
extracted_x = svd.inverse_transform(transformed_x)

# プロットしてみる
fig, (ax1, ax2, ax3) = plt.subplots(1, 3)
fig.set_figheight(4)
fig.set_figwidth(16)
ax1.bar(np.linspace(0, 49, 50), x[0])
ax2.bar(np.linspace(0, 49, 50), extracted_x[0])
ax3.bar(np.linspace(0, 49, 50), x[0] - extracted_x[0])

ax1.set_ylim(-0.4, 0.4)
ax2.set_ylim(-0.4, 0.4)
ax3.set_ylim(-0.4, 0.4)

ax1.set_title("ダウンタウン")
ax2.set_title("強い成分")
ax3.set_title("残りの部分")

f:id:wanchan-daisuki:20180617220242p:plain

もともとの「ダウンタウンのベクトル」から「強い成分」を引いて、検索してみましょう。 gensimでは、most_similarメソッドの引数のnegativeに差し引きたい成分を入れることでやりたいことが実現できます。

model.wv.most_similar(positive=[target_word], negative=[extracted_x[0]], topn=30)
[('毎回', 0.6686477661132812),
 ('トーク', 0.6482667922973633),
 ('バラエティ', 0.6234453916549683),
 ('ギャグ', 0.6228033304214478),
 ('番組内容', 0.6177021265029907),
 ('視聴者', 0.6139947175979614),
 ('リスナー', 0.6131852269172668),
 ('笑い', 0.6000913977622986),
 ('ネタ', 0.5957863330841064),
 ('出演者', 0.5891598463058472),
 ('下ネタ', 0.5873293280601501),
 ('人気番組', 0.5856291651725769),
 ('とんねるず', 0.5822530388832092),
 ('面白い', 0.581822395324707),
 ('コント', 0.5800341367721558),
 ('タモリ', 0.575900137424469),
 ('お笑い', 0.5756425857543945),
 ('楽しく', 0.574203610420227),
 ('本音', 0.5713927745819092),
 ('お色気', 0.5688245892524719),
 ('芸人', 0.5651636719703674),
 ('お笑い番組', 0.5635770559310913),
 ('ナレーション', 0.5626435279846191),
 ('喋り', 0.5624589323997498),
 ('聴取者', 0.5620508790016174),
 ('ファン', 0.561782956123352),
 ('深夜番組', 0.5606791377067566),
 ('フリートーク', 0.5589464902877808),
 ('雰囲気', 0.5542364120483398),
 ('トーク番組', 0.5526514053344727)]

お笑いや芸能関係の単語がたくさん出てきましたね。お笑い芸人の名前もちらほら見えます。

繰り返し抽出する

以下のような手順を何度か繰り返すことで、「ダウンタウン」に関わる多義性を炙り出せるのではなかろうか、と考えられます。

  1. negativeリストを用意する
  2. 単語とnegativeリストで検索する
  3. トップn個の類似単語から、強い成分を抽出する
  4. 得られた強い成分で検索し、類似している語を得る
  5. 強い成分をnegativeリストに加え、2.に戻る
from ipywidgets import interact

def extract_similar_words(word, n_iter=10):
    wordvec = model.wv[word]
    negative = []
    
    vectors = [wordvec]
    top_words = [[(word, 1.0)],]
    
    for i in range(n_iter):
        # topnを変えることで、少し結果が変わります。
        similar_words = [x[0] for x in model.wv.most_similar(positive=[word], negative=negative, topn=10)]  # 類似した単語を集める
        x = np.array([wordvec] + [model.wv[word] for word in similar_words])  # 特異値分解の行列を作成する
        svd = TruncatedSVD(n_components=1)
        transformed_x = svd.fit_transform(x)
        extracted_x = svd.inverse_transform(transformed_x)
        
        vectors.append(extracted_x[0])
        top_words.append(model.wv.most_similar(positive=[extracted_x[0]], topn=10)) 
        
        negative.append(extracted_x[0])  # negativeな成分を追加
        
    # interactiveなwidgetを使っています。
    @interact(factor=(0, n_iter))
    def wordvec_plot(factor=0):
        x = np.arange(50)
        plt.bar(x,vectors[factor])
        plt.title("{}つ目の成分".format(factor))
        plt.ylim((-0.5, 0.5))
        plt.text(60, -0.4, '\n'.join(["{}: {}".format(x[0], x[1]) for x in top_words[factor]]))
        
    return wordvec_plot
extract_similar_words("ダウンタウン")

1つ目の成分で検索してみると、街区や地区に関する、主に横文字が出てきます。 f:id:wanchan-daisuki:20180617220646p:plain

2つ目の成分で検索してみると、お笑い芸人が出てきます。 f:id:wanchan-daisuki:20180617220650p:plain

3つ目の成分で検索してみると、よくわからない結果になります。残されたベクトルの成分も平坦になってきているので、多義性を抽出し尽くした、と言えるのかもしれません。 f:id:wanchan-daisuki:20180617220654p:plain

ウマやイヌで試す

ダウンタウン」には、はっきりと違う2つの意味=多義性がありました。 しかし、例えば「馬」は人間と様々かたちで関わる動物です。 それぞれの観点によって類似している単語は異なってくるはずです。

  • 哺乳類としての馬 → 様々な哺乳類
  • 食べ物としての馬 → 様々な食用動物
  • 役畜としての馬 → 牛
  • 乗り物としての馬 → 車、自転車
  • 競争するものとしての馬 → フォーミュラカー
  • 戦争の道具としての馬 → 戦車

などということが想像できます。

ウマ

extract_similar_words("ウマ")

1つ目の成分で検索してみると、草食動物や家畜っぽい動物が出てきます。 f:id:wanchan-daisuki:20180617222913p:plain

2つ目の成分で検索してみると、競馬っぽい単語が出てきます。 f:id:wanchan-daisuki:20180617222555p:plain

ちなみに、「ウマ」ではなく「馬」で検索してみると、当然ですが、全く違う結果が得られます。

1つ目の成分では、競馬系の単語が並びます。 f:id:wanchan-daisuki:20180618212030p:plain

2つ目の成分で検索してみると、乗り物としての馬や戦(いくさ)を意識させるような単語が出てきましたね。 f:id:wanchan-daisuki:20180618212037p:plain

イヌ

ついでに犬でもやってみましょう。

extract_similar_words("イヌ")

1つ目の成分で検索してみると、動物や昆虫が出てきました。「イヌ」自身も類似後のトップ10に出現しています。 f:id:wanchan-daisuki:20180618212236p:plain

2つ目の成分で検索してみると、、、なぜか法律や病気に関する単語が出てきます。1つ目の成分の類似語に「イヌ」そのものが含まれていたことを考えると、1つ目の成分で「イヌ」の意味はほとんど説明できている、と言えるのかもしれません。 f:id:wanchan-daisuki:20180618212242p:plain

まとめ

Word2Vecで得られた埋め込みベクトルを分析し、単語の中に含まれている多義性ごとに類似単語を得ることができることを確認しました。 また、現実において多様な扱われ方をする「馬」にも、多義性のようなものが含まれていることがわかりました。

Word2Vecを含めたEmbeddingは様々な自然言語処理の深層学習に必須のテクニックですが、単体で見ても分析しがいのある題材ですね。

PyTorchのRNNとRNNCell

概要

PyTorchでRNNを使った実装しようとするとき、torch.nn.RNNtorch.nn.RNNCellというものがあることに気がつきました。 それぞれの違いを明らかにして、注意点を整理しておきたいのです。

リカレント層の実装方法

PyTorchチュートリアルの、名前分類をこなしていて、RNNの実装方法について調べようと思ったのがことの発端。チュートリアルでは、RNNモジュールをイチからで実装しているが、実務上イチからRNNを実装することはほぼ無いと思われるので、調べてみたら、torch.nn.RNNtorch.nn.RNNCellを見つけました。また、代表的なリカレント系レイヤーであるLSTMとGRUについても、torch.nn.LSTMtorch.nn.GRU以外に、torch.nn.LSTMCelltorch.nn.GRUCellがあることがわかりました。

そんなわけで、「Cell」の有無で何が違うのかを調べてみました。

RNNとRNNCellの違い

公式のフォーラムに、ズバリそのものの質問がありました。図を引用してみましょう。ここでは、時系列データが想定されていて、長さ7の入力系列を3層のRNNで受け取り、長さ7の出力系列を出力しています。図ではRNNの出力系列は7つになっていますが、Attention機構をつくらない場合は、出力系列の最後(一番右上の青い四角)のみを利用して、その後の分類やら回帰のためのレイヤーへとつなげることが一般的かと思います。 入力系列が赤、RNNが緑、出力系列が青で示されています。

f:id:wanchan-daisuki:20180610152755p:plain

この図の中で、torch.nn.RNNCellとtorch.nn.RNNがどこに対応するのかを見てみましょう。 まず、torch.nn.RNNCellは下図の赤枠で示すような範囲、つまり、一つ一つの緑の四角に対応します。torch.nn.RNNCellは、下と左から1つずつの入力を受け、上と右に出力します。つまり、入力系列の一つと前のRNNCellの状態を初期状態として入力し、更新された隠れ状態を上(スタックされたRNNCellまたは出力系列)と右(次のRNNCellへの状態入力)に出力しています。

f:id:wanchan-daisuki:20180610152943p:plain

次に、torch.nn.RNNは、下図の青枠で示すような範囲、つまり緑の四角すべてです。torch.nn..RNNは、下から入力系列を受け、上に出力系列を出力します。系列中のRNNCell間の状態のやり取りは、全てtorch.nn.RNN内部で行われます。

f:id:wanchan-daisuki:20180610201939p:plain

torch.nn.RNNCellの使い方

具体的なRNNCellの使い方を確認しましょう。チュートリアルで行っている、名前から国を予測するModuleを、GRUCellを使って実装してみました。 GRUでやっていますが、RNNでもほとんど同じです。

データの準備などはチュートリアルどおりなので割愛するとして、具体的にどのようにネットワークを定義するのかを示します。一応Notebookを上げておきます。

class StackedGRU(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        super(StackedGRU, self).__init__()

        self.hidden_size = hidden_size
        self.gru1 = nn.GRUCell(input_size, hidden_size)
        self.gru2 = nn.GRUCell(hidden_size, hidden_size)
        self.linear = nn.Linear(hidden_size, output_size)
        self.softmax = nn.LogSoftmax(dim=1)

    def forward(self, input, hiddens):
        hidden1 = self.gru1(input, hiddens[0])
        hidden2 = self.gru2(hidden1, hiddens[1])
        output = self.linear(hidden2)
        output = self.softmax(output)
        return output, [hidden1, hidden2]

    def initHidden(self):
        return [torch.zeros(1, self.hidden_size), torch.zeros(1, self.hidden_size)]

n_hidden = 128
rnn = StackedGRU(n_letters, n_hidden, n_categories)

この例ではGRUCellを2層にスタックしています。forwardメソッドを確認すると、引数はinput(入力系列の1要素)とhiddens(2つのGRUCellの状態)で、所属クラスの予測値(output)と、2つのGRUCellの状態(hidden1とhidden2)を返します。これらの返り値は、最終的な所属クラスの予測や次の入力のhiddensになります。以下のtrain関数を見ればどんなふうに使われるのかがわかるでしょう。

def train(category_tensor, line_tensor):
    hidden = rnn.initHidden()

    rnn.zero_grad()

    for i in range(line_tensor.size()[0]):  # 一文字ずつ入力する
        output, hidden = rnn(line_tensor[i], hidden)  # hiddenは次の入力にする。outputは最後以外毎回捨てる。

    loss = criterion(output, category_tensor)
    loss.backward()

    # Add parameters' gradients to their values, multiplied by learning rate
    for p in rnn.parameters():
        p.data.add_(-learning_rate, p.grad.data)

    return output, loss.item()

RNNCellでは、一文字ずつ入力し、RNNCellの状態を更新し、最後に所属クラスを得る、という流れを自分で実装するので、何をやっているのかがわかりやすいといえばわかりやすいですが、めんどくさいといえばめんどくさいです。

torch.nn.RNNの使い方

次に、RNNを見ていきましょう。こちらもGRUでやっています。Notebookも一応上げておきます。

class BidirectionalGRU(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        super(BidirectionalGRU, self).__init__()

        self.hidden_size = hidden_size
        self.num_layers = 1
        self.bigru = nn.GRU(input_size, hidden_size, num_layers=self.num_layers, bidirectional=True)
        self.linear = nn.Linear(hidden_size*2, output_size)
        self.softmax = nn.LogSoftmax(dim=1)

    def forward(self, input):
        input = input.to(device)
        hidden = torch.zeros(self.num_layers*2, 1, self.hidden_size).to(device)  # 各GRUの初期状態は、一つのTensor。
        output, _ = self.bigru(input, hidden)  # (出力系列, 各GRUの状態が一つのTensorにまとめられたもの)
        output = output[-1]  # 出力系列の最後のみ使用
        output = self.linear(output)
        output = self.softmax(output)
        return output

n_hidden = 128
rnn = BidirectionalGRU(n_letters, n_hidden, n_categories)
rnn = rnn.to(device)

RNNはRNNCellとは違い、複数層のリカレントレイヤーを一行で書ける分、引数がいくらか複雑です。 インスタンス作成時の引数を見てみると、num_layersは、リカレントレイヤーを何層スタックするのかを表す引数で、bidirectionalは双方向にするかを表す引数です。推論時の引数にちょっと注意が必要です*1。入力系列が第1引数で、第2引数に各GRUCellの状態の初期値を与える必要があるのですが、リストやタプルではなくて一つのTensorにしてまとめてあげる必要があります。ここのサイズの設定がややこしいのでちゃんと理解しておく必要があります。

hidden = torch.zeros(self.num_layers*2, 1, self.hidden_size).to(device)  # 各GRUの初期状態は、一つのTensor。

サイズは、(Cellの個数, 1, 隠れ層の次元数)という順番で設定します。1になっているところは、この例のバッチサイズが1だからです。上の例では、GRUのスタック数が1で、双方向なので2倍しているという感じです。

LSTMの場合は、内部状態が2つあるためにまた違った指定になるようで、hとcを別々のTensorにして、タプルとして入力する必要があるようです(参考)。

torch.nn.RNNを使えば、学習時にfor文で回す必要がなくなります。こっちのほうがスッキリして書けるので好きです。

def train(category_tensor, line_tensor):
    rnn.zero_grad()
    
    output = rnn(line_tensor)  # 一発で所属クラスの予測ができる。
    
    loss = criterion(output, category_tensor.to(device))
    loss.backward()

    # Add parameters' gradients to their values, multiplied by learning rate
    for p in rnn.parameters():
        p.data.add_(-learning_rate, p.grad.data)

    return output, loss.item()

まとめ

PyTorchのtorch.nn.RNNとtorch.nn.RNNCellの違いについて確認しました。torch.nn.RNNを使うときは、内部状態の次元数、いくつスタックしているのか、双方向か否かをきちんと把握しておく必要があります。

*1:RNNの推論時に内部状態の初期値は、デフォルト値としてゼロが入るので、特にこだわりが無ければ入れないでいいです。しかし、RNNCellの内部状態の初期値の設定は、生成モデルなどでは必ず使う部分なので、デフォルト値以外の指定方法も知っておくべきだと思います。

PyTorchでわんにゃん分類器をつくる

概要

PyTorchで事前学習済みモデルのファインチューニングを行って、犬や猫の種類を分類できる分類器を作成しました。使用している事前学習済みモデルはResNet18、データセットThe Oxford-IIIT Pet Datasetを使用します。

特になんの工夫もしなくても、90%程度の精度で分類が実現できます。

Notebookはgithubに公開しています。

手法

PyTorchチュートリアルTransfer Learning tutorialを元に、事前学習済みのResNet18をファインチューニングすることで、わんにゃん分類器を作ります。

このチュートリアルでは以下の2通りの学習方法を示しています。

  • 事前学習済みモデル全体を学習
  • 事前学習済みモデルに追加した全結合相のみを学習

この記事では前者のみをまとめています。

データセット

チュートリアルでは、ImageNetのサブセットであるアリとハチのデータセットを用いていますが、せっかくならかわいいデータを使いたい。そういうわけで、The Oxford-IIIT Pet Datasetを使用します。

データセットは、犬25種、猫12種、全37個のクラスからなり、各クラスごとに大体200枚の画像が含まれています。

http://www.robots.ox.ac.uk/~vgg/data/pets/breed_count.jpg

データセットは展開して、以下のようなフォルダ構成にします。20%を評価用のデータに使いました

  • train
    • abyssinian
      • Abyssinian_1.jpg
      • Abyssinian_3.jpg
      • Abyssinian_5.jpg
      • ...
    • american_bulldog
    • ...
  • val
    • abyssinian
      • Abyssinian_2.jpg
      • Abyssinian_4.jpg
      • ...
    • american_bulldog
    • ...

実装

基本はチュートリアルのやり方そのままです。 一部混同行列の表示や学習の過程を示す損失と精度のプロットを入れています。詳細はNotebookを見てください。

結果

学習の過程はこんな感じ。 f:id:wanchan-daisuki:20180603133418p:plain

予測精度は92%くらい出せていて、けっこう合っています。

f:id:wanchan-daisuki:20180603135112p:plain

f:id:wanchan-daisuki:20180524130936p:plainf:id:wanchan-daisuki:20180524130935p:plainf:id:wanchan-daisuki:20180524130926p:plain
予測結果

一方で、ハズレの例を見てみると、ラグドールバーマンを混同している例が見られました。似てるからしゃーないね。 あとは、スタッフォードシャーブルテリアアメリカンピットブルテリアを混同していたり、納得の行く間違いが多いですね。

f:id:wanchan-daisuki:20180603133434p:plain

感想

公式のチュートリアルでは2クラスの分類だったのでちょっと感動が薄かったのですが、これくらいのクラス数があっても90%くらいの精度で見分けられると、なかなか楽しいです。

実はこのチュートリアル、だいぶ前にやって今回再びやり直してみたものです。 なので、所々に前のバージョンのチュートリアルのコードが残っているかもしれません。

前のバージョンでは、use_cudaというフラグを使って、CUDAで処理するならこっち、CPUでやるならそっち、というif文がいたるところに存在していました。torch.deviceやTensorsのtoメソッドによってこれが無くなって、だいぶシンプルに書けるようになったと思います。

公式の0.4.0 MigrationGuideのWriting device-agnostic codeを見ると、 今後はこの書き方が推奨されるようですね。

# at beginning of the script
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

...

# then whenever you get a new Tensor or Module
# this won't copy if they are already on the desired device
input = data.to(device)
model = MyModule(...).to(device)