Aidemy Tech Blog

機械学習・ディープラーニング関連技術の活用事例や実装方法をまとめる、株式会社アイデミーの技術ブログです。

深層学習を使うべきで「ない」手書き文字認識【ロジスティック回帰とCNNの比較】

f:id:matsume_goods:20170723180343j:plain

目次

  1. 前回の記事
  2. ロジスティック回帰の問題点
  3. CNNを用いた手書き文字認識の実装
  4. 精度の確認、検証
  5. まとめ

1. 前回の記事

皆さんこんにちは。さっそくですが、まずはこの記事を読んでみて下さい。
blog.aidemy.net

この記事、深層学習の得意分野である手書き文字認識をあえて深層学習を用いずに実装しています。
学習の際の教師データはscilit-learnの"digits"データ。8×8画素の数字データです。
ロジスティック回帰を用いましたが、実際に書いてみた手書き文字の認識精度は63%... 良くはないけど悪くもないといった感じです。

流石にこの精度ではいけないのでは...? ということで、今回は真打ち登場、深層学習を用いて同じデータセットを識別してみたいと思います。

2. ロジスティック回帰の問題点

今回の実装には、畳み込みニュートラルネットワークを使います。

上記の記事ではロジスティック回帰を使っていますが、その基本的な考え方は

画像のピクセル毎の明暗データを説明変数とし、画像に書かれている数を目的変数にすることで機械学習のモデルに適用させる

といったものでした。ここで大事なのが、各ピクセルごとのデータを独立な変数としていた事です。
ところが、文字や画像は空間的に連続なデータの集まりなので、周囲のピクセルとの関係を切って独立に見ていくことはナンセンスでしょう。
そこで、その周囲のピクセルとの関係までうまいこと評価に落とし込められれば精度は上がるだろう、というのが畳み込みニューラルネットワーク(CNN)の基本的な考え方です。

このあたりはエキスパート本がいくつかあるので、そちらを参考にしてみてください。
CNNをはじめとする深層学習の実装に関してより理解を深めたい方は、以下の本がおすすめです。

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

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

理解を深めたい人のための本: とてもわかりやすい。ニューラルネットワークの基本からCNNまで学べる。

3. CNNを用いた手書き文字認識の実装

というわけで、前回作ったプログラムを少し書き換えてCNNによる手書き文字認識を実装してみました。

使用言語はpython3系

使用したパッケージは

  • os: ディレクトリ操作
  • numpy(np): 配列操作
  • pillow(PIL): 画像操作
  • scikit-learn(sklearn): 機械学習のユーティリティ、digitsデータセットの読み込み
  • tensorflow(tf): 深層学習のユーティリティ

さらに今回は自前のCNNのクラスをまとめたモジュールを用意しました。

  • CNNclasses(cnn)

今回の実装の目的は、

  • sklearnのデータセットを使って機械学習をする
  • 深層学習を用いて手書きの数字を判別し、用いなかった時との精度の差を確認する

この2つです。

コードは前回とほぼ似ているので、主に変更点を。読み飛ばしてしまっても問題ありません。

1. パッケージのインポート

割愛

2. 画像データ読み込み、加工

これも前回とほぼ同じですが、ちょとした変更点が。

for filename in filenames:
    """
    画像ファイルを取得、グレースケールにしてサイズ変更
    サイズを更に縮めて配列を作り、sklearnのdigitsと同じ型にする
    64画素の1画素ずつ明るさをプロット(省略)
    """
    # 画像データ内の最小値が0、最大値が1になるように計算
    min_bright = img_data256.min()
    max_bright = img_data256.max()
    img_data16 = (img_data256 - min_bright) / (max_bright - min_bright)
    # 加工した画像データの配列をまとめる
    img_test = np.r_[img_test, img_data16.astype(np.float32).reshape(1, -1)]
#畳み込み、Poolingのためにデータの次元を変える
img_test = img_test.reshape((img_test.shape[0], 8, 8, 1))

そこまで重要ではないのですが、CNNのクラスが受け取る数は[0.0, 1.0] なので、そこに注意します。

加工は前回と同じですが、一応こんな感じになります。

→→→

画像1: 手書き文字の画像をデータセットに合う型に加工する。

