論文紹介 Understanding Batch Normalization
今回は、NIPS2018に投稿されたUnderstanding Batch Normalizationという論文を読んだので、紹介していきたいと思います。この論文は、なぜバッチ正規化(Batch Normalization)が学習に効果的なのかを実証的なアプローチで検証した論文です。
この記事は、ニューラルネットワークの基礎(全結合層や畳み込み層)を理解している方を対象にしています。また、可能な限りバッチ正規化を知らない方でも理解できるようにしています。この記事を読み終わるころには、「なぜバッチ正規化が学習に効果的なのか」が分かるようになっています。
ニューラルネットの基礎は以下の記事で紹介しています。
この記事は論文を要約したものに説明を加えたものとなっています。記事内で1人称で語られている文章については、多くが論文の主張となっています。しかし、あくまで論文の私なりの解釈であるため、間違っている可能性も大いに考えられます。鵜呑みにはせず、参考程度にして頂けると嬉しいです。
Understanding Batch Normalization [Johan Bjorck, Carla Gomes, Bart Selman, Kilian Q. Weinberger, NIPS2018 Poster, arXiv, 2018/09]
論文での主張のまとめ
時間が無い方も多いと思いますので、こちらで先にこの論文での主張をまとめておきます。詳細については続きをご確認ください。
- バッチ正規化によって設定可能になる高い学習率は、SGDのノイズを増加させる原因となるため、正則化の効果と精度が向上する。
- 正規化なしでは、層が深くなるにつれてチャンネルの平均と分散が指数関数的に増加するが、バッチ正規化によって一定に保つことが可能となる。
- チャンネル・ユニットの値の平均値に偏りがある場合、出力層・畳み込み層の勾配の大きさが入力依存ではなくなるが、バッチ正規化によって偏りが緩和され、勾配が入力に高い依存性を持つようになる。
- 高い学習率を設定した場合、正規化なしでは学習が進むにつれて出力層側での誤差が発散するが、バッチ正規化によって各層の出力が正規化されるため、誤差が発散しなくなる。
- 正規化されていない場合、畳み込み層の重みの更新量は対応するイン/アウトチャンネルによっては一貫して小さくなる場合があるが、バッチ正規化によってその現象を抑えることが出来る。
- 重みの初期化は、各チャンネルの分散が一定であるべきという考えから設計されているが、実際は層が深くなるにつれて分散が大きくなる。バッチ正規化はこの重みの初期化の影響を緩和する。
Batch Normalizationのオリジナルの論文では、バッチ正規化の効果は「内部共変量シフトを緩和すること」だと主張されていますが、こちらの論文では「その効果が存在しないとは主張しないが、バッチ正規化の効果はそれなしで説明できると考える」と主張されています。
バッチ正規化
バッチ正規化とは、ニューラルネットワークの各層への入力を正規化する手法です。これによって、高い学習率を設定可能になる・正則化がかかる・精度を向上させる・収束が早くなるなどの効果があります。
バッチ正規化では、正規化を行うためにバッチ内の要素(チャンネルやユニット)ごとに平均と標準偏差を求めます。このとき要素とは、畳み込み層のときはチャンネル、全結合層のときはユニットとなります。
畳み込み層の場合、平均と標準偏差は下図のようにして求めます。チャンネルごとにバッチ × x座標 × y座標の要素の平均と標準偏差を求めています。(正確には違いますが)バッチ正規化では、各チャンネルの値を対応するチャンネルの平均で引き標準偏差で割ることで、正規化を行っています。
このとき、バッチ正規化の入力と出力はそれぞれ4次元テンソル(バッチ、チャンネル、x座標、y座標)で表現することが出来ます。ここで、チャンネルの次元を特徴次元、各座標の次元を空間次元と呼びます。
畳み込み層でのバッチ正規化を数式で表すと、以下のようになります。
- :入力 (バッチ, チャンネル, x座標, y座標)
- :出力 (バッチ, チャンネル, x座標, y座標)
- :各チャンネルの平均値
- :各チャンネルの標準偏差
- :0で割らないための補正項
- :アフィン変換のパラメータ
\begin{align} OUTPUT(b,c,x,y) = γ_c \frac{INPUT(b,c,x,y) - μ_c}{\sqrt{σ^2_c + ε}} + β_c \end{align}
このように、バッチ正規化とは入力から対応するチャンネルの平均値を引き、標準偏差(0にならないための補正項付き)で割ることで正規化行ったものに、アフィン変換を行ったものです。とは学習可能なパラメータです。
正規化した後にアフィン変換を行うことで、完全に分布を制限してしまうことを避け、表現可能な分布に自由度を与えています。
バッチ正規化の利点
ここでは、バッチ正規化が実際に性能にどの程度効果があるのかを検証しています。実験には、オリジナルResNetの論文の110層のResNetと同じものを使用しています。
下図は、バッチ正規化をかけたネットワークの学習率を変えたものと、バッチ正規化をかけていないネットワークの学習結果を示しています。バッチ正規化なしの場合は学習率を下げなくてはいけないため、0.0001となっています。
左図が訓練データの認識率で、右図がテストデータの認識率となっています。
- bn-orig-lr:学習率 0.1, バッチ正規化あり
- bn-med-lr:学習率 0.003, バッチ正規化あり
- bn-small-lr:学習率 0.0001, バッチ正規化あり
- unnorm:学習率 0.0001, バッチ正規化なし
この図から、正規化されていないネットワークの精度は正規化されているネットワークの精度と比較して精度が大きく下がることが分かります。
また、学習率が低い正規化されたネットワークは訓練とテストの精度の差が大きいため、学習率が高いほど正則化の効果があることが分かります。
学習率と正則化
これらの結果を説明するために、SGDの単純なモデルを考えます。を学習率、をミニバッチ、誤差関数をデータセットの全てのデータに対する誤差の平均とします。このとき、SGDの推定する勾配の式を、以下のように2つに分割することが出来ます。
- :学習率
- :ミニバッチ
- :i番目のデータに対する誤差
- :全データに対する誤差の平均
\begin{align} α∇f_{S G D} (x) &= \frac{α}{|B|} \sum_{i∈B} ∇f_i(x) \\ &= α∇f(x) + \frac{α}{|B|} \sum_{i∈B} (∇f_i(x) - ∇f(x)) \end{align}
これは、の変形と似たような変形です。最後の式の右辺の第1項は全データに対する勾配で、第2項はSGDがミニバッチとしてランダムにデータを選択することにより発生するノイズ項です。
データは一様にサンプルされるため、ノイズ項の期待値はとなります。従って誤差にバイアスはかかりませんが、通常はノイズが発生します。
ここで、ノイズ量を]と定義します。すると、SGDによって与えられる勾配推定値のノイズを以下のように表現することが出来ます。
\begin{align} E[|| (∇f(x) - ∇f_{S G D}(x))||^2] &= \frac{α^2}{B} E[(∇f(x) - ∇f_{S G D}(x))^2] \\ &= \frac{α^2}{B} M \end{align}
この式は、バッチサイズと学習率がSGDのノイズ量を調節することを示しています。SGDのノイズは、ニューラルネットワークを正則化するうえで重要な役割を担っていると広く信じられています。
従って、バッチ正規化が可能にする高い学習率設定はSGDノイズを増加させる原因となり、正則化の効果と精度が向上すると考えられます。
勾配と活性化の大きさ
バッチ正規化の利点は、高い学習率を可能にすることによって発生することが判明しました。ここでは、バッチ正規化がなぜ高い学習率によって発生する大きな更新量での学習を可能にするかを検証します。
下図は、正規化されたネットワークの勾配と正規化されていないネットワークの勾配を示しています。正規化されていないネットワークの勾配は、正規化されたネットワークの勾配と比べて約2桁大きく、長いテールで分散していることが分かります。
- 左:正規化あり
- 右:正規化なし
これは、バッチ正規化によってチャンネルの平均と分散が正規化されることによる影響です。
下図は、層が深くなるにつれてチャンネルの平均と分散がどのように変化するかを示しています。y軸が対数スケールであることに注意してください。正規化されていないネットワークでは層が深くなるにつ入れて平均と分散が指数関数的に増加していますが、正規化されたネットワークでは層が深くなっても平均と分散が一定に保たれていることが分かります。
出力層における勾配
ネットワークの深さとともにチャンネルの平均値が増加することが分かりました。分類に対応する出力層では、平均の偏りはネットワークが予測するクラスが偏っていることを意味します。
下図は、出力層の各ユニットの勾配を示しています。正規化されていないネットワークでは、ミニバッチ内のほぼ全てのデータについて勾配がほぼ同じであり、勾配が入力依存ではないことが分かります。しかし、正規化されたネットワークは比較的勾配の入力に対する依存性が高いことが分かります。
- 左:正規化なし
- 右:正規化あり
- x軸:各クラスに対応するユニット
- y軸:ミニバッチ内のデータの番号
畳み込み層における勾配
同様の理由から、正規化されていないネットワークの畳み込み層の勾配が大きい理由を説明することが出来ます。最初の2つの次元が入出力チャンネルに対応し、後の2つが空間次元(x座標, y座標)に対応する畳み込み層の重みを考えます。 また、3×3の畳み込みを行うために、空間次元にを付与します。ここで、畳み込み処理を以下のように表現することが出来ます。
\begin{align} OUTPUT(b,c,x,y) = \sum_{c'} \sum_{i, j∈S} INPUT(b,c', x+i, y+j)K(c,c',i,j) \end{align}
ここで、畳み込み層のパラメータの勾配は、以下の式によって与えられる。
\begin{align} \frac{∂L}{∂K(c_o,c_i,i,j)} = \sum_{b,x,y} d_{c_o c_i i j}^{d x y} \end{align}
\begin{align} d_{c_o c_i i j}^{d x y} = \frac{∂L}{∂OUTPUT(b,c_o,x,y)} INPUT(b,c_i,x+i,y+i) \end{align}
下の表は、勾配の絶対値の和・勾配の和の絶対値などをまとめた表です。ここでは、以下の要素が一致するかどうかを検証しています。
- :勾配の絶対値の和
- :勾配の空間次元の和の絶対値の和
- :勾配のバッチの和の絶対値の和
- :勾配の和の絶対値
また、正規化されていないネットワークでは、勾配の和の絶対値は絶対値の和にほぼ等しいが、正規化されたネットワークでは、和の絶対値と絶対値の和ではのスケールの差があることが分かります。
これらの結果は、正規化されていないネットワークでは、勾配は空間次元内とバッチ内の両方で同じ符号を持ち、入力依存でもなく空間次元にも依存しないことを示している。
勾配のスケールを超えるバッチ正規化
上の表から、バッチ正規化によって勾配が約2桁小さくなることが分かりました。しかし、バッチ正規化によって学習率を約4桁大きくすることが出来ることが分かっているため、バッチ正規化が大きな学習率の設定を可能にする理由を説明することは出来ていません。
正規化されていないネットワークでは、学習率を高く設定した場合、最初の数回のミニバッチで誤差が爆発します。ここで、発散の定義をミニバッチでの損失がを超えた場合とします。実験では、誤差がその範囲に達した場合に、ネットワークは一度も元通りになりませんでした。
下図では、正規化されていないネットワークでの勾配の更新に伴うミニバッチの誤差の遷移が示されています。
この図から、勾配の更新が進むにつれて誤差が発散することが分かります。
下図は、正規化されていないネットワークの勾配の更新によって、チャンネルの平均と分散がどのように遷移するかを示しています。この図から、出力層側の層の平均と分散が特に発散していることが分かります(Layer 44の平均、分散のスケールがそれぞれ1e9, 1e19であることに注意してください)。
バッチ正規化は、各層の出力を正規化することによって出力の指数関数的な増加を阻害することは明らかです。これによって、大きな更新量での学習を行っても、ネットワークの出力が正規化されたパラメータ空間の領域に収まることが保証されます。これは、バッチ正規化がより高い学習率の設定を可能にする第2の主要なメカニズムだと推測されています。
さらなる観測
先ほどの表は、正規化されていないネットワークでは、勾配はバッチ内および空間次元内にわたって類似していること示唆しています。しかし、それらが入力/出力チャンネルによってどのように変化するかは不明です。
下図は、45層目の各イン/アウトチャンネルに対応するパラメータの平均勾配の絶対値を示しています。正規化されていないネットワークでは一部のイン/アウトチャンネルは一貫して小さな勾配を持っており、パラメータの更新量が非常に小さいことが分かります。しかし、この現象はバッチ正規化によって抑えることが出来ます。
重みの初期化の影響
上図は、勾配や活性化のスケールに差があることを示しています。ニューラルネットワークの初期化手法は、ランダムに重みを設定した際に、チャンネルの分散が一定であるべきという考えから設計されています。そこで、最新の初期化手法の結果がどのようになるのかを検証します。
全結合層で構成される単純なニューラルネットワークを考えます。ここで、入力を、出力を、重み行列をとしたとき、出力はとなります。
**下図は、ランダムな正方行列と重み行列の積()の特異値の分布を示しています。
- One matrix:
- Product of 5 matrix:
- Product of 25 matrix:
この図から、特異値の分布はより多くの行列が掛け合わされるにつれて、より長いテールを持つことが分かります。これは、特異値の最大の値と最小の値の比が、層が深くなるにつれて増加することを意味します。
これらの結果から、掛け合わせる行列数が増加することで 1)収束が遅くなる、2)小さい学習率が必要とされる、3)異なる部分空間内の勾配の比が増加、などの影響があることが考えられます。
バッチ正規化は、このような重みの初期化の影響を緩和します(実際に、2個前の図ではバッチ正規化によってすべての勾配が同様に増加・減少することが示されています)。
KelpNetで転移学習
今回は、KelpNetでVGGを元に転移学習を行います。転移学習とは、あるタスクで学習したモデルを他のタスクに利用する手法です。ニューラルネットワークの場合だと、犬を認識するというタスクで学習したネットワークを、猫を認識するというタスクに利用したりします。
今回の記事は、ニューラルネットワークの基礎~VGG、KelpNetの概要を理解している方を対象にしています。読み終わるころには、「転移学習とは何か」「転移学習のメリット」「ファインチューニング・ドメイン適応との違い」「KelpNetでの転移学習の実装方法」が分かるようになっており、説明もこの順番で行っています。
ニューラルネットワークやVGG・KelpNetについては、それぞれ以下の記事で紹介しています。
転移学習という言葉は(恐らく)厳密に定義されておらず、専門書や論文によって若干意味自体や意味の範囲が違います。この記事では、転移学習を「ファインチューニングやドメイン適応を含まず、ニューラルネットワークを前提とした狭義の転移学習」という意味で用います。
また、今回用いたコードは全てこちらにあります。
転移学習
転移学習とは、あるタスクで学習したモデルを他のタスクに利用する手法です。ニューラルネットワークの場合、あるタスクで学習したネットワークの出力層を取り除き、特徴抽出器として用いることが多いです。
ニューラルネットワークは、入力層から中間層の最後の層までで特徴量の設計を学習(表現学習)し、中間層の最後の層から出力層への間でこれまでに得られた特徴量を用いて分類・予測を行います。
転移学習では、この入力層から中間層の最後の層までを特徴抽出器とします。
他のタスクを学習する際に、この特徴抽出器を用います。例えば、新しく分類・予測する出力層を追加する・抽出した特徴ベクトルを分類するSVM (Support Vector Machine) を用いるなどがあります。このとき、特徴抽出器の重みは固定します。
このように、ニューラルネットワークの転移学習では、多くの場合は新しいタスクを学習する際に、学習済みのモデルを特徴抽出器として用います。特に、ImageNetなど膨大な量のデータで物体認識を学習したネットワークは汎用的な特徴を学習すると考えられており、特徴抽出器としてよく用いられています。
転移学習のメリット
転移学習には、以下のようなメリットがあります。
- 少ないデータで高い精度を実現できる
- 計算量が少ない
それぞれのメリットについて、以下で説明します。
少ないデータで高い精度を実現できる
深層学習は、一般的にデータから分類・予測に必要な特徴を学習するために膨大な量のデータを必要とします。データ数が少ない場合、モデルが訓練データに対して過剰に適合(オーバーフィッティング)してしまうため、汎用性の高いモデルを作ることが難しくなってしまいます。しかし、膨大な量のデータを用意するためには多くの時間や費用・労力を必要とします。
転移学習の場合、1から特徴の表現を学習するのではなく、既に特徴として表現されているパーツ(特徴抽出器が出力した特徴量)をどのように組み合わせるかを学習します。そのため、訓練データに過剰に適合してしまうことを防げるため、少ないデータで汎用性の高いモデルを学習することが出来ます。
計算量が少ない
深層学習は膨大な数のパラメータを学習するため、一般的に非常に高いマシンパワーを必要とします。マシンパワーが低い場合、学習に膨大な時間がかかってしまいます。しかし、高いマシンパワーを用意するためには多くの費用を必要とします。また、スマートフォンなどのデバイスでは、現状それほど高いマシンパワーを持つことは困難です。
しかし、転移学習では特徴抽出器が出力した特徴を用いるため、学習に必要なパラメータ数が少ない・少ないデータ量で学習できるなどの理由から、少ない計算量で学習することが可能になります。これによって、スマートフォンなど多くのデバイスで学習を実行することが可能になります。
開発者側のマシンではなくスマートフォンなどのユーザー側のデバイスで学習を実行できるようになることで、多くのユーザに対応するための標準化されたモデルではなく、個々のユーザに最適化されたモデルを開発することが可能になります。
転移学習とファインチューニング
ファインチューニングとは、学習済みモデルの一部もしくはすべての層の重みを微調整する手法です。転移学習では、学習済みモデルの重みを固定して用いますが、ファインチューニングでは学習済みモデルの重みを初期値とし、再度学習によって微調整します。
ファインチューニングは、シミューレーション環境から実環境へ移行する際や、標準化されたモデルを個々のユーザに最適化する際などに使用されます。
転移学習とドメイン適応
ドメイン適応とは、あるデータで学習したモデルを異なる分布のデータの学習に利用する手法です。転移学習では、入力から受け取る特徴を共有し、その特徴の組み合わせ方のみ再学習することによって、異なるタスクでの学習を行いました。ドメイン適応では、低次元の特徴の組み合わせ方を共有し、その低次元の特徴の表現を再学習することで、異なる分布のデータの学習を行います。
例えば音声認識を行う場合、出力層側は認識した単語などを組み合わせて適切な文章を生成し、入力層側は話者の音声から単語などを認識します。ここで、単語から文章を生成する方法は共通ですが、音声から単語を生成するためには、話者によって異なる発音を認識する必要があります。
このように、タスクは同じでも入力データの分布が異なる場合、入力層に近い層を再学習することによって対応することが可能になります。これをドメイン適応といいます。
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パッケージの管理をクリックします。
するとNuGetパッケージマネージャーが表示されるのでprotobuf-netを検索し、一番上のprotobuf-netを選択し、右側のインストールをクリックします。これでprotobuf-netを使用することが出来るようになります。
データセット
データは、こちらのkaggleの犬猫分類のデータセットを用います。kaggleにサインインしていない方は、サインインする必要があります。Dataタブの、以下の画像の右側にあるDownkiad Allからダウンロードすることが出来ます。
データセットがダウンロード出来たら、展開してプロジェクトフォルダのbin/Debug/にDataフォルダを作成し、その中にtrainフォルダを入れてください。
データセットには、以下のような犬や猫の画像がたくさん入っています。かわいいですね。
モデルの定義
今回は、VGGの学習済みモデルを用います。VGGとは、下図のようなネットワークです。各層の上の数字は、流れるデータの縦×横×チャンネル数またはユニット数となっています。
今回は、こちらのネットワークの最終の中間層までを特徴抽出器として重みを固定して用い、最終の中間層から出力層への重みを学習します。
以下がモデルの定義部分となっています。「学習済み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%ほどの認識率となり、きちんと学習出来ていることが分かります。
KelpNetでVGG
今回は、KelpNetでVGG(をスケールダウンしたもの)を実装していきます。VGGは、2014年のILSVRCという画像認識の大会で2位(1位はGoogleNetです)になったネットワークであり、代表的な畳み込みニューラルネットワークの1つです。
今回の記事は、ニューラルネットワークの基礎+CNN、KelpNetの概要を理解している方を対象にしています。この記事を読み終わるころには、「VGGの概要」「VGGの特徴」「CIFAR-10とKelpNetでの使い方」「KelpNetでのVGGの実装方法」が分かるようになっており、順番もこの説明で行っています。
ニューラルネットワークの基礎・KelpNetの概要は、それぞれ以下の記事で紹介しています。
また、今回用いたコードは全てこちらにあります。
VGGの概要
VGGは、3×3の畳み込み・最大値プーリング・全結合層から構成されるとてもシンプルな構造のネットワークです。この、「3×3の小さなフィルタのみ用いていること」がVGGの大きな特徴です。VGGは、224×224のRGB画像を入力として、1000クラスの分類を学習することが出来ます。
下図はVGGの概要です。畳み込み層の上の数字は、出力するチャンネルの「縦のサイズ × 横のサイズ × 枚数」となっております。また、全結合層上の数字はユニット数となっています。各畳み込み層では幅1のパディングを行うことでチャンネルのサイズを変えずに、プーリング層でチャンネルのサイズを半分にしています。
VGGの特徴
VGGの畳み込み層は、3×3のサイズのフィルタのみを用いています。**3×3という小さなフィルタの畳み込み層を積み重ねることによって、5×5や7×7など大きなフィルタの畳み込み層と同様の受容野を持つことが出来ます。受容野とは、入力を行う領域のことです。
下図のように3×3の畳み込み層を2つ積み重ねることによって、5×5の畳み込み層の受容野と同じ大きさの受容野を持つことが出来ます。同様に、3つ積み重ねることによって7×7畳み込み層の受容野と同じ大きさの受容野を持つことが出来ます。VGGでは、従来5×5や7×7のフィルタを用いた畳み込み層を全て以下のように3×3に分解して用いています。**
このように、5×5や7×7ではなく3×3の畳み込みを行っているのには以下の2つの理由があります。
- 活性化関数(ReLU)を組み込む回数を増やすことによって、特徴をより区別しやすくする。
- パラメータ数(重みの数)を減らし、正則化をかける。
5×5の畳み込みの代わりに3×3の畳み込みを2回、もしくは7×7の畳み込みの代わりに3×3の畳み込みを3回行うことが出来ます。また、その過程で同時に活性化関数(ReLU)にかけることが可能になります。このように、3×3の畳み込みを行うことによって活性化関数にかける回数が増え、特徴判断がより段階的に行えるようになります。これによって、画像の特徴をより区別しやすくなるようにしています。
また、大きなフィルタを用いるよりも小さなフィルタを用いて段階的に畳み込みを行うことで、パラメータ数(重みの数)を減らすことが出来ます。例として、チャンネル数を入力出力ともにCと固定した場合を考えてみます。7×7の畳み込みを1回行う場合、重みの数はとなります。また、3×3の畳み込みを3回行う場合、重みの数はとなります。これにより、約45%のパラメータ数の削減が出来ていることがわかります。
- C: 入出力のチャンネル数
- 7×7の畳み込みを1回行う場合のパラメータ数
- 3×3の畳み込みを3回行う場合のパラメータ数
CIFAR-10
今回は、VGGでCIFAR-10というデータセットを使って学習を行います。CIFAR-10とは、以下のようなデータセットです。
- 32x32のRGB画像
- 訓練データが50000枚、テストデータが10000枚
- airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truckの10クラス
KelpNetでCIFAR-10
KelpNetでは、CIFAR-10を簡単に扱うことが出来るように、CIFAR-10用のデータローダーが用意されています。ここでは、それらの導入と使用方法について説明します。KelpNetの導入の際と似たようなことを行いますので、こちらのKelpNetの導入の記事も参考になるかもしれません。こちらでは画像を用いてより詳しく解説されています。
まず、クローンしていない方はこちらからKelpNetをクローンします。
git clone https://github.com/harujoh/KelpNet.git
クローン出来たら、VisualStudioでKelpNetのソリューションファイルであるKelpNet/KelpNet.slnを開きます。次に、開いたKelpNetのソリューションをビルドします。すると、KelpNet/KelpNetTester/bin/Debug/ に複数DLLファイルが作成されていると思います。ここで、DLLとはダイナミックリンクライブラリの略であり、プログラムの実行時にリンクされるライブラリのことです。
このDLLファイルのうち、CIFARLoader.dllとTestDataManager.dll(入れてない方はKelpNet.dllとCloo.dllも)を、VisualStudioで作成したプロジェクトフォルダ(おすすめはコンソールアプリ .NET Frameworkのバージョン4.5です)にコピーしてください。
次に、VisualStudioのソリューションエクスプローラーの参照を右クリックし、参照の追加を選択します。すると参照マネージャーが出てくるので、左側の参照タブをクリックした後に右下の参照をクリックすると、参照するファイルの選択というウィンドウが出てきます。そこで、先ほどのdllファイルをそれぞれ追加してください。(ここで、まだ追加していない方は、アセンブリタグからSystem.Drawingも追加してください。)
次に、KelpNet/KelpNetTester/TestData/にあるCifarData.csとTestDataSet.csを自分のプロジェクトフォルダに置きます。このとき、それぞれのコードのnamespaceをKelpNetTester.TestDataから自分のプロジェクトのnamespaceに変更してください。これで、KelpNetでCIFAR-10を使用する準備は終了です。
KelpNetでVGG (mini version)
それでは、KelpNetでVGGを実装していきます。今回は、より多くの方が学習を実行できるように、VGGをスケールダウンしたモデルを実装します。
今回の学習は、私のマシンではGPUで約5時間かかりました。CPUの場合は10倍以上の時間がかかると思いますので、可能であればGPUでの学習をお勧めします。
モデルの定義
先ほど説明したVGGをスケールダウンするために、上図のように最初の4層分の畳み込み層を削除し、フィルタサイズ・ユニット数を1/4にしています。
またKelpNetの場合、誤差関数のSoftmaxCrossEntropyにSoftmax関数が内包されているため、出力層の活性化関数としてFunctionStackの末尾にSoftmax関数を追加する必要はありません。
//ネットワークの構成を FunctionStack に書き連ねる FunctionStack nn = new FunctionStack ( /* 最初の4層の畳み込み層は使用しない new Convolution2D (3, 64, 3, pad: 1, gpuEnable: true), new ReLU (), new Convolution2D (64, 64, 3, pad: 1, gpuEnable: true), new ReLU (), new MaxPooling(2, 2, gpuEnable: true), new Convolution2D (64, 128, 3, pad: 1, gpuEnable: true), new ReLU (), new Convolution2D (128, 128, 3, pad: 1, gpuEnable: true), new ReLU (), new MaxPooling(2, 2, gpuEnable: true), */ // (3, 32, 32)のサイズ画像を入力 new Convolution2D (3, 64, 3, pad: 1, gpuEnable: true), new ReLU (), new Convolution2D (64, 64, 3, pad: 1, gpuEnable: true), new ReLU (), new Convolution2D (64, 64, 3, pad: 1, gpuEnable: true), new ReLU (), new MaxPooling (2, 2, gpuEnable: true), // (64, 16, 16) new Convolution2D (64, 128, 3, pad: 1, gpuEnable: true), new ReLU (), new Convolution2D (128, 128, 3, pad: 1, gpuEnable: true), new ReLU (), new Convolution2D (128, 128, 3, pad: 1, gpuEnable: true), new ReLU (), new MaxPooling (2, 2, gpuEnable: true), // (128, 8, 8) new Convolution2D (128, 128, 3, pad: 1, gpuEnable: true), new ReLU (), new Convolution2D (128, 128, 3, pad: 1, gpuEnable: true), new ReLU (), new Convolution2D (128, 128, 3, pad: 1, gpuEnable: true), new ReLU (), new MaxPooling (2, 2, gpuEnable: true), // (128, 4, 4) new Linear (128 * 4 * 4, 1024, gpuEnable: true), new ReLU (), new Dropout (0.5), new Linear (1024, 1024, gpuEnable: true), new ReLU (), new Dropout (0.5), new Linear (1024, 10, gpuEnable: true) ); //optimizerを宣言 nn.SetOptimizer (new Adam ());
学習部分
今回は、10epoch分の学習を行います。new CifarData()
によってCIFAR-10の訓練・テストデータを用意します。次に、cifarData.GetRandomXSet(BATCH_DATA_COUNT)
によって指定したバッチサイズ分のデータを訓練データから取得します。最後に、Trainer.Train (nn, datasetX.Data, datasetX.Label, new SoftmaxCrossEntropy ())
によって取得した訓練データを用いて学習を行っています。
// CIFAR-10の訓練・テストデータを取得 CifarData cifarData = new CifarData (); // 全てのデータを何回分使うか for (int epoch = 1; epoch < 10; epoch++) { // 何回バッチを実行するか for (int i = 1; i < TRAIN_DATA_COUNT + 1; i++) { //訓練データからランダムにデータを取得 TestDataSet datasetX = cifarData.GetRandomXSet (BATCH_DATA_COUNT); // 取得したデータを用いて学習を行う Trainer.Train (nn, datasetX.Data, datasetX.Label, new SoftmaxCrossEntropy ()); } }
テスト
最後に、テストを行います。cifarData.GetRandomYSet (TEACH_DATA_COUNT)
によって指定した数のテストデータを取得し、Trainer.Accuracy (nn, datasetY.Data, datasetY.Label)
によって取得したデータから認識率を求めています。
//テストデータからランダムにデータを取得 TestDataSet datasetY = cifarData.GetRandomYSet (TEACH_DATA_COUNT); //テストを実行 Real accuracy = Trainer.Accuracy (nn, datasetY.Data, datasetY.Label); Console.WriteLine ("accuracy " + accuracy);
学習結果
学習は、以下のような結果となりました。epochが進むにつれて認識率 (Accuracy) が上がっていることが分かります。
KelpNetでCNN
今回は、KelpNetでCNN (Convolutional Neural Network) を学習していきます。CNNは、日本語では畳み込みニューラルネットワークと呼ばれています。畳み込みニューラルネットワークとは、畳み込み層やプーリング層を用いて構成されたニューラルネットワークのことです。また、最近のネットワークでは、プーリング層が無い畳み込みニューラルネットワークも多いです。
畳み込みニューラルネットワークは、画像を入力とする場合に多く用いられています。最近は、画像だけでなく音声や自然言語(日本語や英語など)を扱う場合にも用いられることが多いです。
この記事は、ニューラルネットワークの基礎・KelpNetの概要を理解している方を対象にしています。この記事を読み終わるころには、「全結合層の問題」「畳み込み層の概要・特徴・設定・数式での表現」「KelpNetで畳み込み層を用いる方法」「プーリング層の概要・設定」「KelpNetでプーリング層を用いる方法」が分かるようになっており、説明もその順に行っています。
ニューラルネットワークの基礎・KelpNetの概要は、それぞれ以下の記事で紹介しています。
全結合層の問題
全結合層とは、ある層の各ユニットが前の層の全てのユニットと結合している層のことです。これまでの記事では、この全結合層を扱ってきました。
しかし、画像などの高次元のデータに対して全結合層を用いるとパラメータ数(重みの数)が膨大になってしまい、学習が困難になってしまいます。
例として、512×512のサイズのRGB画像を入力とする場合を考えてみます。入力画像の画素数は512 × 512 = 262,144個です。また、1画素につきR・G・Bの3つの色の値を持つため、512×512のサイズのRGB画像は262,144 × 3 = 786,432個の値を持つことになります。これらの値を入力として、次の層を仮に1,024個のユニットからなる層だとすると、パラメータ(重み)の個数は786,432 × 1,024 = 805,306,368個となります。
そこで、画像特有の特徴を考慮してネットワークの構造に制約をかけることによってパラメータ数を減らし、学習を簡単にしたものが畳み込み層です。
畳み込み層の概要
はじめに、畳み込み層の概要を紹介します。全結合層では入力をベクトルとして扱いましたが、畳み込み層では、入力を高さ×幅の行列を基本単位として扱います。また、この行列のことをチャンネルと呼びます。例えば、512×512のサイズのRGB画像は各画素につきR・G・Bの3種類の色を持つため、512×512のサイズのチャンネルが3枚あると考えることが出来ます。
畳み込み層では、下図のように入力チャンネルに対してフィルタをかけることで出力チャンネルを得ます。このフィルタの値が重みの役割を果たしており、学習によって調節されます。
畳み込みは、実際には以下のように左上から順にフィルタをかけることによって行います。入力値に対応するフィルタの値を掛けることによって、出力値を求めます。
畳み込み層の特徴
畳み込み層は、局所的受容野と重み共有という2つの特徴を持っています。これらの特徴によってパラメータ数が大幅に削減されており、全結合層では困難であったサイズの大きい画像での学習を可能にしています。以下では、その2つの特徴について説明します。
局所的受容野
受容野とは、あるニューロン(ユニット)へ入力を行う領域のことです。全結合層では、各ユニットは前の層の全てのユニットから入力を受け付けるため、受容野は前の層の全てのユニットとなります。しかし、受容野が広ければ広いほど多くのユニットと結合するため、多くのパラメータ(重み)が必要となります。
そこで、画像の「近隣の画素とは関係性が強いが、遠くなるほど画素同士の関係性が弱くなる」という特徴について考えます。下図は、その特徴を図で説明したものです。ゾウの牙は近隣のゾウの鼻とは強い関係性を持ちますが、遠く離れたパンダの耳とは関係性が弱いことが分かります。
そこで、受容野に「近隣の画素とのみ結合する」という制約を加えます。これによって、関係性が強い近隣の画素とのみ結合することになり、パラメータ数が大幅に削減されます。このように、受容野に「近隣の画素とのみ結合する」という制約を加えるのが、局所的受容野という考え方です。
実際に比較すると「近隣の画素とのみ結合する」という制約を加えるか加えないかで、結合(重み)の数が大きく削減されることが分かります。
重み共有
畳み込み層は、局所的受容野だけでなく重み共有という工夫も行うことで、さらにパラメータ数を減らしています。重み共有は、画像の「ある位置で重要な特徴は、他の位置でも重要である可能性が高い」という特徴を利用しています。
例として、画像の中に猫がいるかどうかを学習するとします。その際、下図のように様々な位置に猫がいることが考えられます。
この場合、各位置にいる猫について別々のフィルタ(重み)で学習しなくてはいけないため、学習が困難になってしまいます。
そこで、猫がどこにいても同じように認識出来るようにすることを考えます。このように、「位置に関係なくある特徴を認識可能であること」を、位置不変性といいます。
ここで、位置不変性を持てないのは「各位置で別々の重みを持つため」でした。そのため、「1つの畳み込み層の中では、位置によって別々のフィルタ(重み)を用いるのではなく、1つのフィルタ(重み)を共有する」というのが重み共有の考え方です。
このように、重みを共有することで位置不変性を持つことが出来ます。また、重み共有を行うことでこれまで、フィルタをかける枚数分のフィルタが必要でしたが、フィルタ(重み)を共有することで1枚で済むためパラメータ数(重みの数)を大幅に削減することが出来ます。
畳み込み層の特徴まとめ
これで、畳み込み層の特徴である「局所的受容野」と「重み共有」について説明しました。まとめると以下のようになります。
局所的受容野
- 問題:受容野が広すぎるとパラメータ数が膨大になってしまう。
- 特徴:近隣の画素とは関係性が強いが、遠くなるほど画素同士の関係性が弱くなる。
- 解決策:関係性が強い近隣の画素のみを受容野とすることで、パラメータ数を削減する。
重み共有
- 問題:位置によってフィルタ(重み)が違うため、各位置について別々に学習しなくてはいけない。
- 特徴:ある位置で重要な特徴は、他の位置でも重要である可能性が高い。
- 解決策:1つの畳み込み層では、1つのフィルタ(重み)を共有する。
また、図にまとめると以下のようになります。局所的受容野を適用することによってフィルタの大きさを小さくし、重み共有によってフィルタの種類を減らせていることが分かります。このように、局所的受容野と重み共有によってパラメータ数(重みの数)を減らすことが出来ます。
これで畳み込み層のおおよそのイメージを紹介しましたので、最初に示したより具体的な画像に戻ります。局所的受容野・重み共有が適用されていることが分かります。
畳み込み層の設定
畳み込み層には、フィルタの大きさやフィルタをずらす距離など、学習前に設定しなくてはいけないパラメータがあります。このように、学習を行う前に事前に設定するパラメータを、ハイパーパラメータといいます。
畳み込み層の設定を行うハイパーパラメータには、以下のようなものがあります。それぞれのハイパーパラメータについて、以下で説明を行います。
- チャンネルの枚数(入力と出力それぞれ必要)
- フィルタサイズ(フィルタの大きさ)
- ストライド(フィルタをずらす距離)
- パディング(余白の大きさ)
チャンネルの枚数
ここまでは、入力と出力ともにチャンネルの枚数が1枚でした。フィルタは入力と出力のチャンネルのペアごとに1枚必要なので フィルタの枚数 = 入力チャンネル数 × 出力チャンネル数 となります。
下図は、6枚のチャンネルから1枚のチャンネルへ出力を行っている図です。各チャンネルを対応するフィルタで畳み込みした値の合計が、1枚の出力チャンネルの値となります。
ストライド
ストライドとは、フィルタをずらす距離のことです。下図のようにストライドが1のときは1マスずつずらし、ストライドが2のときは2マスずつずらします。
パディング
パディング(ゼロパディング)とは、畳み込みを行う前に入力チャンネルの周囲を0で埋めることです。これによって、畳み込み前と後のチャンネルの大きさを同じにすることが出来ます。
畳み込み層を数式で表現
ここまで、畳み込み層を図で表現してきました。畳み込み層の説明の最後に、畳み込み層を図ではなく数式で表現します。
畳み込み層を数式で表現すると以下のようになります。
- :出力チャンネルの枚数
- :入力チャンネルのサイズ
- :ストライド
- :フィルタの左上を重ねる場所の行番号と列番号
- :枚目の行列目の入力チャンネルの値
- :p行q列目の, k番目の入力チャンネルからm番目の出力チャンネルへの値
- :k枚目のi行j列目の出力チャンネルのバイアス
- :枚目の行列目の出力チャンネルの値
- :活性化関数
\begin{align} y_{i,j,m} &= f(\sum_{k = 0}^{K - 1} \sum_{p,q=0}^{H-1} (w_{p,q,k,m} x_{Si+p, Sj+q, k} + b_{i,j,m})) \end{align}
KelpNetで畳み込み層を用いる
KelpNetでは、畳み込み層はConvolution2Dという名前でFunctionクラスとして実装されています。基本的には、入力チャンネルの数・出力チャンネルの数・カーネルサイズ(フィルタサイズ)・関数の名前を指定して使用します。それ以外のパラメータを指定したい場合は、定義する際に指定したいパラメータにのみ値を代入します。
- Convolution2D (inputChannels, outputChannels, kSize, stride = 1, pad = 0, noBias = false, initialW = null, initialb = null, name = FUNCTION_NAME, gpuEnable = false)
// 重みの初期値 Real[,,,] initial_W = { {{{1.0, 0.5, 0.0}, { 0.5, 0.0, -0.5}, {0.0, -0.5, -1.0}}}, {{{0.0, -0.1, 0.1}, {-0.3, 0.4, 0.7}, {0.5, -0.2, 0.2}}} }; // バイアスの初期値 Real[] initial_b = { 0.5, 1.0 }; // ネットワークの定義 FunctionStack model = new FunctionStack( // pad=1の場合 new Convolution2D(1, 2, 3, name="Conv2D") // パディングを指定する場合 new Convolution2D(1, 2, 3, pad: 2, name: "Conv2D") // GPUを用いる場合 new Convolution2D(1, 2, 3, name: "Conv2D", gpuEnable: true) // 重みとバイアスの初期値を指定する場合 new Convolution2D(1, 2, 3, initialW: initial_W, initialb: initial_b, name: "Conv2D") );
プーリング層
畳み込みニューラルネットワークは、畳み込み層とともにプーリング層を用いる場合があります。プーリング層では、チャンネルの各領域の値を1つの値にまとめます。これによって、局所的な特徴の位置のずれに対して頑健になる・過学習を抑制するなどの効果があります。
代表的なプーリングの手法には、平均値プーリング (average pooling) や 最大値プーリング (max pooling) などがあります。平均値プーリングは各領域の値の平均値を出力とし、最大値プーリングは各領域の値の最大値を出力とします。
下図はプーリングの例です。色分けされているものが、各領域です。青・緑・オレンジ・黄色の領域があります。平均値プーリングでは各領域での平均値を求め、最大値プーリングでは各領域での最大値を求めていることが分かります。
プーリング層の設定
プーリングの設定を行うハイパーパラメータには、以下のようなものがあります。それぞれのハイパーパラメータは畳み込み層と同様のもので、こちらで紹介しています。
- フィルタサイズ(領域の大きさ)
- ストライド(フィルタをずらす距離)
- パディング(余白の大きさ)
KelpNetでプーリング層を用いる
KelpNetでは、平均値プーリングはAveragePooling、最大値プーリングはMaxPoolingという名前でFunctionクラスとして実装されています。カーネルサイズ(フィルタサイズ)・関数の名前を指定することで使用出来ます。それ以外のパラメータを指定したい場合は、定義する際に指定したいパラメータにのみ値を代入します。
- AveragePooling (ksize, stride=1, pad=0, name=FUNCTION_NAME, gpuEnable=false)
- MaxPooling (ksize, stride=1, pad=0, name=FUNCTION_NAME, gpuEnable=false)
- AveragePoolingと同じ
// ネットワークの定義 FunctionStack model = new FunctionStack( new AveragePooling(2, 2, name: "AVEPooling") // 各パラメータを設定する場合 new AveragePooling(2, 2, stride=2, pad=1, name: "AVEPooling") // GPUを使用する場合 new AveragePooling(2, 2, name: "AVEPooling") );
KelpNetをOpenCL・GPUで動かす
今回は、KelpNetをOpenCL・GPUで動かす方法を紹介します。OpenCLは、CPU・GPU・FPGAなどで並列処理を行うためのAPIです。KelpNetはCUDAではなくOpenCLを用いるため、GPUを用いる場合でも環境構築がとても簡単に出来ます。また、Nvidia製のGPUだけでなく、AMD製のRadeon系のGPUなどにも対応しています。
この記事でははじめにOpenCLの導入について説明し、次にコードをGPUに対応させる方法について説明します。
OpenCLの導入
OpenCLは、MacやNvidia製のGPUの最新のドライバなどはデフォルトでサポートされているため、特に導入の作業が増えることはありません。GPUのドライバを長い間アップデートしていないなという方は、こちらからデバイスにあったバージョンをダウンロード出来ます。また、AMD製のCPU・GPUもこちらからダウンロードできます。
以下では、少し手順が必要なIntel製のCPUを用いる場合のOpenCLのドライバのインストール方法について説明します。はじめに、こちらのサイトを開きます。次に、右側のメニューからWindowsもしくはLinuxの内自分のOSに合ったものを選択し、FREE Downloadをクリックしてください。
すると次のようなページに飛びますので、チェックボックスにチェックを入れ、氏名などの情報を入力し、Submitをクリックしてください。
Submitをクリックすると、製品名・ライセンスタイプ・シリアル番号などの情報をIntel側に送信してもよいかどうかを尋ねられるので、チェックボックスにチェックを入れます。
チェックボックスにチェックを入れると、以下のようなダウンロード用のリンクが出現しますので、こちらをクリックしてOpenCLのインストーラーをダウンロードします。
ダウンロードが終わったら、インストーラーを起動してください(注:インストールはVisual Studioを閉じてから行ってください)。インストーラーの起動後は基本的にNext連打 & Installで大丈夫です。最後に再起動(Reboot)するかどうかを聞かれますので、再起動してOpenCLのインストールは終了です。
また、以下のようにWeaverを初期化することで、KelpNetをOpenCLで動かすことが可能になります。Weaverとは、CPU・GPU関連の処理を担うマネージャーのようなものです。CPUが2台以上ある場合は、CPUのインデックスを指定することが出来ます。
// Weaverを初期化 Weaver.Initialize(ComputeDeviceTypes.Cpu); // CPUが2台以上ある場合はインデックスを指定 // Weaver.Initialize(ComputeDeviceTypes.Cpu, 0);
KelpNetのコードをGPUに対応させる
GPUを用いる場合は、まずWeaverのDeviceTypeにGPUを指定して初期化します。CPUの際と同様に、GPUが2台以上ある場合は、GPUのインデックスを指定することができます。次に、ネットワークを定義する際に全結合層(Linear)や畳み込み層(Convolution2D)のGPUフラグ(gpuEnable)をtrueにします。これだけでKelpNetをGPUで動かすことが出来ます。
(2018/09/01 追記)
CPU & GPUなど、複数のプラットフォームにOpenCLをインストールしている場合、Weaverを初期化する際に使用したいplatformIdを指定する必要があります。どのプラットフォームにどのIDが割り当てられているかは、以下のコードによって確認することが出来ます。ここで、ClooとKelpNetは同じ名前のクラスを持つため、コンフリクト(衝突)が発生する可能性があります。そのため、using Cloo;
は学習を行う際にはコメントアウトしてください。
// ComputePlatformを呼び出すために、Clooを用意 using Cloo; // Main関数で以下のfor文を実行 for (int i = 0; i < ComputePlatform.Platforms.Count; i++) Console.WriteLine("platformId: " + i + " " + ComputePlatform.Platforms[i].Name);
すると、出力が以下のようになります。この際は、CPUを用いる場合はplatformIdに0を、GPUを用いる場合はplatformIdに1を指定してください。
platformId 0 Intel (R) OpenCL platformId 1 NVIDIA CUDA
// Weaverを初期化 platformIdは上記の方法で確認し, 指定する Weaver.Initialize(ComputeDeviceTypes.Gpu, platformId: 1); // デバイスが2台以上ある場合はインデックスを指定可能 // Weaver.Initialize(ComputeDeviceTypes.Gpu, platformId: 1, deviceIndex: 1); // CPU FunctionStack model = new FunctionStack( new Convolution2D(3, 32, 5, pad: 2, name: "l1 Conv2D"), new BatchNormalization(32, name: "l1 BatchNorm"), new ReLU(name: "l1 ReLU"), new Linear(5 * 5 * 64, 256, name: "l2 Linear"), new BatchNormalization(256, name: "l2 BatchNorm"), new ReLU(name: "l2 ReLU"), new Linear(256, 3, name: "l3 Linear"), new Softmax(name: "l3 Softmax") ); // GPU FunctionStack model = new FunctionStack( new Convolution2D(3, 32, 5, pad: 2, name: "l1 Conv2D", gpuEnable: true), new BatchNormalization(32, name: "l1 BatchNorm"), new ReLU(name: "l1 ReLU"), new Linear(5 * 5 * 64, 256, name: "l2 Linear", gpuEnable: true), new BatchNormalization(256, name: "l2 BatchNorm"), new ReLU(name: "l2 ReLU"), new Linear(256, 3, name: "l3 Linear", gpuEnable: true), new Softmax(name: "l3 Softmax") );
KelpNetでXOR
今回は、KelpNetを用いてXORを学習していきます。XORは、との片方のみ1のときのみ1になる論理回路です。ANDやORとあわせて表にすると、以下のようになります。今回は、このXORを1か0かの二値分類問題として扱います。今回用いるコードは、KelpNetのプロジェクト内のTest2を参考にして書かれています。
OR | AND | XOR | ||
---|---|---|---|---|
0 | 0 | 0 | 0 | 0 |
0 | 1 | 1 | 0 | 1 |
1 | 0 | 1 | 0 | 1 |
1 | 1 | 1 | 1 | 0 |
この記事は深層学習ライブラリを初めて使う方・他の深層学習ライブラリを使っているけどKelpNetも気になる方など対象にしており、ニューラルネットワークの基礎が分かれば理解が出来る内容になっています。この記事では、KelpNetを用いてXORを学習するニューラルネットの実装を「ネットワークの定義」「学習部分」「学習結果の表示」「学習後の重みの表示」の順で説明しています。
ニューラルネットワークの基礎・KelpNetとインストール方法については、それぞれ以下の記事で紹介しています。また、全体のコードは一番下に載っています。
ネットワークの定義
今回は、下図のような中間層が1層でユニット数が2のネットワークを用います。図ではバイアスの表記は省略されています。入力はとのため入力層のユニット数は2、出力は0~1の1個の実数であるため出力層のユニット数は1となっています。活性化関数は中間層と出力層ともにシグモイド関数を用います。このように、二値分類の際は出力層の活性化関数にはシグモイド関数がよく用いられます。
ネットワークを定義するコードは以下のようになります。Linear()は全結合層を示しており、第1引数が入力の数、第2引数が出力の数、第3引数が関数の名前となっています。また、Sigmoid()はシグモイド関数を示しており、引数は関数の名前となっています。
ネットワークを定義した後に、SetOptimizerを用いることでネットワークの重みの更新に用いる最適化手法を適用することが出来ます。最適化手法は、SGD以外にもAdamやRMSPropなど様々な手法が用意されています。
// ネットワークの構成を FunctionStack に書き連ねる FunctionStack model = new FunctionStack( new Linear(2, 2, name: "l1 Linear"), // 入力2出力2の全結合層 new Sigmoid(name: "l1 Sigmoid"), // シグモイド関数 new Linear(2, 1, name: "l2 Linear"), // 入力2出力1の全結合層 new Sigmoid(name: "l2 Sigmoid") // シグモイド関数 ); // optimizerの宣言 model.SetOptimizer(new SGD());
学習
データと学習部分のコードは以下のようになります。KelpNetは、Trainer.Trainを用いることで、順伝播・損失の計算・誤差逆伝播・重みの更新を1行でまとめて記述することが出来ます。また、それぞれを別々に記述する方法もあり、こちらで紹介しています。誤差関数には二乗誤差関数 (MeanSquaredError) を用いています。
Trainer.Train(functionStack, input, teach, lossFunction, isUpdate = true)
- Parameters
- functionStack (FunctionStack): ネットワークのモデル
- input (NdArray): ネットワークへの入力
- teach (NdArray): 教師データ
- lossFunction (LossFunction): 損失関数
- isUpdate (bool): ネットワークの更新を行うかどうか
- Return
- sumLoss (Real): ミニバッチ内の各データの損失の合計
- Parameters
// 入力データ Real[][] x = { new Real[] { 0, 0 }, new Real[] { 1, 0 }, new Real[] { 0, 1 }, new Real[] { 1, 1 } }; // 教師データ Real[][] target = { new Real[] { 0 }, new Real[] { 1 }, new Real[] { 1 }, new Real[] { 0 } }; // 学習回数 const int EPOCH = 10000; // 順伝播・損失の計算・誤差逆伝播・重みの更新を行う for (int ep = 0; ep < EPOCH; ep++) // 全データをEPOCHの回数分学習する { for (int i = 0; i < x.Length; i++) // データの個数分繰り返す { Trainer.Train(model, new NdArray(x[i]), new NdArray(target[i]), new MeanSquaredError()); } }
学習結果
以下のコードを用いて学習結果を表示します。入力データxから1つずつデータを取り出し、Predictを用いて各入力データを伝播させた後、ネットワークの出力を表示しています。
//訓練結果を表示 foreach (Real[] input in x) { NdArray output = model.Predict(input)[0]; Console.WriteLine(input[0] + " xor " + input[1] + " = " + (output.Data[0] > 0.5 ? 1 : 0) + " " + output); }
プログラムの出力は以下のようになります。XORが学習出来ていることが分かります。
0 xor 0 = 0 [0.03538282] 1 xor 0 = 1 [0.96938088] 0 xor 1 = 1 [0.96969101] 1 xor 1 = 0 [0.03145395]
学習後の重み
以下のコードを用いてネットワークの重みとバイアスを表示し、XORがニューラルネットワークでどのように表現されているかを確認します。
Linear l1 = (Linear)model.Functions[0]; Console.WriteLine("l1 Weight"); Console.WriteLine(l1.Weight); Console.WriteLine("l1 Bias"); Console.WriteLine(l1.Bias); Linear l2 = (Linear)model.Functions[2]; Console.WriteLine("l2 Weight"); Console.WriteLine(l2.Weight); Console.WriteLine("l2 Bias"); Console.WriteLine(l2.Bias);
出力は以下のようになります。
l1 Weight [[ 5.28109151 -5.49548032] [-5.71896640 5.56578034]] l1 Bias [-2.96292368 -3.13341178] l2 Weight [[8.24473497 8.17577662]] l2 Bias [-4.05190012]
このままの数字で考えると少し複雑なため、大雑把ではありますが以下のように四捨五入してどのように表現されているかを考えていきます。
\begin{align} w^{(1)}_{11} &= 5.0, w^{(1)}_{12} = -5.0, \\ w^{(1)}_{21} &= -5.0, w^{(1)}_{22} = 5.0, \\ b^{(1)}_1 &= -3.0, \\ b^{(1)}_2 &= -3.0, \\ \\ w^{(2)}_{11} &= 8.0, w^{(1)}_{12} = 8.0 \\ b^{(2)}_1 &= -4.0, \\ \end{align}
よって、中間層の出力と出力層の出力は以下のようになります。
\begin{align} s_1 &= f(x_1w^{(1)}_{11} + x_2w^{(1)}_{12} + b^{(1)}_1) \\ &= f(5.0x_1 - 5.0x_2 - 3.0) \\ \\ s_2 &= f(x_1w^{(1)}_{21} + x_2w^{(1)}_{22} + b^{(1)}_2) \\ &= f(-5.0x_1 + 5.0x_2 - 3.0) \\ \\ y &= f(s_1w^{(2)}_{11} + s_2w^{(2)}_{12} + b^{(2)}_1) \\ &=f( 8.0s_1 + 8.0s_2 - 4.0) \end{align}
ここで、は活性化関数のシグモイド関数を示しています。シグモイド関数は、下図のような関数です。
\begin{align} y = \frac{1}{1+e^{-x}} \end{align}
これらの式のに値を代入したものを表にすると以下のようになります。
0 | 0 | |||
0 | 1 | |||
1 | 0 | |||
1 | 1 |
大雑把な値ではありますが、表に出来ました。この表から、はのみ1であるときに発火し、はのみ1であるときに発火することが分かります。また、はかのどちらかが発火した場合に発火することが分かります。よって、このニューラルネットワークはまたはのときに1に近い値を、それ以外のときに0に近い値を出力するニューラルネットワークということが分かります。
最後に全体のコードを載せておきます。
using System; using KelpNet.Loss; using KelpNet.Common; using KelpNet.Common.Functions.Container; using KelpNet.Common.Tools; using KelpNet.Functions.Activations; using KelpNet.Functions.Connections; using KelpNet.Optimizers; class XOR { public static void Main() { // 入力データ Real[][] x = { new Real[] { 0, 0 }, new Real[] { 1, 0 }, new Real[] { 0, 1 }, new Real[] { 1, 1 } }; // 教師データ Real[][] target = { new Real[] { 0 }, new Real[] { 1 }, new Real[] { 1 }, new Real[] { 0 } }; // ネットワークの構成を FunctionStack に書き連ねる FunctionStack model = new FunctionStack( new Linear(2, 2, name: "l1 Linear"), // 入力2出力2の全結合層 new Sigmoid(name: "l1 Sigmoid"), // シグモイド関数 new Linear(2, 1, name: "l2 Linear"), // 入力2出力1の全結合層 new Sigmoid(name: "l2 Sigmoid") // シグモイド関数 ); // optimizerの宣言 model.SetOptimizer(new SGD()); // 学習回数 const int EPOCH = 10000; // 順伝播・損失の計算・誤差逆伝播・重みの更新を行う for (int ep = 0; ep < EPOCH; ep++) // 全データをEPOCHの回数分学習する { for (int i = 0; i < x.Length; i++) // データの個数分繰り返す { Trainer.Train(model, new NdArray(x[i]), new NdArray(target[i]), new MeanSquaredError()); } } // 学習結果を表示 foreach (Real[] input in x) { NdArray output = model.Predict(input)[0]; Console.WriteLine(input[0] + " xor " + input[1] + " = " + (output.Data[0] > 0.5 ? 1 : 0) + " " + output); } Console.WriteLine(""); // 学習後の重みを表示 Linear l1 = (Linear)model.Functions[0]; Console.WriteLine("l1 Weight"); Console.WriteLine(l1.Weight); Console.WriteLine("l1 Bias"); Console.WriteLine(l1.Bias); Linear l2 = (Linear)model.Functions[2]; Console.WriteLine("l2 Weight"); Console.WriteLine(l2.Weight); Console.WriteLine("l2 Bias"); Console.WriteLine(l2.Bias); } }
C#の深層学習ライブラリ「KelpNet」
KelpNetは、春条さんによって開発されたC#の深層学習ライブラリです。今回の記事では、このKelpNetとインストール方法を説明します。
C#だけで実装された深層学習ライブラリ『KelpNet』を公開しています
— 春条 (@harujoh) 2016年12月10日
・『Keras』や『Chainer』のように関数を書き連ねる記述スタイルを採用
・数学が苦手な人でも深層学習の仕組みを理解できる様に行列演算は未使用https://t.co/5NhfIIIHEG
この記事は、初めて深層学習のライブラリを使用する方・今まで他のライブラリを使ってきたけどKelpNetも気になる方などを対象としています。この記事を読み終える頃には、「KelpNetとはどのような深層学習ライブラリなのか」「KelpNetの使い方」「KelpNetの導入方法」が分かるようになっており、説明もその順番で行われています。
KelpNet
KelpNetは、C#だけで実装された深層学習のライブラリです。GitHubのページによると、以下のような特徴があります。
また、C#であることから以下のようなメリットがあります。
- 開発環境の構築が容易で、プログラミング初学者にも学びやすい言語です
- WindowsFormやUnity等、処理結果を視覚的に表示するための選択肢が豊富です
- PCや携帯、組み込み機器等、様々なプラットフォームに向けたアプリケーションの開発ができます
この中でも、個人的には特に「Chainerライクであり簡単に使うことが出来ること」「Unityで使用可能であること」がKelpNetの強みだと思います。
C#の深層学習ライブラリは、KelpNetの他にTensorFlowSharpやNeuralNet.Net、Accord.Netなどがあります。しかし、TensorFlowSharpやNeuralNet.Netは低次元のAPIであるため複雑で初学者には難しく、Accord.Netは複雑なネットワークは実装出来ません。それらと比較してKelpNetはChainerライクの高次元なAPIであるため直観的に簡単に扱うことが出来るだけでなく、複雑なネットワークも設計出来ます。
また、最近Unityで任意の環境とエージェントを構築し、それらを用いて深層強化学習を行うことが盛んになってきています。KelpNetを用いることで、環境・エージェント・ニューラルネットワークをC#だけで比較的簡単に構築することが出来ます。
ネットワークの定義
KelpNetでは、以下のように層や関数をFunctionStackに書き連ねることで定義します。 ネットワークの構成は、次のようになっています。
- ネットワーク構成
- 畳み込み層
- バッチ正規化(BatchNormalization)
- 正規化線形関数(ReLU)
- 全結合層
- バッチ正規化(BatchNormalization)
- 正規化線形関数(ReLU)
- 全結合層
- ソフトマックス関数
- 最適化手法: Adam
FunctionStack model = new FunctionStack( new Convolution2D(inputChannels: 3, outputChannels: 32, kSize: 5, pad: 2, name: "l1 Conv2D"), new BatchNormalization(32, name: "l1 BatchNorm"), new ReLU(name: "l1 ReLU"), new Linear(5 * 5 * 64, 256, name: "l2 Linear"), new BatchNormalization(256, name: "l2 BatchNorm"), new ReLU(name: "l2 ReLU"), new Linear(256, 3, name: "l3 Linear"), new Softmax(name: "l3 Softmax") ); // 最適化手法の設定 model.SetOptimizer(new Adam());
このように、KelpNetではFunctionStackに関数を積み重ねることでネットワークを構築します。また、ネットワークを構築した後にSetOptimizerを用いることで最適化手法を適用することが出来ます。
ネットワークの構築に用いる関数は、現状では以下のものが実装されています。また、KelpNetはC#のみで実装されているので、以下に含まれていない関数も実装済みの関数を参考にして自分で追加することが出来ます。
- Activations: ELU,LeakyReLU,ReLU,Sigmoid,Tanh,Softmax,Softplus,Swish
- Connections: Linear,Convolution2D,Deconvolution2D,EmbedID,LSTM
- Poolings: AveragePooling,MaxPooling
- LossFunctions: MeanSquaredError,SoftmaxCrossEntropy
- Optimizers: SGD,MomentumSG, AdaDelta,AdaGrad,Adam,RMSprop
- Normalize: BatchNormalization,LRN
- Noise: DropOut,StochasticDepth
ネットワークの学習
KelpNetでは、順伝播・損失の計算・誤差逆伝播・更新を1行でまとめて行う方法と別々に行う方法の2種類の方法で学習を行うことが出来ます。
まとめて記述する方法は、以下のようなTrainerのTrainという関数を用います。この方法は、順伝播を行う前に教師データを用意できる教師あり学習を行う際に便利です。
- Trainer.Train(functionStack, input, teach, lossFunction, isUpdate = true)
- Parameters
- functionStack (FunctionStack): ネットワークのモデル
- input (NdArray): ネットワークへの入力
- teach (NdArray): 教師データ
- lossFunction (LossFunction): 損失関数
- isUpdate (bool): ネットワークの更新を行うかどうか
- Return
- sumLoss (Real): 損失の合計
また、別々に記述する場合は損失関数を定義した後に、それぞれForward, lossFunction.Evaluate, Backward, Updateを行うことで実行します。この方法は、順伝播を行う前に教師データを用意できない強化学習を行う際に便利です。
// 訓練データからランダムにデータを取得 TestDataSet dataset = mnistData.GetRandomXSet(BATCH_DATA_COUNT); // 1. まとめて記述する方法 Trainer.Train(model, dataset.Data, dataset.Label, new SoftmaxCrossEntropy()); // 2. 順伝播, 損失の計算, 誤差逆伝播, 更新で別々に記述する方法 LossFunction lossFunction = new SoftmaxCrossEntropy(); NdArray[] output = model.Forward(input); Real loss = lossFunction.Evaluate(output, dataset.Label); model.Backward(output); model.Update();
モデルの保存・読み込み
KelpNetは、ModelIOを用いて以下のような方法でモデルの保存・読み込みが可能です。ModelIO.Save()の第一引数は保存したいモデル(FunctionStack)であり、第二引数は保存時のファイル名です。ModelIO.Load()の引数は読み込みたいモデルのファイル名です。
// 学習の終わったネットワークを保存 ModelIO.Save(model, "test.nn"); // 学習の終わったネットワークを読み込み FunctionStack testModel = ModelIO.Load("test.nn");
インストール方法
KelpNetは、以下の2種類の方法で使用することが出来ます。
- KelpNetのプロジェクトフォルダをそのまま自分のプロジェクトに追加
- KelpNetのプロジェクトをビルドしてDLL化し、自分のプロジェクトに追加
そのまま使用する方法は、KelpNet自体のコードを確認しながらコーディングしやすいというメリットがあります。DLL化してから使用する方法は、「複数のソースファイルを1つのファイルにまとめることが出来ること」「コンパイル時間を短縮できる」などのメリットがあります。ここで、DLLとはダイナミックリンクライブラリの略であり、プログラムの実行時にリンクされるライブラリのことです。
今回はVisual Studioを用いて説明を行います。もし同様にVisual Studioを用いる方は、以下からダウンロードが出来ます。
インストールを行う前に、GitHubからKelpNetのリポジトリをクローンします。以下で、それぞれの方法について説明していきます。
git clone https://github.com/harujoh/KelpNet.git
そのまま使用する方法
まず、KelpNetを使用するプロジェクトを作成します。プロジェクトの形態は「コンソールアプリ」で大丈夫です。
プロジェクトが作成出来たら、先ほどクローンしたKelpNetのフォルダ内のKelpNet、Clooという2つのフォルダを自分のソリューションのフォルダ内にコピーします。
次に、ソリューションエクスプローラー内のソリューションを右クリックし、追加→既存のプロジェクトを選択し、自分のソリューションのフォルダ内のKelpNetフォルダに入っているKelpNet.csprojを選択して追加します。
ソリューションエクスプローラーに追加出来たら、ソリューションエクスプローラーのXORプロジェクト内の参照を右クリックし、参照の追加を選択します。
すると参照マネージャーが表示されるので、左側のプロジェクトタブをクリックし、KelpNetにチェックを入れます。これで自分のプロジェクトからKelpNetを参照できます。Clooも同様にCloo.csprojを選択し参照に追加することで使用できます。
最後に、畳み込み層(Convolution2D)などで必要なSystem.Drawingをプロジェクトの参照に追加します。先ほどと同様に、ソリューションエクスプローラーの参照を右クリックし、参照の追加を選択します。すると参照マネージャーが表示されるので、アセンブリタブをクリックし、System.Drawingにチェックを入れ、OKをクリックします。
DLL化して使用する方法
はじめにdllを作成する方法を説明し、次に作成したdllを使用する方法を説明します。まず、Visual Studioで先ほどクローンしたフォルダ内のプロジェクトファイルである KelpNet/KelpNet/KelpNet.csproj を開きます。
次に、ソリューションのビルドを実行します。
すると、KelpNet/KelpNet/bin/Debugの中にKelpNet.dllとCloo.dllが作成できていると思います。Cloo.dllはOpenCLを用いる際に使用します。
次に、dllを使用する方法を説明します。まず、KelpNetを使用するプロジェクトを作成します。プロジェクトの形態はとりあえず「コンソールアプリ」で大丈夫です。
プロジェクトが作成出来たら、先ほど作成したKelpNet.dllとCloo.dllをプロジェクトフォルダ内にコピーします。次に、ソリューションエクスプローラーの参照を右クリックし、参照の追加を選択します。すると参照マネージャーが表示されるので、左側の参照タブをクリックし、右下の参照をクリックしてプロジェクトフォルダ内のKelpNet.dllとCloo.dllをそれぞれ追加します。
最後に、畳み込み層(Convolution2D)などで必要なSystem.Drawingをプロジェクトの参照に追加します。先ほどと同様に、ソリューションエクスプローラーの参照を右クリックし、参照の追加を選択します。すると参照マネージャーが表示されるので、アセンブリタブをクリックし、System.Drawingにチェックを入れ、OKをクリックします。
これで、KelpNetを使用することが出来るようになります。
以上でKelpNetの導入は終了です。KelpNetは導入から使用までとても簡単に行うことが出来るとても良いライブラリです。私自身もUnityで機械学習を行う際などに使用しています。ご質問やご指摘・改善案などございましたら、私か春条さん(@harujoh)にリプかDMを頂ければ喜んで対応させて頂きます。KelpNetがより多くの方に知られ、利用されるようになるととても嬉しいです。