AnyTech Engineer Blog

AnyTech Engineer Blogは、AnyTechのエンジニアたちによる調査や成果、Tipsなどを公開するブログです。

ImageNetモデルを用いた異常検知手法の解説【第1回:導入と転移学習】

ImageNetモデルを用いた異常検知手法の解説【第1回:導入と転移学習】

こんにちは、AnyTechの木村と申します。
AnyTechにて、機械学習エンジニアやAIエンジニアといった役割にて、R&Dに日々従事しております。


この記事は、近年流行しているImageNetモデルを応用した異常検知手法について、リサーチをする機会がありましたので、小職の備忘も兼ねまして、その解説をさせて頂くものです。



目次



シリーズ



はじめに

この度は、近年流行しているImageNetモデルを応用した異常検知手法について、リサーチをする機会がありましたので、小職の備忘も兼ねまして、解説をさせて頂こうと思います。
具体的には目次にもありますように、以下の4手法についての解説となります。

  1. DN2(Deep Nearest Neighbor Anomaly Detection)
  2. SPADE(Sub-Image Anomaly Detection with Deep Pyramid Correspondences)
  3. PaDiM(a Patch Distribution Modeling Framework for Anomaly Detection and Localization)
  4. PatchCore(Towards Total Recall in Industrial Anomaly Detection)


これらは非常に実用性が高く、昨今、人気を博しているアルゴリズムかと思われます。
しかし、アルゴリズムの具体が分かっていないと、意外と精度が発揮できなかったりします。
そのようなことがないように、或いは、実際に適用を試みたけれど思ったより精度が出なかったという方のために、極力詳細に異常検知4手法を紐解かせて頂こうと思います。
読んで下さった方の助けに少しでもなれば幸いです。



導入

昨今、画像処理に適用されるアルゴリズムの多くは、Deep Learningによるものとなってきています。
紹介の異常検知4手法らも、Deep Learningによる異常検知手法となります。
そして、特にPatchCoreという手法については、いわゆるSOTAです。
SOTAとはState Of The Artの略で、「最先端精度を叩き出している手法」という意味になります。
つまり、今、最もホットな手法と言っても過言ではありません。


ちなみに、Deep Learningの適用には、一般に多くの学習時間を要することが知られています。
しかしながら、紹介の異常検知4手法ではその学習時間が短く済むことが特徴となっています。
異常検知の精度は高いのに、学習時間が短くて済む次第です。
これは、紹介の異常検知4手法における、代表的な強みとなります。


学習時間が短く済む理由は、予めImageNetという大規模なデータで学習が行われている識別モデルを、転移学習の要領で用いている為です。
つまりは、先駆者の方が苦労の上に生成してくれた学習モデルを拝借し、そのレバレッジを効かせることで、学習時間の短縮という恩恵を得られる形となっています。
(とても有り難いことです…。🙏)


さて、ここから紹介の異常検知4手法を解説をさせて頂くに当たっては、その転移学習について予め把握しておくと、理解が非常に捗ると思います。
その為、「転移学習なんぞや」というところから、解説をさせて頂こうと思います。



転移学習なんぞや

それでは、異常検知4手法解説の前段としまして、「転移学習なんぞや」の解説をさせて頂きます。
「転移学習(transfer learning)」とは、簡潔にいうと、解く課題のすげ替えになります。
モデルが元々解こうとしていた課題を、所望の別課題へとシフトさせる形です。
転移学習のwikpediaには、「たとえば、乗用車を認識するために得た知識は、トラックを認識するためにも応用できるかもしれない。」という例が紹介されています。

転移学習は、ImageNetで学習されたモデルから実施されることが多いかと思います。
或いは、画像系で転移学習というと、ImageNetで学習されたモデルを用いるケースが殆どかもしれません。
歴史的にそれで上手く行ったケースが多かったことや、多様な特徴の捕捉能力が高いと考えられている為かと思われます。


ImageNetについてですが、これは世界最大級の画像データセットの呼称となります。
ImageNet元来の課題感は、動物 乗り物 果物 楽器 等々からなる1000クラスの識別です。
1000クラスの詳細につきましては、以下の記事が非常に分かりやすかったので、参考までに。
ILSVRC2012データセット クラス別画像 | 有意に無意味な話

つまり、ImageNetモデルは、任意の画像を入力としてモデルの計算処理にかけると、その出力として入力画像が1000クラスの何れに分類されるかを、確率表現に近い自信度合いという表現で、推論/予測をしてくれる形となっています。
出力の具体的なイメージとしては以下図で、該当すると予測されるカテゴリに対し、より高い自信度合いが割り振られる形となります。
出力値としては、0〜1の浮動小数で格納されます。
出力される1000次元のベクトルを  \widehat{y} とすると、  \sum^{1000}_{i = 1}{\widehat{y_i}} = 1 となります。

https://medium.com/@rinu.gour123/tensorflow-image-recognition-using-python-c-df4d05e1ffec


尚、紹介の異常検知4手法にて用いられているモデル構造は、CNNとなります。
CNNとは、Convolutional Neural Networkの略で、Deep Learning機構の1主流のこととなります。
CNNの機構は、一般的に以下図のようになります。

https://www.pinecone.io/learn/imagenet/


尚、上記図は特にAlexNetという名称の、old classicなモデル構造となります。
このAlexNetが発表されたのは、2012年とかなり古めです。
そこから、CNNのモデル構造は様々な進化を経ています。
紹介の異常検知4手法にて用いられるモデル構造についても、そんなAlexNetよりも新規性が高く、達成精度も高いものですが、当該転移学習の説明においては、先ずは構造がシンプルなAlexNetにて行わせて頂こうと思います。

というのも、紹介の異常検知4手法にて用いられるモデル構造は、主にWide Residual Networkとなってはいますが、実はAlexNetを用いることも可能です。
或いは、一般的なCNN構造であれば、概ねどんなモデル構造でも適用可能です。
その柔軟性を感じて頂くためにも、先ずはAlexNetで説明をさせて頂こうと思います。

ただ、ResNet等のより先進的なモデル構造、或いは、より達成精度が高いモデルを用いた方が、異常検知の精度が高くなる傾向があります。
達成精度が高さの理由は、上手く多様な特徴を捉えてくれている為と思われます。
異常検知4手法論文の著者の方々も、幾つかのモデル構造を試した上で、達成精度の高さからWideResNetを選択したものと思われます。

そのようにベースとなるモデル構造の拡張性が高い点は、学習時間の短さに加えて、紹介の異常検知4手法の強みとなります。
新たに先進的なモデル構造、つまり、上手く特徴を捉えてくれるモデル構造が発表されれば、新たにそれを拝借して異常検知精度を押し上げることが可能となる為です。
或いは、処理速度が高速なモデルを用いることで、処理の高速化を図ることなども可能です。


さて、転移学習の話に戻ります。
AlexNet構造において、最終的に抽出された特徴ベクトルからOutputまでの間は、多クラスLogistic回帰の構造となっています。
多クラスLogistic回帰をご存知ない方は、以下記事を参考にして頂くと、分かりやすいかと思います。 ロジスティック回帰による多クラス分類 - 薬剤師のプログラミング学習日記

AlexNet構造におけるLogistic回帰部分については、図の抽象度が高くて迷うのですが、恐らくは赤字で表現した箇所かと思われます。
最終層の特徴ベクトルに対して、最後の最後に実施する積和演算+αの部分です。


