KelpNetで転移学習

今回は、KelpNetでVGGを元に転移学習を行います。転移学習とは、あるタスクで学習したモデルを他のタスクに利用する手法です。ニューラルネットワークの場合だと、犬を認識するというタスクで学習したネットワークを、猫を認識するというタスクに利用したりします。

今回の記事は、ニューラルネットワークの基礎~VGG、KelpNetの概要を理解している方を対象にしています。読み終わるころには、「転移学習とは何か」「転移学習のメリット」「ファインチューニング・ドメイン適応との違い」「KelpNetでの転移学習の実装方法」が分かるようになっており、説明もこの順番で行っています。

ニューラルネットワークやVGG・KelpNetについては、それぞれ以下の記事で紹介しています。

転移学習という言葉は(恐らく)厳密に定義されておらず、専門書や論文によって若干意味自体や意味の範囲が違います。この記事では、転移学習を「ファインチューニングやドメイン適応を含まず、ニューラルネットワークを前提とした狭義の転移学習」という意味で用います。

また、今回用いたコードは全てこちらにあります。

転移学習

転移学習とは、あるタスクで学習したモデルを他のタスクに利用する手法です。ニューラルネットワークの場合、あるタスクで学習したネットワークの出力層を取り除き、特徴抽出器として用いることが多いです。

ニューラルネットワークは、入力層から中間層の最後の層までで特徴量の設計を学習(表現学習)し、中間層の最後の層から出力層への間でこれまでに得られた特徴量を用いて分類・予測を行います。

f:id:jinbeizame007:20180809234352p:plain

転移学習では、この入力層から中間層の最後の層までを特徴抽出器とします。

f:id:jinbeizame007:20180909181143p:plain

他のタスクを学習する際に、この特徴抽出器を用います。例えば、新しく分類・予測する出力層を追加する・抽出した特徴ベクトルを分類するSVM (Support Vector Machine) を用いるなどがあります。このとき、特徴抽出器の重みは固定します。

f:id:jinbeizame007:20180909200720p:plain

このように、ニューラルネットワークの転移学習では、多くの場合は新しいタスクを学習する際に、学習済みのモデルを特徴抽出器として用います。特に、ImageNetなど膨大な量のデータで物体認識を学習したネットワークは汎用的な特徴を学習すると考えられており、特徴抽出器としてよく用いられています。

転移学習のメリット

転移学習には、以下のようなメリットがあります。

  • 少ないデータで高い精度を実現できる
  • 計算量が少ない

それぞれのメリットについて、以下で説明します。

少ないデータで高い精度を実現できる

深層学習は、一般的にデータから分類・予測に必要な特徴を学習するために膨大な量のデータを必要とします。データ数が少ない場合、モデルが訓練データに対して過剰に適合(オーバーフィッティング)してしまうため、汎用性の高いモデルを作ることが難しくなってしまいます。しかし、膨大な量のデータを用意するためには多くの時間や費用・労力を必要とします。

転移学習の場合、1から特徴の表現を学習するのではなく、既に特徴として表現されているパーツ(特徴抽出器が出力した特徴量)をどのように組み合わせるかを学習します。そのため、訓練データに過剰に適合してしまうことを防げるため、少ないデータで汎用性の高いモデルを学習することが出来ます。

計算量が少ない

深層学習は膨大な数のパラメータを学習するため、一般的に非常に高いマシンパワーを必要とします。マシンパワーが低い場合、学習に膨大な時間がかかってしまいます。しかし、高いマシンパワーを用意するためには多くの費用を必要とします。また、スマートフォンなどのデバイスでは、現状それほど高いマシンパワーを持つことは困難です。

しかし、転移学習では特徴抽出器が出力した特徴を用いるため、学習に必要なパラメータ数が少ない・少ないデータ量で学習できるなどの理由から、少ない計算量で学習することが可能になります。これによって、スマートフォンなど多くのデバイスで学習を実行することが可能になります。

開発者側のマシンではなくスマートフォンなどのユーザー側のデバイスで学習を実行できるようになることで、多くのユーザに対応するための標準化されたモデルではなく、個々のユーザに最適化されたモデルを開発することが可能になります。

