人工知能に関する断創録

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

15周年記念

2017年2月21日で人工知能に関するブログ(というか自分のWebサイト)を初めてから15周年を迎えます。基本的に飽きっぽい性格ですが、こんなに続いたのは読んで応援してくださったみなさんのおかげだと思います。ありがとうございます!

ここら辺で15年を振り返ってみるのも面白いんじゃないかと思いインターネットアーカイブを掘り起こしてまとめてみることにしました。個人的な話なのであんまり興味ないかもしれないですけど(^^;

/tmp(2002年~)

全世界に公開できるWebサーバ立てたよ。みんな何か書いてみよう!」みたいな話があって書き始めたのがきっかけです。研究室に配属されたてだったのでこれからやる研究の履歴をまとめていこうかなと思っていました。あと文章を書くのが苦手だったので抵抗をなくそうという趣旨でした。当時はHTMLファイルを書いてFTPでアップロードという時代だったのでとってもシンプルなページでした。タイトルも/tmpとか超適当でした(笑)

f:id:aidiary:20170213223932p:plain

下のような感じで読んだ本や論文、勉強した内容などをつらつら書いていました。当時はバイオインフォマティクスを勉強していたので生物系の内容が多かったです。

f:id:aidiary:20170213224819p:plain

レポートはこんな感じ。このときは、教授や研究室の同僚や後輩向けに書いていて学外からアクセスが来ているとあまり意識していなかったですね・・・

f:id:aidiary:20170213225010p:plain

上の絵でGAの調査というのはこれです。

研究履歴(2003年~)

そこそこ履歴が書き溜まってきたので/tmpという超適当なタイトルはやめて研究履歴に変えました。バイオインフォマティクスは何か違うと思ったので、やっぱり前から興味があった人工知能機械学習やりたいと無理言ってテーマ替えしました。

このときは、ニューラルネットワーク、遺伝的アルゴリズム、強化学習といったいわゆるソフトコンピューティングをやりたいと思っていました。最終的に生物の学習にもっとも近いと惹かれた強化学習の領域を選びました。

強化学習の基本的なアルゴリズムやタスクをJavaアプレットで実装して公開していったのがこの時代です。当時は今ほどネット上に参考資料がなくてSuttonさん(まだある、すごい!)が公開していたLispのプログラムをJavaでグラフィック付きで再実装していました。そのときのアプレットはJavaでゲーム作りますが何か?(2004/9/18)に移植して残してあります。

f:id:aidiary:20170213225528p:plain

このアプレットの公開からカウンターが急上昇してかなり怖気づいた覚えがあります(笑)

f:id:aidiary:20170213225915j:plain

あとこのころに人工知能研究のサーベイを徹底的にしました。図書館にこもって人工知能学会誌を1986年のVol.1、No.1からすべて読破しました。「エキスパートシステムってつまんないなぁ」とか愚痴ってた覚えがあります(笑)朝から晩までこもってたので今でも図書館の本の匂いを思い出します(あとかわいかった司書のお姉さんも)。

サーベイをしながら分野間の関係を把握するために上のような図を書いていました。まあ今見てもけっこう妥当じゃないでしょうか?分野の流行り廃りはありますけど新しくできた分野ってないような・・・

f:id:aidiary:20170213230246p:plain

所属研究室が並列分散処理をやっていたこともあってpthreadやMPIの勉強もしていました。そのせんで強化学習の並列分散化に関する研究を始めました。あまり満足のいく結果は出なかったけれど何とか卒業させてもらいました(笑)

f:id:aidiary:20170213230536j:plain

2005年3月31日の卒業とともにWebサイトの更新は終わりました。卒業式で事前に何にも知らされない状態でいきなり名前を呼ばれて表彰されたのはいい思い出です。Webサイトで役に立つ情報を公開していたことに対する表彰でした。同時に表彰された二人が学内で超優秀な有名人だったので自分だけ「誰だよあいつ」状態で公開処刑されてるみたいですごく恥ずかしかった(笑)

スクリーンショットは2004年3月23日(更新停止)になっているけど2005年の間違えだ・・・

Javaでゲーム作りますが何か?(2004年~)

人工知能でゲームの研究というと当時はチェス・バックギャモン・将棋・碁などの知的ゲームがメインでビデオゲームは人工知能の研究対象ではありませんでした。

人工知能と強化学習のサーベイをしている中で人工知能のビデオゲームへの応用は今後重要になると確信して興味を持ち始めました。これ以上テーマを変えるのは許されなかった(笑)ので片手間で研究していました。かの人工知能の名付け親ジョン・マッカーシーでさえレミングスでゲームAIの研究してたんだよ!これはやるっきゃないでしょ。ファミコン世代ということもありビデオゲーム大好きっ子だったので飛びつきました。

ただ当時はOpenAI Universeのような整った環境があるはずもなく、そもそもゲームってどうやって作るんだ?状態でした。そんなわけでJavaで基本的なゲームを一通り作ってみようと始めたのが「Javaでゲーム作りますが何か?」です。

f:id:aidiary:20170213232953j:plain

当時は(今も?)ゲームはC++で作るのがセオリーでJavaみたいにくそ重い言語で作るなんて馬鹿じゃないの?という風潮があったのでこんなタイトルになりました(笑)ゲームを要素に分解して徐々に作り上げていく解説スタイルが珍しかったのかかなり好評でした。12年前のコンテンツですが、今でもこのブログのアクセス数で断トツ不動の1位です。

当時はこんな感じでHTMLでソースコード書いてました。コメントの色を緑にするのすごく大変だったな(^^;

f:id:aidiary:20170213232833j:plain

人工知能に関する備忘録(2005年3月~)

大学に残してきた研究履歴はいい思い出なので全部消すのはおしいということで、卒業とともに内容をコピーしてはてなダイアリーに引っ越しました。このときのタイトルは今とちょっと違って「人工知能に関する備忘録」というタイトルで始めました。

今も使っているハンドルネームaidiaryはこのときから使っています。人工知能に関する日記だからaidiaryと本当はサイト名でした。これも超適当(笑)最初のブログのスクリーンショットはアーカイブされてなかった。残念。

A.I.に関する備忘録(2005年5月~)

ちょっとタイトル変えてみようということで一時期気まぐれで変えました。

人工知能に関する断想録(2005年12月~)

f:id:aidiary:20170213233732j:plain

なんかしっくりこないということで人工知能に関する断想録に変更しました。

断想録というのはあまり聴かない単語かもしれませんがオリジナルではありません。えらい人が何人か「断想録」というタイトルの本を書いています。

このタイトルは私が人工知能に惹かれたきっかけであるマービン・ミンスキーの「心の社会」にあやかっています。今はまだ関連がはっきりしない断片を積み上げていって、最後に全体として統合されるようなブログにしたいなと壮大な野望を抱いてこんなネーミングにした覚えがあります。まだ道半ばです(^^;

このブログのテーマは一見バラバラだけど上の記事で書いたように一応自分の中では筋を通しています。

Deep Learningを始めたのもこの筋の一環です。Deep Learningで画像・音声認識精度が○○%上がりましたという段階では見向きもしなかったのですが、モーダル(言語・画像・音声)の統合機械による創造が実現できそうだというあたりで非常に興味を持ちました。

人工知能に関する断創録(2012年1月~)

f:id:aidiary:20170213234228j:plain

2012年1月1日はブログを初めて10周年記念ということでタイトルを今も続いている「人工知能に関する断録」に変えました。今までは技術がなくてただ想像していることが多かったけれど、これからは創造のフェーズに入りたいという希望をこめています。まああくまで希望です(^^;

このブログの略歴はこんな感じ。

人工知能は情報科学最後のフロンティアで一生かけても極められない未踏の領域です。今後もライフワークとして興味の赴くままに楽しくやっていこうと思います。

VGG16のFine-tuningによる17種類の花の分類

前回(2017/1/10)は、VGG16をFine-tuningして犬か猫を分類できる2クラス分類のニューラルネットワークを学習した。今回は、同様のアプローチで17種類の花を分類するニューラルネットワークを学習してみたい。前回の応用編みたいな感じ。この実験はオリジナルなので結果がどう出るかわからなかったけどうまくいったのでまとめてみた。

リポジトリ:17flowers

使用したデータは、VGG16を提案したOxford大学のグループが公開している 17 Category Flower Dataset である。下のような17種類の花の画像データ。とっても美しい。

f:id:aidiary:20170131203715j:plain:h700

前に実験した(2017/1/4)ようにデフォルトのVGG16ではひまわりさえ分類できなかったが、VGG16をFine-tuningすることで果たしてこれら17種類の花(ひまわりもある)を分類できるようになるのだろうか?さっそく試してみよう!

セットアップ

17flowers.tgzというファイルをダウンロードする。これを解凍するとjpgというディレクトリの中にimage_0001.jpgからimage_1360.jpgまで各クラス80枚、計1360枚の画像が含まれている。1360枚は畳み込みニューラルネットワークをスクラッチから学習するには心もとないデータ数であるが、今回はVGG16を少量データでチューニングする転移学習を使うので十分だろう。

各クラス80枚で17クラスなので1360枚なのだが、各画像がどのラベルなのかがわからない。とりあえずサンプル画像と見比べて下のようにラベルを割り振ったlabels.txtというファイルを作成した。たとえば、1行目はimage_0001.jpgからimage_0080.jpgまでがTulipであることを意味する。

1       80      Tulip
81      160     Snowdrop
161     240     LilyValley
241     320     Bluebell
321     400     Crocus
401     480     Iris
481     560     Tigerlily
561     640     Daffodil
641     720     Fritillary
721     800     Sunflower
801     880     Daisy
881     960     ColtsFoot
961     1040    Dandelion
1041    1120    Cowslip
1121    1200    Buttercup
1201    1280    Windflower
1281    1360    Pansy

まずは、Kerasからロードしやすいように以下のsetup.pyで画像ファイルを分割する。各クラスで訓練データが70枚、テストデータが10枚になるように分割した。犬猫分類でやったようにサブディレクトリにクラス名を付けておくと自動的に認識してくれる。このクラス名を付けるために先ほどのlabels.txtを使った。

import os
import shutil
import random

IN_DIR = 'jpg'
TRAIN_DIR = 'train_images'
TEST_DIR = 'test_images'

if not os.path.exists(TRAIN_DIR):
    os.mkdir(TRAIN_DIR)

if not os.path.exists(TEST_DIR):
    os.mkdir(TEST_DIR)

# name => (start idx, end idx)
flower_dics = {}

with open('labels.txt') as fp:
    for line in fp:
        line = line.rstrip()
        cols = line.split()

        assert len(cols) == 3

        start = int(cols[0])
        end = int(cols[1])
        name = cols[2]

        flower_dics[name] = (start, end)

# 花ごとのディレクトリを作成
for name in flower_dics:
    os.mkdir(os.path.join(TRAIN_DIR, name))
    os.mkdir(os.path.join(TEST_DIR, name))

# jpgをスキャン
for f in sorted(os.listdir(IN_DIR)):
    # image_0001.jpg => 1
    prefix = f.replace('.jpg', '')
    idx = int(prefix.split('_')[1])

    for name in flower_dics:
        start, end = flower_dics[name]
        if idx in range(start, end + 1):
            source = os.path.join(IN_DIR, f)
            dest = os.path.join(TRAIN_DIR, name)
            shutil.copy(source, dest)
            continue

# 訓練データの各ディレクトリからランダムに10枚をテストとする
for d in os.listdir(TRAIN_DIR):
    files = os.listdir(os.path.join(TRAIN_DIR, d))
    random.shuffle(files)
    for f in files[:10]:
        source = os.path.join(TRAIN_DIR, d, f)
        dest = os.path.join(TEST_DIR, d)
        shutil.move(source, dest)

1. 小さな畳み込みニューラルネットをスクラッチから学習する

前回と同様に今回もベースラインとしてLeNet相当の小さな畳み込みニューラルネットワークを学習してみよう*1。まずはモデルを構築する。

model = Sequential()
model.add(Convolution2D(32, 3, 3, input_shape=(img_rows, img_cols, channels)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Convolution2D(32, 3, 3))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Convolution2D(64, 3, 3))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Flatten())
model.add(Dense(64))
model.add(Activation('relu'))
model.add(Dropout(0.5))
model.add(Dense(nb_classes))
model.add(Activation('softmax'))

model.compile(loss='categorical_crossentropy',
              optimizer='adam',
              metrics=['accuracy'])

今まで何回もやってきたので特に難しいところはない。今回は17クラスの多クラス分類なので損失関数にcategorical_crossentropyを使う。

画像ファイルしか提供されていないときはデータの読み込みにImageDataGeneratorを使うと便利。今回もデータ拡張(2016/12/12)には前回と同じくshear_rangezoom_rangehorizontal_flipを使ったがデータの特徴を見て慎重に決めるとより精度が向上するかも。

# ディレクトリの画像を使ったジェネレータ
train_datagen = ImageDataGenerator(
    rescale=1.0 / 255,
    shear_range=0.2,
    zoom_range=0.2,
    horizontal_flip=True)

test_datagen = ImageDataGenerator(rescale=1.0 / 255)

上の設定を使って実際にジェネレータを作成。画像ファイルを含むディレクトリを指定するだけでよい。ここで、classesを自分で指定すると順番にクラスラベルを割り振ってくれる。color_modeclass_modeは何に使われるかいまいち把握できていないがrgbcategoricalでよさそう。

classes = ['Tulip', 'Snowdrop', 'LilyValley', 'Bluebell', 'Crocus',
           'Iris', 'Tigerlily', 'Daffodil', 'Fritillary', 'Sunflower',
           'Daisy', 'ColtsFoot', 'Dandelion', 'Cowslip', 'Buttercup',
           'Windflower', 'Pansy']

train_generator = train_datagen.flow_from_directory(
    directory='train_images',
    target_size=(img_rows, img_cols),
    color_mode='rgb',
    classes=classes,
    class_mode='categorical',
    batch_size=batch_size,
    shuffle=True)

test_generator = test_datagen.flow_from_directory(
    directory='test_images',
    target_size=(img_rows, img_cols),
    color_mode='rgb',
    classes=classes,
    class_mode='categorical',
    batch_size=batch_size,
    shuffle=True)

ジェネレータができたのでジェネレータが生成する画像の4Dテンソルを使ってモデルを訓練する。ここは前回と同じ。

history = model.fit_generator(
    train_generator,
    samples_per_epoch=samples_per_epoch,
    nb_epoch=nb_epoch,
    validation_data=test_generator,
    nb_val_samples=nb_val_samples)
save_history(history, os.path.join(result_dir, 'history_smallcnn.txt'))

学習途中の損失と精度はあとで参照できるようにファイルに保存した。

2. VGG16をFine-tuningする

次に前回と似た感じでVGG16をFine-tuningしてみよう。最後の畳込み層ブロックとフル結合層のみ重みを再調整する。

# VGG16モデルと学習済み重みをロード
# Fully-connected層(FC)はいらないのでinclude_top=False)
input_tensor = Input(shape=(img_rows, img_cols, 3))
vgg16 = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)

# FC層を構築
top_model = Sequential()
top_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
top_model.add(Dense(256, activation='relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(nb_classes, activation='softmax'))

# VGG16とFCを接続
model = Model(input=vgg16.input, output=top_model(vgg16.output))

# 最後のconv層の直前までの層をfreeze
for layer in model.layers[:15]:
    layer.trainable = False

# Fine-tuningのときはSGDの方がよい
model.compile(loss='categorical_crossentropy',
              optimizer=optimizers.SGD(lr=1e-4, momentum=0.9),
              metrics=['accuracy'])

前回とほとんど同じ。違いは、FC層の出力が17クラス分類になるので活性化関数にsoftmaxを使っているところと損失関数にcategorical_crossentropyを使っているところくらい。あとはほとんど同じなので省略。

実験結果

損失と精度の履歴は下のようになった。小さな畳み込みニューラルネットでは60%程度の分類精度しか出ないが、VGG16をFine-tuningすると85%くらいまで分類精度が跳ね上がるのが確認できた。

f:id:aidiary:20170131204317p:plain f:id:aidiary:20170131204327p:plain

1000クラスにひまわりがなかったVGG16ではあるが、花の分類に役立つ特徴抽出器は学習できていたようだ。

花の分類例

f:id:aidiary:20170131205422j:plain:w200

% python predict.py test_images/Sunflower/image_0724.jpg
input: test_images/Sunflower/image_0724.jpg
('Sunflower', 0.9999969)
('ColtsFoot', 1.3097971e-06)
('Iris', 8.8473638e-07)
('Tigerlily', 6.5053348e-07)
('Buttercup', 1.2474243e-07)

念願のひまわりもちゃんと分類できている!

f:id:aidiary:20170131205545j:plain:w200

% python predict.py test_images/Iris/image_0410.jpg
input: test_images/Iris/image_0410.jpg
('Iris', 0.99993575)
('Crocus', 1.9791674e-05)
('Sunflower', 1.734318e-05)
('Buttercup', 9.1189122e-06)
('Fritillary', 4.9292394e-06)

統計屋にはおなじみのアイリスもちゃんと分類できる。

f:id:aidiary:20170131205647j:plain:w200

% python predict.py test_images/Dandelion/image_0966.jpg
input: test_images/Dandelion/image_0966.jpg
('Dandelion', 0.99502832)
('ColtsFoot', 0.0034611411)
('Sunflower', 0.0014925624)
('Tigerlily', 5.2598648e-06)
('Buttercup', 4.0573868e-06)

蜂がいてもなんのその。何となくひまわりに似ているけどちゃんと区別できているのがすごい。3番目にひまわりが出ていた。

f:id:aidiary:20170131205854j:plain:w200

% python predict.py test_images/Windflower/image_1219.jpg
input: test_images/Windflower/image_1219.jpg
('Windflower', 0.99989629)
('Pansy', 4.6885198e-05)
('Daisy', 3.5976835e-05)
('LilyValley', 8.592202e-06)
('Snowdrop', 5.5862884e-06)

日本語だとアネモネ。これも正解。

f:id:aidiary:20170131210015j:plain:w200

% python predict.py test_images/Daisy/image_0807.jpg
input: test_images/Daisy/image_0807.jpg
('Daisy', 0.99801069)
('Sunflower', 0.0019772816)
('ColtsFoot', 1.0563146e-05)
('Pansy', 8.2547245e-07)
('Windflower', 4.5572747e-07)

デイジーも正解。

こういうきれいな画像を見ているとテンション上がる。とっても楽しい実験だった。

次は畳込みニューラルネットのフィルタの可視化方法を深堀りしてDeep Dreamへとつなげていきたい。何か今さら感が強いんだけどね(^^;

参考

*1:ちなみにここでVGG16をスクラッチから学習しようとすると全然精度が出ないのは確認済み

VGG16のFine-tuningによる犬猫認識 (2)

VGG16のFine-tuningによる犬猫認識 (1) (2017/1/8)のつづき。

前回、予告したように下の3つのニューラルネットワークを動かして犬・猫の2クラス分類の精度を比較したい。

  1. 小さな畳み込みニューラルネットをスクラッチから学習する
  2. VGG16が抽出した特徴を使って多層パーセプトロンを学習する
  3. VGG16をFine-tuningする

リポジトリ:dogs_vs_cats

1. 小さな畳み込みニューラルネットをスクラッチから学習する

ベースラインとしてVGG16は使わずに小規模な畳み込みニューラルネットワークをスクラッチから学習する。学習データは、犬クラス1000枚、猫クラス1000枚と小規模なデータを使うのであまり大規模なネットワークは学習できない。そこで、畳込みが3層のLeNet相当の小さなモデルを構成した。

f:id:aidiary:20170110195058p:plain:h400

横の矢印はそのレイヤでの出力の4Dテンソルのサイズ (samples, rows, cols, channels) を表している。入力は、150x150ピクセルで3チャンネルのカラー画像とした。青で囲った部分が畳込み層(Convolution2D)+プーリング層(MaxPooling2D)で緑で囲った部分がフル結合層(FC)である。犬猫分類では、出力ユニット数は1であり、0が猫で1が犬とする。

(補足)犬クラスと猫クラスの割り当てはImageDataGeneratorが勝手に決める。どっちが0でどっちが1に割り当てられたかはclass_indicesでわかる。

> print(train_generator.class_indices)
{'dogs': 1, 'cats': 0}

今回は猫が0で犬が1なのでNNの出力は犬である確率(出力が1に近いほど犬で0に近いほど猫)と解釈できる。0.5を閾値として0.5未満なら猫、0.5以上なら犬と判定すればよい。どのディレクトリがどのクラスに割り当てられるかは表示してみないとわからないみたい。いつもアルファベット順ではなさそうだ。

さっそく、Kerasで実装してみよう。

スクリプト: smallcnn.py

    # モデルを構築
    model = Sequential()
    model.add(Convolution2D(32, 3, 3, input_shape=(150, 150, 3)))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))

    model.add(Convolution2D(32, 3, 3))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))

    model.add(Convolution2D(64, 3, 3))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))

    model.add(Flatten())
    model.add(Dense(64))
    model.add(Activation('relu'))
    model.add(Dropout(0.5))
    model.add(Dense(1))
    model.add(Activation('sigmoid'))

    model.compile(loss='binary_crossentropy',
                  optimizer='adam',
                  metrics=['accuracy'])

    # 訓練データとバリデーションデータを生成するジェネレータを作成
    train_datagen = ImageDataGenerator(
        rescale=1.0 / 255,
        shear_range=0.2,
        zoom_range=0.2,
        horizontal_flip=True)

    test_datagen = ImageDataGenerator(rescale=1.0 / 255)

    train_generator = train_datagen.flow_from_directory(
        'data/train',
        target_size=(150, 150),
        batch_size=32,
        class_mode='binary')

    validation_generator = test_datagen.flow_from_directory(
        'data/validation',
        target_size=(150, 150),
        batch_size=32,
        class_mode='binary')

    # 訓練
    history = model.fit_generator(
        train_generator,
        samples_per_epoch=2000,
        nb_epoch=nb_epoch,
        validation_data=validation_generator,
        nb_val_samples=800)

    # 結果を保存
    model.save_weights(os.path.join(result_dir, 'smallcnn.h5'))
    save_history(history, os.path.join(result_dir, 'history_smallcnn.txt'))

