かなの手書き文字認識を作ってみた Part 2/3 データの作成と学習

# 概要

前回 (1/3)： https://qiita.com/tfull_tf/items/6015bee4af7d48176736
次回 (3/3)： https://qiita.com/tfull_tf/items/d9fe3ab6c1e47d1b2e1e

コード： https://github.com/tfull/character_recognition

かな認識システムの作成において、まずは CNNを使ったモデルを構築して MNIST でどのくらいの精度が出るかを確かめました。次に、かなの画像データを用意して同じようにモデルを構築し、モデルを改良していきます。

# データの作成

かなの画像データというと特に思いつくものがないので、自動で作ってしまいます。一応、公開されているデータセットもあるようです。

自動生成するために、 ImageMagick を使います。  convert コマンドで画像に文字を入れられるので、真っ黒の画像をまず作成し、それに白い文字を1つだけ書く、という手順で作成します。

## データ増殖手法

一文字について複数の画像を用意するために、データを増やす方法を用意しました。

### 1: フォントを複数用いる

違うフォントで書けば、同じ文字でもフォントの種類数だけ異なる画像が生成できます。

次のコマンドでフォントが見れるので、使えそうなやつをピックアップします。

```shell
convert -list font
```

気をつけないといけないのは、全てが日本語に対応しているわけではないので、かなを出力しようとしても何も書き出されないケースがあります。

メインで作業していた Mac OS 10.15 には良さそうなフォントが無かったため、 Ubuntu で画像を生成しました。次のようなフォントが最初から入っていたので、これらを使うことにしました。

```python
font_list = [
    "Noto-Sans-CJK-JP-Thin",
    "Noto-Sans-CJK-JP-Medium",
    "Noto-Serif-CJK-JP"
]
```

### 2: 文字の大きさを変える

画面に目一杯の文字を書くか、ちょっと控えめに書くかでも、異なる画像を生成できます。今回は、約半分のサイズから目一杯ちょっと下のサイズまで、少しずつ大きくしながら文字を書きました。

### 3: 文字をずらす

小さめの文字を書いた場合、空白が上下左右にできますので、縦方向と横方向それぞれに、文字をずらすという手法が使えます。例えば、空白 / 2 と 空白 / 3 をずらすことを上下左右に考えれば、 5 x 5 通りの画像が生成できます。

### 4: 文字を回転させる

convert では文字を回転させることができます。時計回り、反時計回りに少し回転させることで、画像を増やすことができます。

### （未使用） ぼかしを入れる

ぼかしを入れた画像を用意することで画像が増やせますが、画像の半分がボケた画像というのはどうなんだ？と思ったため、やりませんでした。1~4で十分な画像数が確保できています。

### （未使用） ノイズを入れる

画像に小さい点などのノイズを入れることで、単純に画像が増えるだけでなく、ノイズに強くなる可能性は考えられます。いい感じのノイズを入れる簡単な手法が見つからないためやりませんでしたが、今後の課題としても良いかもしれません。

## 結果

1~4の組み合わせ（掛け算）で文字入りの画像を生成します。縦横 256px で作成し、一文字につき、4000枚以上の画像が得られました。手法に使う種々のパラメーターを弄れば、枚数は変化させることができます。ひらがな (0x3041 ~ 0x3093) とカタカナ (0x30A1 ~ 0x30F6) で 169 種類あるため、結構な容量になります。

## コード

```python

data_directory = "/path/to/data"
image_size = 256

# 真っ黒な画像作成
def make_template():
    res = subprocess.call([
        "convert",
        "-size", "{s}x{s}".format(s = image_size),
        "xc:black",
        "{}/tmp.png".format(data_directory)
    ])

# 白文字の画像を作成
def generate(path, font, pointsize, character, rotation, dx, dy):
    res = subprocess.call([
        "convert",
        "-gravity", "Center",
        "-font", font,
        "-pointsize", str(pointsize),
        "-fill", "White",
        "-annotate", format_t(rotation, dx, dy), character,
        "{}/tmp.png".format(data_directory), path
    ])

# 移動フォーマットの関数
def format_t(rotation, x, y):
    xstr = "+" + str(x) if x >= 0 else str(x)
    ystr = "+" + str(y) if y >= 0 else str(y)
    return "{r}x{r}{x}{y}".format(r = rotation, x = xstr, y = ystr)
```

最初の1回だけ真っ黒な画像を作成し、ループで font, pointsize, character, rotation, dx, dy のパラメーターを変えながら白文字の画像を作成していきます。

