日本の古文書で機械学習を試す(8) 別の作品の文字を認識できるか？


前々回と前回、[日本の古文書で機械学習を試す(6)](https://qiita.com/ariera/items/17666fac3ead0b5674a9)、[(7)](https://qiita.com/ariera/items/e10744e9128e5cb173d7)で、[人文学オープンデータ共同利用センター](http://codh.rois.ac.jp/)の[日本古典籍くずし字データセット](http://codh.rois.ac.jp/char-shape/book/)からダウンロードした「好色一代男」のデータを学習用データとテストデータに分けて学習させ、テストデータでの精度0.928(CNNのみ)、0.943(CNN+RNN)と、そこそこ良い結果が出た。ここで作ったモデルを他の作品の文字に適用するとどうなるか試してみた。

###他の作品のデータを用意する

まず、テスト用の元データとして、[日本古典籍くずし字データセット](http://codh.rois.ac.jp/char-shape/book/)から「雨月物語」「おらが春」「養蚕秘録」「物類称呼」「当世料理」の5作品をダウンロードして、zipを適当なところに解凍する。「好色一代男」のデータと同様、charactersフォルダの下に、文字コードごとのフォルダがあり、その中に文字の画像が保存されている。

前回、モデル学習時に使った文字しか認識できないので、5作品のデータからモデル学習に使った文字のデータのみを抜き出してnumpyのarrayに格納する関数を作成する。学習に使った文字のデータは[日本の古文書で機械学習を試す(6)](https://qiita.com/ariera/items/17666fac3ead0b5674a9)で、学習データ生成時についでに作成した200003076chars.csvを使う。

```python:5作品のデータを読み込む関数を作成
# 「好色一代男」の学習に使った文字コードと番号をCSVから読み込んでdictに格納
import csv
with open('200003076chars.csv', 'r') as f:
    csvdata = csv.reader(f)
    data = [x for x in csvdata]
    char_indices = dict((x[1],int(x[0])) for x in data) # 文字コード→番号
    indices_char = dict((int(x[0]),x[1]) for x in data) # 番号→文字コード

# 画像を読み込んで、行列に変換する関数を定義
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

# ダウンロードデータから辞書にある文字のデータのみを抽出する関数を定義
import glob, os
from keras.utils import np_utils
import numpy as np

def get_train_test_data(root, char_dict, nb_classes):
    X_ret = []
    Y_ret = []
    chars = glob.glob(root) # 画像のルートディレクトリ内のファイル／ディレクトリ一覧
    use_char_count = 0  # テスト用に使う文字種の数
    nouse_char_count = 0 # 使わない文字種の数
    for char in chars:
        if os.path.isdir(char) and os.path.basename(char) in char_dict:  # 学習データにある文字コードなら
            use_char_count = use_char_count + 1
            img_files = glob.glob(char+"/*.jpg")
            for img_index, img_file in enumerate(img_files):                # ディレクトリ(文字種)ごとのファイル一覧取得
                x = img_to_traindata(img_file, img_rows, img_cols)          # 各画像ファイルを読み込んで行列に変換
                X_ret.append(x)                                             # 画像データ
                Y_ret.append(char_dict[os.path.basename(char)])             # 正解文字コードに対応する番号
        elif os.path.isdir(char): # 学習データにない文字コード
            nouse_char_count = nouse_char_count + 1
    # listからnumpy.ndarrayに変換
    X_ret = np.array(X_ret, dtype=float)
    Y_ret = np.array(Y_ret, dtype=float)
    Y_ret = np_utils.to_categorical(Y_ret, nb_classes)
    return [X_ret, Y_ret, use_char_count, nouse_char_count]
```

###モデルを用意する

モデルは前回までに作成したCNNのうち、デフォルト層構造(畳み込み2層)のもの(cnn_orig)、チューニング後の畳み込み3層のもの(cnn_opt)の2種類と、RNN縦方向、RNN横方向の4種類と、CNNとRNNを組み合わせたものを比較してみる。

```python:モデルを読み込んで、精度チェック用の関数を定義
# CNN,RNNのモデルを読み込む
from keras.models import load_model
model_cnn_orig = load_model('layer_models/model_k1_39_0.888.h5')
model_cnn_opt = load_model('layer_models/model_k15_37_0.928.h5')
model_rnn_row = load_model('layer_models/model_k_rnn_33_0.897.h5')
model_rnn_col = load_model('layer_models/model_k_rnn2_33_0.900.h5')

# 各作品のデータで精度をチェックする関数を定義
def check_acc(X, Y, img_rows, img_cols):
    print(X.shape)
    
    score_cnn_orig = model_cnn_orig.evaluate(X, Y, verbose=1) # モデルはとりあえずグローバル変数から取得
    print('CNN(2) Test accuracy:', score_cnn_orig[1])

    score_cnn_opt = model_cnn_opt.evaluate(X, Y, verbose=1)
    print('CNN(3) Test accuracy:', score_cnn_opt[1])
    
    X_RNN_R = X.reshape(len(X), img_rows, img_cols)
    score_rnn_row = model_rnn_row.evaluate(X_RNN_R, Y, verbose=1)
    print('RNN ROW accuracy:', score_rnn_row[1])
    
    X_RNN_C = X_RNN_R.transpose(0, 2, 1)
    score_rnn_col = model_rnn_col.evaluate(X_RNN_C, Y, verbose=1)
    print('RNN ROW accuracy:', score_rnn_col[1])

    # CNNとRNN2種類の加算で精度チェック
    miss_count1 = 0
    miss_count2 = 0
    miss_count3 = 0
    for i, x in enumerate(X):
        # 正解データ
        correct_index = np.where(Y[i] == True)[0][0]  # データがTrueになっている箇所のインデックス
        # 各モデルで判別結果のベクトルを求める
        preds_cnn = model_cnn_opt.predict(np.array([x]))
        preds_rnn_row = model_rnn_row.predict(np.array([X_RNN_R[i]]))
        preds_rnn_col = model_rnn_col.predict(np.array([X_RNN_C[i]]))
        # ベクトルを加算
        preds1 = (preds_cnn + preds_rnn_row + preds_rnn_col) / 3
        preds2 = (preds_cnn + preds_rnn_row*0.5 + preds_rnn_col*0.5) / 2
        preds3 = (preds_cnn*2 + preds_rnn_row*0.5 + preds_rnn_col*0.5) / 3
        ## どの文字かを判別する
        pred_index1 = preds1.argmax() # 確率の一番高い文字の番号
        pred_index2 = preds2.argmax() 
        pred_index3 = preds3.argmax() 
        # 間違っている場合にインクリメント
        if pred_index1 != correct_index:
            miss_count1 = miss_count1 + 1
        if pred_index2 != correct_index:
            miss_count2 = miss_count2 + 1
        if pred_index3 != correct_index:
            miss_count3 = miss_count3 + 1

    print("正解率 CNN(3)+RNN(R)+RNN(C)          ", 1-miss_count1/len(X))
    print("正解率 CNN(3)+RNN(R)*0.5+RNN(C)*0.5  ", 1-miss_count2/len(X))
    print("正解率 CNN(3)*2+RNN(R)*0.5+RNN(C)*0.5", 1-miss_count3/len(X))
```
```python:作品ごとに精度チェック(1作品分のみ例示)
img_rows = 28
img_cols = 28
nb_classes = len(indices_char) # 辞書にある文字種数

# 雨月物語
img_root = "../200014740/characters/*"
ugetsu_data = get_train_test_data(img_root, char_indices, nb_classes)
check_acc(ugetsu_data[0], ugetsu_data[1], img_rows, img_cols)
```

###テスト結果

結果はこうなった。比較のため、「好色一代男」についても、他作品と同じ処理を行ってみた。学習モデル作成時の学習データ、テストデータの両方を含むため、当然精度はかなり高く出ている。

countはテストに用いた文字数、useはテストに用いた文字種数(作品に含まれる文字種中、学習時のデータに含まれていたもの)、no useはテストに用いなかった文字種数(作品に含まれるが、学習時のデータには含まれないもの)、MIX1は(CNN(3)+RNN(R)+RNN(C))/3、MIX2は(CNN(3)+RNN(R)*0.5+RNN(C)*0.5)/2、MIX3は(CNN(3)*2+RNN(R)*0.5+RNN(C)*0.5)/3の結果。

作品       | count |  use  | no use | CNN(2) | CNN(3) | RNN(R) | RNN(C) | MIX1 | MIX2 | MIX3 |
|:--------:|:-----:|:-----:|:------:|:------:|:------:|:------:|:------:|:----:|:----:|:----:|
| 雨月物語 | 41113 |845    |1124    |0.590|0.625|0.552|0.559|0.657|0.662|0.649|
| おらが春 | 10385 |669    |450     |0.244|0.278|0.281|0.295|0.363|0.333|0.296|
| 養蚕秘録 | 26964 |754    |1004    |0.462|0.525|0.498|0.482|0.583|0.581|0.560|
| 物類称呼 | 66498 |827    |1370    |0.495|0.552|0.469|0.467|0.579|0.589|0.575|
| 当世料理 | 4312  |310    |107     |0.301|0.383|0.190|0.218|0.354|0.390|0.387|
|好色一代男| 63073 |1061   |659     |0.952|0.978|0.977|0.977|0.989|0.988|0.984|

全体の傾向として、単体のCNN、RNNの中ではCNN(畳み込み3層)の精度が高く、それよりもCNN+RNNのほうが精度が高くなっている。3種類の組み合わせ中でどれが良いかは作品によって異なる。

「好色一代男」を除き、一番精度が高いものが「雨月物語」のMIX1の0.657。モデルで識別できる文字を選んだ状態でこの精度なので、モデルにない文字を含めたら、さらに精度は落ちることになる。(モデルにない文字は100%間違えるわけで…)　実用には全く使えそうにない。

一番精度が低い「おらが春」に至っては、最高精度が0.363しかなく、しかもCNN(3層)よりもRNNのほうが精度がちょっと高い(謎)

###複数作品のデータを使ってモデル生成

学習データを増やせば良くなるかもしれないので、「好色一代男」、「おらが春」、「養蚕秘録」の3作品のデータを足して学習データを作成し、畳み込み3層のCNNを作成してみる。データ数が少ない「当世料理」のデータを仮にテストデータにセットして学習させてみる。

```python:3作品を足してモデル作成

from keras.models import Sequential
from keras.layers import Dense, Dropout, Activation, Flatten
from keras.layers import Conv2D, MaxPooling2D
import tensorflow as tf
import random
import os
from keras import backend as K

# 学習データ(軸0でデータを連結)
X_train = np.concatenate([kosyoku_data[0],oraga_data[0],yosan_data[0]], 0)
Y_train = np.concatenate([kosyoku_data[1],oraga_data[1],yosan_data[1]], 0)

# 畳み込み3層のモデルを使って学習
# 【パラメータ設定】
batch_size = 32
epochs = 40
img_rows = 28
img_cols = 28
nb_classes = len(indices_char) # 辞書にある文字種数

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_{epoch:02d}_{val_acc:.3f}.h5'),
    monitor='val_acc',
    mode='max',
    save_best_only=True,
    verbose=1)

# 再現性を得るための設定をする関数を定義
# https://keras.io/ja/getting-started/faq/#how-can-i-obtain-reproducible-results-using-keras-during-development
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)

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

# 【精度チェック】
score = model.evaluate(X_train, Y_train, verbose=1)
print('Train accuracy:', score[1])

score = model.evaluate(tosei_data[0], tosei_data[1], verbose=1)
print('Test accuracy:', score[1])

score = model.evaluate(ugetsu_data[0], ugetsu_data[1], verbose=1)
print('Ugetsu accuracy:', score[1])

score = model.evaluate(butsu_data[0], butsu_data[1], verbose=1)
print('Butsu accuracy:', score[1])
```
精度はこうなった。

学習データ(3作品)：0.980
テストデータ(当世料理)：0.596
雨月物語：0.806
物類称呼：0.783

1作品だけで学習させたときよりはかなり良くなったが、まだまだ実用的ではなさそうだ。認識結果を1文字に決めるのではなく、確率が上位とか閾値以上のものを全部候補に出すようにすれば、候補中に正解が入っている確率は上がりそうな気がする。別の機会に試してみよう。

###おまけ：正解／間違いの文字を表示してみる

最初に使った「好色一代男」のCNN(3)のモデルを使って、「雨月物語」の文字を認識させたときの正解した文字と間違えた文字を画像表示させてみた。試しに4000番～4100番のデータを表示させた。

```python:認識に成功／失敗した文字の一部を表示してみる
X_corr = []
Y_corr = []
X_miss = []
Y_miss = []
check_data = ugetsu_data # チェックするデータ
check_start = 4000 # チェック、表示する文字番号の最初
check_end = 4100 # チェック、表示する文字番号の最後

# 認識の正誤をチェック
for i in range(check_start,check_end):
    # 正解データ
    correct_index = np.where(check_data[1][i] == True)[0][0]  # データがTrueになっている箇所のインデックス
    ## どの文字かを判別する
    x = np.array([check_data[0][i]])
    preds = model_cnn_opt.predict(x) # CNN(4)のモデルでチェック
    pred_index = preds.argmax() # 確率の一番高い文字の番号
    if correct_index == pred_index: # 正解
        X_corr.append(check_data[0][i])
        Y_corr.append(correct_index)
    else:
        X_miss.append(check_data[0][i])
        Y_miss.append(correct_index)

# 成功データ、失敗データを表示
import matplotlib.pyplot as plt
%matplotlib inline

# 正解
fig = plt.figure(figsize=(10,int(len(X_corr)/10)+1)) # 全体の表示領域のサイズ(横, 縦)
fig.subplots_adjust(left=0, right=1, bottom=0, top=1, hspace=0.5, wspace=0.05)
# ↑サブ画像余白(左と下は0、右と上は1で余白なし) サブ画像間隔 縦,横
print(len(X_corr))
for i in range(len(X_corr)):
    ax = fig.add_subplot(int(len(X_corr)/10)+1, 10, i+1, xticks=[], yticks=[]) # 縦分割数、横分割数、何番目か、メモリ表示なし
    ax.title.set_text("[" + str(Y_corr[i]) + "]") # グラフタイトルとして正解番号を表示
    ax.imshow(X_corr[i].reshape((img_rows, img_cols)), cmap='gray')

# 間違い
fig = plt.figure(figsize=(10,int(len(X_miss)/10)+1)) # 全体の表示領域のサイズ(横, 縦)
fig.subplots_adjust(left=0, right=1, bottom=0, top=1, hspace=0.5, wspace=0.05)
# ↑サブ画像余白(左と下は0、右と上は1で余白なし) サブ画像間隔 縦,横
print(len(X_miss))
for i in range(len(X_miss)):
    ax = fig.add_subplot(int(len(X_miss)/10)+1, 10, i+1, xticks=[], yticks=[]) # 縦分割数、横分割数、何番目か、メモリ表示なし
    ax.title.set_text("[" + str(Y_miss[i]) + "]") # グラフタイトルとして正解番号を表示
    ax.imshow(X_miss[i].reshape((img_rows, img_cols)), cmap='gray')
```
正解した画像
![雨月正解２.png](https://qiita-image-store.s3.amazonaws.com/0/306431/c220da46-6b08-c4cb-85c6-077bd0c073d6.png)

間違えた画像
![雨月間違い２.png](https://qiita-image-store.s3.amazonaws.com/0/306431/07dbd1f5-f727-9022-71cd-ffbaf058886b.png)

正解はひらがなの「く」なのだが、正解と間違いの理由が私には分からない。正解画像の中にも、私には読めないものもあるし、間違い画像のほとんどは「く」に見える。

ちなみに、「く」と間違えやすい文字は、UTF-8のコードU+3031の「〱」なのだが、これはひらがなではなく、縦書きの繰り返し記号らしい。活字にされても私には区別がつかない(笑)