出力層は0から1なので出力層の活性化関数にはsigmoidを指定。また、2クラス分類なので損失関数にbinary_crossentropyを指定。最適化アルゴリズムはadamを使った。dataにある画像を直接ロードするためにImageDataGeneratorflow_from_directory()を使った。ここら辺は前回(2017/1/8)まとめた。学習履歴はあとでグラフ化したいのでsave_history()という自作関数でファイルに保存するようにした。

結果はあとで3つまとめて考察したいので次へいこう。

2. VGG16が抽出した特徴を使って多層パーセプトロンを学習する

f:id:aidiary:20170110200655p:plain:h500

2つめの方法はVGG16を特徴抽出器として使うアプローチである。VGG16は左の図のように畳み込み層のブロックが5つ続いたあとにフル結合層のブロックがくっつく構成になっている。VGG16のフル結合層はImageNetの1000クラスを分類するようになっているためそのままでは2クラスの犬・猫分類には使えない。

そこで、この使えないフル結合層(FC)を捨ててしまって直前の畳み込みブロックまでを使うのがこのアプローチである。

f:id:aidiary:20170110201742j:plain:w250
Fine-tuningのためにいつも捨てられる全結合層のイラスト(from @yu4u)

直前の畳込みブロックの出力は、(None, 4, 4, 512) でフラット化すると4x4x512=8192次元ベクトルになる。つまり、入力画像 (None, 150, 150, 3) の150x150x3=67500次元ベクトルを8192次元ベクトルに圧縮した特徴量(ボトルネック特徴量)を抽出する。そして、この8192次元ベクトルのボトルネック特徴量を入力として2クラスを出力とする多層パーセプトロン(2016/11/3)を新たに学習する。

