「くずし字」識別チャレンジ

β版ProbSpaceコンペ第2弾!

賞金: 100,000 参加ユーザー数: 181 5年弱前に終了

ここからはじめよう

データサイエンスや機械学習の知識を活かして、コンペティションに挑戦してみよう.

コンペの説明

画像データを扱う練習として,日本で古くから使われていた「くずし字」を識別しよう.くずし字のクラスは10種類あるため,多クラス分類モデルを作成します.

賞金

1位 100,000円
※ 対象者には、コンペティション終了後 メールにてご連絡いたします。

必要なスキル

  • Pythonの基本
  • 機械学習による多クラス分類

ダウンロード

データをダウンロードするにはログインまたはユーザー登録して下さい

概要

KMNIST(Kuzushiji-MNIST)は,日本語の「くずし字」のデータセットです.
国文学研究資料館(National Institute of Japanese Literature/ NIJL)が作成し,人文学オープンデータ共同利用センター(Center for Open Data in the Humanities / CODH)が公開しています.

各くずし字の画像データは,28×28の大きさで,グレースケールの画像データです.
くずし字の種類は,10種類で,お(o),き(ki),す(su),つ(tsu),な(na),は(ha),ま(ma),や(ya),れ(re),を(wo)の10文字が含まれています.
データ数は,訓練データが60000枚,テストデータが10000枚の計70000枚です.

より詳細については,以下を参照してください.
https://arxiv.org/abs/1812.01718

メトリック

このコンペティションは,accuracyによって評価されます.つまり,正解した予測と,全ての予測の比率です.
kmnist-test-imgs.npzに対して作成したモデルで予測を行い,その結果を次のフォーマットのcsvファイルで提出してください.

提出ファイルのフォーマット

以下のフォーマットで提出します.

ImageId,Label
1, 0
2, 1
3, 8
...

参加者ごとに1つのアカウント

複数のアカウントを使ってコンペティションに参加することはできません。

私的な情報共有の禁止

コードやデータを,特定のコンペティション参加者に個人的に共有することはできません。コンペティションの全ての参加者が利用できるような状態であれば、コードを共有することは問題ありません。

送信制限

1日あたり最大5件の応募が可能です。
審査のために最終提出を5件まで選択することができます。

コンペティションのタイムライン

開始日 2019/4/9 0:00 JST
エントリー締め切り なし
終了日 2019/6/17 0:00 JST

賞金の支払い

優勝者は、以下の条件を満たすことで賞金を受け取ることができます。

  1. モデルの提出
    最終成果物であるcsvの作成に使用した、学習済みモデルをご提出いただきます。提出後、弊社側で不正がないことを確認します。
    ※不正が疑われる場合は、コンペティション開催中であってもモデル提出を依頼する場合があります
  2. モデルの説明
    ディスカッション&ナレッジにて、学習済みモデルの解説をお願いします。
    また解説では、学習済みモデルのソースコード(ファイル)を添付するか、ソースコードへのリンク(URL)を貼り付けてください。

外部データの使用は禁止

本コンペティションで公開されているデータのみを用いてチャレンジして下さい。また、学習済みモデルを用いることも外部データを用いているとみなします(FAQより転載)。

運営からのお願い

公平性の担保、チーティング等の不正防止のため、β版のコンペでは、予告なくルールの追加・変更を行う場合がございます。
ご不便をおかけすることもあるかと思いますが、サービス向上のためご了承ください。

このコンペティションでは賞金はでますか?

はい。最も精度の高い学習モデルを作成した優勝者には、賞金10万円を贈呈します。ただし、賞金を受け取るにあたっては、学習済みモデルの提出、解説の作成が必要となります。

チームで参加できますか?

可能です。現時点ではチームとして参加する機能は未実装のため、参加者ごとに同じ結果を提出いただいて構いません。
※ 同率スコアの場合は提出時間の早いユーザーが上位となりますこと、ご了承ください 。

外部データを使うことは可能ですか?

本コンペティションで公開されているデータのみを用いてチャレンジして下さい。また、学習済みモデルを用いることも外部データを用いているとみなします。

どこでアカウントをつくればいいですか?

こちらから作っていただけます。
アカウントを作らないと、コンペティションに参加することはできないので気をつけてください。

コードを提出するにあたって Seed を固定する必要はありますか?

Seed を固定することが推奨です.ただし,Seed を固定しなくても提出用コードとしては認めていく方針です。

概要

