(2021/04/27) 去年10月に書いた記事ですが、一般公開にしてなかったのですこし書き加えて公開にしました。
はじめに
はじめまして。python、機械学習を学び始めてまだ一年もたっていない駆け出しのエンジニア大学生です。
自分は今アルバイトでよく深層学習による画像分析を行っています。そういった中で、自分はすでに実装されていたネットワークを使ったもで解析をしていたのですが、実際そういうネットワークはどういうものなのか、CNNやネットワークそのものについてはあまり勉強ができていませんでした。
そこで、「そもそもCNNってなんだろう?」、「手法による違いでネットワークの構造ってどう変わってくるのだろう?」 ということについて、自分自身のための勉強も兼ねて記事を書きました。
この記事は自分のような__エンジニア初心者を対象にした__記事になります。記事の中で簡単なコードを挟んでいるので、実際に実行してみてCNNやその中身で使われているもの、また論文を読む際に必要な知識についての理解が深まっていただければと思います(数式はまったく使いません)。
間違いもあるかと思いますが、コメント等で指摘をお願いしますm(_ _)m
#1. CNN(Convolutional Neural Network)について
CNNというのは、__畳み込み(Convolution)__と__プーリング(Pooling)__という層が使われたニューラルネットワークのことを指します。CNNの層が深いネットワークのことを__Deep Convolutional Neural Network(DCNN)__ともいいますが、全体的にCNNと呼ぶ方が一般的です。
ここでは、CNNで使われている畳み込みとプーリングというのはどんな処理が行われているかを例を通して説明していきます。
##1.1 畳み込みとは
CNNで使われている畳み込み処理は画像処理で使われる__空間フィルタリング__とほとんど同じです。「空間フィルタリングって何?」と思う方もいるかもしれないので、まず空間フィルタリングについて説明してから畳み込み処理を解説します。
###空間フィルタリング
空間フィルタリングというのは、あるサイズの__カーネル__(空間フィルタともいいます)と、それと__同じサイズの画素との内積を取っていく__ことで、新しい画像データを作る(フィルタされた画像を得る)という手法です。
例えば、次のように3x3ピクセルのフィルタ係数(オレンジ)と5x5の画像データがあるとします。
このフィルタ係数の画像データの青枠で示した部分の各画素値について、同じ成分とを掛け合わせ、それらの和をとります。この操作を__畳み込み__といいます。
この畳み込みによって得られた値(上図では18)が新しい画素値、つまり特徴量となります。これを1マスずつスライドしていくことによって他の画素に対しても同様に行っていきます。
こうして、新しい特徴量で示された画像データができました。ただし、これで得られる画像は元の画像に対して外枠1マス分だけ小さくなってしまうので、あらかじめ外枠を1マス増やし、増やした領域を0で埋めることで変換前と後で画像サイズが変化しないようにするという方法(ゼロパディングなど)があります。
この一連の操作が空間フィルタリングです。空間フィルタリングでは、このカーネル内の係数を変えることで様々な変換が行うことができます。例として、ソーベルフィルタでは下図のような鹿の画像が中央のカーネルを用いることで右下のようにエッジが抽出された画像に変換できます:
(自身の撮影した画像より)
サンプルコード
import cv2
# 画像の読み込み
# フィルタはRGBではなくグレイスケールで読み込ませる
img_base = cv2.imread('sample.png', cv2.IMREAD_GRAYSCALE)
# ソーベルフィルタ(y)を適用させる
sobel_img = cv2.Sobel(img_base, cv2.CV_64F, 0, 1, ksize=3)
# ちなみに、x方向では次のようになる
# sobel_img = cv2.Sobel(img_base, cv2.CV_64F, 1, 0, ksize=3)
# 出力
cv2.imwrite('sobel_sample.png', sobel_img)
###畳み込み層
本題です。前項で説明した空間フィルタリングでは、出力は1つのフィルタが施された画像になりますが、CNNでの畳み込み層では、入力から、__特徴マップ__と呼ばれるデータが出力されます。
特徴マップは入力データの中にある特徴的な部分などの情報が入ったデータのことで、鹿の鼻や角だったり、先ほど説明したようなソーベルフィルタで得られるエッジ等の特徴が含まれています。
畳み込み層ではフィルタの数だけカーネルが用意されており、それぞれで空間フィルタリングの処理を行っていくことで、最終的に縦横のサイズ、フィルタ数だけの深さのようなデータが返されます(例えば、出力の縦横が28x28、フィルタ数が32であれば(28,28,32)の3階テンソル)。
※実際は上のようなきれいなものが得られるわけではないので、VGG16ネットワークの各レイヤの特徴を可視化するなどを参考にしてみてください。)
学習においては、フィルタリングをする際のカーネルのパラメータ(重み行列)と__バイアス__が学習されます。初期値は任意の重み行列(バイアスも任意)となり、それらを学習によって調整していくことで、最適な出力が得られるようになります。
tf.kerasでは次のように実装されています。画像に対して一般的に使われるのはConv2Dというレイヤーです。時系列データなどにおいてはConv1D、3次元の空間データにはConv3Dなども使われています。
conv2d_layer = tf.keras.layers.Conv2D(
filters, kernel_size, strides=1, padding='valid',
activation='relu',
kernel_initializer='glorot_uniform',
bias_initializer='zeros')
)
引数 | 内容 |
---|---|
fileters | 出力されるフィルタの数 |
kernel_size | カーネルの大きさ、int型であれば縦横が同じサイズ、tupleであれば指定サイズになります。 |
strides | スライスするときのステップ数。1でスキップなしのスライディングになります。kernel_sizeと同様、int型であれば縦横が同じサイズ、tupleであれば指定サイズになります。 |
padding | 'valid'でなし、'same'で外枠0埋めです。 |
activation | 活性化関数。中間層には'relu'が、出力層は'softmax'が使われるのが一般的です。 |
kernel_initializer | 学習前のカーネルの重み行列の初期化手法を指定します。0埋めや1埋め、乱数など初期化の手法も様々ありますが、活性化関数にreluを使う畳み込み層ではよく'he_normal'が使われています。 |
bias_initializer | 学習前のバイアスの初期化手法を指定します。デフォルトは'zeros'(=0)です。 |
(他にも引数は存在しますが、話が難しくなるので省略しています。それらは公式ドキュメントを参照してください。)
サンプルコード
from tensorflow.keras.layers import Conv2D, Input
# 28x28x3(28x28, RGB)の入力
x = Input(shape=(28,28,3))
# パディング無し
x_nopadding = Conv2D(filters=32, kernel_size=3, strides=1, padding='valid')(x)
print(x_nopadding.shape)
# バッチサイズ(今回は入力で指定してないのでNone)が加わった4階テンソルが返される
# >>(None, 26, 26, 32)
# パディングあり
x_padding = Conv2D(filters=32, kernel_size=3, strides=1, padding='same')(x)
print(x_padding.shape)
# >>(None, 28, 28, 32)
##1.2 プーリングとは
プーリングは畳み込みと比べて非常にわかりやすいです。簡単に言ってしまえば、「指定した領域の中から条件をみたす特徴量を取り出して、特徴マップのサイズを小さくする」になります。プーリングをまとめて__ダウンサンプリング__ともいったりします。
- 平均値プーリング
選択範囲の平均を取ったものを出力します。
tf.kerasではtf.keras.layers.AveragePooling2D()
で実装がなされています(公式ドキュメント)。
- 最大値プーリング
選択範囲の最も大きい値を出力します。
tf.kerasではtf.keras.layers.MaxPooling2D()
で実装がなされています(公式ドキュメント)。
プーリンの種類は上記2です。ほかにもGlobalのついたプーリング層(GlobalAveragePooling, GlobalMaxPooling)も存在しますが、それらは層のすべてが対象になるので、全結合層(後述)で使われるものになります。
このようなダウンサンプリングとは対象に、セマンティックセグメンテーション(後述)において用いられているアップサンプリング(tf.keras.Upsampling2D
、公式ドキュメント)と呼ばれるものがあります。これは画像の拡大とほとんど同じです。
##1.3 プーリング層の必要性
一般的なCNNのエンコーダにはInput→Conv→Pooling→Conv→Pooling→…といった畳み込みとプーリングがセットになったものが多く見られます。
「畳み込みだけで十分じゃないのか?」と僕も初めは思ったことがあったりしましたが、実際はそうは上手くいかないみたいです。
-
より広範囲の特徴量が得られない
例えば256x256の画像で3x3の畳み込みをすると、そのマスの中の特徴量を見るわけですが。その際得られる特徴量は非常に細かい部分を見たものになります。人間が何かを学ぶ上で小さいところから大きいところまで観察するように、機械学習においても細かい部分だけでなくより局所的な部分の特徴量も必要です。そうでないと、学習が進まなくなったりして精度が悪くなってしまいます。小さいサイズの画像を学習したりする場合はあまり気にすることがないときもありますが、その場合は次ののようなことが起こります。 -
学習パラメータが多すぎて過学習になる
畳み込みを繰り返すと、学習パラメータの量は増えていきます。パラメータは増えるほど様々な情報をより正確に学習できますが、それだと学習に時間がかかりすぎたり、データに対してパラメータの数が過剰で過学習や学習不足に陥ります。
2020/11/10追記 :
コメントの指摘にもある通り、単に多いとダメというわけでなく、層が深いDNN(後述のセマンティックセグメンテーション等)では逆にパラメータが多い方が学習が良くなるケースが多いみたいです。ここは見なかったことにしてください…
このように、畳み込みだけで構成するのは過学習や学習不足などの原因になります。ダウンサンプリングをしてから畳み込みを行うことで、より広い範囲の特徴量を取得していく必要があるわけです(ただし、畳み込みは連続して使うのはダメということではなく、プーリングを途中で入れたほうが良いということの意味であることは理解してください)。
#2. 画像認識による構造の違いについて
CNNはいろんな様式の分類で使われていますが、今回は画像認識の中の「クラス分類」、「物体検出」、「セマンティックセグメンテーション」について説明していきます。
##2.1 クラス分類
最も有名な手法の1つです。__入力の画像から、その中に何があるか__を分類します。入力は画像で、出力はクラス毎の確率になります。
###特徴
クラス分類においては、CNN層の出力層の直前で__全結合層(Fully connected layer)__が使われるのが特徴です。全結合層より前の層では、それまでの画像から得られた特徴量が含まれています、それらを1次元に畳み込むことで、「その画像には何があるか」という情報へと変換できるのです。最終的にクラス数だけのベクトルに変換することで分類の出力になります。
全結合層で使われるものとして、にtf.keras内ではFlatten
、Dense
またはGlovalAveragePooling2D
などがそれにあたります。
-
Flatten
: n階テンソルの形状をを1次元テンソルに変換する(パラメータ数は保持)
サンプルコード
from tensorflow.keras.layers import Flatten, Input
x = Input(shape=(256, 256, 3))
x = Flatten()(x) # 256x256x3=196,608
print(x.shape)
# >>> TensorShape([None, 196608])
-
Dense
: 1次元ベクトルのパラメータを指定したパラメータ数へ変換する。入力は1次元であることが必要
サンプルコード
from tensorflow.keras.layers import Dense, Input
x = Input(shape=(256*256*3, ))
x = Dense()(x)
print(x.shape)
# >>> TensorShape([None, 256])
-
GlobalAveragePooling2D
: n階テンソルの各ベクトルについて、各層の特徴量の平均を求めることで1次元テンソルに変換する(パラメータ数が減る)
サンプルコード
from tensorflow.keras.layers import GlobalAveragePooling2D, Input
x = Input(shape=(256, 256, 3))
x = GlobalAveragePooling2D()(x)
print(x.shape)
# >>> TensorShape([None, 3])
###例
以下はmnistデータをCNNを使って分類してみた例です。
import numpy as np
import tensorflow as tf
from tensorflow.keras.datasets.mnist import load_data
from tensorflow.keras.models import Model
from tensorflow.keras.layers import *
from tensorflow.keras.utils import to_categorical
# mnistデータを読み込む
(train_images, train_labels), (test_images, test_labels) = load_data()
# 出力するクラス数
classes = 10
# バッチサイズ
batch_size = len(train_images)//16 #60000//16=3750
# エポック
epochs = 20
# Functional APIでの実装
# ______________________________________________________________________________________________
# 入力は(28,28,1)のグレイスケール
# 学習時は(batch_size, 28, 28, 1)が入力になる。
input_layer = Input(shape=(28,28,1))
# 1つ目の畳み込み
x = Conv2D(filters=32, kernel_size=3, strides=1, padding='same', activation='relu')(input_layer)
# 最大値プーリング
# (28, 28, 32) → (14, 14, 32)
x = MaxPooling2D((2,2))(x)
# 2つ目の畳み込み
x = Conv2D(filters=64, kernel_size=3, strides=1, padding='same', activation='relu')(x)
# 最大値プーリング
# (14, 14, 64) → (7, 7, 64)
x = MaxPooling2D((2,2))(x)
# 3つ目の畳み込み
# 出力は(7, 7, 64)
x = Conv2D(filters=64, kernel_size=3, strides=1, padding='same', activation='relu')(x)
# ここから全結合層。Flattenをつかって(3136, )の2次元ベクトルに変換。※7x7x64=3136
x = Flatten()(x)
# Denseをつかってサイズ(64, )にする
x = Dense(64, activation='relu')(x)
# Denseをつかって(classes, )のサイズにする
# 活性化関数にsoftmaxを使うことで、出力の各成分は区間[0, 1]で表現される
output_layer = Dense(classes, activation='softmax')(x)
# ______________________________________________________________________________________________
# 入力層と出力層を引数に入れてmodelを作成
model = Model(input_layer, output_layer)
# データの前処理
# 学習データを[0,1]で正規化する
train_images = train_images / 255.0
test_images = test_images / 255.0
# ラベルをバイナリクラスに変換する(categorical_crossentropyを使うため)
train_labels = to_categorical(train_labels)
test_labels = to_categorical(test_labels)
# モデルのコンパイル
model.compile(optimizer='adam',
loss='categorical_crossentropy',
metrics=['accuracy'])
# 学習
model.fit(train_images, train_labels, batch_size=batch_size ,epochs=epochs)
# テストデータを使って精度を確かめる
test_loss, test_acc = model.evaluate(test_images, test_labels, verbose=2)
print('\nTest accuracy:', test_acc)
出力:
Test accuracy: 0.9883000254631042
###有名なもの
クラス分類で使われネットワークの例としては、おそらく機械学習を触れたならば必ず聞くであろうこの2つが有名です:
tensorflowでも上記は実装されています。
##2.2 物体検出
物体検出とは、__入力の画像の中から、その中に何があり、それがどこにあるか__が欲しいときに使われる手法です。出力は、分類結果に加えて対象のバウンディングボックス(矩形)を表示するために必要な座標などです。
(自身の撮影した画像より)
###特徴
物体検出では、上の画像のように複数の物体のクラスとその位置を出力としてできるのが特徴です(複数でなく、1つの結果とそのバウンディングボックスを検出するものをImage Classification&Localizationともいいます)。
ネットワークの構造の特徴については、すみません、もう少し勉強に時間がかかりそうなのでいつか紹介できればと思います…
参考になるリンクや記事は掲載しておきます。
物体検出についての歴史まとめ(1)
最近のSingle Shot系の物体検出のアーキテクチャまとめ
###有名なもの
- R-CNN(Regions with Convolutional Neural Networks)
- Faster R-CNN
- YOLO(You Only Look Once)
- SSD(Single Shot MultiBox Detector)
- FPN(Feature Pyramid Networks)
##2.3 セマンティックセグメンテーション
セマンティックセグメンテーションとは、__入力の画像から、その中に何があり、どこにあってどのような形か__が必要なときに使われます。物体検出では矩形を用いて物体のクラスとその位置を識別しましたが、セマンティックセグメンテーションでは1ピクセルにつきクラス分類することでそのクラスと形状を検出し、マスク画像が出力となります。
(A Unified Architecture for Instance and Semantic Segmentationより引用)
###特徴
構造については、一番わかりやすいUNetで紹介します。
(論文U-Net: Convolutional Networks for Biomedical Image Segmentation Fig.1より少し編集)
UNetは、畳み込みとプーリングを繰り返すエンコーダと、アップサンプリングを繰り返すことで元のサイズに戻す__encoder-decoderネットワーク__の構造をしています。
エンコーダの部分は、クラス分類で見られた全結合層を畳み込み層に置き換えた__Fully Convolutional Network(FCN)__という構造を採用しています。出力層で全結合によって1次元にするのではなく、畳み込みを行うことで「物体が何か」という情報から「どこにあるのか」という特徴量が得られるようになるわけです。
デコーダについては、小さくなったマップサイズをUpsamplingという解像度を上げる層と畳み込み層を使うことで元の入力画像サイズへと拡大していきます。
図中の中央の灰色の矢印ですが、これは__Skip Connection__と呼ばれるものです。エンコーダでのダウンサンプリング前に得られた特徴量を、デコーダの得られた特徴量とつなげることによって、より精度の高い出力結果が得られるようになります。加えて、層を深くすることで発生しやすくなる過学習などを抑えるなどの効果もあります。
###有名なもの
- UNet
- PSPNet(Pyramid Scene Parsing Network) : Pyramid Poolingモジュールという手法が特徴
- P-FPN(Panoptic FPN)
など…
これらはsegmentation_modelsですでに実装がされています(サンプルコード参照)
サンプルコード
# おまじない
import os
os.environ['SM_FRAMEWORK'] = 'tf.keras'
# segmentation_modelsをインポート
import segmentation_models as sm
# 入力サイズ
input_size = (480, 480, 3)
# PSPNet
pspnet_model = sm.PSPNet(
backbone_name='resnet50', # バックボーンの構造(エンコーダの構造)は何を使うか
input_shape=input_size, #入力サイズ(縦横は48の倍数であること)
encoder_weights="imagenet", # バックボーンの重みは何を使うか
# あらかじめ学習済みの重みを使うことで、学習の精度が上げられる
classes=3, # 出力される画像内に含まれるラベルの数。つまり(480, 480, classes)のような出力になる
activation='softmax') # 出力層の活性化関数。sigmoidかsoftmax
# UNet
unet_model = sm.Unet(
backbone_name='resnet50',
input_shape=input_size, #入力サイズ(縦横は48の倍数であること)
encoder_weights="imagenet",
classes=3,
activation='sigmoid')
# FPN
fpn_model = sm.FPN(
backbone_name='resnet50',
input_shape=input_size, #入力サイズ(縦横は32の倍数であること)
encoder_weights="imagenet",
classes=3,
activation='softmax')
#3. おわり
長い記事になってしまいましたが、参考になっていただければ幸いです。バッチ正規化、最適化のための損失関数やオプティマイザについては触れていなかったので、機会があれば書いてみたいです。
→ オプティマイザ、損失関数について記事にしました (学習最適化のための損失関数とOptimizer & MRI画像を使った比較)
畳み込み層については、もしかしたら今回のでは十分理解できないかもしれません。そうであった場合は、Kerasライブラリの作者であるFrancois Chollet氏によって書かれたPythonとKerasによるディープラーニング本を手に取ってみてください。