f:id:aidiary:20170110202247p:plain:w300

この程度の小さな多層パーセプトロンの学習なら小さなデータでも十分だろう。

以下の実装では、訓練データとバリデーションデータの画像からボトルネック特徴量を抽出するsave_bottleneck_features()と抽出したボトルネック特徴量を使って多層パーセプトロンを学習するtrain_top_model()に分けている。VGG16include_topオプションをFalseにするとFC層を除外したネットワークがロードできる。

スクリプト: extractor.py

def save_bottleneck_features():
    """VGG16にDog vs Catの訓練画像、バリデーション画像を入力し、
    ボトルネック特徴量(FC層の直前の出力)をファイルに保存する"""

    # VGG16モデルと学習済み重みをロード
    # Fully-connected層(FC)はいらないのでinclude_top=False)
    model = VGG16(include_top=False, weights='imagenet')
    model.summary()

    # ジェネレータの設定
    datagen = ImageDataGenerator(rescale=1.0 / 255)

    # Dog vs Catのトレーニングセットを生成するジェネレータを作成
    generator = datagen.flow_from_directory(
        train_data_dir,
        target_size=(img_width, img_height),
        batch_size=32,
        class_mode=None,
        shuffle=False)

    # ジェネレータから生成される画像を入力し、VGG16の出力をファイルに保存
    bottleneck_features_train = model.predict_generator(generator, nb_train_samples)
    np.save(os.path.join(result_dir, 'bottleneck_features_train.npy'),
            bottleneck_features_train)

    # Dog vs Catのバリデーションセットを生成するジェネレータを作成
    generator = datagen.flow_from_directory(
        validation_data_dir,
        target_size=(img_width, img_height),
        batch_size=32,
        class_mode=None,
        shuffle=False)

    # ジェネレータから生成される画像を入力し、VGG16の出力をファイルに保存
    bottleneck_features_validation = model.predict_generator(generator, nb_validation_samples)
    np.save(os.path.join(result_dir, 'bottleneck_features_validation.npy'),
            bottleneck_features_validation)