このチュートリアルでは,kmnistデータに対して,

  • データの読み込み
  • データをプロットして確認
  • 前処理
  • kerasを用いてCNNモデルの作成,学習
  • 後識別したデータを確認

を行います.

環境

  • python 3.6.8
  • tensorflow 1.13.1
  • numpy 1.16.1
  • matplotlib 3.0.3

データのロード

まずはデータの読み込みをしてみましょう.

import numpy as np
import os

class KMNISTDataLoader(object):
    """
    Example
    -------
    >>> kmnist_dl = KMNISTDataLoader()
    >>> datapath = "./data"
    >>> train_imgs, train_lbls, validation_imgs, validation_lbls = kmnist_dl.load(datapath)
    """
    def __init__(self, validation_size: float):
        """
        validation_size : float
        [0., 1.]
        ratio of validation data
        """
        self._basename_list = [
        'kmnist-train-imgs.npz',\
        'kmnist-train-labels.npz'
        ]
        self.validation_size = validation_size

    def load(self, datapath: str, random_seed: int=13) -> np.ndarray:
        filenames_list = self._make_filenames(datapath)
        data_list = [np.load(filename)['arr_0'] for filename in filenames_list]

        all_imgs, all_lbls = data_list

        # shuffle data
        np.random.seed(random_seed)
        perm_idx = np.random.permutation(len(all_imgs))
        all_imgs = all_imgs[perm_idx]
        all_lbls = all_lbls[perm_idx]

        # split train and validation
        validation_num = int(len(all_lbls)*self.validation_size)

        validation_imgs = all_imgs[:validation_num]
        validation_lbls = all_lbls[:validation_num]

        train_imgs = all_imgs[validation_num:]
        train_lbls = all_lbls[validation_num:]

        return train_imgs, train_lbls, validation_imgs, validation_lbls

    def _make_filenames(self, datapath: str) -> list:
        filenames_list = [os.path.join(datapath, basename) for basename in self._basename_list]
        return filenames_list

データのフォーマットが.npzなので,numpynp.load関数を使って読み込みます.

それ以外のコードは,データを保存した場所(datapath)を渡すだけで,そこから読み込んでくれるようにするための処理です.

ここで定義したクラスを使うことで,以下のようにしてデータをロードすることができます.

datapath = "./data"
validation_size = 0.2
train_imgs, train_lbls, validation_imgs, validation_lbls = KMNISTDataLoader(validation_size).load(datapath)

validation_sizeではテストデータの比率を指定しており,ここでは2割のデータをテストデートとして扱っています.

プロットしてみよう

データを各クラスごとに,どんな画像データなのか表示してみます.
ここではプロットにmatplotlibを用います.

import numpy as np
import matplotlib.pyplot as plt

class RandomPlotter(object):
    def __init__(self):
        self.label_char = ["お(o)", "き(ki)", "す(su)", "つ(tsu)",\
                           "な(na)", "は(ha)", "ま(ma)", "や(ya)",\
                           "れ(re)", "を(wo)"]
        plt.rcParams['font.family'] = 'IPAPGothic'

    def _get_unique_labels(self, labels: np.ndarray) -> np.ndarray:
        label_unique = np.sort(np.unique(labels))
        return label_unique

    def _get_random_idx_list(self, labels: np.ndarray) -> list:
        label_unique = self._get_unique_labels(labels)

        random_idx_list = []
        for label in label_unique:
            label_indices = np.where(labels == label)[0]
            random_idx = np.random.choice(label_indices)
            random_idx_list.append(random_idx)

        return random_idx_list

    def plot(self, images: np.ndarray, labels: np.ndarray) -> None:
        """
        Parameters
        ----------
        images : np.ndarray
        train_imgs or validation_imgs

        labels : np.ndarray
        train_lbls or validation_lbls
        """
        random_idx_list = self._get_random_idx_list(labels)

        fig = plt.figure()
        for i, idx in enumerate(random_idx_list):
            ax = fig.add_subplot(2, 5, i+1)
            ax.tick_params(labelbottom=False, bottom=False)
            ax.tick_params(labelleft=False, left=False)
            img = images[idx]
            ax.imshow(img, cmap='gray')
            ax.set_title(self.label_char[i])
        fig.show()

このコードでは,各クラスについて一つずつランダムにデータを取り出して,それをプロットしています.

__init__()plt.rcParams['font.family'] = 'IPAPGothic'では,matplotlibのフォントを日本語に対応したものに変更しています.
もしもこのフォントがないとエラーが出る場合は,このフォントを入れるか,すでにある別の日本語対応フォントに変更してください.