転移学習は、この多クラスLogistic回帰構造よりも前側に位置するネットワーク部分を固定して、多クラスLogistic回帰構造部分だけを学習し直す方法となります。
即ち、多クラスLogistic回帰構造の重みだけを更新/アップデートすることとなります。
また、Logistic回帰の学習も、勾配法によって行いますので、学習方法はImageNetで行う際と同様です。
その為、転移学習前後で変更をするのは、主に学習に用いるデータと、それに合わせたOutput構造を調整のみとなります。


尚、プログラム上で転移学習を実践する方法としては、主に以下の2つとなります。

  • 多クラスLogistic回帰構造より前側のネットワーク部分に対して、重みの更新をしないように重み固定の指定を行った上で、ネットワーク全体を学習する
  • 予めImageNetモデルから抽出しておいた特徴ベクトルを用いて、多クラスLogistic回帰の学習を別途実施する


紹介の異常検知4手法は、後者の方法をベースに実装がされています。
予めImageNetモデルから特徴を抽出しておき、そこから各種アルゴリズムが展開される形です。
つまりは、ImageNetモデルを特徴抽出器として扱う形です。
その為、ここでの転移学習の説明/実装はそれに倣い、ImageNetモデルを特徴抽出器として用いる形で行ってみます。


それでは、AlexNetを用いた転移学習のプログラムを実装/実行してみましょう。
先ずは、以下コードによって、torchvisionライブラリからAlexNetをloadします。
尚、私はGPU環境上にて、jupyter notebookで実施していきます。
GPU環境でない方は、CPU環境での実践でも問題はないと思いますが、幾らか時間はかかってしまうかと思います。
或いは、Google Colabを使ってもらっても良いかと思います。

import torch
import torchvision.models as models

device = torch.device('cuda:0')  # torch.device('cpu')

model = models.alexnet(weights=models.AlexNet_Weights.DEFAULT)
model.eval()
model.to(device)

print('model =', model)


これにて、AlexNetモデルがbuildされ、学習済みの重みパラメータがloadされました。
尚、 model = models.alexnet(pretrained=True) という引数指定でも、ImageNetでの学習パラメータをloadすることができるのですが、future warningが出てしまう為、今回は先述のようにさせて頂きました。


尚、 model.eval() というコードについての補足ですが、これは学習時以外の場合、即ち、評価時や本番運用時の場合に必要な指定となります。
この指定を行わないと、モデルによる予測/推論の計算処理の都度、動き方が変動するネットワーク要素がモデル構造内に存在してしまい、予測/推論の結果が毎回変動してしまう、ということです。
torchのofficialページにも、必ず実施してくれとの記載があります。
Saving and Loading Models — PyTorch Tutorials 2.0.1+cu117 documentation

或いは、紹介の異常検知4手法の実装の際には、必ず実施を忘れないようにして下さい。
意外と、忘れてしまう注意ポイントです。

ちなみに、学習時の指定には、model.train() とします。
この辺りについて、もう少し詳しく把握されたい方は、以下の記事が分かりやすかった為、参考にされると良いかと思われます。
PyTorchの気になるところ(GW第1弾) - Qiita


また、torchvisionライブラリの補足になりますが、今回構築したAlexNetの他にも、複数のモデルが用意されています。
以下リンクでそれらが確認できますので、参考までに。
Models and pre-trained weights — Torchvision 0.15 documentation


加えて、torchvisionが用意してくれているAlexNetについて、少しマニアックな補足をさせて頂きます。
torchvisionのAlexNetのコードが以下になります。

