概要

「人工知能は名刺をどこまで解読できるのか?!」コンテスト( https://deepanalytics.jp/compe/26 ) のチュートリアルです.
本コンテストで与えられる画像データは学習用に3,480枚,テスト用に1,001枚です. 各名刺画像につき, 平均で約8領域の位置座標が
指定されていて, それらの領域画像に対して自動的にラベル付を行うアルゴリズムを作成するのが本コンテストの課題です.
領域の種類は全部で9種類で, 会社名, 名前, 役職, 住所(郵便番号を含む), 電話番号, fax番号, 携帯番号, E-mailアドレス, HPのURLです.

今回用いる評価尺度は平均絶対誤差(Mean Absolute Error)です. この評価尺度は値が小さいほど精度のよさを表すため,
テストデータに対する平均絶対誤差をなるべく小さくするモデルを作成するのが今回の最終的な目的です.

このチュートリアルでは(画像データの取り込み)⇒(特徴抽出)⇒(モデルの学習)⇒(予測値の出力+評価)までのフローの実装を一つ一つ説明していきます.
実装にはpythonを用います. pythonには画像処理ライブラリが充実していて, PIL, mahotas, cv2(opencvのpythonバインディング)などがありますが,
今回は画像処理ライブラリとして, scikit-image(http://scikit-image.org/) とPIL(https://pillow.readthedocs.io/en/3.3.x/) という
ライブラリを用いて実装したいと思います. このチュートリアルの残りの構成は次のようになります.

  • scikit-imageとPILのインストール
  • 必要なデータのダウンロード
  • 処理フローの実装
    1. 画像データの取り込み
    2. 特徴抽出
    3. モデルの学習
    4. 予測値の出力+評価
  • まとめ

画像データの取り込みの部分でPILを, 特徴抽出の部分でscikit-imageを使いたいと思います.
この実装はあくまで一例なのでほかにも方法はたくさんあると思われますが, 参考になれば幸いです.
本チュートリアルを読んだ後にできるようになると期待されることは

  • 基本的な画像データの取り扱い
  • scikit-imageによる画像データの特徴抽出
  • 本コンテストの基本的なモデリングアプローチの理解

等です.以下の実装ではnumpy, scikit-learn, pandasはインストール済みとします.

scikit-imageとPILのインストール

pipが使える環境ならば pip install scikit-image pip install PIL でインストールできます. またはAnacondaという無料のディストリビューションにはscikit-imageをはじめデータ分析を行うのに
必要なライブラリ(numpyやscikit-learnなど)をほとんど含んでいるのでインストールすることをお勧めします.
インストールはhttps://www.continuum.io/downloads からできるのでお使いの環境に応じてインストールすれば後が楽になります.

以下ではpython3.5の64-bitをインストールした環境下で作業を行います.

必要なデータのダウンロード

https://deepanalytics.jp/compe/26/download からデータをダウンロードします. 以下のデータをダウンロードしてください.

  • 学習用データ(train.csv)
  • 学習用画像データ(train_images1.zip, train_images2.zip, train_images3.zip)

本チュートリアルでは, テスト用データ(test.csv), テスト用画像データ(test_images.zip), 応募用サンプルファイル(sample_submit.csv)は
特に扱いません. 学習用データ(train.csv)には名刺画像と対応する領域の位置情報とラベルが記載されています. 中身を見てみます.

In [1]:
import pandas as pd

df = pd.read_csv('train.csv')
df.head()
Out[1]:
filename left top right bottom company_name full_name position_name address phone_number fax mobile email url
0 2842.png 491 455 796 485 0 0 0 0 0 0 1 0 0
1 182.png 24 858 311 886 0 0 0 0 0 0 1 0 0
2 95.png 320 498 865 521 0 0 0 0 0 1 1 0 0
3 2491.png 65 39 497 118 1 0 0 0 0 0 0 0 0
4 3301.png 271 83 333 463 0 1 1 0 0 0 0 0 0

左から順に, カラム"filename"は画像データファイル, カラム"left"~"bottom"はhttps://deepanalytics.jp/compe/26 の「項目領域の表現の仕方」
にあるように, 画像データにおける項目領域の座標にあたります. カラム"company_name"~"url"はそれぞれの項目種類かどうかのフラグです.

学習用画像データはzip形式ですが, 解凍して一つのフォルダ(ここではtrain_imagesとします)にまとめます.

処理のフローの実装

全体としては(画像データの取り込み)⇒(特徴抽出)⇒(モデル学習)⇒(予測値の出力+評価)という形になります. それぞれの実装を 見ていきます.

1. 画像データの取り込み

画像データはpng形式です. まず必要なモジュールなどをインポートします.

In [2]:
import os
import numpy as np
from time import clock
from PIL import Image

train.csvの最初のデータを見てみます. 名刺画像から該当する項目領域を切り取ります.

In [3]:
img = Image.open(os.path.join('train_images', df.ix[0].filename))                      # 2842.pngの読み込み
img_cropped = img.crop((df.ix[0].left, df.ix[0].top, df.ix[0].right, df.ix[0].bottom)) # cropメソッドにより項目領域を切り取る
img_cropped
Out[3]:

このデータには"mobile"にフラグが立っています. 実際に見てみると, 確かに携帯電話が書いてある領域です.
もう一つのデータを見てみます.

In [4]:
img_2 = Image.open(os.path.join('train_images', df.ix[2].filename))
img_2_cropped = img_2.crop((df.ix[2].left, df.ix[2].top, df.ix[2].right, df.ix[2].bottom))
img_2_cropped
Out[4]:

このデータには"fax"と"mobile"にフラグが立っています. 実際見てみると, 確かにファックス番号と携帯番号が書いてある領域です.
今後のために最初の画像データ(img_cropped)をグレースケール化しておきます. 以下のようにするとできます.

In [5]:
img_cropped = img_cropped.convert('L') # convertメソッドによりグレースケール化
print(img_cropped.size)
(305, 30)

305×30の画像データということがわかります.

それぞれの画像データは大きさがバラバラなので, 今後のために画像データの大きさをそろえるべく, 大きさを変えておきます.
ここでは216×72で統一しておきます. 画像データの大きさを変えるには以下のようにします.

In [6]:
img_resized = img_cropped.resize((216, 72))  # resizeメソッドにより画像の大きさを変える
img_resized
Out[6]:

実際にモデリングする場合は画像データを数値化しておく必要があります.
numpyのarrayオブジェクトに変換します.

In [7]:
img_array = np.array(img_resized)
print(img_array.shape)
(72, 216)

数値データ化すると, 大きさの表記が画像データの場合と変わることに注意してください.
最初の要素がY軸方向の大きさで, 2番目の要素がX軸方向の大きさです.

2. 特徴抽出

これで画像データを読み込んで数値データ化することができるようになりました. 次はモデリングする際に入力する特徴量を求めてみます.
このままの数値データだと次元が大きすぎるので低次元のベクトルに変換します. データを低次元に落とすことは計算量の削減と余計な
情報の削減をしてデータの本質的な特徴を抽出する効果があります. そのため, この処理をうまく行えれば性能を大きく向上させることが期待できます.
画像データの特徴量を抽出する方法論は数多く存在しますが, ここでは画像の特性を考慮した
HOG(Histogram of Oriented Gradients)という特徴量を紹介します. HOGの詳しい説明についてはたとえば

などを参考にしてください.一般に, 幾何学的変換(平行移動や回転移動)や照明の変動に頑健な特徴量です. scikit-imageでは
featureモジュールのhogに実装されています. skimage.featureからhogをインポートします.

In [8]:
from skimage.feature import hog

img_data = np.array(hog(img_array,orientations = 6,
                        pixels_per_cell = (12, 12),
                        cells_per_block = (1, 1)))     
print(img_data.shape)
(648,)

pixels_per_cell, cells_per_blockなどのパラメータについて詳しく知りたい方は
http://scikit-image.org/docs/dev/api/skimage.feature.html?highlight=hog#skimage.feature.hog
などを参照してください.
今回の場合は上記のようなパラメータにより, 648次元の特徴量が得られました. パラメータを変えることで特徴ベクトルの
大きさを変えることができます.

これまでの処理(データの読み込み)⇒(特徴抽出)を一つの関数にまとめてみます.

In [9]:
def load_data(file_name, img_dir, img_shape, orientations, pixels_per_cell, cells_per_block):
    classes = ['company_name', 'full_name', 'position_name', 'address', 'phone_number', 'fax', 'mobile', 'email', 'url']
    df = pd.read_csv(file_name)
    n = len(df)
    Y = np.zeros((n, len(classes)))
    print('loading...')
    s = clock()
    for i, row in df.iterrows():
        f, l, t, r, b = row.filename, row.left, row.top, row.right, row.bottom
        img = Image.open(os.path.join(img_dir, f)).crop((l,t,r,b)) # 項目領域画像を切り出す
        if img.size[0]<img.size[1]:                                # 縦長の画像に関しては90度回転して横長の画像に統一する
            img = img.transpose(Image.ROTATE_90)
        
        # preprocess
        img_gray = img.convert('L')
        img_gray = np.array(img_gray.resize(img_shape))/255.       # img_shapeに従った大きさにそろえる


        # feature extraction
        img = np.array(hog(img_gray,orientations = orientations,
                           pixels_per_cell = pixels_per_cell,
                           cells_per_block = cells_per_block))
        if i == 0:
            feature_dim = len(img)
            print('feature dim:', feature_dim)
            X = np.zeros((n, feature_dim))
        
        X[i,:] = np.array([img])
        y = list(row[classes])
        Y[i,:] = np.array(y)
    
    print('Done. Took', clock()-s, 'seconds.')
    return X, Y

学習データを1:1に分けて改めて学習用データと検証用データを作ります.

In [10]:
from sklearn.cross_validation import train_test_split

img_shape = (216,72)
orientations = 6
pixels_per_cell = (12,12)
cells_per_block = (1, 1)
X, Y = load_data('train.csv', 'train_images', img_shape, orientations, pixels_per_cell, cells_per_block)
x_train, x_test, y_train, y_test = train_test_split(X, Y, test_size=0.5, random_state=1234)
print('学習データの数:', len(x_train))
print('検証データの数:', len(x_test))
loading...
feature dim: 648
Done. Took 553.6994099436382 seconds.
学習データの数: 12678
検証データの数: 12679

12678個の学習データと12679個の検証データができました.
次はこれらのデータを使ってモデリングとその性能の評価を行います.

3. モデルの学習

生成したデータ(x_train, y_train)を用いてモデルの学習を行います.
このコンテストは一般的にmulti-label分類問題といえます.
画像データに対して複数種類のラベルを複数割り当てる問題です. アプローチは様々考えられますが,
ここでは"one versus rest"法によりmulti-label問題に対応します.
これは, "カテゴリ1とその他", ..., "カテゴリCとその他"という具合に, ラベルの数Cだけ
2値識別器を学習させることにより, multi-label問題に対応する方法です.
2値識別器としては様々考えられますが, ここではロジスティック回帰モデルを用います.
実装はscikit-learnで行います. 以下のように抽象クラスを定義し, multi-label問題に 対応できるようにします.

In [11]:
from sklearn.linear_model import LogisticRegression

class MultiLabelLogistic():
    def __init__(self, n_out):
        self.n_out = n_out
        model_list = []
        for l in range(self.n_out):
            model_list.append(LogisticRegression())
        self.models = model_list
        
    def fit(self, X, Y):
        i = 0
        start_overall = clock()
        for model in self.models:
            start = clock()
            print('training model No.%s...'%(i+1))
            model.fit(X, Y[:,i])
            print('Done. Took', clock()-start, 'seconds.')
            i += 1
        print('Done. Took', clock()-start_overall, 'seconds.')
    
    def predict(self, X):
        i = 0
        predictions = np.zeros((len(X), self.n_out))
        start = clock()
        print('predicting...')
        for model in self.models:
            predictions[:,i] = model.predict(X)
            print(str(i+1),'/',str(self.n_out))
            i += 1
        print('Done. Took', clock()-start, 'seconds.')
        
        return predictions

次に学習データ(x_train, y_train)でモデルを学習させます.

In [12]:
model = MultiLabelLogistic(n_out = 9)  # 今回は9項目あるため, クラス数は9個に設定
model.fit(x_train, y_train)
training model No.1...
Done. Took 2.077760749859749 seconds.
training model No.2...
Done. Took 1.9388669916156687 seconds.
training model No.3...
Done. Took 2.355269105194111 seconds.
training model No.4...
Done. Took 1.882941159807956 seconds.
training model No.5...
Done. Took 1.95400902101062 seconds.
training model No.6...
Done. Took 1.7645349565185597 seconds.
training model No.7...
Done. Took 1.850140545324848 seconds.
training model No.8...
Done. Took 1.7992244246303244 seconds.
training model No.9...
Done. Took 1.6150470324963635 seconds.
Done. Took 17.24171537960376 seconds.

4. 予測値の出力+評価

学習が終わったら(x_test, y_test)のデータでモデルを検証してみます. まず, 予測値を出力し, 評価します.

In [13]:
predictions = model.predict(x_test)
predicting...
1 / 9
2 / 9
3 / 9
4 / 9
5 / 9
6 / 9
7 / 9
8 / 9
9 / 9
Done. Took 0.17603163388503162 seconds.

平均絶対誤差(Mean Absolute Error)は以下のように実装できます.

In [14]:
def mae(y, yhat):
    return np.mean(np.abs(y - yhat))

平均絶対誤差(Mean Absolute Error)によりモデルの精度を評価します.

In [15]:
print('MAE:', mae(y_test, predictions))
MAE: 0.019060388569

1つのラベルが写った画像に対する精度は

In [16]:
single = np.where(y_test.sum(axis=1)==1)
print('num of samples (single label):', len(single[0]))
print('MAE:', mae(y_test[single], predictions[single]))
num of samples (single label): 12189
MAE: 0.0136097209688

2つのラベルが写った画像に対する精度は

In [17]:
double = np.where(y_test.sum(axis=1)==2)
print('num of samples (double label):', len(double[0]))
print('MAE:', mae(y_test[double], predictions[double]))
num of samples (double label): 415
MAE: 0.123694779116

3つのラベルが写った画像に対する精度は

In [18]:
triple = np.where(y_test.sum(axis=1)==3)
print('num of samples (triple label):', len(triple[0]))
print('MAE:', mae(y_test[triple], predictions[triple]))
num of samples (triple label): 64
MAE: 0.303819444444

4つのラベルが写った画像に対する精度は

In [19]:
quadruple = np.where(y_test.sum(axis=1)==4)
print('num of samples (quadruple label):', len(quadruple[0]))
print('MAE:', mae(y_test[quadruple], predictions[quadruple]))
num of samples (quadruple label): 9
MAE: 0.41975308642
In [21]:
print('prediction:\n', predictions[quadruple].astype(np.int))
print('ground truth:\n', y_test[quadruple].astype(np.int))
prediction:
 [[0 0 0 1 1 0 0 0 0]
 [1 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 1 0 0]
 [0 0 0 1 0 0 0 0 0]
 [0 0 0 1 0 0 0 0 0]
 [0 0 0 1 0 0 0 0 0]
 [0 0 0 1 0 0 0 0 0]
 [0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0 0]]
ground truth:
 [[0 0 0 0 1 1 1 0 1]
 [0 0 0 1 0 0 1 1 1]
 [0 0 0 0 1 1 1 0 1]
 [0 0 0 1 1 1 1 0 0]
 [0 0 0 1 1 1 1 0 0]
 [0 0 0 0 1 1 1 0 1]
 [0 0 0 1 1 1 1 0 0]
 [0 0 0 1 1 1 1 0 0]
 [0 0 0 0 1 1 1 0 1]]

いくつかは当たっていますが, やはり複数種類の項目が写った画像に対する認識は難しいようです.

まとめ

本チュートリアルでは, 画像データの読み込みから精度の算出のやり方までを一通り示しました.
あくまで一例なので, 参考程度にしてください. たとえば特徴抽出の手法はHOGのほかに実に
様々提案されていて, 本コンテストにより適した特徴量が存在するはずです. 組み合わせなどを考えて,
複数項目ある画像データに対する精度改善などに努め, 実際に予測結果を投稿してみてください.
また, 深層学習(ディープラーニング)のように, 特徴抽出もデータドリブンで行う方法もあります.
近年では深層学習のフレームワークが充実しつつあり, たとえば,
googleが提供しているtensorflow( https://www.tensorflow.org/ )や,
PFNのchainer( http://chainer.org/ ), theanoとtensorflowバックエンドに対応した
keras( https://keras.io/ [ 日本語版: https://keras.io/ja/ ] )などがあるので,
画像データの読み込みに続くモデリングに使ってみてください.