_get_random_idx_list()では,各クラスごとにランダムにデータのインデックスを抜き出しています.

plot()内が実際に画像をプロットするコードで,matplotlibimshowを用いて表示しています.

ここで定義したクラスを用いると,以下のようにしてデータをプロットすることができます.

RandomPlotter().plot(train_imgs, train_lbls)
RandomPlotter().plot(validation_imgs, validation_lbls)

出力は以下のようになり,どういったくずし字データなのか確認できます.

Figure_1.png

前処理

データの前処理を行います.

ここでは,画像データに対しては,

  • 数値データの型をfloat32へ変更
  • 画像のndarrayのshapeを(N, 28, 28)から(N, 28, 28, 1)に変更(Nは画像の枚数)
  • 値を[0, 255]から[0, 1]に標準化
    を行います.

kmnistのデータは1枚あたり(28, 28)のサイズですが,カラーの画像データであればRGBの三色で(28, 28, 3)の多次元配列で表されます.kmnistのデータが白黒で1次元なので,省略された(28, 28, 1)を補っています.

ラベルデータに対しては,

  • 0から9のintで表されたラベルを,one-hot表現に変更
    を行います.
import numpy as np
from tensorflow.keras.utils import to_categorical

class Preprocessor(object):
    def transform(self, train_imgs, train_lbls, validation_imgs, validation_lbls):
        train_imgs, validation_imgs = self._convert_imgs_dtypes(train_imgs, validation_imgs)
        train_imgs, validation_imgs = self._convert_imgs_shape(train_imgs, validation_imgs)
        train_imgs, validation_imgs = self._normalize(train_imgs, validation_imgs)

        train_lbls, validation_lbls = self._to_categorical_labels(train_lbls, validation_lbls)
        return train_imgs, train_lbls, validation_imgs, validation_lbls

    def _convert_imgs_dtypes(self, train_imgs, validation_imgs):
        _train_imgs = train_imgs.astype('float32')
        _validation_imgs = validation_imgs.astype('float32')
        return _train_imgs, _validation_imgs

    def _convert_imgs_shape(self, train_imgs, validation_imgs):
        _train_imgs = train_imgs[:,:,:,np.newaxis]
        _validation_imgs = validation_imgs[:,:,:,np.newaxis]
        return _train_imgs, _validation_imgs

    def _normalize(self, train_imgs, validation_imgs):
        _train_imgs = train_imgs / 255.0
        _validation_imgs = validation_imgs / 255.0
        return _train_imgs, _validation_imgs

    def _to_categorical_labels(self, train_lbls, validation_lbls):
        label_num = len(np.unique(train_lbls))
        _train_lbls = to_categorical(train_lbls, label_num)
        _validation_lbls = to_categorical(validation_lbls, label_num)
        return _train_lbls, _validation_lbls

ここで定義したコードを用いると,以下のように前処理を行うことができます.

train_imgs, train_lbls, validation_imgs, validation_lbls = Preprocessor().transform(train_imgs, train_lbls, validation_imgs, validation_lbls)

識別してみよう

DNNのフレームワークであるkerasを用いて簡易なCNNを作成して識別してみましょう.
kerasはtensorflowに統合され,tensorflowの高レベルAPIとなっているので,tensorflowからインポートします.
(tensorflow.kerasがないというエラーが出た場合は,古いバージョンのtensorflowを使用している可能性があるので,tensorflowをアップデートしてみてください.)

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras.models import Sequential
from tensorflow.keras import layers
from tensorflow.keras import backend as K

# dataの準備
datapath = "./data"
train_imgs, train_lbls, validation_imgs, validation_lbls = KMNISTDataLoader(validation_size).load(datapath)

train_imgs, train_lbls, validation_imgs, validation_lbls = Preprocessor().transform(train_imgs, train_lbls, validation_imgs, validation_lbls)

# modelの設定
batch_size = 128
label_num = 10
epochs = 5
input_shape = (28, 28, 1)


# model作成
model = Sequential()

model.add(layers.Conv2D(32, kernel_size=(3, 3),\
activation='relu',\
input_shape=input_shape))
model.add(layers.MaxPooling2D(pool_size=(2, 2)))
model.add(layers.Dropout(0.25))

model.add(layers.Conv2D(64, kernel_size=(3, 3),\
activation='relu'))
model.add(layers.MaxPooling2D(pool_size=(2, 2)))
model.add(layers.Dropout(0.4))