def train_top_model():
    """VGGのボトルネック特徴量を入力とし、Dog vs Catの正解を出力とするFCネットワークを訓練"""
    # 訓練データをロード
    # ジェネレータではshuffle=Falseなので最初の1000枚がcats、次の1000枚がdogs
    train_data = np.load(os.path.join(result_dir, 'bottleneck_features_train.npy'))
    train_labels = np.array([0] * int(nb_train_samples / 2) + [1] * int(nb_train_samples / 2))

    # (2000, 4, 4, 512)
    print(train_data.shape)

    # バリデーションデータをロード
    validation_data = np.load(os.path.join(result_dir, 'bottleneck_features_validation.npy'))
    validation_labels = np.array([0] * int(nb_validation_samples / 2) + [1] * int(nb_validation_samples / 2))

    # (800, 4, 4, 512)
    print(validation_data.shape)

    # FCネットワークを構築
    model = Sequential()
    model.add(Flatten(input_shape=train_data.shape[1:]))
    model.add(Dense(256, activation='relu'))
    model.add(Dropout(0.5))
    model.add(Dense(1, activation='sigmoid'))

    model.compile(loss='binary_crossentropy',
                  optimizer=optimizers.SGD(lr=1e-4, momentum=0.9),
                  metrics=['accuracy'])

    history = model.fit(train_data, train_labels,
                        nb_epoch=nb_epoch,
                        batch_size=32,
                        validation_data=(validation_data, validation_labels))

    model.save_weights(os.path.join(result_dir, 'bottleneck_fc_model.h5'))
    save_history(history, os.path.join(result_dir, 'history_extractor.txt'))


