人工知能に関する断創録

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

モダリティを超えて

2018年の目標(2018/1/1)にも書いたように今年はマルチモーダル深層学習の深耕を目標に上げていました。社内の輪講会でマルチモーダル深層学習の研究動向について発表する機会があったので資料を公開します。

をベースに自分なりに調査した研究を追加してまとめています。

上の論文では、モダリティとして言語・音声・画像のみを取り上げているのですが、私はここに行動(制御)も付け加えています。

また、発表時間の関係から一部の研究を省略しています。他にも面白い研究はまだあるので、興味のある方は元のサーベイ論文やチュートリアル資料を読んでみてください。このブログでもおいおい紹介していきたいと思います!

マルチモーダルについては15年前にシンボリズム vs コネクショニズムの話を聴いたころから興味があったのですが、最近まですっかり忘れていました。

Deep Learningが言語・画像・音声・制御・その他のモダリティを統合することで、今まで想像もできなかったような面白いアプリケーションがたくさん出てくるんじゃないかと期待してます。


ちなみにタイトルは「複雑系を超えて」から。すごく好きな本です。

「複雑系」を超えて―システムを永久進化させる9つの法則

「複雑系」を超えて―システムを永久進化させる9つの法則

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は入っていません。どちらも正解していた!

参考文献

PyTorch (15) CycleGAN (horse2zebra)

今回はCycleGANの実験をした。CycleGANはあるドメインの画像を別のドメインの画像に変換できる。アプリケーションを見たほうがイメージしやすいので論文の図1の画像を引用。

f:id:aidiary:20180324132034p:plain

  • モネの絵を写真に変換する(またはその逆)
  • 馬の画像をシマウマに変換する(またはその逆)
  • 夏の景色を冬の景色に変換する(またはその逆)

こんな魔法のようなことが実現できる。

似たような技術にpix2pixという技術がある(両方ともUC Berkeley)が、これは変換元画像と変換先画像の1対1のペアの訓練データが必要になる。その一方で、CycleGANはこのようなペアとなる訓練画像が必要ないという利点がある。ドメインAの画像セット(馬の画像セット)とドメインBの画像セット(シマウマの画像セット)だけがあればよい。

f:id:aidiary:20180324132433p:plain

いろいろなアプリケーションがあるが、今回はウマをシマウマに変換するというアプリケーションを実験してみた。論文の著者がPyTorch版のコードを公開してくれているのでそれを参考にした。ただ、いろいろなパラメータやアルゴリズムで実験できるように大量のオプションがあってわかりにくかったため必要最小限のコードだけ抜き出してJupyter Notebook形式で書き直した。

Jupyter Notebook

全コードを説明するととても長くなるので今回から省略しよう (^^;) 今後も自分のメモ書き程度でまとめていくことになるかも・・・

horse2zebraデータセット

CycleGANで使われるデータセットは著者らのレポジトリのスクリプトを使えば簡単に取得できるが、ここから直接ダウンロードできる。

Model

CycleGANはその名の通りGANの一種であるため画像を生成するGeneratorとその画像が本物か偽物かを判定するDiscriminatorから構成される。今回の実験では、Generatorは9ブロックのResNet、Discriminatorは一般的なCNNとした。

CycleGANはGeneratorが2つ(G_AとG_B)とDiscriminatorが2つ(D_AとD_B)の4つのサブモデルから構成される。

self.netG_A = Generator()
self.netG_B = Generator()
self.netD_A = Discriminator()
self.netD_B = Discriminator()

これらの関係がわかりにくいので論文の図をベースに図示してみた。論文と実装では表記が異なるので混乱しやすいのだが、紫字がコードに相当する。

f:id:aidiary:20180324142105p:plain

  • CycleGANはドメインAの画像集合(馬)とドメインBの画像集合(シマウマ)を相互に変換する
  • netG_A は馬(real_A)から偽のシマウマ(fake_B)を生成するGenerator
  • netG_B はシマウマ(real_B)から偽の馬(fake_A)を生成するGenerator
  • netD_A は本物のシマウマ(real_B)と生成した偽のシマウマ(fake_B)を見分けるDiscriminator
  • netD_B は本物の馬(real_A)と生成した偽の馬(fake_A)を見分けるDiscriminator
  • これまでのGANではGANの入力が1次元のノイズベクトルだったが、CycleGANではソースドメインの画像になる

