多クラス(Multi-class)分類は、複数のクラスに対して、各画像が1つのクラスに属する問題です。各画像が1つずつのクラスに属するのではなく、いくつかのクラスに属する場合を考えます。これを多ラベル(Multi-label)分類といいます。
多クラス分類の記事は多くあっても、多ラベル分類の記事はかなり少ないのでこれを見ていきます。
多クラス分類 vs 多ラベル分類
まずは多クラス分類から。これは単純で、クラスが複数(下の例の場合は4)あり、サンプルに対してどれかのクラス1つに対して割り当てるように最適化しなさいという問題。MNISTやCIFARなどはこれにあたります。
次は多ラベル問題ですが、クラスが複数あるというのは変わりないのですが、多クラス問題から「サンプルに対してクラスは1つ」という制約を取り除きます。つまり、サンプルに対してクラスを何個も割り当てて良いということになります。
今サンプルとして2つの画像を同時に入力させています。1番目のように自動車の画像を2枚入れてもいいですし、2番目以降のように異なるクラスの画像をつなぎ合わせて入れてもいいです。つまり、サンプルが属するクラスの数は必ず1つという保証がありません。
1番目のラベルは「2 0 0 0」ではないかという疑問もありますが、これは問題設定によって変えられて、「2 0 0 0」のようにクラス単位の数を数えるような回帰問題に変更することもできます。しかし、この記事ではそのクラスに含まれるか含まれないかという「0,1問題」で解きたいため、1番目のように同一クラスが複数枚あっても今回は1としてカウントします(つまり「2 0 0 0」ではなく「1 0 0 0」とします)。枚数を数え上げるような問題も面白いので興味があったらやってみてください。
多ラベル問題の実装上のポイント
出力層の活性化関数をsoftmaxではなくsigmoidにする
例えば多クラス分類ではKerasでの表記だと出力層は次のようになっていました。
x = Dense(10, activation="softmax")(x)
多ラベル問題ではこのsoftmaxをsigmoid関数に置き換えます。
x = Dense(10, activation="sigmoid")(x)
これは明確で、softmaxの関数の定義が、sigmoidの和で割ったものだからです。softmax関数$y_i(1\leq i\leq n)$の定義1は、
$$y_i=\frac{e^{x_i}}{e^{x_1}+e^{x_2}+\cdots+e^{x_n}} $$
でした。softmax$y_i$の合計は必ず1になりますが、先程みたように、多ラベル分類では、クラスあたりの出力の和が1になる保証なんてどこにもありません。これが出力層の活性化関数をsoftmaxにしてはいけない理由です。
softmaxではダメというだけであって、問題によってはtanhでもいいと思いますよ。
binary_crossentropyを使う
損失関数は多クラス分類ではcategorical_crossentropyを使いましたが、多ラベル問題ではクラスが複数あってもbinary_crossentropyを使います。二値分類の問題に戻るわけです。より正確にいえば、多ラベル問題でcategorical_crossentropyを使うのが間違いで、後で確認しますが、categorical_crossentropyを使うと明確に精度が落ちます。多ラベル分類での損失関数は、別にbinary_crossentropyでなくてはいけないというわけではなく、mean_squared_errorでもOKなはずです。
確率の推定ではなく、白黒の画像を出力するものとして考えるとわかりやすい
ラベルだの確率だのと考えるややこしくなりますが、「白黒画像を出力する問題」として考えるとわかりやすいのではないでしょうか。
今「1=白」「0=黒」で塗りつぶしてみました。つまり、この場合は入力画像(例えば64×32)を4ピクセル(1×4)の白黒画像にマッピングしなさい、という問題に置き換えることができます。
このような問題は、画像を出力するようなネットワーク(AutoEncoderや超解像、Semantic Segmentation)ではよく出てきます。これとのアナロジーで考えると、binary_crossentropyやmean_squared_errorを損失関数として使う理由がわかってくるのではないでしょうか。
CIFAR-10から多ラベル分類用のデータを作る
多ラベル分類のいいデータがなかったので、CIFAR-10からデータを作りました。具体的には、CIFAR-10の32×32の画像をカテゴリー別に上下左右4枚タイルして64×64の画像を作り、それを分類します。どのカテゴリーを使うか、どのサンプルを使うかは乱数で決めます。
from keras.datasets import cifar10
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
def combine_single_image(X, y):
out_image = np.zeros((64,64,3), dtype=np.uint8)
out_label = np.zeros(10, dtype=np.float32)
category_idx = np.random.permutation(np.arange(10))[:4]
for i, category in enumerate(category_idx):
filters = np.where(y==category)[0]
item_idx = np.random.permutation(filters)[0]
if i == 0:
out_image[:32, :32, :] = X[item_idx, :, :, :]
elif i == 1:
out_image[:32, 32:, :] = X[item_idx, :, :, :]
elif i == 2:
out_image[32:, :32, :] = X[item_idx, :, :, :]
else:
out_image[32:, 32:, :] = X[item_idx, :, :, :]
out_label[category] = 1.0
return out_image, out_label
def demo():
(X_train, y_train), (_, _) = cifar10.load_data()
X, y = combine_single_image(X_train, y_train)
plt.imshow(X)
plt.title(y)
plt.show()
このdemo()を実行すると次のように出てきます。乱数で選んでいるので実行時によって変わります。
これは飛行機(0)、犬(5)、馬(7)、船(8)の合成画像です。このような画像を訓練5万枚、テスト1万枚作ります。画像ソースはそれぞれCIFAR-10の訓練画像、テスト画像から作ります2。
データ作成はこのようにしました。PandasのDataFrameにして保存しています。
def create_data(X, y, num):
df = pd.DataFrame(columns=["Image", "Label"])
for i in range(num):
if i % 100 == 0:
print(i)
X_item, y_item = combine_single_image(X, y)
df.loc[i] = [X_item, y_item]
return df
def create_train_test():
(X_train, y_train), (X_test, y_test) = cifar10.load_data()
df_train = create_data(X_train, y_train, 50000)
df_test = create_data(X_test, y_test, 10000)
df_train.to_msgpack("multilabel_train.pd", compress="zlib")
df_test.to_msgpack("multilabel_test.pd", compress="zlib")
評価関数の作成
多ラベル分類ではそのまま一番確率が大きいのを推定ラベルとは取れないので、自分で評価関数を作ることになります。ここでは2つの評価関数を考えました。ここでの評価関数は全部自分で考えたものなので、もしかしたらもっといい尺度があるかもしれません。
1つ目は完答の精度(total accuracy)。真の値が「0 1 1 0」で、予測値が「0 1 1 1」と1つでも違ったら間違い(0)とみなします。部分点は一切考えずに完答したかどうかで見ます。これはクラス間の一致に対して積を取ることで再現できます。
def total_acc(y_true, y_pred):
pred = K.cast(K.greater_equal(y_pred, 0.5), "float")
flag = K.cast(K.equal(y_true, pred), "float")
return K.prod(flag, axis=-1)
2つ目は部分点ありの精度(binary accuracy)。真の値が「0 1 1 0」で、予測値が「0 1 1 1」なら、4つ中3つが合っているので0.75とみなします。これはkeras.metrics.binary_accuracy()
と同じです。完答の場合は積だったのに対して、部分点ありの場合は平均で集計します。
def binary_acc(y_true, y_pred):
pred = K.cast(K.greater_equal(y_pred, 0.5), "float")
flag = K.cast(K.equal(y_true, pred), "float")
return K.mean(flag, axis=-1)
人間でもそうですが、完答のほうが難しいので、完答精度のほうが低くなります。
訓練
損失関数をbinary_crossentropy, categorical_crossentropy(間違い)と変えて実験してみました。binary_crossentropyの場合は、バックエンド関数のほうのbinary_crossentropyを使い、それをラベル軸で和を取っています。
from keras.layers import Conv2D, BatchNormalization, Activation, Input, AveragePooling2D, GlobalAveragePooling2D, Dense
from keras.models import Model
from keras.callbacks import History
from keras.optimizers import SGD
import keras.backend as K
import pandas as pd
import numpy as np
import pickle
# モデル
def create_block(input, ch, reps):
x = input
for i in range(reps):
x = Conv2D(ch, 3, padding="same")(x)
x = BatchNormalization()(x)
x = Activation("relu")(x)
return x
def create_network():
input = Input((64,64,3))
x = create_block(input, 32, 3)
x = AveragePooling2D(2)(x)
x = create_block(x, 64, 3)
x = AveragePooling2D(2)(x)
x = create_block(x, 128, 3)
x = AveragePooling2D(2)(x)
x = create_block(x, 256, 3)
x = GlobalAveragePooling2D()(x)
x = Dense(10, activation="sigmoid")(x)
return Model(input, x)
# ジェネレーター
def generator(df_path, batch_size):
df = pd.read_msgpack(df_path)
df = df.values
while True:
img_cahce, label_cache = [], []
indices = np.arange(df.shape[0])
np.random.shuffle(indices)
for i in indices:
img_cahce.append(df[i, 0])
label_cache.append(df[i, 1])
if len(img_cahce) == batch_size:
X_batch = np.asarray(img_cahce, np.float32) / 255.0
y_batch = np.asarray(label_cache, np.float32)
img_cahce, label_cache = [], []
yield X_batch, y_batch
# 損失関数
def categorical_loss(y_true, y_pred):
return K.categorical_crossentropy(y_true, y_pred)
def binary_loss(y_true, y_pred):
bce = K.binary_crossentropy(y_true, y_pred)
return K.sum(bce, axis=-1)
# 評価関数
def total_acc(y_true, y_pred):
pred = K.cast(K.greater_equal(y_pred, 0.5), "float")
flag = K.cast(K.equal(y_true, pred), "float")
return K.prod(flag, axis=-1)
def binary_acc(y_true, y_pred):
pred = K.cast(K.greater_equal(y_pred, 0.5), "float")
flag = K.cast(K.equal(y_true, pred), "float")
return K.mean(flag, axis=-1)
def train(use_binary_loss):
model = create_network()
if use_binary_loss:
model.compile(SGD(0.01, 0.9), loss=binary_loss, metrics=[total_acc, binary_acc])
else:
model.compile(SGD(0.01, 0.9), loss=categorical_loss, metrics=[total_acc, binary_acc])
hist = History()
batch_size = 256
model.summary()
model.fit_generator(generator("multilabel_train.pd", batch_size), steps_per_epoch=50000//batch_size,
validation_data=generator("multilabel_test.pd", batch_size), validation_steps=10000//batch_size,
callbacks=[hist], epochs=1)
history = hist.history
with open(f"multiclass_binary_{use_binary_loss}.dat", "wb") as fp:
pickle.dump(history, fp)
if __name__ == "__main__":
train(True)
VGGライクな13層モデルで訓練しました。
結果
categorical_crossentropy(間違い), binary_crossentropy(正しい)の順です。
categorical_crossentropy(間違い)を使った場合
一応これでもエラーは出ないのですが、精度が低いです。categorical_crossentropyの場合のValidationの完答精度が38.94%、部分点ありの精度89.86%であることを覚えておきます。
しかもロスも良くないです。普通は、訓練誤差は0に近くなるのですが、categorical_crossentropyの場合は5ぐらいで高止まりしています。これはおかしいです。
binary_crossentropy(正しい)を使った場合
binary_crossentropyに変えると、Validationの完答精度が49.19%、部分点ありの精度が91.16%となりました。完答精度が10%以上上がっています。損失関数を適切なものにしただけでこれだけ変わります。
こちらはロスもいい感じで、訓練誤差はちゃんと0近くなっています。
つまり、多ラベル分類でcategory_crossentropyを使うのは間違いということです。
まとめ
Kerasで1つの画像が複数のクラスに属する場合、つまり多ラベルの場合の分類を実装することができました。気をつけるのは2点です。
- 出力層をシグモイド関数にする
- 損失関数にcategorical_crossentropyを使ってはいけなく、binary_crossentropyやmean_squared_errorを使う
損失関数については他にもHamming lossやFocal lossを使うといった例があるそうです。参考
-
重複を一切考えてないランダムサンプリングなので、もしかすると完全に同一の合成画像がデータ内にできてしまうかもしれません。しかし、テスト画像1万内で、1クラス1000枚だとしたら、完全に同一のサンプルができてしまう確率は1/1000の3乗なので、確率的には限りなく小さいので無視してもいいのではないかなと思います。 ↩