if __name__ == '__main__':
    # 訓練データとバリデーションデータのボトルネック特徴量の抽出
    save_bottleneck_features()

    # ボトルネット特徴量でFCネットワークを学習
    train_top_model()

ここで、save_bottleneck_features()では一切学習を行っていない点に注意。VGG16の学習した重みをそのまま使ってpredict_generator()で出力を求めている(フィードフォワードのみ)。一方、train_top_model()ではランダムな初期値から多層パーセプトロンの重みをfit()で学習している。ここで、optimizeradamではなく、学習率を小さくしたSGDを使った。実際は、adamでもうまくいくのだが、次のFine-tuningと比較するためにSGDにしている。

この方法では、大規模データで学習したVGG16を特徴抽出器として使っている。ニューラルネットのこのような使い方は知ってしまえば当たり前だが初めて知ったときは衝撃を受けた。Deep Learningの強みは青色部分の多段の特徴抽出にあって分類を行うフル結合層はおまけみたいなものなのだ。実際は、多層パーセプトロンを使わずにSVM(Support Vector Machine)を使うケースもあるようだ。

3. VGG16をFine-tuningする

f:id:aidiary:20170110205228p:plain:h500

2番目の方法では、実質的に重みを学習したのはフル結合層の多層パーセプトロンの部分のみだった。これに対してFine-tuningではフル結合層だけでなく、その1つ前の畳み込み層の重みも学習する。畳込みニューラルネットでは浅い層ほどエッジやブロブなど汎用的な特徴が抽出されているのに対し、深い層ほど学習データに特化した特徴が抽出される傾向がある。そこで、浅い層の汎用的な特徴抽出器はそのまま固定(frozen)し、深い層の重みのみ今回の犬・猫分類に合うように再調整(fine-tuning)してしまう

