メルカリの画像分類で、end-to-endで学習した学習済みモデルを使って、特徴抽出してから、faissで類似画像検索する手法が使われてた。
詳しくは語られてないけど、下の図の流れ。
手順でいうとこんな感じ
→訓練済みモデルを使い特徴抽出(feature extraction)
→抽出した特徴量をPCAで次元削減(PCA)
→faissでクエリ画像と似てる画像をデータセット画像から類似画像を検索(faiss)
この手法を前からやってみたかったので、今回は上の図の手法通りに、訓練済みモデルを使い、faissで類似画像検索をしてみた。
目次
・今回の手法の概要
・faissで類似画像検索の行程
1.データセット画像とクエリ画像の用意
2.訓練済みモデルで特徴抽出
3.PCAで次元削減について
4.faissで類似画像検索
今回の手法の概要
以前書いた画像分類記事のときに、inception-resnet-v2でend-to-end学習した時の、学習済みモデルを使った。inception-resnet-v2を使ったモデルで、200ラベルの精度は50%くらいだった。ただ4種のスニーカー
は形が同じで、ロゴが違うだけなので、各々の画像の分類精度はよくなかった。
そこでメルカリの類似画像検索手法で、さらに精度よく分類できるか試してみたのが今回の記事。
特徴抽出は一般的に訓練済みモデルを使い、より汎用的な特徴を取り出すことが目的。CNNは出力層の全結合層前の、より深い部分の畳み込み層から取り出した方が、特徴量という意味ではより汎用性が高い。
一方、faissはクラスタリングとか、類似画像検索ができるライブラリらしい。今回は類似画像検索に使ってみた。全体の順序としては、下図のメルカリの画像通りの手順でやってみる。
手順
2.訓練済みのモデルで畳み込み層('conv_7b')から特徴抽出した出力と、入力画像から新しいモデルを作り、予測
3.次元削減(PCA)
4.faissでクエリ画像と似てる画像をデータセットから検索
faissで類似画像検索の行程
1.データセット画像とクエリ画像の用意
end-to-endで学習した学習済みモデルでは、スニーカー4種類「vans, new-balance, adidas, nike」の精度は10〜50%までと低い。今回は類似画像検索でこれらを上手く分類できるか試す。
まず4種類のデータセット画像を、Googleから適当に集めた。その後、画像を左右反転して水増し、合計928枚用意した。
# image dataset # ex:new balance path='/Users/desktop/imgs/new_balance' new_b=[cv2.resize(cv2.imread(path+'/'+i),dsize=(150, 150)) for i in os.listdir(path)] new_b=[preprocess_input(x) for x in new_b] new_b=np.reshape(new_b, (len(new_b),150,150,3)) # 画像枚数水増し flip=[cv2.flip(i, 1) for i in new_b] new_bs = np.r_[new_b, flip] # 他省略 # 4つを結合 ext_img = np.r_[new_bs, nikes, adidass, vanss] print('total shape {}'.format(ext_img.shape)) >>> (266, 150, 150, 3) (244, 150, 150, 3) (218, 150, 150, 3) (200, 150, 150, 3) total shape (928, 150, 150, 3) # ラベル(0~3)も作成 ext_label = np.r_[new_bl, nikel, adidasl, vansl] print('total lenth {}'.format(len(ext_label))) >>> total lenth 928
クエリ画像は以下の6枚。この画像と同じラベルの画像と似た画像を、データセット画像から検索できるか試す
# クエリラベル (6, 150, 150, 3) query image1 name nike.jpeg query image2 name adidas2.jpeg query image3 name vans2.jpeg query image4 name vans1.jpeg query image5 name nikes.jpeg query image6 name adidas.jpeg query_label:[1, 2, 3, 3, 1, 2]
・クエリ画像
2.訓練済みモデルで特徴抽出
特徴抽出は前述の通り深い層からより汎用的な特徴を抽出する方法。以下の手順で特徴抽出して出力と入力画像から、新しいモデルを作成して、そのモデルで予測するまでをやってみる。
sess = tf.Session(config=tf.ConfigProto(allow_soft_placement=True, log_device_placement=False)) K.set_session(sess) # Inception-resnet-v2呼び出す train_model= InceptionResNetV2(Input((150, 150, 3))) # 訓練済みモデル読み込み train_model.load_weights('/Users/downloads/saved_model.h5') # 畳み込み層の呼び出し feature = train_model.get_layer('conv_7b') print(type(feature)) # <class 'keras.layers.convolutional.Conv2D'> print(feature.name, feature.output_shape) # conv_7b (None, 3, 3, 1536) # Globalpooling層の追加 output = GlobalMaxPooling2D()(feature.output) # 新しいモデル作成 model = Model(inputs=train_model.input, outputs=output) print(model.output_shape) # (None, 1536) model.summary() """ global_max_pooling2d_1 (GlobalM (None, 1536) conv_7b[0][0] ================================ Total params: 54,332,128 Trainable params: 54,274,656 Non-trainable params: 57,472 """ # predict()でデータセット画像の特徴抽出 features = model.predict(ext_img) print(features.shape) # (1564, 1536) # クエリ画像の特徴抽出 query = model.predict(query_imgs) print('query shape:{}'.format(query.shape)) # query shape:(6, 1536)
これでデータセット画像とクエリ画像の特徴抽出ができた
3.PCAで次元削減について
PCAを行うのは計算量を減らすため。クエリ画像が数万枚〜数百万以上のときとか、特徴抽出した特徴量を使い、新しいモデルで再訓練するときに行う。メルカリは多分、特徴抽出してから新しいモデルで再訓練してるから、そのときに次元削減してるかもしれない。
今回はPCAはしないけど、
・クエリ画像が数万枚以上あるとき
・特徴抽出して、新しいモデルで再訓練する
際のコードを試したので、書いとく
・クエリ画像が数万枚以上あるときのPCA実行
""" クエリ画像が数千枚以上ある場合はPCAで圧縮して、高速化(今回はしない) """ from sklearn.decomposition import PCA pca = decomp.PCA(n_components = 700) pca.fit(features) # 主成分分析の結果に基づき、次元削減する。 ex_features = pca.transform(features) print('extracted feature shape {}'.format(ex_features.shape)) E = pca.explained_variance_ratio_ # 寄与率 cumsum_explained = np.cumsum(E)[::-1][0] # 累積寄与率 print('累積寄与率{}'.format(cumsum_explained)) # extracted feature shape (928, 700) # 累積寄与率0.9828264117240906
・特徴抽出後、PCAして(簡易なモデルを使い)再訓練する
# PCA pca = decomp.PCA(n_components = 700) pca.fit(features) ex_features = pca.transform(features) # ラベル ext_label = np.r_[new_bl, nikel, adidasl, vansl] n_labels = len(np.unique(ext_label)) test_y=np.eye(n_labels)[ext_label] test_y=np.reshape(test_y, (len(test_y), 4)) # train ext_model=models.Sequential() ext_model.add(Dense(2048, input_dim=400, activation='relu')) ext_model.add(Dense(1024, activation='relu')) ext_model.add(Dense(512, activation='relu')) ext_model.add(Dense(256, activation='relu')) ext_model.add(Dense(4, activation='softmax')) ext_model.compile(optimizer=SGD(lr=0.01, momentum=0.9, decay=0.001, nesterov=True), loss='categorical_crossentropy', metrics=['accuracy']) history=ext_model.fit(ex_features, test_y, epochs=20, callbacks=callback) """ loss: 1.2685 → loss: 0.0013 acc: 0.4073 → acc: 1.0000 """
4.faissで類似画像検索
あとはクエリ画像と似てる画像をデータセット画像から検索する。jupyter上で試すので、まずfaissのインストール
$ conda install -c pytorch faiss-cpu
クエリ画像とデータセット画像と同じ次元数にする(dim=1536)。
あとは類似画像検索を実行。
import faiss import collections dim = 1536 index = faiss.IndexFlatL2(dim) index.add(features) # Search candidate=10 for q in query: D, I = index.search(np.expand_dims(q, axis=0), candidate) for i in I: print(ext_label[i]) c = collections.Counter(ext_label[i]) print(c)
パーフォマンス結果は以下の感じ
・クエリ画像、そのラベル、そのスニーカータイプ
query_label:[1, 2, 3, 3, 1, 2]
query images name:nike.jpeg, adidas2.jpeg, vans2.jpeg, vans1.jpeg, nikes.jpeg, adidas.jpeg
・各クエリ画像の検索結果
# candidate(検索数)=1のとき [1] Counter({1: 1}) [2] Counter({2: 1}) [3] Counter({3: 1}) [3] Counter({3: 1}) [1] Counter({1: 1}) [0] Counter({0: 1}) # candidate=10のとき(左から類似してる順) [1 1 2 0 2 2 1 1 0 2] Counter({1: 4, 2: 4, 0: 2}) [2 2 2 0 2 2 2 2 2 2] Counter({2: 9, 0: 1}) [3 3 3 3 3 2 3 3 3 3] Counter({3: 9, 2: 1}) [3 1 3 3 3 1 3 3 1 3] Counter({3: 7, 1: 3}) [1 2 0 3 0 1 1 1 1 0] Counter({1: 5, 0: 3, 2: 1, 3: 1}) [0 1 3 3 3 1 2 2 0 0] Counter({0: 3, 3: 3, 1: 2, 2: 2})
スニーカーがドアップのやつはちゃんと検索できてる。最後のスニーカーが2枚写ってるadidasのは、
・データセットに含まれてない
・データセットが足りないか
のどちらかで、多分前者。
結果的に、前回の精度が10%〜50%くらいの画像を、ほぼ間違いなく検索できてる。
画像分類はend-to-endで学習したら終わりだと思ってた。けど「特徴抽出、ファインチューニング、転移学習」などをすることで、より精度が向上しそう。end-to-endで学習することしか画像分類手法としては知らなかったので、今回はいいアウトプットになった。
追記:GlobalAveragePooling2Dでの精度再検証
GlobalAveragePooling2Dは平均プーリング演算で、maxPoolingよりも、メモリ消費量が少なくて、識別率も同じか、少し上回る精度らしい。特徴抽出をGlobalAveragePooling2Dに置き換えて、faissでの精度を再検証してみた。
・特徴抽出部分でGlobalAveragePooling2Dに変える
output = GlobalAveragePooling2D()(feature.output) model = Model(inputs=train_model.input, outputs=output) print(model.output_shape) model.summary() """ # Total paramsはmaxPoolingと変化なし conv_7b (Conv2D) (None, 3, 3, 1536) 3194880 block8_10[0][0] _____________________ global_average_pooling2d_1 (Glo (None, 1536) 0 conv_7b[0][0] ============ Total params: 54,332,128 Trainable params: 54,274,656 Non-trainable params: 57,472 """
・faissでcandidate=10で再検証
[1 0 1 3 2 1 1 0 2 2] Counter({1: 4, 2: 3, 0: 2, 3: 1}) [2 2 2 2 2 2 2 1 2 0] Counter({2: 8, 1: 1, 0: 1}) [3 3 3 3 3 3 3 3 3 3] Counter({3: 10}) [3 3 1 3 3 1 3 3 3 0] Counter({3: 7, 1: 2, 0: 1}) [2 1 1 0 1 0 2 0 0 0] Counter({0: 5, 1: 3, 2: 2}) [2 0 3 0 3 1 3 2 3 2] Counter({3: 4, 2: 3, 0: 2, 1: 1})
query_label:[1, 2, 3, 3, 1, 2]
maxPoolingで間違えてた、「最後の画像=Adidasの2枚写ってる画像」が正確に判断できてる。
代わりに、最後から二番目のnikeの画像が間違えてる、多分ロゴマークが鮮明でないからだと思う。でも上位3つ出す使い方すれば、当ってる事になるし、GlobalAveragePooling2Dの方がパフォーマンス的にベターみたい。
InceptionResNetV2も本来、使ってるのはMaxPoolingではなくGlobalAveragePooling2Dだし、本来の精度出すにはGlobalAveragePooling2D使った方がいい。