# モデル構築

画像ができたので、 MNIST と同様にモデル構築をしていくわけですが、最初からうまくは行きませんでした。毎バッチで Cross Entropy 誤差の値が同じになっており、デバッグとして学習させるときの層の中の値を観察していると、絶対値が数百、数千といった大きな値が入っており、出力が常に同じようになっていました。そういうわけで Batch Normalization を挿入して、精度を大きく向上させることができました。

```python
import torch.nn as nn

class Model(nn.Module):
    def __init__(self, image_size, output):
        super(Model, self).__init__()
        n = ((image_size - 4) // 2 - 4) // 2

        self.conv1 = nn.Conv2d(1, 4, 5)
        self.relu1 = nn.ReLU()
        self.normal1 = nn.BatchNorm2d(4)
        self.pool1 = nn.MaxPool2d(2, 2)
        self.dropout1 = nn.Dropout2d(0.3)
        self.conv2 = nn.Conv2d(4, 16, 5)
        self.relu2 = nn.ReLU()
        self.normal2 = nn.BatchNorm2d(16)
        self.pool2 = nn.MaxPool2d(2, 2)
        self.dropout2 = nn.Dropout2d(0.3)
        self.flatten = nn.Flatten()
        self.linear1 = nn.Linear(n * n * 16, 1024)
        self.relu3 = nn.ReLU()
        self.normal3 = nn.BatchNorm1d(1024)
        self.dropout3 = nn.Dropout(0.3)
        self.linear2 = nn.Linear(1024, 256)
        self.relu4 = nn.ReLU()
        self.normal4 = nn.BatchNorm1d(256)
        self.dropout4 = nn.Dropout(0.3)
        self.linear3 = nn.Linear(256, output)
        self.softmax = nn.Softmax(dim = 1)

    def forward(self, x):
        x = self.conv1(x)
        x = self.relu1(x)
        x = self.normal1(x)
        x = self.pool1(x)
        x = self.dropout1(x)
        x = self.conv2(x)
        x = self.relu2(x)
        x = self.normal2(x)
        x = self.pool2(x)
        x = self.dropout2(x)
        x = self.flatten(x)
        x = self.linear1(x)
        x = self.relu3(x)
        x = self.normal3(x)
        x = self.dropout3(x)
        x = self.linear2(x)
        x = self.relu4(x)
        x = self.normal4(x)
        x = self.dropout4(x)
        x = self.linear3(x)
        x = self.softmax(x)
        return x
```

# 学習

基本的に MNIST でやったのと同じような手順で学習させていきます。 Cross Entropy Loss, Adam (learning rate = 0.001) を使いました。

## 学習時に気をつけた点

ループでパラメータを変えながら画像を生成したため、順番通りに学習させるとデータが偏りそうなので避けます。また、それぞれの文字を満遍なく学習させたいため、こちらは順番に並べて学習させたいと思います。

ループで画像を読みながら学習させると、1バッチで1枚の学習になってしまいます。とはいえ、画像データが沢山あるので、全部を一度に読み込むとメモリが足りなくなる可能性があります。その両方を避けるために、 yield を使って chunk 枚数ずつデータを読むことにしました。

```python
# a1, a2 の2重ループを chunk 個数ずつ取得
def double_range(a1, a2, chunk = 100):
    records = []

    for x1 in a1:
        for x2 in a2:
            records.append((x1, x2))
            if len(records) >= chunk:
                yield records
                records = []

    if len(records) > 0:
        yield records
```

配列を2つ与えて、二重ループで得られる組を chunk の数ずつ返す関数です。これをさらに for に与えます。

```python:擬似コード
for indices in double_range("1~Nまでのシャッフルされた画像番号", "文字に割り振った番号 (0~168)"):
    inputs = []
    for i_character, i_image in indices:
        inputs.append("i_character 番目の文字の i_image 枚目の画像を読み込み")

    model.train(inputs) # 学習
```

これで、バッチサイズの分だけ画像を読み出して学習させるループを行うことで、メモリ使用を抑えられます。

# モデルの性能

4236 [枚/文字] ✕ 169 [文字] の画像データを作成してから実験に取り組みました。全体の 5%をテストデータとし、エポック数2で学習させてテストデータの正答率を測ったところ、約 71.4% でした。最初プログラムを間違えて169択ではなく4236択にしていたのですが、そのときは 80% くらい出ていたのが謎です。もう少し性能を良くしたいですが、とりあえず認識システムを作って動かしてみるくらいはできそうです。