KerasでFine-tuningをするには下のようなコードを書く。

スクリプト: finetuning.py

if __name__ == '__main__':
    # VGG16モデルと学習済み重みをロード
    # Fully-connected層(FC)はいらないのでinclude_top=False)
    # input_tensorを指定しておかないとoutput_shapeがNoneになってエラーになるので注意
    # https://keras.io/applications/#inceptionv3
    input_tensor = Input(shape=(150, 150, 3))
    vgg16_model = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)
    # vgg16_model.summary()

    # FC層を構築
    # Flattenへの入力指定はバッチ数を除く
    top_model = Sequential()
    top_model.add(Flatten(input_shape=vgg16_model.output_shape[1:]))
    top_model.add(Dense(256, activation='relu'))
    top_model.add(Dropout(0.5))
    top_model.add(Dense(1, activation='sigmoid'))

    # 学習済みのFC層の重みをロード
    top_model.load_weights(os.path.join(result_dir, 'bottleneck_fc_model.h5'))

    # vgg16_modelはkeras.engine.training.Model
    # top_modelはSequentialとなっている
    # ModelはSequentialでないためadd()がない
    # そのためFunctional APIで二つのモデルを結合する
    # https://github.com/fchollet/keras/issues/4040
    model = Model(input=vgg16_model.input, output=top_model(vgg16_model.output))
    print('vgg16_model:', vgg16_model)
    print('top_model:', top_model)
    print('model:', model)

    # Total params: 16,812,353
    # Trainable params: 16,812,353
    # Non-trainable params: 0
    model.summary()

    # layerを表示
    for i in range(len(model.layers)):
        print(i, model.layers[i])

    # 最後のconv層の直前までの層をfreeze
    for layer in model.layers[:15]:
        layer.trainable = False

    # Total params: 16,812,353
    # Trainable params: 9,177,089
    # Non-trainable params: 7,635,264
    model.summary()

    # ここでAdamを使うとうまくいかない
    # Fine-tuningのときは学習率を小さくしたSGDの方がよい?
    model.compile(loss='binary_crossentropy',
                  optimizer=optimizers.SGD(lr=1e-4, momentum=0.9),
                  metrics=['accuracy'])