Loss

CycleGANのキモとなるLossの定義。次の3つのLossを組み合わせて最適化している。

1. Adversarial Loss

  • 一般的なGANで使われるLoss
  • Generatorは、生成した偽物の画像(馬またはシマウマ)をDiscriminatorに本物と判定させたい
# GAN loss D_A(G_A(A))
# G_Aとしては生成した偽物画像が本物(True)とみなしてほしい
fake_B = self.netG_A(real_A)
pred_fake = self.netD_A(fake_B)
loss_G_A = self.criterionGAN(pred_fake, True)

# GAN loss D_B(G_B(B))
# G_Bとしては生成した偽物画像が本物(True)とみなしてほしい
fake_A = self.netG_B(real_B)
pred_fake = self.netD_B(fake_A)
loss_G_B = self.criterionGAN(pred_fake, True)
  • Discriminatorは、本物画像を入れたときは本物と判定し、偽物画像を入れたときは偽物と判定したい
  • これまで実装したGANのDiscriminatorは0または1のスカラーを出力していたが、Discriminatorの出力は30x30ピクセルのfeature mapになっている。その場合は、30x30を0または1で埋め尽くした行列との間でMSELossをとればOK
# 本物画像を入れたときは本物と認識するほうがよい
pred_real = self.netD_A(real_B)
loss_D_real = self.criterionGAN(pred_real, True)

# ドメインAから生成した偽物画像を入れたときは偽物と認識するほうがよい
# fake_Bを生成したGeneratorまで勾配が伝搬しないようにdetach()する
pred_fake = self.netD_A(fake_B.detach())
loss_D_fake = self.criterionGAN(pred_fake, False)
# 本物画像を入れたときは本物と認識するほうがよい
pred_real = self.netD_B(real_A)
loss_D_real = self.criterionGAN(pred_real, True)

# 偽物画像を入れたときは偽物と認識するほうがよい
pred_fake = self.netD_B(fake_A.detach())
loss_D_fake = self.criterionGAN(pred_fake, False)

2. Cycle Consistency Loss

  • CycleGANの名前のもととなる重要なLoss
  • 馬の画像をシマウマのドメインに変換し、さらに戻したときに元の馬の画像が復元されるようにしたい
  • シマウマの画像を馬のドメインに変換し、さらに戻したときに元のシマウマの画像が復元されるようにしたい
  • 実際は画像間のL1Loss
  • このLossがあるおかげで馬とシマウマが対応すると学習できている?
  • 背景などはドメインと無関係なものはなるべく維持しようとする?

f:id:aidiary:20180324150739p:plain

# forward cycle loss
# real_A => fake_B => rec_Aが元のreal_Aに近いほどよい
rec_A = self.netG_B(fake_B)
loss_cycle_A = self.criterionCycle(rec_A, real_A) * lambda_A

# backward cycle loss
# real_B => fake_A => rec_Bが元のreal_Bに近いほどよい
rec_B = self.netG_A(fake_A)
loss_cycle_B = self.criterionCycle(rec_B, real_B) * lambda_B

3. Identity Mapping Loss

  • Generatorに対象ドメインでない画像を入れたときに何もしないようにするLoss
  • たとえば、netG_A は馬をシマウマに変換するが、シマウマをいれたときはそのままシマウマを出力する
  • netG_B はシマウマを馬に変換するが、馬をいれたときはそのまま馬を出力する
# G_A, G_Bは変換先ドメインの本物画像を入力したときはそのまま出力するべき
# netG_AはドメインAの画像からドメインBの画像を生成するGeneratorだが
# ドメインBの画像も入れることができる
# その場合は何も変換してほしくないという制約
idt_A = self.netG_A(real_B)
loss_idt_A = self.criterionIdt(idt_A, real_B) * lambda_B * lambda_idt

idt_B = self.netG_B(real_A)
loss_idt_B = self.criterionIdt(idt_B, real_A) * lambda_A * lambda_idt

CycleGANではこれら3つのLossを重み付けしてGeneratorとDiscriminatorのパラメータを更新していく。

