薬剤師のプログラミング学習日記

プログラミングやコンピュータに関する記事を書いていきます

自分の手書き数字のデータセットを作る

自分で集めた手書きの数字画像からデータセットを作りました。MNISTのように、手軽に自分のモデルの学習に使うことができるようにプログラムを書きました。


数字画像データを人手でラベル付けする

ここで用いる手書き数字画像は、機械学習の数字認識を使って数を集計するちょっとしたアプリケーションから集めたものです。
詳細についてはよろしければこちらをご参照ください。

www.yakupro.info


以下のようなフォルダ構成にして、数字画像をひとつずつ目で見て0~9のフォルダに振り分けていきます。
(実は、自分で集めた数字画像に対してもMNIST学習のCNNの精度はかなり高そうだということがこの時点でわかっていました。なので、先に「認識結果によって自動でファイルを移動させるような分類ツール」を作り、その結果を後で自分が修正する、とした方が時間と手間は短縮できたと思います。もっと考えてから行動すればよかったと、反省。)
f:id:enokisaute:20210410193004p:plain

各ラベル毎のデータ数を見てみると次のようでした。
f:id:enokisaute:20210411150317p:plain
データの総数は全部で5399、0と1が圧倒的に多くてこの2つで43%くらいを占めています。なお、数字を書いた人の人数は10人くらいです。

データ数について言うと、MNISTでは訓練とテストを合わせて70000、特徴量の数が違います*1がsklearnの数字画像データセットでは1797個です。

今回特徴量はMNISTと同じ28×28を想定しているので、データ数としては少ないかもしれませんが、一から機械学習システムを作りたいわけでなく、MNISTで既に学習したモデルを使ったうえでさらに学習、くらいに考えているのでとりあえずプログラム中でデータセットとして使える形にしようと思います。

データ拡張(Data Augmentation)

これ以上データを収集することなくデータセットを作るにしてもラベル(クラス)によって偏りがあるのが気になります。このような偏りのあるデータセットで学習したモデルでは、特定のラベルだけ認識精度が良くない、というようなバランスの悪い分類器ができるということにもなりかねません。
MNISTでもラベル間で最大20%くらいの差はありますが、だいたい均等になっています。
f:id:enokisaute:20210411145134p:plain
そこで、元々の画像データに拡大や縮小、回転等の歪みを人工的に加えてデータを水増しするData Augmentationという手法があり、これを用いて画像の枚数を増やすことにします。
f:id:enokisaute:20210417131201p:plain
人間にとってはそれほど大きな違いでなくても、学習モデルにとっては異なる画像(入力)となるため、簡単な手法でありながら認識精度を向上させるための有効なテクニックのひとつです。

しかし、水増しの前に考えなくてはいけないことは、実際のデータで観測する場合があると想定できるようなノイズや歪みでなくては意味がない、ということです。たとえば、数字画像の場合でいうと、左右が非対称の「5」や「3」などでの左右反転、90度回転させたりするような純粋にランダムな拡張は(今回のケースでは)役に立ちません。

ちなみに、データのラベル付けの際にも、次のようなものは分類する必要はないとしてデータからは除外しています(実際にはあったものですが)。書き直しをしている、数字を消している、大きく見切れている、等です。
f:id:enokisaute:20210411113040p:plain
一方で、実際にありそうな(実データにおいても多くみられると想定される)もの、たとえば、以下のような少し端が切れている程度のもの、文字のかすれ等によるノイズらしきものが入っている、継ぎ足して書いているが人間が読むことが難しくないもの、等はデータとして含めるようにしています。
f:id:enokisaute:20210411115652p:plain
もっとも、これらは28×28に縮小する前の画像なので、少々の細かい違いはリサイズの過程で吸収されるかもしれませんが。

画像を水増ししてラベル間でのデータの偏りをなくす

各ラベル画像で足りない分はデータ拡張で水増しして一定の任意の数に揃えられる*2ようなプログラムを書いてみました。

概要は次の通りです。

  • 各ラベル画像を設定した任意の数(今回は1000とした)に揃える
  • 足りないラベル画像は一旦1000以上になるようデータ拡張で水増しするが、元々(オリジナル)の画像を残すようにして、水増しした画像の中からランダムに削除することで1000にする
  • 元々の画像数が1000を超えている場合は水増しはせずランダムに削除して1000にする
import os
import random
from keras.preprocessing.image import ImageDataGenerator, load_img, img_to_array
 
# 各ラベルのファイルを水増しして設定した数に揃える
 