(1) VGG16と多層パーセプトロンの接続

2つめの実装と異なり、今回はFC層を除いたVGG16とフル結合層をKerasのFunctional APIを使って結合している。vgg16_modelがフル結合層を除いたVGG16(図の青色と黄色部分)でtop_modelが多層パーセプトロン(図の緑色部分)である。この2つのネットワークを結合する必要がある。

vgg16_model.add(top_model)とできれば理想的なのだができなかった。よくよく調べるとtop_modelSequentialクラスであるのに対し、vgg16_modelkeras.engine.training.Modelという独自クラスでなぜかこのクラスにはadd()がない!そこで、少し面倒だがこの2つの入出力をつなげる下のような新しいモデルを定義する。

model = Model(input=vgg16_model.input, output=top_model(vgg16_model.output))

この式は、新しいモデルへの入力(input)はvgg16_modelの入力と同じで、出力(output)はtop_modelvgg16_modelの出力を入力した値という意味。なんというややこしさ(^^; ここら辺はベースのTheanoやTensorFlowの書き方に少し近いと思った。

(2) フル結合層の初期値

今回は、フル結合層(top_model)の重みとして2番目の方法で学習した重みを初期値として用いた。実際はあとで結果に示すようにランダムな初期値から初めても最終的に同じ精度に収束するのでこの行は省いてもよさそう。

    # 学習済みのFC層の重みをロード
    top_model.load_weights(os.path.join(result_dir, 'bottleneck_fc_model.h5'))

同じように黄色部分の畳込み層の重みの初期値はVGG16のものだけどこれをランダム初期値から学習するとどうなるか気になった。結局、時間がかかっても同じ精度まで収束するのかな?ニューラルネットは初期値がかなり重要なファクターだからなあ。

(3) 重みの固定

VGG16の図の青色の部分は重みを固定(frozen)したい。Kerasではlayer.trainableFalseにすると重みの学習が行われなくなる。layersInputLayerlayers[0]でパラメータがないMaxPooling2Dlayersに含むのでlayers[:15]はちょうど図の青色の部分にあたる。

    # 最後のconv層の直前までの層をfreeze
    for layer in model.layers[:15]:
        layer.trainable = False

(4) 最適化には学習率の小さいSGDを使う

最初、最適化にAdamを使っていたのだがうまく学習できなかった。Fine-tuningはあくまで重みの再調整なので学習率を小さくしたSGDを使う方がよさそうだ。

実験結果

以上の3つの学習履歴をプロットすると下のようになった。

f:id:aidiary:20170110212330p:plain f:id:aidiary:20170110212333p:plain

ほぼ予想通りの結果で1つめの小さな畳み込みニューラルネット(smallcnn)では75%くらいの精度しか出ていないが、VGG16をFine-tuning(finetuning)すると94%くらいまで精度が跳ね上がる。VGG16を特徴抽出器(extractor)として用いた場合はその中間くらい。

先の(2)で述べたようにFine-tuningでフル結合層をランダムな初期値から始めても(finetuning random)最終的には学習済み初期値を使った場合と同様に94%近くまで上がることもわかった。

Kaggleのスコアを見ると精度94%はトップ100位以内には入っているのでけっこうよい順位だろう。しかも、今回は訓練に25000枚のうちたった2000枚しか使っていない。VGG16の学習済みモデルがいかに強力かわかる。

あとでテストデータで評価してKaggleにsubmitしてみよう!

犬と猫の分類

訓練データに含まれないテスト画像をいくつか入力して本当に分類できているか確認しておいた。train.zipの画像から適当に選んだけど各クラス最初の1000枚しか学習に使っていないので以下の画像は訓練外データである。出力が0.5未満だと猫、0.5以上だと犬と判定すればよい。

スクリプト: predict.py

f:id:aidiary:20170110215210j:plain:w300

input: train/cat.6341.jpg
[ 0.00011273] => 猫!

f:id:aidiary:20170110215214j:plain:w300

input: train/cat.6342.jpg
[ 0.01214203] => 猫!

f:id:aidiary:20170110215220j:plain:w300

input: train/dog.3877.jpg
[ 1.] => 犬!

f:id:aidiary:20170110215234j:plain:w300

input: train/dog.4143.jpg
[ 0.99994457] => 犬!

うちの愛犬を忘れるところだった(笑)

f:id:aidiary:20170104210733j:plain:w300

input: IMG_1550.jpg
[ 0.99496752] => 犬!

f:id:aidiary:20170104210738j:plain:w300

input: IMG_0041.JPG
[ 0.98027724] => 犬!

犬だってよ。よかったね。コロちゃん、くうちゃん(^^)v

実際は、Fine-tuningも限界がある。実は、VGG16を学習したImageNetに犬や猫の画像をたくさんあり、犬猫を分類するのに適した特徴量が抽出できていたため今回の犬猫分類がうまくいったのだ。つまり、ImageNetの画像とまったく異なる種類の画像ではうまく特徴抽出ができずFine-tuningもうまくいかなかったと予測される。

次回はVGG16のFine-tuningの別の例として花の品種の分類を試してみたい。

参考