1つの画像が複数のクラスに属する場合（Multi-label）の画像分類

多クラス（Multi-class）分類は、複数のクラスに対して、各画像が1つのクラスに属する問題です。各画像が1つずつのクラスに属するのではなく、いくつかのクラスに属する場合を考えます。これを**多ラベル（Multi-label）分類と**いいます。

多クラス分類の記事は多くあっても、多ラベル分類の記事はかなり少ないのでこれを見ていきます。

## 多クラス分類 vs 多ラベル分類
まずは**多クラス分類**から。これは単純で、クラスが複数（下の例の場合は4）あり、**サンプルに対してどれかのクラス1つに対して割り当てるように**最適化しなさいという問題。MNISTやCIFARなどはこれにあたります。

![multilabel_01.png](https://qiita-image-store.s3.amazonaws.com/0/232088/80089aa7-0771-1690-da15-96d0b86b650a.png)

次は**多ラベル問題**ですが、クラスが複数あるというのは変わりないのですが、**多クラス問題から「サンプルに対してクラスは1つ」という制約を取り除きます**。つまり、サンプルに対してクラスを何個も割り当てて良いということになります。

![multilabel_02.png](https://qiita-image-store.s3.amazonaws.com/0/232088/9306f5ce-7d5e-9170-b066-c0c0f68073ad.png)

今サンプルとして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での表記だと出力層は次のようになっていました。

```python:多クラス分類
    x = Dense(10, activation="softmax")(x)
```

多ラベル問題ではこのsoftmaxを**sigmoid関数**に置き換えます。


```python:多ラベル分類
    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でもいいと思いますよ。

[^1]: https://mathtrain.jp/softmax

### binary_crossentropyを使う
損失関数は多クラス分類ではcategorical_crossentropyを使いましたが、多ラベル問題ではクラスが複数あってもbinary_crossentropyを使います。二値分類の問題に戻るわけです。より正確にいえば、**多ラベル問題でcategorical_crossentropyを使うのが間違い**で、後で確認しますが、categorical_crossentropyを使うと明確に精度が落ちます。多ラベル分類での損失関数は、別にbinary_crossentropyでなくてはいけないというわけではなく、mean_squared_errorでもOKなはずです。

### 確率の推定ではなく、白黒の画像を出力するものとして考えるとわかりやすい
ラベルだの確率だのと考えるややこしくなりますが、「白黒画像を出力する問題」として考えるとわかりやすいのではないでしょうか。

![multilabel_03.png](https://qiita-image-store.s3.amazonaws.com/0/232088/950887a2-7130-7947-01e4-f2352dfca41e.png)

今「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の画像を作り、それを分類します。どのカテゴリーを使うか、どのサンプルを使うかは乱数で決めます。

```python
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()を実行すると次のように出てきます。乱数で選んでいるので実行時によって変わります。

![multilabel_04.png](https://qiita-image-store.s3.amazonaws.com/0/232088/33214345-93fc-327f-2574-78cd458418ba.png)

これは飛行機（0）、犬（5）、馬（7）、船（8）の合成画像です。**このような画像を訓練5万枚、テスト1万枚作ります**。画像ソースはそれぞれCIFAR-10の訓練画像、テスト画像から作ります[^2]。

[^2]: 重複を一切考えてないランダムサンプリングなので、もしかすると完全に同一の合成画像がデータ内にできてしまうかもしれません。しかし、テスト画像1万内で、1クラス1000枚だとしたら、完全に同一のサンプルができてしまう確率は1/1000の3乗なので、確率的には限りなく小さいので無視してもいいのではないかなと思います。

データ作成はこのようにしました。PandasのDataFrameにして保存しています。

```python
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）とみなします。部分点は一切考えずに完答したかどうかで見ます。これはクラス間の一致に対して積を取ることで再現できます。

```python
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()```と同じです。完答の場合は積だったのに対して、部分点ありの場合は平均で集計します。

```python
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を使い、それをラベル軸で和を取っています。

```python
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（間違い）を使った場合
![multilabel_05.png](https://qiita-image-store.s3.amazonaws.com/0/232088/a5619d39-43e1-914c-198e-0af88bd6a995.png)

一応これでもエラーは出ないのですが、精度が低いです。**categorical_crossentropyの場合のValidationの完答精度が38.94%、部分点ありの精度89.86%**であることを覚えておきます。

しかもロスも良くないです。普通は、訓練誤差は0に近くなるのですが、categorical_crossentropyの場合は5ぐらいで高止まりしています。これはおかしいです。

### binary_crossentropy（正しい）を使った場合
![multilabel_06.png](https://qiita-image-store.s3.amazonaws.com/0/232088/a93c0f24-16b8-00e2-da57-bb712858ed16.png)

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](https://www.slideshare.net/DeepLearningJP2016/dlfocal-loss-for-dense-object-detection)を使うといった例があるそうです。[参考](https://github.com/keras-team/keras/issues/10371)