ついでに、教師データの方はこんな感じ。

画像2: sklearnの手書き文字データ(digits)。

3. 教師データの加工
# sklearnのデータセットから取得、目的変数Xと説明変数yに分ける
digits = load_digits()
#Xの次元を変え、最小値0、最大値1にする
digit_X = digits.data.reshape(
    (digits.data.astype(np.float32).shape[0], 8, 8, 1)) / digits.data.max()
#yを1-of-k表現にする
digit_y = np.zeros((digits.target.size, 10))
index = np.arange(digit_y.shape[0])
digit_y[index, digits.target[index]] = 1
# 教師データとテストデータに分ける
train_X, valid_X, train_y, valid_y = train_test_split(
    digit_X, digit_y, test_size=0.1, random_state=0)

変更点は二箇所。Xの次元をCNNが受け取る形にする事と、yを1-of-k表現にする事です。
今回学習に使うdigitsデータ(train)と評価用データ(valid)の比率は9:1となっています。

4. CNNの層、tfのグラフを構築
#層を用意 畳込み->Pooling->平滑化->全結合
layers = [
    cnn.Conv((5, 5, 1, 50), tf.nn.relu),
    cnn.Pooling((1, 2, 2, 1)),
    cnn.Flatten(),
    cnn.Dense(2 * 2 * 50, 10, tf.nn.softmax)
]

x = tf.placeholder(tf.float32, [None, 8, 8, 1])
t = tf.placeholder(tf.float32, [None, 10])
#順伝播
def f_props(layers, x):
    for layer in layers:
        x = layer.f_prop(x)
    return x
#出力
y = f_props(layers, x)
#誤差関数
cost = -tf.reduce_mean(tf.reduce_sum(t *
                                     tf.log(tf.clip_by_value(y, 1e-10, 1.0)), axis=1))
#誤差を最小化するように学習するよう設定
train = tf.train.GradientDescentOptimizer(0.01).minimize(cost)
#出力を1-of-k方式から10進の配列へ
valid = tf.argmax(y, 1)
#学習回数
n_epochs = 500
batch_size = 100
n_batches = train_X.shape[0] // batch_size

ここからCNNの実装です。まずCNNの層を用意するのですが、今回データのサイズが小さいこともあって畳み込み層(cnn.Conv)とPooling層(cnn.Pooling)はひとつずつしか用意できませんでした。

それ以降の部分はtensorflowの変数を用意したり、出力や誤差関数を定義したりしています。
深層学習をするには教師データが少なすぎるので、学習回数は500回にしました。

5. 教師データから学習
#学習前にグラフの初期化
sess = tf.Session()
sess.run(tf.global_variables_initializer())
#学習開始
for epoch in range(n_epochs):
    train_X, train_y = shuffle(train_X, train_y, random_state=42)
    for i in range(n_batches):
        start = i * batch_size
        end = start + batch_size
        #出力結果の誤差を最小化するようにバイアスを調整
        sess.run(train, feed_dict={
                 x: train_X[start:end], t: train_y[start:end]})
    pred_y_train = sess.run(valid, feed_dict={x: train_X, t: train_y})
    pred_y_valid = sess.run(valid, feed_dict={x: valid_X, t: valid_y})
    #学習過程を出力
    if epoch % 100 == 99:
        f1_train = f1_score(np.argmax(train_y, 1).astype('int32'), pred_y_train, average='macro')
        f1_valid = f1_score(np.argmax(valid_y, 1).astype('int32'), pred_y_valid, average='macro')
        print('EPOCH:: %i, F1値(train): %.3f, F1値(valid): %.3f' % (epoch + 1, f1_train, f1_valid))1).astype('int32'), pred_y, average='macro')))

いろいろやっていますが、大事な部分は
sess.run(train, feed_dict={x: train_X[start:end], t: train_y[start:end]})

です。ここで出力と教師データとの誤差を小さくするように調整しているわけです。

また、学習100回毎に教師データに対する学習精度と評価データに対する精度を出力します。

6.画像データの判別
#画像データを判別
pred_y = sess.run(valid, feed_dict={x: img_test})