model.add(layers.Flatten())
model.add(layers.Dense(128, activation='relu'))
model.add(layers.Dense(label_num, activation='softmax'))

loss = keras.losses.categorical_crossentropy
optimizer = keras.optimizers.Adam(lr=0.001, beta_1=0.9, beta_2=0.999)
model.compile(loss=loss, optimizer=optimizer, metrics=['accuracy'])

# modelを学習する
model.fit(train_imgs, train_lbls,
    batch_size=batch_size,
    epochs=epochs,
    verbose=1,
    validation_data=(validation_imgs, validation_lbls))

# modelを評価する
train_score = model.evaluate(train_imgs, train_lbls)
validation_score = model.evaluate(validation_imgs, validation_lbls)
print('Train loss :', train_score[0])
print('Train accuracy :', train_score[1])
print('validation loss :', validation_score[0])
print('validation accuracy :', validation_score[1])

convolution+poolingのレイヤーを二段重ねたモデルです.
CNNを用いた画像の識別モデルの中ではシンプルな部類に入ると思いますが,95から97%ほどのaccになります.
これをベースラインとして改善してみましょう.

誤識別したデータの確認

95から97%の精度と聞くと良さそうに聞こえますが,残りがどんなデータなのか気になってきます.
そこで,誤識別した画像に限ってプロットしてみましょう.

class MisclassifiedDataPlotter(object):
    """
    このクラスへの入力はpreprocess処理済みのデータを仮定する.
    """
    def __init__(self):
        self.label_char = ["お(o)", "き(ki)", "す(su)", "つ(tsu)",\
        "な(na)", "は(ha)", "ま(ma)", "や(ya)",\
        "れ(re)", "を(wo)"]
        plt.rcParams['font.family'] = 'IPAPGothic'

    def _convert_onehot2intvec(self, labels):
        labels_int_vec = np.argmax(labels, axis=1)
        return labels_int_vec

    def _get_mixclassified_idx_list(self, labels_intvec, pred_labels_intvec):
        misclassified = labels_intvec != pred_labels_intvec
        mis_idxs_list = np.where(misclassified == True)[0]

        return mis_idxs_list

    def plot(self, images, labels, pred_labels, plot_num: int=5):
        """
        >>> images.shape
        (10000, 28, 28, 1)

        >>> labels
        array([[0., 0., 1., ..., 0., 0., 0.],
        ...,
        [0., 0., 1., ..., 0., 0., 0.]], dtype=float32)

        >>> pred_labels
        array([[2.8434190e-06, 4.1375683e-06, 9.9899501e-01, ..., 3.7393205e-05,
        2.5519948e-05, 3.0874473e-04],
        ...,
        [4.1747628e-09, 1.8852282e-07, 9.9982470e-01, ..., 1.4897050e-07,
        7.9116326e-05, 3.7392081e-05]], dtype=float32)
        """
        labels_intvec = self._convert_onehot2intvec(labels)
        pred_labels_intvec = self._convert_onehot2intvec(pred_labels)

        mis_idxs_list = self._get_mixclassified_idx_list(labels_intvec, pred_labels_intvec)
        random_idx_list = list(np.random.choice(mis_idxs_list, size=plot_num, replace=False))

        fig = plt.figure()
        for i, idx in enumerate(random_idx_list):
            ax = fig.add_subplot(1, plot_num, i+1)
            ax.tick_params(labelbottom=False, bottom=False)
            ax.tick_params(labelleft=False, left=False)
            img = images[idx].reshape((28,28)) # (28, 28, 1) -> (28, 28)
            ax.imshow(img, cmap='gray')

            actual_label = self.label_char[labels_intvec[idx]]
            pred_label = self.label_char[pred_labels_intvec[idx]]
            ax.set_title(f"{pred_label} : actual {actual_label}")
        fig.show()

このコードは,前処理をした後のデータを入力すると仮定しています.
つまり,画像データは(28, 28)から(28, 28, 1)に変更され,ラベルがone-hot表現になったデータです.

ここで定義したクラスを用いると,以下のようにして誤識別した画像を確認できます.

prediction = model.predict(validation_imgs) # このmodelは上で作成したkerasのCNNです.

mis_plotter = MisclassifiedDataPlotter()
mis_plotter.plot(validation_imgs, validation_lbls, prediction, plot_num=5)

Figure_2.png

右から二番目の本来は「な」である画像を「れ」と認識しているケースなどは,人間の目で見ても識別が難しいと思われます.