転移学習とファインチューニング

ファインチューニングとは、学習済みモデルの一部もしくはすべての層の重みを微調整する手法です。転移学習では、学習済みモデルの重みを固定して用いますが、ファインチューニングでは学習済みモデルの重みを初期値とし、再度学習によって微調整します。

f:id:jinbeizame007:20180910143714p:plain

ファインチューニングは、シミューレーション環境から実環境へ移行する際や、標準化されたモデルを個々のユーザに最適化する際などに使用されます。

転移学習とドメイン適応

ドメイン適応とは、あるデータで学習したモデルを異なる分布のデータの学習に利用する手法です。転移学習では、入力から受け取る特徴を共有し、その特徴の組み合わせ方のみ再学習することによって、異なるタスクでの学習を行いました。ドメイン適応では、低次元の特徴の組み合わせ方を共有し、その低次元の特徴の表現を再学習することで、異なる分布のデータの学習を行います。

例えば音声認識を行う場合、出力層側は認識した単語などを組み合わせて適切な文章を生成し、入力層側は話者の音声から単語などを認識します。ここで、単語から文章を生成する方法は共通ですが、音声から単語を生成するためには、話者によって異なる発音を認識する必要があります。

このように、タスクは同じでも入力データの分布が異なる場合、入力層に近い層を再学習することによって対応することが可能になります。これをドメイン適応といいます。

f:id:jinbeizame007:20180910170531p:plain

KelpNetで転移学習

それでは、KelpNetで転移学習を行っていきます。今回は、ImageNetという巨大なデータセットで学習済みのVGGを用いて転移学習を行います。タスクは、犬猫分類を行います。

以下では、「データセット」「モデルの定義」「データセットの読み込み」「学習」の順に説明します。

また、今回用いたコードは全てこちらにあります。

今回の学習は、私のマシンではGPUでデータの用意に約40分、学習に3秒ほどかかりました。CPUの場合は10倍以上の時間がかかると思いますので、GPUでの学習をお勧めします。

環境構築

KelpNetの環境構築は、以下のページを参考に行ってください。

今回は、KelpNet.dll、Cloo.dllだけでなく、CaffemodelLoader.dll、TestDataManager.dllも同様の方法で追加してください。

また、今回はNuGetからprotobuf-netをインストールする必要があります。インストールするには、まずVisual Studioの右側のソリューションエクスプローラー内の参照を右クリックし、NuGetパッケージの管理をクリックします。

f:id:jinbeizame007:20180917235103p:plain

するとNuGetパッケージマネージャーが表示されるのでprotobuf-netを検索し、一番上のprotobuf-netを選択し、右側のインストールをクリックします。これでprotobuf-netを使用することが出来るようになります。

f:id:jinbeizame007:20180917234242p:plain

データセット

データは、こちらのkaggleの犬猫分類のデータセットを用います。kaggleにサインインしていない方は、サインインする必要があります。Dataタブの、以下の画像の右側にあるDownkiad Allからダウンロードすることが出来ます。

f:id:jinbeizame007:20180917122323p:plain

データセットがダウンロード出来たら、展開してプロジェクトフォルダのbin/Debug/にDataフォルダを作成し、その中にtrainフォルダを入れてください。

データセットには、以下のような犬や猫の画像がたくさん入っています。かわいいですね。

f:id:jinbeizame007:20180917123049j:plain:w300f:id:jinbeizame007:20180917123052j:plain:w300

モデルの定義

今回は、VGGの学習済みモデルを用います。VGGとは、下図のようなネットワークです。各層の上の数字は、流れるデータの縦×横×チャンネル数またはユニット数となっています。

f:id:jinbeizame007:20180828015545p:plain

今回は、こちらのネットワークの最終の中間層までを特徴抽出器として重みを固定して用い、最終の中間層から出力層への重みを学習します。

f:id:jinbeizame007:20180917132444p:plain

