「名刺の項目予測」モデル実装例

create date : Jul. 23, 2018 at 20:37:23

概要

「Sansan株式会社 名刺の項目予測」( https://signate.jp/competitions/26 )のチュートリアルです.

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

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

このチュートリアルでは古典的な画像処理手法HOGを用いて特徴量抽出をして, 様々なモデルを用いたモデリングを行い比較しました.
結果, 異なるモデルでアンサンブル(majority voting)を行うことで精度が改善することがわかりました.

はじめに

分析環境としては以下を想定しています.

  • OS: Windows 10 Pro
  • 言語: Python==3.6.3
  • ライブラリ
    • pandas==0.23.0
    • numpy==1.14.3
    • Pillow==5.1.0
    • scikit-image==0.13.0
    • scikit-learn==0.19.1

メタデータの読み込みでpandasを, 画像データの読み込みの部分でPillowを, 特徴抽出の部分でscikit-imageを使いたいと思います.

学習用画像に対するメタデータ(train.csv)の読み込み

まず, 学習用画像に対するメタデータ(train.csv)を読み込み, 中身を確認します.

In [1]:
import os
import pandas as pd
In [2]:
_data_path = os.path.join('..','data','image','sansan')
_train_path = os.path.join(_data_path,'train.csv')
In [3]:
df = pd.read_csv(_train_path)
df.head()
Out[3]:
file_name 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"は画像データにおける項目領域の座標にあたります.
カラム"company_name"~"url"はそれぞれの項目種類かどうかのフラグです.
後で実際の画像で確認しますが, 例えば1行目のデータを見ると画像ファイル"2842.png"の((491, 455), (796, 485))に当たる領域は"mobile",
つまり携帯電話番号が写っている画像となります.

学習用画像データ(train_images_1.zip, train_images_2.zip, train_images_3.zip)の読み込み

学習用画像データはzip形式ですが, まず作業ディレクトリにおいて, 解凍して一つのフォルダ(ここではtrain_imagesとします)にまとめておいてください.
画像ファイル"2842.png"の((491, 455), (796, 485))に当たる領域を見てみます.

In [4]:
from PIL import Image
In [5]:
_img_path = os.path.join(_data_path,'train_images')
In [6]:
img = Image.open(os.path.join(_img_path, '2842.png'))

領域を切り出すにはcropメソッドを使います. (left, top, right, bottom)を渡します.

In [7]:
img_cropped = img.crop((491, 455, 796, 485))
img_cropped
Out[7]:

確かに携帯電話番号が写っています.

train.csvの3行目を見ると, 画像ファイル"95.png"の((320, 498), (865, 521))に当たる領域は"mobile"と"fax", つまり携帯電話番号とfax番号が写っている
画像となることが確認できます. 実際に見てみます.

In [8]:
img = Image.open(os.path.join(_img_path, '95.png'))
img_cropped = img.crop((320, 498, 865, 521))
img_cropped
Out[8]:

確かにfax番号と携帯番号が写っています.

画像ファイルの数と領域の総数を確認します.

In [9]:
print('画像ファイルの数:', len(df['file_name'].unique()))
print('領域の総数:', len(df))
画像ファイルの数: 2840
領域の総数: 25357

次に全データ数とそれぞれの項目の出現数を見てみます.

In [10]:
print('全データ:', len(df))
print(df[['company_name','full_name','position_name', 'address', 'phone_number', 'fax', 'mobile', 'email','url']].agg(sum))
全データ: 25357
company_name     2994
full_name        2913
position_name    3545
address          2895
phone_number     2841
fax              2840
mobile           2799
email            2837
url              2793
dtype: int64

領域別の項目の出現数を見てみます.

In [11]:
print('全データ:', len(df))
print(df[['company_name','full_name','position_name', 'address', 'phone_number', 'fax', 'mobile', 'email','url']].sum(axis=1).value_counts())
全データ: 25357
1    24422
2      791
3      127
4       14
5        2
6        1
dtype: int64

画像ファイル別の領域の数の分布を見てみます.

In [12]:
%matplotlib inline
df_img_file = df.groupby('file_name').apply(len)
df_img_file.hist()
Out[12]:
<matplotlib.axes._subplots.AxesSubplot at 0x1f97e78fa58>

手法

次にHOGにより画像の特徴量抽出を行い, ロジスティック回帰モデルによって項目を予測するモデルを構築します.

HOGによる特徴量抽出

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

HOGによる特徴量抽出を行うまえに画像をグレースケール化し, 画像データの大きさをそろえるとその後都合がよいので, 以下のようにして,
領域を切り出した画像をグレースケール化し, 指定の大きさ(横, 縦)=(216, 72)にします.

In [13]:
img_cropped = img_cropped.convert('L') # convertメソッドによりグレースケール化
print('元の大きさ:', img_cropped.size)
img_resized = img_cropped.resize((216, 72)) # (216, 72)にリサイズする
print('リサイズ後の大きさ:', img_resized.size)
元の大きさ: (545, 23)
リサイズ後の大きさ: (216, 72)
In [14]:
img_cropped
Out[14]:
In [15]:
img_resized
Out[15]:

scikit-imageではfeatureモジュールのhogに実装されています. skimage.featureからhogをインポートします.
また, 画像データを数値データ(配列)に変換するためにnumpyをインポートします.

In [16]:
import numpy as np
from skimage.feature import hog
In [17]:
img_array = np.array(img_resized)  # 画像データを数値データに変換
img_feat = hog(img_array,
               orientations = 12,
               pixels_per_cell = (12, 12),
               cells_per_block = (1, 1),
               block_norm='L1-sqrt')
print(img_feat.shape)
(1296,)

今回の場合は上記のようなパラメータにより, 1296次元の特徴量が得られました.
pixels_per_cell, cells_per_blockなどのパラメータについて詳しく知りたい方は[4]を参照してください.
パラメータを変えることで特徴ベクトルの大きさを変えることができます.

これまでの処理(データの読み込み)⇒(特徴抽出)を一つの関数にまとめてみます.
項目ラベルが与えられたの場合(学習や検証の場合)は対応する項目ラベルも返すようにします.

In [18]:
import time

def load_data(file_name, img_dir, img_shape, orientations, pixels_per_cell, cells_per_block, label=True):
    classes = ['company_name', 'full_name', 'position_name', 'address', 'phone_number', 'fax', 'mobile', 'email', 'url']
    df = pd.read_csv(file_name)
    n = len(df)
    if label:
        Y = np.zeros((n, len(classes)))
    print('loading...')
    s = time.clock()
    for i, row in df.iterrows():
        f, l, t, r, b = row['file_name'], 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 = img.convert('L')
        img_array = np.array(img.resize(img_shape))/255.       # img_shapeに従った大きさにそろえる
        
        
        # feature extraction
        img_feat = hog(img_array,
                       orientations = orientations,
                       pixels_per_cell = pixels_per_cell,
                       cells_per_block = cells_per_block, 
                       block_norm='L1-sqrt')
        if i == 0:
            feature_dim = len(img_feat)
            print('feature dim:', feature_dim)
            X = np.zeros((n, feature_dim))
        if (i+1)%5000==0:
            print((i+1), '/', n)
        
        X[i,:] = np.array([img_feat])
        if label:
            y = list(row[classes])
            Y[i,:] = np.array(y)
    
    print('Done. Took', time.clock()-s, 'seconds.')
    if label:
        return X, Y
    else:
        return X

モデルを学習させるための学習用データを作成します.

In [19]:
img_shape = (216,72)
orientations = 12
pixels_per_cell = (12, 12)
cells_per_block = (1, 1)
X_train, Y_train = load_data(_train_path, _img_path, img_shape, orientations, pixels_per_cell, cells_per_block)
print('学習データの数:', X_train.shape[0])
print('特徴ベクトルの次元数:', X_train.shape[1])
loading...
feature dim: 1296
5000 / 25357
10000 / 25357
15000 / 25357
20000 / 25357
25000 / 25357
Done. Took 481.213179611916 seconds.
学習データの数: 25357
特徴ベクトルの次元数: 1296

モデルの学習

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

In [134]:
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.neural_network import MLPClassifier

class MultiLabelClassifier():
    def __init__(self, n_out, model_name=None):
        self.n_out = n_out
        if model_name == 'LR':
            print('LR')
        elif model_name == 'GB':
            print('GB')
        elif model_name == 'RF':
            print('RF')
        elif model_name == 'MLP':
            print('MLP')
        else:
            print('LR')
        model_list = []
        for l in range(self.n_out):
            if model_name == 'LR':
                model_list.append(LogisticRegression(C=0.1))
            elif model_name == 'GB':
                model_list.append(GradientBoostingClassifier())
            elif model_name == 'RF':
                model_list.append(RandomForestClassifier())
            elif model_name == 'MLP':
                model_list.append(MLPClassifier(alpha=0.05, early_stopping=True))
            else:
                model_list.append(LogisticRegression())
        self.models = model_list
        
    def fit(self, X, Y):
        i = 0
        start_overall = time.clock()
        for model in self.models:
            start = time.clock()
            print('training model No.%s...'%(i+1))
            model.fit(X, Y[:,i])
            print('Done. Took', time.clock()-start, 'seconds.')
            i += 1
        print('Done. Took', time.clock()-start_overall, 'seconds.')
    
    def predict(self, X):
        i = 0
        predictions = np.zeros((len(X), self.n_out))
        start = time.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', time.clock()-start, 'seconds.')
        
        return predictions

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

まずはロジスティック回帰モデルで学習を行います.

In [135]:
model_lr = MultiLabelClassifier(n_out = 9, model_name='LR')  # 今回は9項目あるため, クラス数は9個に設定
model_lr.fit(X_train, Y_train)
LR
training model No.1...
Done. Took 4.513328033433027 seconds.
training model No.2...
Done. Took 4.316454731437261 seconds.
training model No.3...
Done. Took 4.603055222286457 seconds.
training model No.4...
Done. Took 4.663617036017968 seconds.
training model No.5...
Done. Took 4.495839456784779 seconds.
training model No.6...
Done. Took 4.344307287241463 seconds.
training model No.7...
Done. Took 4.9404337409150685 seconds.
training model No.8...
Done. Took 4.433236573687282 seconds.
training model No.9...
Done. Took 4.2947939420682815 seconds.
Done. Took 40.606530546752765 seconds.

次に多層ニューラルネットワークモデルで学習を行います.

In [136]:
model_mlp = MultiLabelClassifier(n_out = 9, model_name='MLP')  # 今回は9項目あるため, クラス数は9個に設定
model_mlp.fit(X_train, Y_train)
MLP
training model No.1...
Done. Took 6.120318066946311 seconds.
training model No.2...
Done. Took 7.0039173069699245 seconds.
training model No.3...
Done. Took 10.654255904951242 seconds.
training model No.4...
Done. Took 5.90686239830211 seconds.
training model No.5...
Done. Took 4.7239990664402285 seconds.
training model No.6...
Done. Took 7.057709349096513 seconds.
training model No.7...
Done. Took 3.6201452124032585 seconds.
training model No.8...
Done. Took 6.571743752256225 seconds.
training model No.9...
Done. Took 4.995939741593247 seconds.
Done. Took 56.65645961804239 seconds.

予測値の出力

学習が終わったらそれぞれの学習済みモデルに対してX_trainの予測値を出力します.

In [137]:
predictions_lr_train = model_lr.predict(X_train)
predictions_mlp_train = model_mlp.predict(X_train)
predicting...
1 / 9
2 / 9
3 / 9
4 / 9
5 / 9
6 / 9
7 / 9
8 / 9
9 / 9
Done. Took 0.4446044949472707 seconds.
predicting...
1 / 9
2 / 9
3 / 9
4 / 9
5 / 9
6 / 9
7 / 9
8 / 9
9 / 9
Done. Took 1.4750166837457073 seconds.

精度評価

まず, 学習データX_trainに対する予測値predictionsと答えY_trainを照らし合わせて学習データに対する精度を評価します.

本コンペの評価関数である平均絶対誤差(Mean Absolute Error)は以下のように実装できます.

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

平均絶対誤差(Mean Absolute Error)によりそれぞれのモデルの学習データに対する精度を評価します.

In [139]:
print('Logistic Regression:', mae(Y_train, predictions_lr_train))
print('MLP:', mae(Y_train, predictions_mlp_train))
Logistic Regression: 0.010766257838072326
MLP: 0.00964011690832687

次に評価用画像に対する予測を行い, 精度を評価してみます.

In [94]:
_test_path = os.path.join(_data_path, 'test.csv')
_test_img_path = os.path.join(_data_path, 'test_images')
_ans_path = os.path.join(_data_path, 'ans.csv')
_fin_path = os.path.join(_data_path, 'fin.csv')
_tmp_path = os.path.join(_data_path, 'temp.csv')
In [27]:
X_test = load_data(_test_path, _test_img_path, img_shape, orientations, pixels_per_cell, cells_per_block, label=False)
loading...
feature dim: 1296
5000 / 8918
Done. Took 163.83244997611394 seconds.
In [140]:
predictions_lr_test = model_lr.predict(X_test)
predictions_mlp_test = model_mlp.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.16607346682758362 seconds.
predicting...
1 / 9
2 / 9
3 / 9
4 / 9
5 / 9
6 / 9
7 / 9
8 / 9
9 / 9
Done. Took 0.4822995489012101 seconds.

評価用画像データに対する答えのファイルを読み込みます.

In [141]:
ans = pd.read_csv(_ans_path,header=None,index_col=0)
ans_array = np.array(ans)

平均絶対誤差(Mean Absolute Error)によりそれぞれのモデルの評価用データに対する精度を評価します.

In [142]:
print('Logistic Regression:', mae(ans_array, predictions_lr_test))
print('MLP:', mae(ans_array, predictions_mlp_test))
Logistic Regression: 0.02324885998355386
MLP: 0.022937380080237223

多層ニューラルネットワークモデルの精度が良いようです.

得られた2つの結果をアンサンブル(多数決)して精度を評価してみます.

In [163]:
predictions_ensemble_test = ((predictions_lr_test+predictions_mlp_test)>=1).astype(np.int)
In [164]:
mae(ans_array, predictions_ensemble_test)
Out[164]:
0.022787869726645236

少し改善が見られました.

まとめ

本チュートリアルでは, 特徴抽出⇒モデル学習という古典的な手法でモデリングを試してみました.
結果, 異なるモデルによるアンサンブルにより若干の精度改善がみられました.
さらなる改善案として, HOGのパラメータ(orientations, pixels_per_cellなど)を変え, 特徴量の次元数を変えること,
またはHOGのほかにいろいろな特徴量を連結させて新たに特徴量を作ることなどが考えられます.
そして, モデルのハイパーパラメータ(l2正則化項など)を最適化することでさらに精度が改善する可能性があります.
あるいは, 特徴量抽出を深層学習モデル(畳み込みニューラルネットワーク)によって自動的に獲得してしまうことも考えられます.
また, 2項目以上写っている場合はお互いがノイズとなって, 結果として誤認識率が上がってしまったことが考えられるので,
領域をさらに細かく特定するアルゴリズムによって1項目のみ写るように画像を分割することが改善案としてあります.

create date : Jul. 23, 2018 at 20:37:23