実験結果

TensorboardXで出力したログファイルは下のコマンドで読み込める。

tensorboard --logdir logs_cyclegan_horse2zebra

各Lossの推移を描画してみると下のようになった。やはりこれまでと同様にGeneratorのLossは上がる傾向が見える。

f:id:aidiary:20180324152447p:plain:w300 f:id:aidiary:20180324152512p:plain:w300 f:id:aidiary:20180324152554p:plain:w300 f:id:aidiary:20180324152623p:plain:w300 f:id:aidiary:20180324152654p:plain:w300 f:id:aidiary:20180324152712p:plain:w300 f:id:aidiary:20180324152727p:plain:w300 f:id:aidiary:20180324152742p:plain:w300

テストデータを使って馬をシマウマにしてみた。

model = CycleGAN()
model.log_dir = 'logs_cyclegan_horse2zebra/'
model.load('epoch195')

batch = iter(test_loader).next()

# 馬をシマウマに変換
fake_B = model.netG_A(batch['A'])

左が本物の馬の画像で右が netG_A で変換したシマウマの画像。

epoch 5

f:id:aidiary:20180324174940p:plain

epoch 50

f:id:aidiary:20180324174557p:plain

epoch 195

f:id:aidiary:20180324174506p:plain

次は netG_B を使ってシマウマを馬にしてみた。左が本物のシマウマの画像で右が netG_B で変換した馬の画像。

# シマウマを馬に変換
fake_A = model.netG_B(batch['B'])

f:id:aidiary:20180324154851p:plain

  • 茶色い馬はシマウマになりやすい。白馬はデータが少ないのかうまくいかなかった
  • シマウマを馬にするのはあまりできなかった。シマウマの縞がどうしても残ってしまう。もしかしてAとBを逆にして再学習しないとダメ?

要調査事項

  • netG_A で馬からシマウマ、netG_B でシマウマから馬と相互変換が同時に学習できるのか?と思ったのだが、元の実装では、--which_direction というオプションがあり、デフォルトでは AtoB (馬からシマウマへ)になっていた。逆方向の変換をきちんと学習させるにはAをシマウマ、Bを馬にひっくり返して再学習する必要があるのかも。
  • netG_A に馬やシマウマ以外の画像を入れると何が起きるのか?
  • Discriminatorの出力を0 or 1 のスカラーにせずにFeature mapにしているのはなぜなのか?FC層を入れるとパラメータが増えるから?
  • Image2Imageのタスクではバッチサイズを1にして InstanceNorm2d を使う方がよいのか?

ImagePoolの意味

原論文に次のような文章があった。実際はこれなくても学習はできたが、合ったほうが安定するようだ。

Second, to reduce model oscillation [14], we follow Shrivastava et al’s strategy [45] and update the discriminators using a history of generated images rather than the ones produced by the latest generative networks. We keep an image buffer that stores the 50 previously generated images.

馬やシマウマ以外の画像を変換すると何が起きるのか?

def convert2zebra(filename):
    img = Image.open(filename).convert('RGB')

    img_tensor = test_dataset.transform(img)
    img_tensor.unsqueeze_(0)

    fake_B = model.netG_A(Variable(img_tensor))

    plt.figure(figsize=(10, 20))

    plt.subplot(1, 2, 1)
    imshow(make_grid(img_tensor, nrow=2))
    plt.axis('off')

    plt.subplot(1, 2, 2)
    imshow(make_grid(fake_B.data, nrow=2))
    plt.axis('off')

convert2zebra('data/cat.jpg')
  • おそらく訓練データに含まれる馬っぽい馬しか変換できないだろうと思ったがその通りだった
  • 茶色に反応するようで茶色い犬や猫や熊などは縞模様が浮き出る
  • 白馬はやはりダメみたい。やはり限界はあるか・・・
  • Cycle Consistency LossやIdentity Lossが機能しているためか入力画像に近い画像がちゃんと生成されるのはすごい

f:id:aidiary:20180324165123p:plain f:id:aidiary:20180324165218p:plain f:id:aidiary:20180324165726p:plain f:id:aidiary:20180324165226p:plain f:id:aidiary:20180324165235p:plain f:id:aidiary:20180324165241p:plain

参考