以下がモデルの定義部分となっています。「学習済みVGGのダウンロード」「VGGの出力層と活性化関数を削除し」「VGGの各FunctionをGPU対応へ」「学習用の新しい層を追加」「最適化手法の設定」という流れになっています。

// VGGの学習済みモデルのダウンロードURLとファイル名
const string DOWNLOAD_URL = "http://www.robots.ox.ac.uk/~vgg/software/very_deep/caffe/VGG_ILSVRC_16_layers.caffemodel";
const string MODEL_FILE = "VGG_ILSVRC_16_layers.caffemodel";

// ネットからVGGの学習済みモデルをダウンロード
string modelFilePath = InternetFileDownloader.Donwload (DOWNLOAD_URL, MODEL_FILE);
// 学習済みモデルをFunctionのリストとして保存
List<Function> vgg16Net = CaffemodelDataLoader.ModelLoad (modelFilePath);

// VGGの出力層とその活性化関数を削除
vgg16Net.RemoveAt (vgg16Net.Count () - 1);
vgg16Net.RemoveAt (vgg16Net.Count () - 1);

// VGGの各FunctionのgpuEnableをtrueに
for (int i = 0; i < vgg16Net.Count - 1; i++) {
    // GPUに対応している層であれば、GPU対応へ
    if (vgg16Net [i] is Convolution2D || vgg16Net [i] is Linear || vgg16Net [i] is MaxPooling) {
        ((IParallelizable)vgg16Net [i]).SetGpuEnable (true);
    }
}

// VGGをリストからFunctionStackに変換
FunctionStack vgg = new FunctionStack (vgg16Net.ToArray ());

// 層を圧縮
vgg.Compress ();

// 新しく出力層とその活性化関数を用意
FunctionStack nn = new FunctionStack (
    new Linear (4096, 1, gpuEnable: true),
    new Sigmoid ()
);

// 最適化手法としてAdamをセット
 nn.SetOptimizer (new Adam ());

データセットの読み込み

今回は転移学習のデモということで、データ数は犬猫ともに各12500枚の内、1/10以下の1000枚ずつに限定し、エポック数も10回としています。また、テストデータは各100枚ずつとなっています。

今回は、時間短縮のために先に全てのデータをVGGに入力し、出力した特徴量をデータセットとして用意しています。データは、一度そのままのサイズで読み込んだ後に、224×224のサイズに変換しています。

const int TRAIN_DATA_LENGTH = 1000;
const int TEST_DATA_LENGTH = 100;
const int BATCH_SIZE = 50;
const int TRAIN_DATA_COUNT = 20; // 1000 / 50 = 20

// 訓練・テストデータ用のNdArrayを用意
NdArray [] trainData = new NdArray [TRAIN_DATA_LENGTH * 2];
NdArray [] trainLabel = new NdArray [TRAIN_DATA_LENGTH * 2];
NdArray [] testData = new NdArray [TEST_DATA_LENGTH * 2];
NdArray [] testLabel = new NdArray [TEST_DATA_LENGTH * 2];