class AlexNet(nn.Module):
    def __init__(self, num_classes: int = 1000, dropout: float = 0.5) -> None:
        super().__init__()
        _log_api_usage_once(self)
        self.features = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(64, 192, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(192, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(256, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
        )
        self.avgpool = nn.AdaptiveAvgPool2d((6, 6))
        self.classifier = nn.Sequential(
            nn.Dropout(p=dropout),
            nn.Linear(256 * 6 * 6, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(p=dropout),
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            nn.Linear(4096, num_classes),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = self.features(x)
        x = self.avgpool(x)
        x = torch.flatten(x, 1)
        x = self.classifier(x)
        return x


このtorchvision実装が、オリジナルと大きく異なる点として、 AdaptiveAvgPool2d(output_size=(6, 6)) という層の存在が挙げられます。
オリジナルのAlexNetは、 高さ224pixel x 幅224pixel というサイズのRGBカラー画像しか入力が許されず、それ以外のサイズの画像が入力されるとエラーが発生してしまっていました。
ですが、このtorchvision実装ですと、 AdaptiveAvgPool2d(output_size=(6, 6)) という層が追加されたことにより、入力画像の高さ/幅のpixel数が異なる場合でも、入力が許されるようになっており、エラーとなりません。

私が試してみた限りでは、pixel数の上限はなさそうで、高さ9999pixel x 幅9999pixel 等でも正常終了しました。
pixel数の下限としては、高さか幅が共に63pixel以上であれば正常終了するようでした。
畳み込みやpoolingを多段に繰り返す中で、feature mapの縦横サイズが0pixelになってしまう程に入力画像が小さい場合には、特徴消失に伴うエラーが発生してしまう様子です。
尚、torchvisionのAlexNetのDocumentにも、 min_size という形でその旨の記載がありました。

この辺りの挙動を確認してみたい方は、以下コードの入力pixel部分 224, 224 を、所望のpixel大に変更してもらえれば、 AdaptiveAvgPool2d(output_size=(6, 6)) 層が上手く機能している様子が伺えるかと思います。

from torchinfo import summary

summary(model,input_size=(1, 3, 224, 224))


ただ、概ねどんな大きさの画像入力でも正常終了するとはいえ、サイズ感が 高さ224pixel x 幅224pixel からかけ離れた画像については、その入力想定での学習をしていないであろう為か、精度が発揮されません。
torchvisionのAlexNetのDocumentによれば、画像に対する前処理機能も、 AlexNet_Weights.IMAGENET1K_V1.transforms という形で用意がされているようなので、その仕様に沿う形で 高さ224pixel x 幅224pixel にリサイズをする等して、入力とするのが安牌かと思われます。
厳密には、ImageNet画像の写り込みの大きさや、いわゆる受容野を配慮する必要があるかと思います。


ここで試しに、学習済み重みパラメータをロードしたAlexNetモデルに、実際に画像を入力してみて、その実力を伺ってみようと思います。
入力する画像は以下とします。
処理を追って頂く方は、画像をダウンロードして、cat.jpg と名前を変更して下さい。

https://www.kaggle.com/competitions/dogs-vs-cats/data


画像が準備できたら、その読込と、読み込んだ画像データの可視化を、以下コードにて行います。

import cv2
import matplotlib.pyplot as plt

img = cv2.imread('cat.jpg')[..., ::-1]  # BGR2RGB

mode = 1
if (mode == 0):
    img_prep = img
elif (mode == 1):
    img_prep = cv2.resize(img, (224, 224))
elif (mode == 2):
    img_prep = img[20:(20+224), 20:(20+224)]
elif (mode == 3):
    img_prep = cv2.resize(img, (256, 256))
    img_prep = img_prep[16:(16+224), 16:(16+224)]
else:
    assert False

plt.figure(figsize=(10, 6), dpi=100, facecolor='white')
plt.subplot(1, 2, 1)
plt.imshow(img)
plt.title(img.shape)
plt.subplot(1, 2, 2)
plt.imshow(img_prep)
plt.title(img_prep.shape)
plt.show()


これを実行すると、以下図が出力されます。
出力において、画像の上に記載されている数字は、(縦pixel数, 横pixel数, RGBチャンネル数) となります。
左側が元画像で、右側が大きさを調整した画像となります。


また、先程のコードにて、画像を調整するモードを4種類用意してみました。
mode という変数の値に、0 1 2 3 の何れかの値をセットすることで、画像に対する前処理の内容が変更されます。

  • mode = 0:ロードした 高さ280pixel x 幅300pixel の画像をそのままに、調整をしない (※torchvisionのAlexNetの場合、異常終了しない)
  • mode = 1:ロードした画像を 高さ224pixel x 幅224pixel に拡大縮小する
  • mode = 2:ロードした画像から、猫の顔部分を 高さ224pixel x 幅224pixel にて切り出す(※汎用性の低い方法)
  • mode = 3:ロードした画像を 高さ256pixel x 幅256pixel に拡大縮小した後、その中央部分を 高さ224pixel x 幅224pixel にて切り出す


これらは、何れのモードを採用しても、予測結果が同様となります。
ですが、微妙に結果が揺れるので、結構面白いです。笑
よかったら試してみて下さい。
非常に多くのデータで学習したImageNetのモデルだけあって、見えが少し異なる場合でも、上手く対応をしてくれているものと思われます。


さて、入力画像の準備ができたら、それをImageNetモデルに入力してみます。
ImageNetモデルに入力するためには、先程調整した画像の状態から、もう少しデータとしての調整を行う必要があります。

先ず、0〜255の整数型で管理されている画像データを、浮動小数型にキャストした上で、標準化(標準偏差が1、平均が0になるようにするスケール調整)をします。
標準化を行う際は、単一の画像内の標準偏差や平均を用いるのではなく、ImageNet全体より得られたそれらを用います。

次に、データの次元の並びを (Batch数, RGBチャンネル数, 縦pixel数, 横pixel数) にしてあげる必要があります。
Batch数はデータが1つである場合は、1となります。

以下が、それらを実践するコードとなります。

import numpy as np

x = img_prep
x = x / 255
x = x - np.array([[[0.485, 0.456, 0.406]]])
x = x / np.array([[[0.229, 0.224, 0.225]]])
x = torch.from_numpy(x.astype(np.float32)).unsqueeze(0).permute(0, 3, 1, 2)
x = x.to(device)

print('x.shape =', x.shape)


これにて用意される x という変数は、(1, 3, 224, 224) という形状になります。

特に形状に関して、このように調整する必要性は、pytorchが採用しているお作法の為となります。
なぜ、このようにする必要があるか、その明記を確認したことはないのですが、個人的な推測としては、CNNの畳込みフィルタがスライディングする縦横の2次元を明確化するためだと思われます。
今回の場合でいうと、畳み込みフィルタを、 縦224pixel x 横224pixel 上でスライディングさせるために、そう調整するということです。

仮に、形状変換を行わずに、元の画像データの管理上でスライディングを実施しようとすると、メモリ上で隣り合うデータがRGBとなり、スライディングの際にはメモリ参照を2つ飛ばししながらループ処理を行う必要があります。
「R:参照 → G:スキップ → B:スキップ → R:参照 → …」という形でです。
深い計算処理上、例えば、pythonの下で動いているC/C++上ですと、隣り合うメモリを順に参照していく方が高速に処理ができる為、メモリ配置調整を事前に明示的にそうしているものかと思われます。


標準化に関しては、先述の通り、ImageNetでの学習の際に入力のデータに対して行われているであろう為に、予測/推論時にも行う必要があるものです。
その際には、実際に学習で使われている標準化パラメータ、つまり、ImageNet全体での標準偏差と平均を用いる必要があります。
それが、標準偏差: [0.229, 0.224, 0.225] 、平均: [0.485, 0.456, 0.406] となります。
左からRGB毎のそれらとなります。
尚、この平均と標準偏差は、画像データの輝度と呼ばれる0〜255の整数を、0〜1の浮動小数に変換した上でのものであり、torchvisionのモデルに入力する際にも、先ず画像データを255で割ってから、次に平均を引いて、最後に標準偏差で割ります。

標準化の目的は、wikiにて、「比較・演算などの操作のために望ましい性質を持った一定の形に変形」というように説明がされています。
併せて、モデル側の重みパラメーターも同様のスケールに調整をすることで、コンピューター上の計算誤差、特に情報落ちと呼ばれる誤差が発生しづらくなります。
重みパラメーターのスケール調整については、幾つかテクニックがあり、それが以下に紹介されていますので、参考までに。
Weight Initialization for Deep Learning Neural Networks - MachineLearningMastery.com


尚、画像データの標準化については、torchvisionのtransformというライブラリを用いてもOKです。
以下、transformの使用例について、紹介をします。

import cv2
from torchvision import datasets, models, transforms
import numpy as np
import matplotlib.pyplot as plt

img = cv2.imread('cat.jpg')[..., ::-1]  # BGR2RGB
img = cv2.resize(img, (224, 224))

print('img.shape =', img.shape)

# transformを用いた方法
tr = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
x = tr(img.copy())

print('x.shape =', x.shape)

x_ = img.copy()
x_ = x_ / 255
x_ = x_ - np.array([[[0.485, 0.456, 0.406]]])
x_ = x_ / np.array([[[0.229, 0.224, 0.225]]])
x_ = torch.from_numpy(x_).unsqueeze(0).permute(0, 3, 1, 2)
x_ = x_.to(torch.float)

print('x_.shape =', x_.shape)

plt.figure(figsize=(10, 6), dpi=100)
plt.hist(x.reshape(-1), bins=100, alpha=0.5)
plt.hist(x_.reshape(-1), bins=100, alpha=0.5)
plt.grid()
plt.show()

print('torch.mean(torch.abs(x - x_)) =', torch.mean(torch.abs(x - x_)))


このtransformについて、torchvisionにて用意されているものを使用することも可能です。
尚、少し誤差が発生してしまうのは、画像の大きさをリサイズする際に、そのアルゴリズムがopencvとtorchとで異なることが原因と思われます。
ですが、特に未知のデータに対して、精度劣化を及ぼす程ではないかと思います。

import cv2
from torchvision import datasets, models, transforms
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image

img = cv2.imread('cat.jpg')[..., ::-1]  # BGR2RGB

print('img.shape =', img.shape)

# AlexNet_Weights.IMAGENET1K_V1.transformsを用いた方法
tr = models.AlexNet_Weights.IMAGENET1K_V1.transforms()
x = tr(Image.fromarray(img.copy()))

print('x.shape =', x.shape)

x_ = img.copy()
x_ = cv2.resize(x_, (256, 256))
x_ = x_[16:-16, 16:-16]
x_ = x_ / 255
x_ = x_ - np.array([[[0.485, 0.456, 0.406]]])
x_ = x_ / np.array([[[0.229, 0.224, 0.225]]])
x_ = torch.from_numpy(x_).unsqueeze(0).permute(0, 3, 1, 2)
x_ = x_.to(torch.float)

print('x_.shape =', x_.shape)

plt.figure(figsize=(10, 6), dpi=100)
plt.hist(x.reshape(-1), bins=100, alpha=0.5)
plt.hist(x_.reshape(-1), bins=100, alpha=0.5)
plt.grid()
plt.show()

print('torch.mean(torch.abs(x - x_)) =', torch.mean(torch.abs(x - x_)))


さて、入力画像の形状変換ができましたら、それをモデルに入力してみます。

import torch.nn.functional as F

with torch.no_grad():
    y_hat = model(x)
    y_hat = F.softmax(y_hat, dim=1)[0]

print('y_hat.shape =', y_hat.shape)
print('torch.torch.topk(y_hat, 3) =', torch.torch.topk(y_hat, 3))

plt.figure(figsize=(10, 4), dpi=100, facecolor='white')
plt.plot(y_hat.detach().cpu().numpy())
plt.title(torch.torch.topk(y_hat, 3)[1])
plt.grid()
plt.ylim([-0.05, 1.05])
plt.show()


上記コードを実施すると、以下図が出力されます。
入力の画像が、1000クラスのカテゴリ分類の内の何れであるかを、自信度合いで表現したものとなります。

図上に記載されている数値は左から、[最も自信度合いが高いカテゴリ, 2番目に自信度合いが高いカテゴリ, 3番目に自信度合いが高いカテゴリ] となっています。
また、ImageNetが定義しているカテゴリは、以下のページに分かりやすく載っています。
IMAGENET 1000 Class List - WekaDeeplearning4j

それによれば、予測されたクラスは以下です。
予測されたカテゴリのwikipediaの画像も一緒に添付します。


  1. クラス:281(tabby, tabby cat)、自信度合い:0.6502

    https://en.wikipedia.org/wiki/Tabby_cat

  2. クラス:282(tiger cat)、自信度合い:0.1900

    https://en.wikipedia.org/wiki/Oncilla

  3. クラス:285(Egyptian cat)、自信度合い:0.1570

    https://en.wikipedia.org/wiki/Egyptian_Mau


どれも合ってそうな気がしますね。笑
ImageNetのクラス分けの細かさに驚きます。


尚、出力される自信度合いの値については、計算環境/計算リソースの違いによって、多少変動してしまうことがあります。
これは、複雑性の高い計算をしている背景から、計算環境の違いによる計算上の誤差が生じてしまう為です。
コードの再現実行をして下さった結果、もしも記事の表記と合致しなかった場合、オペレーションのミスではなく、計算環境による誤差かもしれませんので、その場合はそういうものだと割り切って頂けたらと思います。
この誤差は一般的に、実運用上困る程の水準ではないかと思われます。


さて、ここまでで、モデルに画像を入力する際のお作法とAlexNetの実力とを紹介させて頂きました。
次に、転移学習を行うために、AlexNetを特徴抽出器として用いる方法を、実装していこうと思います。

最終的な特徴ベクトルを取り出すには、print(model) の出力や、ソースコードなどの情報を頼りにして、以下のような実装をします。

# set model's intermediate outputs
outputs = []

def hook(module, input, output):
    outputs.append(output)

model.classifier[5].register_forward_hook(hook)


ネットワーク要素の中項目名称や、その小項目の要素番号を指定するような形です。
尚、この実装方法は、紹介の異常検知4手法の1つ、PaDiMのオープンソースの一部を踏襲したものとなります。
PaDiMリポジトリのコードにて、上記実装方法により、ImageNetのモデルから特徴抽出が行われている次第です。

このように実装することで、hookしたネットワーク要素、即ち、特徴出力元と指定した中間層から、モデルでの予測/推論を実施する度に、その特徴を取得することができます。
最終的な出力とは別に、別途ピックアップされるイメージです。
ピックアップされた特徴は、list変数 outputs に蓄積されていきます。
蓄積は、モデルでの予測/推論が行われる度に、次々とされていきます。
その為、出力先をリセットしたい場合には、outputs = [] という形で、出力先のリストを初期化する必要があります。
この辺り、言葉だけの説明では難しく、百聞は一見に如かずということで、早速試してみましょう。


先程の register_forward_hook のコードを実施した後、以下コードにて推論を実施してみます。

outputs = []

with torch.no_grad():
    y_hat = model(x)
    print(len(outputs))
    y_hat = F.softmax(y_hat, dim=1)[0]

print('y_hat.shape =', y_hat.shape)
print('torch.torch.topk(y_hat, 3) =', torch.torch.topk(y_hat, 3))

plt.figure(figsize=(10, 4), dpi=100, facecolor='white')
plt.plot(y_hat.detach().cpu().numpy())
plt.title(torch.torch.topk(y_hat, 3)[1])
plt.grid()
plt.ylim([-0.05, 1.05])
plt.show()

f = outputs[0]

plt.figure(figsize=(10, 4), dpi=100, facecolor='white')
plt.plot(f[0].reshape(-1).detach().cpu().numpy())
plt.title(model.classifier[5])
plt.grid()
plt.show()


このコード実行によって、下記の図が表示されます。

この図示された値が、最終層からピックアップされた、4,096要素からなる特徴ベクトル配列になります。
4,096次元の特徴ベクトル、という呼称が一般的かもしれません。
縦224pixel x 横224pixel から、畳み込み層のフィルタリング処理、pooling、ReLU活性化等を多段に経た結果、最終的にImageNetの識別を行うために、このような特徴ベクトルが抽出される形です

詳細には、model.classifier[5].register_forward_hook(hook) というコードで引っ掛けた先が、AlexNet -> classifier -> (5): ReLU(inplace=True) となっており、その層の出力値がピックアップされた形です。
つまり、ReLU層による処理が行われた直後の値がピックアップされています。

尚、ReLUという活性化を行う層は、「ReLU(x) = max(0, x):マイナスの値を 0 に更新する」という計算仕様となっています。
その為、ReLU層の出力には、マイナス値が存在しない仕様となるのですが、実際に抽出された特徴にもマイナス値が存在していません。
中間値のピックアップが上手く行えていることが伺えます。


こうして、hookによる特徴抽出が行えました。
この方法によって、任意モデルの任意中間層に対して後付けで指定をすることによって、その値をピックアップできます。

尚、register_forward_hook を用いる際の注意点として、引っ掛けるコードを2回以上実行しないようにして下さい。
コードを2回実行すると、特徴の出力口が2個設けられてしまい、1回の推論/予測処理につき、2個の特徴がピックアップされてしまいます。
つまり、重複した特徴が、outputs に出力されてしまいます。
notebook等で実験していると、起こりがちなミスかと思いますので、注意して頂けたらと思います。


抽出した特徴ベクトルの検証として、先程の猫画像に加え、犬画像と別途猫画像とを入力して、特徴ベクトル形状の違いについて確認してみましょう。
画像はそれぞれ以下を用います。
処理を追って頂く場合には、dog.jpg cat2.jpg とファイル名変更して頂けたらと思います。


ここで、次なる予測/推論の実施は、3つの画像を1まとまりのBatch数3として、1回の実施にしようと思います。
model(x) を1callだけして、3枚の画像データに対して、予測/推論を行います。
その為、先ずは、3つの画像に対して前処理を行ったデータを格納するための x_list という変数を設けます。

x_list = []


次に、先程の猫の画像から、推論のためのデータを改めて生成し、x_list に追加します。

img = cv2.imread('cat.jpg')[..., ::-1]  # BGR2RGB

img_prep = cv2.resize(img, (224, 224))

plt.figure(figsize=(10, 6), dpi=100, facecolor='white')
plt.subplot(1, 2, 1)
plt.imshow(img)
plt.title(img.shape)
plt.subplot(1, 2, 2)
plt.imshow(img_prep)
plt.title(img_prep.shape)
plt.show()

x = img_prep
x = x / 255
x = x - np.array([[[0.485, 0.456, 0.406]]])
x = x / np.array([[[0.229, 0.224, 0.225]]])
x = torch.from_numpy(x.astype(np.float32)).unsqueeze(0).permute(0, 3, 1, 2)
x = x.to(device)

print('x.shape =', x.shape)

x_list.append(x)


次に、犬の画像から、推論のためのデータを生成し、x_list に追加します。

img = cv2.imread('dog.jpg')[..., ::-1]  # BGR2RGB

img_prep = cv2.resize(img, (224, 224))

plt.figure(figsize=(10, 6), dpi=100, facecolor='white')
plt.subplot(1, 2, 1)
plt.imshow(img)
plt.title(img.shape)
plt.subplot(1, 2, 2)
plt.imshow(img_prep)
plt.title(img_prep.shape)
plt.show()

x = img_prep
x = x / 255
x = x - np.array([[[0.485, 0.456, 0.406]]])
x = x / np.array([[[0.229, 0.224, 0.225]]])
x = torch.from_numpy(x.astype(np.float32)).unsqueeze(0).permute(0, 3, 1, 2)
x = x.to(device)

print('x.shape =', x.shape)

x_list.append(x)


次に、別の猫の画像から、推論のためのデータを生成し、x_list に追加します。

img = cv2.imread('cat2.jpg')[..., ::-1]  # BGR2RGB

img_prep = cv2.resize(img, (224, 224))

plt.figure(figsize=(10, 6), dpi=100, facecolor='white')
plt.subplot(1, 2, 1)
plt.imshow(img)
plt.title(img.shape)
plt.subplot(1, 2, 2)
plt.imshow(img_prep)
plt.title(img_prep.shape)
plt.show()

x = img_prep
x = x / 255
x = x - np.array([[[0.485, 0.456, 0.406]]])
x = x / np.array([[[0.229, 0.224, 0.225]]])
x = torch.from_numpy(x.astype(np.float32)).unsqueeze(0).permute(0, 3, 1, 2)
x = x.to(device)

print('x.shape =', x.shape)

x_list.append(x)


3つの画像データをlistに登録しましたら、そのデータをtorchのTensor型にキャストし、(Batch数, RGBチャンネル数, 縦pixel数, 横pixel数) という形とします。
それを実現するコードが以下となります。

x = torch.vstack(x_list)

print('x.shape =', x.shape)

これによって、(3, 3, 224, 224) というモデル入力のためのデータが生成されます。
torch.vstack は、Batch数1のデータN個を格納したlistに対して実施をすると、Batch数Nのデータへと変換してくれる便利なメソッドとなります。


ImageNetモデルへの入力データが準備できたところで、予測を実施してみます。

outputs = []

with torch.no_grad():
    y_hat = model(x)
    y_hat = F.softmax(y_hat, dim=1)

print('y_hat.shape =', y_hat.shape)

for i in range(len(y_hat)):
    print('torch.torch.topk(y_hat[%d], 3) =' % i, torch.torch.topk(y_hat[i], 3))

    plt.figure(figsize=(10, 4), dpi=100, facecolor='white')
    plt.plot(y_hat[i].detach().cpu().numpy())
    plt.title(torch.torch.topk(y_hat[i], 3)[1])
    plt.grid()
    plt.ylim([-0.05, 1.05])
    plt.show()

cat.jpg での予測/推論結果

dog.jpg での予測/推論結果

cat2.jpg での予測/推論結果

それぞれの予測/推論は、正解している様子です。


ここで、抽出された特徴同士の比較検証を行ってみます。
尚、図示する画像は取り違えがないように、推論/予測用に調整した入力データ x から、前処理と逆の計算過程を経ることで復元したものを用います。

f_cat = outputs[0][0].reshape(-1).detach().cpu().numpy()
f_dog = outputs[0][1].reshape(-1).detach().cpu().numpy()
f_cat2 = outputs[0][2].reshape(-1).detach().cpu().numpy()

img = x.detach().cpu().numpy()
img = img.transpose(0, 2, 3, 1)
img = img * np.array([[[0.229, 0.224, 0.225]]])
img = img + np.array([[[0.485, 0.456, 0.406]]])
img = img * 255
img = img.astype(np.uint8)

plt.figure(figsize=(10, 10), dpi=100, facecolor='white')
plt.subplot(2, 2, 1)
plt.imshow(img[0])
plt.subplot(2, 2, 2)
plt.imshow(img[1])
plt.subplot(2, 1, 2)
plt.plot(f_cat, alpha=0.5, label='cat.jpg')
plt.plot(f_dog, alpha=0.5, label='dog.jpg')
plt.grid()
plt.legend()
plt.show()

plt.figure(figsize=(10, 10), dpi=100, facecolor='white')
plt.subplot(2, 2, 1)
plt.imshow(img[0])
plt.subplot(2, 2, 2)
plt.imshow(img[2])
plt.subplot(2, 1, 2)
plt.plot(f_cat, alpha=0.5, label='cat.jpg')
plt.plot(f_cat2, alpha=0.5, label='cat2.jpg')
plt.grid()
plt.legend()
plt.show()

ベクトル比較:cat.jpg vs dog.jpg

ベクトル比較:cat.jpg vs cat2.jpg


図を見ると、猫と犬との特徴ベクトルに比べて、猫と猫との特徴ベクトルの方が、類似していることが確認できるかと思います。
このことから、似た概念を入力すると、似た形状の特徴ベクトルが生成されるであろうことが伺えます。


もう少し、比較を分かりやすくする為、1枚目の猫の画像の大きさに従って、各特徴ベクトルの並びをシャッフルしてから、比較をしてみようと思います。
先程のコードに、並び替えのコードを追加します。

f_cat = outputs[0][0].reshape(-1).detach().cpu().numpy()
f_dog = outputs[0][1].reshape(-1).detach().cpu().numpy()
f_cat2 = outputs[0][2].reshape(-1).detach().cpu().numpy()

idx_sort = np.argsort(f_cat)
f_cat = f_cat[idx_sort]
f_dog = f_dog[idx_sort]
f_cat2 = f_cat2[idx_sort]

img = x.detach().cpu().numpy()
img = img.transpose(0, 2, 3, 1)
img = img * np.array([[[0.229, 0.224, 0.225]]])
img = img + np.array([[[0.485, 0.456, 0.406]]])
img = img * 255
img = img.astype(np.uint8)

plt.figure(figsize=(10, 10), dpi=100, facecolor='white')
plt.subplot(2, 2, 1)
plt.imshow(img[0])
plt.subplot(2, 2, 2)
plt.imshow(img[1])
plt.subplot(2, 1, 2)
plt.plot(f_cat, alpha=0.5, label='cat.jpg')
plt.plot(f_dog, alpha=0.5, label='dog.jpg')
plt.grid()
plt.legend()
plt.show()

plt.figure(figsize=(10, 10), dpi=100, facecolor='white')
plt.subplot(2, 2, 1)
plt.imshow(img[0])
plt.subplot(2, 2, 2)
plt.imshow(img[2])
plt.subplot(2, 1, 2)
plt.plot(f_cat, alpha=0.5, label='cat.jpg')
plt.plot(f_cat2, alpha=0.5, label='cat2.jpg')
plt.grid()
plt.legend()
plt.show()

ベクトル比較:cat.jpg vs dog.jpg

ベクトル比較:cat.jpg vs cat2.jpg


こうして眺めることで更に、猫と猫との特徴ベクトルの方が、犬と猫との特徴ベクトルよりも、類似していることが確認しやすいかと思います。
転移学習はこうした、概念の近い入力からは近しい特徴が抽出される、という特性を活用して、改めてクラス分類の学習を行う方法となっています。
畳み込みを何層も実施することは、所望の予測結果に向けて、テンプレートマッチングを共起共起と繰り返す原理となりますので、直感的な納得感もあるかと思います。
また、この考え方が、紹介の異常検知4手法におけるベースの考え方ともなっています。


さて、ここまでで、最終層の特徴ベクトルの取り出し方と、その概念が理解できたかと思います。
最後に仕上げとして、転移学習そのものを実施します。

転移学習の新しい課題先としてのデータには、ここまでの猫画像や犬画像の出処である、以下リンクのデータを用いたいと思います。
犬画像と猫画像、それぞれ12,500枚ずつ、計25,000枚の学習データが用意されている為、そこから入力画像が猫なのか犬なのかを識別するモデルの生成を試みます。
Dogs vs. Cats | Kaggle


上記リンクから、データをダウンロードしましたら、その画像群から特徴ベクトルの抽出を行うことを目指します。
そのために先ずは、以下コードでファイル名の一括取得を行います。

import os

path = './dogs-vs-cats/train/'
files = os.listdir(path)

files_cat = [os.path.join(path, f) for f in files
             if (os.path.isfile(os.path.join(path, f)) &
                 ('cat.' in f) & ('.jpg' in f))]
files_dog = [os.path.join(path, f) for f in files
             if (os.path.isfile(os.path.join(path, f)) &
                 ('dog.' in f) & ('.jpg' in f))]

files_cat = sorted(files_cat)
files_dog = sorted(files_dog)

print('len(files_cat) =', len(files_cat))
print('files_cat[:10] =\n', files_cat[:10])
print()
print('len(files_dog) =', len(files_dog))
print('files_dog[:10] =\n', files_dog[:10])


次に、特徴抽出を行います。
特徴抽出は、猫画像からの特徴抽出、犬画像からの特徴抽出、という順番で行います。
尚、犬猫12,500枚ずつの全枚数で試すのは、実験として少し重たい為、各1,000枚ずつをrandomにピックアップしたものを用いたいと思います。

猫画像からの特徴抽出

import random
from tqdm.notebook import tqdm
import cv2
import numpy as np

N_sample = 1000

random.seed(0)

outputs = []

files_cat = random.sample(files_cat, N_sample)
for file_cat in tqdm(files_cat):
    img = cv2.imread(file_cat)[..., ::-1]  # BGR2RGB
    img_prep = cv2.resize(img, (224, 224))

    x = img_prep
    x = x / 255
    x = x - np.array([[[0.485, 0.456, 0.406]]])
    x = x / np.array([[[0.229, 0.224, 0.225]]])
    x = torch.from_numpy(x.astype(np.float32)).unsqueeze(0).permute(0, 3, 1, 2)
    x = x.to(device)

    with torch.no_grad():
        _ = model(x)

x_cat = torch.vstack(outputs).reshape(len(outputs), -1).detach().cpu().numpy()

print('x_cat.shape =', x_cat.shape)


犬画像からの特徴抽出

outputs = []

files_dog = random.sample(files_dog, N_sample)
for file_dog in tqdm(files_dog):
    img = cv2.imread(file_dog)[..., ::-1]  # BGR2RGB
    img_prep = cv2.resize(img, (224, 224))

    x = img_prep
    x = x / 255
    x = x - np.array([[[0.485, 0.456, 0.406]]])
    x = x / np.array([[[0.229, 0.224, 0.225]]])
    x = torch.from_numpy(x.astype(np.float32)).unsqueeze(0).permute(0, 3, 1, 2)
    x = x.to(device)

    with torch.no_grad():
        _ = model(x)

x_dog = torch.vstack(outputs).reshape(len(outputs), -1).detach().cpu().numpy()

print('x_dog.shape =', x_dog.shape)


次に、抽出した特徴を変数 x にまとめつつ、カテゴリクラス値を変数 y として生成します。
尚、y = 0:猫 y = 1:犬 とします。
また、後々の検証のために、ファイル名称についても同様にまとめておきます。

x = np.concatenate([x_cat, 
                    x_dog], axis=0)
y = np.concatenate([np.zeros([len(x_cat)]), 
                    np.ones([len(x_dog)])], axis=0).astype(np.int16)
files = np.array(files_cat + files_dog)

print('x.shape =', x.shape)
print('y.shape =', y.shape)
print('files.shape =', files.shape)


これで学習に必要な説明変数 x と、目的変数の y が準備できました。
尚、xy は、numpy配列型になります。

直感的な理解を助けるために、データの状態を可視化してみます。

plt.figure(figsize=(10, 12), dpi=100, facecolor='white')

plt.subplot(3, 1, 1)
plt.hist(x.reshape(-1), bins=100, alpha=0.5)
plt.grid()
plt.title('histogram of x')

plt.subplot(3, 1, 2)
plt.hist(x.reshape(-1), bins=100, alpha=0.5)
plt.grid()
plt.yscale('log')
plt.title('histogram of x (log scale)')

plt.subplot(3, 1, 3)
plt.bar(0, np.sum(y == 0), alpha=0.5, width=0.3, label='y = 0')
plt.bar(1, np.sum(y == 1), alpha=0.5, width=0.3, label='y = 1')
plt.xticks([0, 1])
plt.grid()
plt.legend()
plt.title('histogram of y')

plt.show()


ここで、参考までに、説明変数と目的変数の関係性を、主成分分析を用いた2次元マッピングによって、可視化してみましょう。
主成分分析は、分散を最大化する直交軸を、超次元空間上から導出する分析手法です。
イメージ的には、超次元空間を手に持って、グルグルと回しながら観察し、データが最も広がる、或いは、散らばる角度を、主成分軸とすると制定する、というアルゴリズムです。

その直交軸の内、最も分散が高い第一主成分軸と、次に分散が高い第二主成分軸とで、2次元の可視化をすると、多次元の特徴をラフに平面図示することができます。
正確性が凄く高いという訳でもないのですが、参考にはなります。

今回であれば、最終的な特徴ベクトルが4,096次元ですので、それを2次元平面にマッピングする形となります。
主成分分析を用いた2次元マッピングを実施するコードは、以下となります。

from copy import copy
from sklearn.decomposition import PCA
import matplotlib.gridspec as gridspec

def visualize_understandable(x,
                             y,
                             model=PCA(),
                             dim_visualize=[0, 1],
                             normalize_x=True,
                             care_outlier_x=False,
                             remain_ratio_xy=None,
                             regularize_hist=False,
                             font_size=12):
    # copy just in case...
    x_ = copy(x)
    y_ = copy(y)

    # get info...
    N = x_.shape[0]

    # reduction for processing speed when data amount is very large
    if (remain_ratio_xy is not None):
        idx_remain = np.random.permutation(np.arange(N))
        idx_remain = idx_remain[:int(N * remain_ratio_xy)]
        x_ = x_[idx_random_crop, :]
        y_ = y_[idx_random_crop]

    # normalize
    if (normalize_x):
        x_, _, _ = normalization_x(x=x_)
        if (care_outlier_x):
            # suppress outlier
            for i_col in range(x_.shape[1]):
                x_norm = x_[:, column_i]
                x_[(x_norm > 3), column_i] = 3
                x_[(x_norm < -3), column_i] = -3

    x_proj = model.fit_transform(x_)

    x_proj_horz = []
    x_proj_vert = []
    for val_class in np.unique(y_):
        x_proj_horz.append(x_proj[(y_ == val_class), dim_visualize[0]])
        x_proj_vert.append(x_proj[(y_ == val_class), dim_visualize[1]])

    # scatter point
    fig = plt.figure(figsize=(12, 8),dpi=100)
    gs = gridspec.GridSpec(9, 9)

    plt.subplot(gs[:6, :6])
    for i_class in range(len(np.unique(y_))):
        plt.scatter(x_proj_horz[i_class], x_proj_vert[i_class], alpha=0.5)
    plt.title('principal component')
    plt.xlabel('pc%d' % (dim_visualize[0] + 1))
    plt.ylabel('pc%d' % (dim_visualize[1] + 1))
    plt.rcParams["font.size"] = font_size
    plt.grid()

    # histogram 1
    plt.subplot(gs[-2:, :6])
    for i_class in range(len(np.unique(y_))):
        plt.hist(x_proj_horz[i_class], alpha=0.5, bins=20, density=True)
    plt.xlabel('pc%d value' % (dim_visualize[0] + 1))
    plt.rcParams["font.size"] = font_size
    plt.grid()

    # histogram 2
    plt.subplot(gs[:6, -2:])
    for i_class in range(len(np.unique(y_))):
        plt.hist(x_proj_vert[i_class], alpha=0.5, bins=20, 
                 density=True, orientation="horizontal")
    plt.xlabel('pc%d value' % (dim_visualize[1] + 1))
    plt.gca().invert_xaxis()
    plt.rcParams["font.size"] = font_size
    plt.grid()

    plt.show()

    return model, x_, x_proj


def normalization_x(x, mean=None, std=None, eps=1e-20):

    if (mean is None):
        mean = np.mean(x, axis=0)

    if (std is None):
        std = np.std(x, axis=0)
    
    return ((x - mean) / (std + eps)), mean, std


_, x, x_proj = visualize_understandable(x=x, y=y)

コードを実施すると、以下のような図が出力されます。


比較的上手く分布が分かれているかと思われます。
図上の点1つ1つが、画像1枚1枚と対応しています。
尚、繰り返しになりますが、これが全容ではありません。
参考までに大まかなデータ傾向を表現した様子が上記図となる形で、predictableかunpredictableが大まかに見積もれるという次第です。


ちなみに、特徴を抽出する前の画像データそのものに、主成分分析を行ってみた場合は、以下のような形になります。

ImageNetモデルによって、如何に上手く特徴が抽出されているかが伺えるかと思います。


参考までに、第一主成分軸について、猫における値の大小TOP5、犬における値の大小TOP5をピックアップして、確認してみようと思います。

実施コードは以下等となります。

flg_dog = 1
i_pc = 0
flg_asc = True

x_proj_ = x_proj[(y == flg_dog), i_pc]
if flg_asc:
    idx_sort = np.argsort(x_proj_)
else:
    idx_sort = np.argsort(-x_proj_)

for i in idx_sort[:5]:
    img = cv2.imread(files[y == flg_dog][i])[..., ::-1]  # BGR2RGB
    img_prep = cv2.resize(img, (224, 224))
    print(x_proj_[i])

    plt.figure(figsize=(10, 6), dpi=100, facecolor='white')
    plt.subplot(1, 2, 1)
    plt.imshow(img)
    plt.title(img.shape)
    plt.subplot(1, 2, 2)
    plt.imshow(img_prep)
    plt.title(img_prep.shape)
    plt.show()

  • 猫:第一主成分値の高さTOP1、38.0

  • 猫:第一主成分値の高さTOP2、32.4

  • 猫:第一主成分値の高さTOP3、31.9

  • 猫:第一主成分値の高さTOP4、31.9

  • 猫:第一主成分値の高さTOP5、31.5


  • 猫:第一主成分値の低さTOP1、-15.1

  • 猫:第一主成分値の低さTOP2、-13.4

  • 猫:第一主成分値の低さTOP3、-12.0

  • 猫:第一主成分値の低さTOP4、-11.6

  • 猫:第一主成分値の低さTOP5、-11.4


  • 犬:第一主成分値の高さTOP1、9.2

  • 犬:第一主成分値の高さTOP2、8.6

  • 犬:第一主成分値の高さTOP3、8.6

  • 犬:第一主成分値の高さTOP4、8.2

  • 犬:第一主成分値の高さTOP5、7.5


  • 犬:第一主成分値の低さTOP1、-22.7

  • 犬:第一主成分値の低さTOP2、-22.4

  • 犬:第一主成分値の低さTOP3、-21.6

  • 犬:第一主成分値の低さTOP4、-21.4

  • 犬:第一主成分値の低さTOP5、-21.4


結果を眺めてみると、第一主成分の大小に伴う傾向としては、以下等が挙げられそうです。

第一主成分が大きい(+) 第一主成分が小さい(−)
- 猫
- 顔のアップ
- 正面
- 耳が立っている
- 子供っぽい
- 犬
- 全身が写り込んでいる
- 横向き
- 耳が寝ている
- 大人っぽい

抽象的な概念と具体的な概念を混ぜてしまい、少し分かりづらいかもしれませんが、上記傾向を多く満たす程に、どうも第一主成分が大きくなっていそうです。
こういった形で、AlexNetが捉えようとしている特徴の拠り所などが伺えることも、主成分分析の面白いところとなります。


さて、いよいよ抽出した特徴ベクトルを用いて、学習を実施します。

学習は、torchを用いて実施しても良いのですが、簡略化の為にscikit-learnを用いてしまおうと思います。
また、scikit-learnには、Logistic回帰以外に、RandomForestやK-Nearest Neighbor等も用意されていますので、それも試してみようと思います。

また、学習するに当たっては、汎化精度を推し量りたい為、猫と犬と合わせて画像2,000枚分の特徴を要した内、6割を学習に用い、4割を精度評価に用いようと思います。
検証のために、ファイル名称についても、併せて振り分けを行います。
その振り分けを行うコードは、以下となります。

np.random.seed(0)

ratio_train = 0.6
idx_shuffle = np.random.permutation(np.arange(len(x)))
idx_train = idx_shuffle[:int(len(x) * ratio_train)]
idx_val = idx_shuffle[int(len(x) * ratio_train):]

x_train = x[idx_train]
x_val = x[idx_val]
y_train = y[idx_train]
y_val = y[idx_val]

files_train = files[idx_train]
files_val = files[idx_val]

print('x_train.shape =', x_train.shape)
print('x_val.shape =', x_val.shape)
print('y_train.shape =', y_train.shape)
print('y_val.shape =', y_val.shape)
print('files_train.shape =', files_train.shape)
print('files_val.shape =', files_val.shape)

これで、学習データが1,200件、評価データが800件という形で振り分けられます。


このデータを用いて、先ずは、Logistic回帰の学習をしてみます。
コードは以下です。

from sklearn.linear_model import LogisticRegression

model_lr = LogisticRegression(C=0.01, max_iter=10000, random_state=0)
model_lr.fit(x_train, y_train)

y_hat_train = model_lr.predict_proba(x_train)[:, 1]
y_hat_val = model_lr.predict_proba(x_val)[:, 1]

idx_sort = np.argsort(y_hat_val + y_val*(1+1e-10))

plt.figure(figsize=(10, 6), dpi=100, facecolor='white')
plt.plot(y_val[idx_sort], alpha=0.4, color='C0', linewidth=10, label='target')
plt.scatter(np.arange(len(y_hat_val)), y_hat_val[idx_sort],
            alpha=0.2, color='C1', label='prediction')
plt.title('accuracy of training = %.3f, accuracy of validation = %.3f' %
          (np.mean((y_hat_train > 0.5).astype(np.int16) == y_train), 
           np.mean((y_hat_val > 0.5).astype(np.int16) == y_val)))
plt.grid()
plt.legend()
plt.show()


評価データでの正解率が96.5%となりました。
上手く識別が行えていそうです。

ちなみに、有り難いことに以下のgithubリポジトリにて、Cats vs Dogsデータセットで1から学習を行った結果がまとめられておりました。
転移学習なしで、1からの学習です。
GitHub - Tyler-Hilbert/Cat_vs_Dog_Image_Classification: Compares KNN, HOG/SVM and CNN for classifying images as cat or dog

結果としては、以下精度だったとのことです。

  • 転移学習なしのAlexNet:91%
  • HOG特徴を抽出した上での、SupportVectorMachine:60%前後
  • 32x32にResizeした上での、K-NearestNeighbor:50〜60%


上記数字、特にAlexNetの精度より、転移学習の精度貢献が伺えます。
学習が気軽に行えるのみならず、1から学習するよりも達成精度が向上する効果が、転移学習にあることが伺えます。
(改めまして、ImageNetというデータセットを構築して下さった方々、それを用いて学習を実施して下さった方々、新しいモデルを発明して下さった方々等、改めて全ての方々に感謝です…。🙏)

ちなみに、データを猫画像/犬画像の各1,000枚ずつしか用いていませんが、この数を増やすと、精度は少しずつ向上していくようでした。
データが増えることで、猫らしさ/犬らしさといった特徴の多様性が捉えやすくなるのでしょう。
興味のある方は、変数 N_sample の数を調整して、試してみて下さい。


尚、予測/推論を誤っている対象が、どんな様相かは気になるところです。
大きく間違えたTOP5を確認してみましょう。


  • 猫:予測値 y_hat と正解ラベルとの差分大TOP1

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP2

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP3

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP4

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP5


  • 犬:予測値 y_hat と正解ラベルとの差分大TOP1

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP2

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP3

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP4

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP5


面白いですね。

遠目アングルの猫画像、格子で猫が隠れてしまっている対象などを、犬と誤識別してしまっています。
これは、学習データにて、犬画像の傾向が猫画像よりもそちら寄りであった為に、そう勘違いされてしまっているのかもしれません。
また、猫画像とラベル付けされているにも関わらず、犬も一緒に写り込んでしまっている対象もあり、これはデータ側の問題な気がします。

犬画像が猫と勘違いされてしまっているケースでは、耳が立っていたり、毛がモフモフとしていたり、可愛らしい子犬であったり、といったところでしょうか。

こうして、予測が誤っているデータを眺めてみると、間違ってもしょうがないと思える対象がありつつも、もう少し頑張って識別して欲しいなという対象もありそうです。
学習データの不足が原因か、或いは、この辺りがAlexNetの限界なのかもしれません。


ちなみに、torchvisionが用意している、AlexNetよりも先進的なWideResNet50にて、転移学習を実施をすると、評価データでの正解率が99.5%まで向上します。
これは、別の先進的なネットワークが発明されれば、気軽にそれを導入できるという、転移学習のメリットとなります。
興味がある方は、AlexNetをWideResNet50に切り替えて、是非試してみて下さい。
少しのコードを上手く変更すれば、実践が可能です。


次に、AlexNetによる転移学習にて、RandomForestも実施してみます。
コードは以下です。

from sklearn.ensemble import RandomForestClassifier

model_rf = RandomForestClassifier(random_state=0)
model_rf.fit(x_train, y_train)

y_hat_train = model_rf.predict_proba(x_train)[:, 1]
y_hat_val = model_rf.predict_proba(x_val)[:, 1]

idx_sort = np.argsort(y_hat_val + y_val*(1+1e-10))

plt.figure(figsize=(10, 6), dpi=100, facecolor='white')
plt.plot(y_val[idx_sort], alpha=0.4, color='C0', linewidth=10, label='target')
plt.scatter(np.arange(len(y_val)), y_hat_val[idx_sort],
            alpha=0.2, color='C1', label='prediction')
plt.title('accuracy of training = %.3f, accuracy of validation = %.3f' %
          (np.mean((y_hat_train > 0.5) == y_train), 
           np.mean((y_hat_val > 0.5) == y_val)))
plt.grid()
plt.legend()
plt.show()

こちらも、評価データでの正解率が95.3%と、高精度になりました。


RandomForestについても、大きく間違えた対象のTOP5を見てみます。


  • 猫:予測値 y_hat と正解ラベルとの差分大TOP1

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP2

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP3

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP4

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP5


  • 犬:予測値 y_hat と正解ラベルとの差分大TOP1

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP2

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP3

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP4

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP5


Logistic回帰と似た傾向となっています。
分析の整合性が取れているかと思われます。


次に、AlexNetによる転移学習にて、K-NearestNeighborも試してみます。
尚、このK-NearestNeighborというアルゴリズムが、紹介の異常検知4手法において、非常に重要な鍵となります。
ざっくりとは、K個のご近所さんのラベルを見て、そのご近所さんが持つラベル値の平均を取ることで、入力データへのラベル推測を行うのが、K-NearestNeighborとなります。

KNNについて詳しく把握されたい方は、以下の記事が分かりやすかったので、参考までに。
kNNの説明とその実装 - Qiita


転移学習実践のコードは以下です。

from sklearn.neighbors import KNeighborsClassifier

model_knn = KNeighborsClassifier(n_neighbors=20)
model_knn.fit(x_train, y_train)

y_hat_train = model_knn.predict_proba(x_train)[:, 1]
y_hat_val = model_knn.predict_proba(x_val)[:, 1]

idx_sort = np.argsort(y_hat_val + y_val*(1+1e-10))

plt.figure(figsize=(10, 6), dpi=100, facecolor='white')
plt.plot(y_val[idx_sort], alpha=0.4, color='C0', linewidth=10, label='target')
plt.scatter(np.arange(len(y_val)), y_hat_val[idx_sort],
            alpha=0.2, color='C1', label='prediction')
plt.title('accuracy of training = %.3f, accuracy of validation = %.3f' %
          (np.mean((y_hat_train > 0.5) == y_train), 
           np.mean((y_hat_val > 0.5) == y_val)))
plt.grid()
plt.legend()
plt.show()

評価データの精度が93.5%とこちらも高精度です。
先程紹介したGithubリポジトリにて、 縦32pixel x 横32pixel にリサイズした画像で、K-NearestNeighborを適用した際の精度が50〜60%とのことだったので、それと比べて飛躍的に精度が向上しています。
特に転移学習の効果が出た例と言えそうです。


KNNについても、大きく間違えた対象のTOP5を見てみます。


  • 猫:予測値 y_hat と正解ラベルとの差分大TOP1

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP2

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP3

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP4

  • 猫:予測値 y_hat と正解ラベルとの差分大TOP5


  • 犬:予測値 y_hat と正解ラベルとの差分大TOP1

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP2

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP3

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP4

  • 犬:予測値 y_hat と正解ラベルとの差分大TOP5


他2手法と同傾向であり、分析の整合性が取れていることが伺えます。
尚、スコアの分解能が低い分、少し分析としては荒い結果となってしまっているかもしれません。


という訳で、以上が、異常検知4手法を紹介する前段としての、転移学習の解説となります。
この知識を前提とし、次回以降の記事にて、異常検知4手法の説明をさせて頂こうと思います。



おわりに

以上、今回の記事では、異常検知4手法の導入と、前提知識として役に立つ転移学習の話をさせて頂きました。
ここまで読んでくださった方には感謝です。🙇
次回の記事では、異常検知4手法の1つ目、DN2について説明をさせて頂こうと思います。
そちらもお読み頂けますと、有り難い次第です。🙇

AnyTechでは、流体のためのAI「DeepLiquid」の研究/開発/事業に携わってくれる方を募集しております。
ご興味のある方はこちらから、是非お問い合わせください。