# 画像ファイル1枚あたりを何倍に拡張するかを求める
# file_num: ラベルのファイル数, s: 揃えたい任意の数
def calc_extend_num(file_num, s):
    return int((s + file_num - 1) / file_num) - 1
 
# filelistの中からランダムにnum個ファイルを削除する
def delete_file(filelist, num):
    delete_filelist = random.sample(filelist, num)
    for d_f in delete_filelist:
        os.remove(d_f)
 
def extend_img(generator, data_dir, sample_num, f_prefix='ex_'):
    for i in range(10):
        dir_path = os.path.join(data_dir, str(i))
        # ラベルフォルダ内にあるファイルのpathリスト
        filelist = [os.path.join(dir_path, file) for file in os.listdir(dir_path)]
        file_cnt = len(filelist)
        # 画像1枚あたり何倍に拡張するかを求める
        ex_num = calc_extend_num(file_cnt, sample_num)
        # 拡張後の全ファイル数 = 元々のファイル数 * (拡張数 + 1)
        f_num = file_cnt * (ex_num + 1)
        # 削除するファイル数 = 拡張後の全ファイル数 - サンプル数
        delete_num = f_num - sample_num
        print(dir_path, file_cnt, ex_num, f_num, delete_num)
        # 拡張する必要がない場合はここでサンプル数に揃える
        if ex_num == 0:
            delete_file(filelist, delete_num)
            continue
 
        for f_path in filelist:
            img = load_img(f_path)
            # 画像をnumpy形式に変換して4次元に
            x = img_to_array(img)
            x = x.reshape((1,) + x.shape)
            _dir, _file = os.path.split(f_path)
            # 拡張後のファイル名が被ると上書きされてしまうためランダムな5桁の数字を挟む
            g = generator.flow(x, batch_size=1, save_to_dir=_dir,
                         save_prefix=f_prefix + str(random.randint(0, 99999)), save_format='png')
            for j in range(ex_num):
                g.next()
 
        # 拡張(名前にf_prefixを持つ)ファイルのリスト
        ex_filelist = [os.path.join(dir_path, file)
                      for file in os.listdir(dir_path) if f_prefix in file]
        # 拡張したファイルの中から削除する
        delete_file(ex_filelist, delete_num)
 
 
if __name__ == '__main__':
 
    generator = ImageDataGenerator(
        rotation_range=10,  # ランダムに回転する回転範囲
        width_shift_range=0.1,  # 水平方向にランダムでシフト(横幅に対する割合)
        height_shift_range=0.1,  # 垂直方向にランダムでシフト(縦幅に対する割合)
        zoom_range=0.1,
        shear_range=0.3,  # 斜め方向に引っ張る
        fill_mode='nearest',  # 'nearest'はデフォルト. 入力画像の境界周りを埋める
    )
 
    # 一旦各ラベルsample_numを超えるように水増しする. 元々超えているものはしない
    # 水増しした画像の中からランダムに選んでsample_numになるよう削除する
 
    sample_num = 1000   # 各ラベルの画像がこの数になるよう拡張する
    dataset_dir = 'dataset'
    extend_img(generator, dataset_dir, sample_num)
 

プログラムはdatasetフォルダと同じ階層に置いて実行します。
画像枚数の水増しにはkerasのImageDataGeneratorを利用しました。
本来は学習時にデータを拡張しながらバッチを生成する使い方をするようですが、拡張した画像を保存することもできます。ImageDataGeneratorクラス生成時に画像として実際にありそうなパラメータを選んで設定してやります。

画像1枚につき何枚水増しするかは、(元々の画像+水増し画像)が設定した任意の数を超えるのに必要な最小の数をcalc_extend_num()関数で求めるようにしています。
その後、設定した数になるよう拡張した画像の中からランダムに選んで削除しています。

以下はデータを1000で揃えるよう設定してプログラムを実行した際のオリジナルと水増し画像枚数の積み上げグラフです。元画像の数は変えずに水増し画像だけを増やして任意の数まで揃えています。
f:id:enokisaute:20210417132719p:plain

データセットをMNIST風にロードできるようにする

後々いろいろな機械学習モデルで何度も実験することを考えて、作成したデータセットをpickleで保存し、コード1行で画像とラベルをロードできるようにしました。

import os, pickle
import numpy as np
from PIL import Image
from sklearn.model_selection import train_test_split
 