for (int i = 0; i < TRAIN_DATA_LENGTH + TEST_DATA_LENGTH; i++) {
    // 犬・猫の画像読み込み
    Bitmap baseCatImage = new Bitmap ("Data/train/cat." + i + ".jpg");
    Bitmap baseDogImage = new Bitmap ("Data/train/dog." + i + ".jpg");
    // 変換後の画像を格納するBitmapを定義
    Bitmap catImage = new Bitmap (224, 224, PixelFormat.Format24bppRgb);
    Bitmap dogImage = new Bitmap (224, 224, PixelFormat.Format24bppRgb);
    // Graphicsオブジェクトに変換
    Graphics gCat = Graphics.FromImage (catImage);
    Graphics gDog = Graphics.FromImage (dogImage);
    // Graphicsオブジェクト(の中のcatImageに)baseImageを変換して描画
    gCat.DrawImage (baseCatImage, 0, 0, 224, 224);
    gDog.DrawImage (baseDogImage, 0, 0, 224, 224);
    // Graphicsオブジェクトを破棄し、メモリを解放
    gCat.Dispose ();
    gDog.Dispose ();

    // 訓練・テストデータにデータを格納
   // 先にテストデータの枚数分テストデータに保存し、その後訓練データを保存する
   // 画素値の値域は0 ~ 255のため、255で割ることで0 ~ 1に正規化
    if (i < TEST_DATA_LENGTH) {
        // ImageをNdArrayに変換したものをvggに入力し、出力した特徴量を入力データとして保存
        testData [i * 2] = vgg.Predict (NdArrayConverter.Image2NdArray (catImage, false, true) / 255.0) [0];
        testLabel [i * 2] = new NdArray (new Real [] { 0 });
        testData [i * 2 + 1] = vgg.Predict (NdArrayConverter.Image2NdArray (dogImage, false, true) / 255.0) [0];
        testLabel [i * 2 + 1] = new NdArray (new Real [] { 1 });
    } else {
         trainData [(i - TEST_DATA_LENGTH) * 2] = vgg.Predict (NdArrayConverter.Image2NdArray (catImage, false, true) / 255.0) [0];
        trainLabel [(i - TEST_DATA_LENGTH) * 2] = new NdArray (new Real [] { 0 });//new Real [] { 0 };
        trainData [(i - TEST_DATA_LENGTH) * 2] = vgg.Predict (NdArrayConverter.Image2NdArray (dogImage, false, true) / 255.0) [0];
        trainLabel [(i - TEST_DATA_LENGTH) * 2] = new NdArray (new Real [] { 1 });// = new Real [] { 1 };
    }    
}

学習

学習では、各ステップでミニバッチを生成し、順伝播、誤差の計算、逆伝播、更新を行っています。また、各エポックの終了時に認識率(accuracy)を計算しています。

// ミニバッチ用のNdArrayを定義
NdArray batchData = new NdArray (new [] { 4096 }, BATCH_SIZE);
NdArray batchLabel = new NdArray (new [] { 1 }, BATCH_SIZE);

// 誤差関数を定義(今回は二値分類なので二乗誤差関数(MSE))
LossFunction lossFunction = new MeanSquaredError ();

// エポックを回す
for (int epoch = 0; epoch < 10; epoch++) {
    // 1エポックで訓練データ // バッチサイズ の回数分学習
    for (int step = 0; step < TRAIN_DATA_COUNT; step++) {

        // ミニバッチを用意
        for (int i = 0; i < BATCH_SIZE; i++) {
            // 0 ~ 訓練データサイズ-1 の中からランダムで整数を取得
            int index = Mother.Dice.Next (trainData.Length);
            // trainData(NdArray[])を、batchData(NdArray)の形にコピー
            Array.Copy (trainData [index].Data, 0, batchData.Data, i * batchData.Length, batchData.Length);
            batchLabel.Data [i] = trainLabel [index].Data [0];
        }

        // 学習(順伝播、誤差の計算、逆伝播、更新)
        NdArray [] output = nn.Forward (batchData);
        Real loss = lossFunction.Evaluate (output, batchLabel);
        nn.Backward (output);
        nn.Update ();
    }

    // 認識率(accuracy)の計算
    // テストデータの回数データを回す
    Real accuracy = 0;
    for (int i = 0; i < TEST_DATA_LENGTH * 2; i++) {
        NdArray [] output = nn.Predict (testData [i]);
        // 出力outputと正解の誤差が0.5以下(正解が0のときにoutput<0.5、正解が1のときにoutput>0.5)
        // の際に正確に認識したとする
        if (Math.Abs (output [0].Data [0] - trainLabel [i].Data [0]) < 0.5)
                        accuracy += 1;
     }
     accuracy /= TEST_DATA_LENGTH * 2.0;
     Console.WriteLine ("Epoch:" + epoch + "accuracy:" + accuracy);
}

学習結果

学習の結果、以下のようになりました。転移学習を行うことによって、少ないデータ数・少ないエポック数でも90%ほどの認識率となり、きちんと学習出来ていることが分かります。

f:id:jinbeizame007:20180917161642p:plain