日本の古文書で機械学習を試す(9) 筆跡鑑定？どの作品の文字画像かを当てる

前回、[日本の古文書で機械学習を試す(8) ](https://qiita.com/ariera/items/64ee1d88265e8dbc4e37)で、[人文学オープンデータ共同利用センター](http://codh.rois.ac.jp/)の[日本古典籍くずし字データセット](http://codh.rois.ac.jp/char-shape/book/)から複数作品のデータをダウンロードしたので、文字認識以外にも使ってみよう、ということで、どの作品から切り出した文字かを当てるモデルを作成してみる。

###学習、テスト用データを作る

まずは、前回までにダウンロードした「好色一代男」「雨月物語」「おらが春」「養蚕秘録」「物類称呼」の5作品のデータから、学習、テスト用データを作る。前回使った作品のうち「当世料理」はデータ数が少ないので今回は除外する。

学習データとテストデータは、前半後半で分けると文字種が片寄ってしまうので、画像を順番に読み込みながら画像数をカウントし、画像数が10で割り切れるときにテストデータとした。(つまり、10個に1個の割合でテストデータとした。)

```python:学習、テストデータ生成
# 画像を読み込んで、行列に変換する関数を定義
from keras.preprocessing.image import load_img, img_to_array
def img_to_traindata(file, img_rows, img_cols):
    img = load_img(file, color_mode = "grayscale", target_size=(img_rows,img_cols)) # <- Warningが出たので grayscale=True から修正
    # x = img.convert('L') # PIL/Pillow グレイスケールに変換
    x = img_to_array(img)
    x = x.astype('float32')
    x /= 255
    return x

# 1つの作品のデータから学習用データとテストデータを生成する関数を定義
import glob, os
from keras.utils import np_utils
import numpy as np

def get_train_test_data(root, y_value, nb_classes, img_rows, img_cols):
    X_train = []
    Y_train = []
    X_test = []
    Y_test = []
    count = 0
    chars = glob.glob(root) # 画像のルートディレクトリ内のファイル／ディレクトリ一覧
    for char in chars:
        if os.path.isdir(char):  # ディレクトリなら
            img_files = glob.glob(char+"/*.jpg")
            for img_file in img_files:       # ディレクトリ(文字種)ごとのファイル一覧取得
                x = img_to_traindata(img_file, img_rows, img_cols) # 各画像ファイルを読み込んで行列に変換
                if count % 10 == 0: # 10個おきにテストデータにする
                    X_test.append(x)                                    # 画像データ
                    Y_test.append(y_value)                              # 正解データ=作品ごとに振った番号
                else: # のこりは学習用データにする
                    X_train.append(x)                                    # 画像データ
                    Y_train.append(y_value)                              # 正解データ=作品ごとに振った番号
                count = count + 1
    # listからnumpy.ndarrayに変換
    X_train = np.array(X_train, dtype=float)
    Y_train = np.array(Y_train, dtype=float)
    Y_train = np_utils.to_categorical(Y_train, nb_classes)
    X_test = np.array(X_test, dtype=float)
    Y_test = np.array(Y_test, dtype=float)
    Y_test = np_utils.to_categorical(Y_test, nb_classes)
    return [X_train, Y_train, X_test, Y_test]

# 作品ごとにデータを読み込んで学習、テストデータ生成
nb_classes = 5
img_rows = 28
img_cols =28

# 好色一代男　正解=0
kosyoku_data = get_train_test_data("../200003076/characters/*", 0, nb_classes, img_rows, img_cols)
# 雨月物語　正解=1
ugetsu_data = get_train_test_data("../200014740/characters/*", 1, nb_classes, img_rows, img_cols)
# おらが春　正解=2
oraga_data = get_train_test_data("../200003967/characters/*", 2, nb_classes, img_rows, img_cols)
# 養蚕秘録　正解=3
yosan_data = get_train_test_data("../200021660/characters/*", 3, nb_classes, img_rows, img_cols)
# 物類称呼　正解=4
butsu_data = get_train_test_data("../brsk00000/characters/*", 4, nb_classes, img_rows, img_cols)

# 複数作品の学習データ、テストデータを連結(軸0でデータを連結)
X_train = np.concatenate([kosyoku_data[0],ugetsu_data[0],oraga_data[0],yosan_data[0],butsu_data[0]], 0)
Y_train = np.concatenate([kosyoku_data[1],ugetsu_data[1],oraga_data[1],yosan_data[1],butsu_data[1]], 0)
X_test = np.concatenate([kosyoku_data[2],ugetsu_data[2],oraga_data[2],yosan_data[2],butsu_data[2]], 0)
Y_test = np.concatenate([kosyoku_data[3],ugetsu_data[3],oraga_data[3],yosan_data[3],butsu_data[3]], 0)

# 学習データ、テストデータの型を表示
print(X_train.shape)
print(Y_train.shape)
print(X_test.shape)
print(Y_test.shape)
```

学習データは205175個、テストデータは22800個生成された。今までの文字認識に比べたら、かなり多いデータ数だ。

###モデルを作って学習

次にモデルを定義して学習させてみる。前回までで一番良かった畳み込み3層、プーリング2層のモデルを使い、パラメータと最適化関数も前回までにチューニングしたものを使う。やろうとしていることが違うので、前回までと同じ条件が最適かどうかは分からないが。

```python:モデル生成、学習
# 再現性を得るための設定をする関数を定義
# https://keras.io/ja/getting-started/faq/#how-can-i-obtain-reproducible-results-using-keras-during-development
import tensorflow as tf
import random
import os
from keras import backend as K
def set_reproducible():
    os.environ['PYTHONHASHSEED'] = '0'
    np.random.seed(1337)
    random.seed(1337)

    session_conf = tf.ConfigProto(intra_op_parallelism_threads=1, inter_op_parallelism_threads=1)
    tf.set_random_seed(1337)
    sess = tf.Session(graph=tf.get_default_graph(), config=session_conf)
    K.set_session(sess)

# 畳み込み3層のモデルを使って学習
from keras.models import Sequential
from keras.layers import Dense, Dropout, Activation, Flatten
from keras.layers import Conv2D, MaxPooling2D

# 【パラメータ設定】
batch_size = 32
epochs = 40

input_shape = (img_rows, img_cols, 1)
nb_filters = 32
# size of pooling area for max pooling
pool_size = (2, 2)
# convolution kernel size
kernel_size = (3, 3)

# 【モデル定義】
model = Sequential()
model.add(Conv2D(nb_filters, kernel_size, # 畳み込み層
                        padding='valid',
                        activation='relu',
                        input_shape=input_shape))
model.add(Conv2D(nb_filters, kernel_size, activation='relu')) # 畳み込み層
model.add(MaxPooling2D(pool_size=pool_size)) # プーリング層
model.add(Conv2D(nb_filters, kernel_size, activation='relu')) # 畳み込み層
model.add(MaxPooling2D(pool_size=pool_size)) # プーリング層
model.add(Dropout(0.25)) # ドロップアウト(過学習防止のため、入力と出力の間をランダムに切断)

model.add(Flatten()) # 多次元配列を1次元配列に変換
model.add(Dense(128, activation='relu'))  # 全結合層
model.add(Dropout(0.2))  # ドロップアウト
model.add(Dense(nb_classes, activation='softmax'))  # 全結合層

model.compile(loss='categorical_crossentropy',
              optimizer='adam',
              metrics=['accuracy'])

# 【各エポックごとの学習結果を生成するためのコールバックを定義(前回より精度が良い時だけ保存)】
from keras.callbacks import ModelCheckpoint
import os
model_checkpoint = ModelCheckpoint(
    filepath=os.path.join('add_models','model_kantei_{epoch:02d}_{val_acc:.3f}.h5'),
    monitor='val_acc',
    mode='max',
    save_best_only=True,
    verbose=1)

# 【学習】
set_reproducible()
result = model.fit(X_train, Y_train, batch_size=batch_size, epochs=epochs, verbose=1, validation_data=(X_test, Y_test),
                   callbacks=[model_checkpoint])
```

###結果

データ量が多いため、1エポック当たり950～1000秒 × 40エポックで、前回までより学習にかなり時間がかかった。

いつものように結果をプロットしてみる。[日本の古文書で機械学習を試す(6)](https://qiita.com/ariera/items/17666fac3ead0b5674a9)の最後に、学習データのプロットはあまり意味がないかも？ということが判明したのだが、とりあえず、確認のためプロットしてみる。

```python:結果をプロット
# 【学習データとテストデータに対する正解率をプロット】
import matplotlib.pyplot as plt
%matplotlib inline
plt.plot(range(1, epochs+1), result.history['acc'], label="Training")
plt.plot(range(1, epochs+1), result.history['val_acc'], label="Validation")
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.ylim([0.8,1])# y軸の最小値、最大値
plt.grid(True) # グリッドを表示
plt.xticks(np.arange(0, epochs+1, 10))
plt.legend(bbox_to_anchor=(1.8, 0), loc='lower right', borderaxespad=1, fontsize=15)
plt.show()
```
![筆跡鑑定.png](https://qiita-image-store.s3.amazonaws.com/0/306431/2d426e99-a0cd-38e4-34a9-3e2565b91a16.png)

途中で一度ガクッと精度が下がっているのが謎だが、だいたい0.975あたりで安定してきている。テストデータに対する精度の最大値は0.97794 (30エポック目)だった。

学習データが約20万で、5クラス分類(0～4のどれかをあてる)問題なので、前回までの文字認識と比べるとかなり精度は高めだ。パラメータをいじったり、RNNと組み合わせたりするともっと良くなるのだろうか？

### データの一部を表示してみる

確認のため、データの一部を表示してみた。各作品のテストデータから、ひらがなの「あ」を表示させた。

```python:テストデータの一部を表示
import matplotlib.pyplot as plt
%matplotlib inline
fig = plt.figure(figsize=(10,5)) # 全体の表示領域のサイズ(横, 縦)
fig.subplots_adjust(left=0, right=1, bottom=0, top=1, hspace=0.5, wspace=0.05)
# ↑サブ画像余白(左と下は0、右と上は1で余白なし) サブ画像間隔 縦,横
# 好色一代男
for i in range(10):
    ax = fig.add_subplot(5, 10, i+1, xticks=[], yticks=[]) # 縦分割数、横分割数、何番目か、メモリ表示なし
    ax.title.set_text("[0]") # グラフタイトルとして正解番号を表示
    ax.imshow(kosyoku_data[2][i+50].reshape((img_rows, img_cols)), cmap='gray')
# 雨月物語
for i in range(10):
    ax = fig.add_subplot(5, 10, i+11, xticks=[], yticks=[]) # 縦分割数、横分割数、何番目か、メモリ表示なし
    ax.title.set_text("[1]") # グラフタイトルとして正解番号を表示
    ax.imshow(ugetsu_data[2][i+40].reshape((img_rows, img_cols)), cmap='gray')
# おらが春
for i in range(10):
    ax = fig.add_subplot(5, 10, i+21, xticks=[], yticks=[]) # 縦分割数、横分割数、何番目か、メモリ表示なし
    ax.title.set_text("[2]") # グラフタイトルとして正解番号を表示
    ax.imshow(oraga_data[2][i+12].reshape((img_rows, img_cols)), cmap='gray')
# 養蚕秘録
for i in range(10):
    ax = fig.add_subplot(5, 10, i+31, xticks=[], yticks=[]) # 縦分割数、横分割数、何番目か、メモリ表示なし
    ax.title.set_text("[3]") # グラフタイトルとして正解番号を表示
    ax.imshow(yosan_data[2][i+30].reshape((img_rows, img_cols)), cmap='gray')
# 物類称呼
for i in range(10):
    ax = fig.add_subplot(5, 10, i+41, xticks=[], yticks=[]) # 縦分割数、横分割数、何番目か、メモリ表示なし
    ax.title.set_text("[4]") # グラフタイトルとして正解番号を表示
    ax.imshow(butsu_data[2][i+100].reshape((img_rows, img_cols)), cmap='gray')
```

![各作品のテスト画像.png](https://qiita-image-store.s3.amazonaws.com/0/306431/e5400acd-bfc0-dfe5-516a-d732d8b0a2ca.png)

ひらがなの「あ」を選ぶのは、原始的だが、データの何番目を表示するかの数字(ソースコード中のax.imshowの最初の引数内の[i+**]の**の数字部分)を、表示データを見ながら手動で調整した。3列目の「おらが春」(作品番号[2])だけ、「あ」の数が9個しかなく、最後の文字は「い」になっている。

今回は全部の文字種を混ぜて学習させたので、字形の違いはあまり関係ないはずだが、人間の目で見ると、作品ごとの特徴としては、筆遣いよりも、背景(紙の特徴とか、画像化のしかたとか？)の影響の方が大きいように見える。

### 畳み込みフィルタをかけた結果を表示してみる

学習時にどんな特徴を抽出したのかを知るため、モデルの中間層の出力を見てみる。


まず、学習させたモデルの層構造を表示させてみる。

```python:モデルの層構造を表示
print(model.layers)
```
結果はこうなった。出力が見づらかったので、カンマで改行したら少し見やすくなった。<>で囲まれた要素が、モデル定義の際に model.add で追加した要素に対応しているっぽい。

```text:
[<keras.layers.convolutional.Conv2D object at 0x00000219D5ACAA20>, 
<keras.layers.convolutional.Conv2D object at 0x00000219D5ACAA58>, 
<keras.layers.pooling.MaxPooling2D object at 0x00000219D5ACAE80>, 
<keras.layers.convolutional.Conv2D object at 0x00000219D5967F28>, 
<keras.layers.pooling.MaxPooling2D object at 0x00000219D5ACAC50>, 
<keras.layers.core.Dropout object at 0x00000219D5982208>, 
<keras.layers.core.Flatten object at 0x0000021939311320>, 
<keras.layers.core.Dense object at 0x0000021939311A58>, 
<keras.layers.core.Dropout object at 0x000002193932FFD0>, 
<keras.layers.core.Dense object at 0x0000021914DD3C50>]
```

1層目の畳み込みフィルタ model.layers[0] を「好色一代男」の50番目のテストデータ(上の画像の左上端の画像、kosyoku_data[2][50])に適用した結果を表示してみる。途中の層のフィルタを取り出すには、新しく中間層のみのモデルを作れば良いらしい。

```python:1層目の畳み込みフィルタをかけた結果を出力
from keras.models import Model
#中間層のmodelを作成
intermediate_layer_model = Model(inputs=model.input,
                                 outputs=model.layers[0].output)
#1つのテストデータの画像に対して、フィルタをかけた結果を出力
intermediate_output = intermediate_layer_model.predict([[kosyoku_data[2][50]]]) # 好色一代男
print(intermediate_output.shape)
```
出力されたデータ型を表示してみると、(26, 26, 32)となっている。最初の26x26が画素数、最後の32がフィルタの数だろう。このままだと扱いにくいので、(32, 26, 26)に変換してから表示させてみる。

```python:1層目の畳み込みフィルタをかけた結果を表示
# (26, 26, 32) -> (32, 26, 26)
data = intermediate_output[0].transpose(2, 0, 1)

import matplotlib.pyplot as plt
%matplotlib inline
fig = plt.figure(figsize=(8,4)) # 全体の表示領域のサイズ(横, 縦)
fig.subplots_adjust(left=0, right=1, bottom=0, top=1, hspace=0.5, wspace=0.05)
for i in range(32):
    ax = fig.add_subplot(4,8, i+1, xticks=[], yticks=[]) # 縦分割数、横分割数、何番目か、メモリ表示なし
    ax.imshow(data[i], cmap='gray')
```

![フィルタ１層目.png](https://qiita-image-store.s3.amazonaws.com/0/306431/e55c8e0d-c42e-e6ba-5596-7614b3b8bba4.png)

これが、「好色一代男」の50番目のテストデータに32種類の畳み込みフィルタをかけた結果です。結局どんな特徴を抽出したのかは、私の目にはサッパリ分からない。