true_X = []
for filename in filenames:
    true_X = true_X + [int(filename[:1])]
true_X = np.array(true_X)

score = np.ones(pred_y.size)[pred_y == true_X].sum() / pred_y.size
#結果を出力
print('判別結果')
print('観測:', true_X)
print('予測:', pred_y)
print('正答率:', score)

学習が完了した vaild にデータを放り込むと、判別した結果を返します。
それ以外は前回と同じ。代わり映えしないですね!


以上になります。長かった...

それでは動かしてみます。

4. 精度の確認、検証

今回判別してもらう文字は前回と同じものです。こんな感じの手書き数字が0〜9まで各3個、計30個あります。

画像3: 用意した手書き文字。スマホで書いた。

それではターミナルからプログラムを動かします。果たして精度はどのくらい上がるのか...

$ python3 Handwrite_recognition.py

学習に時間がかかる...(30秒ほど)

EPOCH:: 100, F1値(train): 0.933, F1値(valid): 0.937
EPOCH:: 200, F1値(train): 0.960, F1値(valid): 0.963
EPOCH:: 300, F1値(train): 0.971, F1値(valid): 0.963
EPOCH:: 400, F1値(train): 0.977, F1値(valid): 0.984
EPOCH:: 500, F1値(train): 0.982, F1値(valid): 0.988
判別結果
観測: [0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5 6 6 6 7 7 7 8 8 8 9 9 9]
予測: [0 0 0 1 1 1 2 2 4 1 1 4 4 4 4 5 4 4 6 4 4 7 7 9 8 1 4 4 4 9]
正答率: 0.566666666667

結果はこうなりました。
"判別結果"より上は教師データによる学習の過程を表しています。
"誤差"、"F1値"は学習に用いていないdigitsデータ(valid_X,)について識別した結果の誤差関数の値とF1値(精度の指標)を表しています。
ここを見ると精度は常に上がっているので、過学習の心配は無さそうです。
問題はその下で、
"観測"が用意した手書き文字の実際の値、"予測"がプログラムによって判断された値となっています。

...
...あれ??
私の書いた文字は正答率56%って... ロジスティック回帰の時は精度63%だったのに対して低くなっていますね。
3の予測がすべて外れています。ロジスティック回帰のときは予想がすべて外れる文字など無かったのですが...

このあとショックだったので勉強の回数(n_epochs)をいろいろいじってみたのですが、せいぜい66%止まりでした。乱数のseed値によっては40%台のときもありました。
ロジスティック回帰よりも精度が低い...?

結果

CNNを用いても精度は上がらないどころか、精度が下がる。

考えられる理由としては、

  • 画素数が低く、畳み込みやPoolingが十分にできなかった
  • 前回と同じ画像の加工方法自体に問題があった

この2点でしょうか。
と言うのも、実は手書き文字の教師データにはsklearnのdigitsの他にMNISTという有名なものがありまして(28×28画素と解像度が段違いに高い)、そちらを使えばより多くの畳み込み、Pooling層を用意できるのです。

ちなみにそちらの方の結果は下記(学習には3分ほどかかる)

判別結果
観測: [0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5 6 6 6 7 7 7 8 8 8 9 9 9]
予測: [0 0 0 5 1 1 2 2 2 3 3 3 4 1 4 5 5 5 6 6 5 7 7 7 8 8 8 4 9 4]
正答率: 0.833333333333

学習回数は10回ですが、正答率が高いです。

5. まとめ

変数の少ないデータに畳み込みニューラルネットを用いても、その精度は高くはならない。

当然ながら、深層学習にも不得意なデータがあるようです。
CNNを用いる場合は小さなデータで学習しようとしてもたくさんの学習を必要とするため、単純な機械学習よりも処理時間が増えてしまいますし、精度も上がりません。

といったことを確認できたところで、今回はおしまいです。

今回作成したスクリプト
CNNの層クラスモジュール

opem / HR / source / CNNclasses.py — Bitbucket

CNNによるdigitsデータを用いた手書き文字認識

opem / HR / source / CNN_hr_bydigits.py — Bitbucket

CNNによるMNISTデータを用いた手書き文字認識

opem / HR / source / CNN_hr_byMINST.py — Bitbucket