class DigitImage:
    def __init__(self):
        self.dataset = {}
 
    def load_data(self, save_file, normalize=True, flatten=False, one_hot_label=True):
        # save_fileがなければデータセットを作成, あればデータを読み込む
        if not os.path.exists(save_file):
            self.create_dataset(save_file)
        else:
            with open(save_file, 'rb') as f:
                self.dataset = pickle.load(f)
 
        if normalize:
            # float32に変換して0~1の間になるよう正規化
            self.dataset['img'] = self.dataset['img'].astype(np.float32) / 255
 
        if not flatten:
            self.dataset['img'] = self.dataset['img'].reshape(-1, 1, 28, 28)
 
        if one_hot_label:
            self.dataset['label'] = self.change_one_hot_label()
 
        return self.dataset['img'], self.dataset['label']
 
    def change_one_hot_label(self):
        T = np.zeros((self.dataset['label'].size, 10))
        for idx, row in enumerate(T):
            row[self.dataset['label'][idx]] = 1
        return T
 
    def create_dataset(self, save_file):
        image, label = [], []
        for i in range(10):
            dir_path = os.path.join('dataset', str(i))
            filelist = [os.path.join(dir_path, file) for file in os.listdir(dir_path)]
 
            # ファイル毎に画像を1次元のnumpy配列として追加していく
            images = []
            for file in filelist:
                _img = Image.open(file).convert('L')
                _img = _img.resize((28, 28))
                _img = np.asarray(_img, dtype=np.uint8).reshape(-1)
                images.append(_img)
            image.extend(images)
 
            label.extend([i] * len(filelist))
 
        self.dataset = {'img': np.array(image), 'label': np.array(label)}
        with open(save_file, 'wb') as f:
            pickle.dump(self.dataset, f)
 
 
if __name__ == '__main__':
 
    digit = DigitImage()
    X, y = digit.load_data('digit_dataset.pkl', normalize=True,
                    flatten=False, one_hot_label=True)
    print(X.shape)
    print(y.shape)
 
    # ここでロードしたデータを学習用とテスト用に分割. シャッフルもされる
    X_train, X_test, y_train, y_test = train_test_split(
                                            X.reshape((len(X), 28, 28, 1)),
                                            y, train_size=0.6)
    print(y_train[:10])

 

このプログラムもdatasetフォルダと同じ階層に置いて実行します。
MNISTデータセットを取得するTensorFlow(keras)のload_data()では学習用とテスト用の2つのデータセットをロードできましたが、私のコードではまとめて1つのデータセット(画像データ、ラベル)として取得し、学習用-テスト用のデータの分割はsklearnのtrain_test_split()メソッドを利用することにしました。

digit = DigitImage()
X, y = digit.get_data('digit_dataset.pkl', normalize=True,
                    flatten=False, one_hot_label=True)
print(X.shape)    # (10000, 1, 28, 28)
print(y.shape)    # (10000, 10)

load_data()メソッドのパラメータのnormalize、flatten、one_hot_labelによって、取得できるデータの形式を変えることができます。

畳み込みニューラルネットワークを自作の手書き数字画像に利用する』の学習時は画像データを255で割って0~1になるよう正規化しましたが、パラメータnormalize=Falseとしてロードすると、グレースケール画像データのuint8配列として取得できます。
ラベル「8」のある画像データを28×28に並べて表示したものがこちらです。
f:id:enokisaute:20210419215643p:plain

下図はこのデータをmatplotlibでグレースケール画像として描画して各ピクセルに対応する数値を表示させたものです。ちょっと見にくいところもありますが、こちらでも画像が0~255の数値で構成されていることがわかります。
f:id:enokisaute:20210420224553p:plain

またflatten=Falseだと、画像データは1×28×28の3次元配列として取得できますが、Trueの場合には1次元配列として得られます。

# flatten=True
print(X.shape)    # (10000, 784) 


one_hot_labelもFalseにすると以下のように手書き数字が0~9のどの数字なのかというラベルで得られます。

# one_hot_label=False
print(y_train)    # [0 8 0 ... 9 4 2]

# one_hot_label=True
[[0. 0. 0. 0. 0. 0. 0. 0. 0. 1.]
 [0. 0. 0. 0. 0. 1. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0. 0. 1. 0. 0. 0.]
...

これでとりあえず自作データセットをMNIST風に手軽に扱えるようになりました。

参考

・ゼロから作るDeep Learning ―Pythonで学ぶディープラーニングの理論と実装

*1:こちらは8×8の64

*2:ぴったり揃える必要はありませんが