u++の備忘録

人工知能(AI)にミス東大2017の結果を予想させてみる

はじめに

昨日、某所で「画像認識」を題材にしたハッカソンに参加しました。

秋の到来→学園祭シーズン→学園祭といえばミスコン という思考でアイデアを考えた結果、下記の記事を参考にすれば比較的手軽に実装ができそうだと思い、タイトルのようなテーマで取り組むことにしました。

qiita.com

コードはGithubで公開しています。

github.com

提案手法

提案手法の概要

今回は、画像分類で高い性能を示すConvolutional Neural Network(CNN)を用いた分類器を開発することにしました。CNNは、皆大好きディープラーニングの一つです。

  1. 過去データで学習
    1. サイトから画像を収集
    2. 顔検出
    3. 順位でラベルを付与
    4. CNNで学習
  2. 新規データの順位を予想

CNNの選定理由

  • 画像分類で高い性能を出している
  • 特徴量の設計が難しいタスク

サイトから画像を収集

ここでは、過去のミスコン出場者の画像を収集します。

冒頭で紹介したサイト(以下、先行研究)に掲載されていたコードを参考に、「日本初!ミスコンテストに特化したポータルサイト MISS COLLE」から画像を取得しました。

Python3系版に移植した方のコードを使い、htmlを取得できなかった場合の例外処理のみを追加しました。

def fetch_photos():
    with open('page_urls.txt') as f:
        for url in f:
            try:
                # Make directories for saving images
                dirpath = 'photos/{}'.format(url.strip().split('/')[-1].replace("20","/20").replace("miss",""))
                os.makedirs(dirpath, exist_ok=True)
    
                y = int(url[-4:])
    
                if y > 2012:
                    # 2013年以降用
                    html = urllib.request.urlopen('{}/photo'.format(url.strip()))
                else:
                    # 2012年以前用
                    url2012 = "https://misscolle.com/" + url.strip().split('/')[-1].replace("miss","")
                    html = urllib.request.urlopen('{}/photo'.format(url2012))
    
                soup = bs4.BeautifulSoup(html, 'html.parser')
                photos = soup.find_all('li', class_='photo')
                paths = map(lambda path: path.find('a').get('href'), photos)
    
                for path in paths:
                    filename = '_'.join(path.split('?')[0].split('/')[-2:])
                    filepath = '{}/{}'.format(dirpath, filename)
                    print(filepath)
                    # Download image file
                    urllib.request.urlretrieve('{}{}'.format(base_url, path), filepath)
                    # Add random waiting time (4 - 6 sec)
                    time.sleep(4 + random.randint(0, 2))
            except:
                pass

上記の"try", "except:", "pass"を追加しただけです。

顔検出

ここでは、取得した画像から顔部分のみを抽出します。背景や服装などの影響をなくし、単に顔の情報のみで学習させるためです。

先行研究をPython3系版に移植した方のコードをそのまま利用しました。OpenCVのHaar特徴量に基づくCascade識別器を用いた顔検出を利用しています。

順位でラベルを付与

過去の結果を参考に、「ミス(1st)」「準ミス(2nd)」「その他(others)」に手作業でフォルダごとに分類していきます。本当はこの部分も自動化すべきだったのですが、コードを書く時間+データを取得する時間が共に不足したため、今回は手作業で行いました。そのため、最終的に学習に利用できたデータ量が極端に少なくなっています。ここを修正するのが、直近の最大の課題です。「test」には検証データを入れます。

f:id:upura:20171105180113p:plain

CNNで学習

TensorflowのMnist分類用のサンプル(Multilayer Convolutional Network)を編集して実装しました。下記サイトを参考にし実装し、Tensorflowのバージョン更新のせいか発生したバグを何点か修正しました。

qiita.com

Tensorflowの可視化ツールTensorBoardで出力したネットワーク図は以下です。

f:id:upura:20171105180559p:plain

デフォルトの設定通り、活性化関数にはReLU、パラメータ最適化にはAdamを使っています。またミニバッチ処理で学習させています。

import tensorflow as tf
sess = tf.InteractiveSession()

import os
import numpy as np
import cv2

NUM_CLASSES = 3 # 分類するクラス数
IMG_SIZE = 28 # 画像の1辺の長さ
COLOR_CHANNELS = 3 # RGB
IMG_PIXELS = IMG_SIZE * IMG_SIZE * COLOR_CHANNELS # 画像のサイズ*RGB

# 画像のあるディレクトリ
train_img_dirs = ['pics/1st', 'pics/2nd', 'pics/others']

# 学習画像データ
train_image = []
# 学習データのラベル
train_label = []

for i, d in enumerate(train_img_dirs):
    # ./data/以下の各ディレクトリ内のファイル名取得
    files = os.listdir('./'+d)
    for f in files:
        # 画像読み込み
        img = cv2.imread('./' + d + '/' + f)
        # 1辺がIMG_SIZEの正方形にリサイズ
        img = cv2.resize(img, (IMG_SIZE, IMG_SIZE))
        # 1列にして
        img = img.flatten().astype(np.float32)/255.0
        train_image.append(img)
        # one_hot_vectorを作りラベルとして追加
        tmp = np.zeros(NUM_CLASSES)
        tmp[i] = 1
        train_label.append(tmp)

# numpy配列に変換
train_image = np.asarray(train_image)
train_label = np.asarray(train_label)

def weight_variable(shape):
    initial = tf.truncated_normal(shape, stddev=0.1)
    return tf.Variable(initial)

def bias_variable(shape):
    initial = tf.constant(0.1, shape=shape)
    return tf.Variable(initial)

def conv2d(x, W):
    return tf.nn.conv2d(x, W, strides=[1, 1, 1, 1], padding='SAME')

def max_pool_2x2(x):
    return tf.nn.max_pool(x, ksize=[1, 2, 2, 1],
                        strides=[1, 2, 2, 1], padding='SAME')

x = tf.placeholder(tf.float32, shape=[None, IMG_PIXELS])
y_ = tf.placeholder(tf.float32, shape=[None, NUM_CLASSES])

W = tf.Variable(tf.zeros([IMG_PIXELS, NUM_CLASSES]))
b = tf.Variable(tf.zeros([NUM_CLASSES]))

sess.run(tf.initialize_all_variables())

y = tf.nn.softmax(tf.matmul(x,W) + b)

cross_entropy = -tf.reduce_sum(y_*tf.log(y))

train_step = tf.train.GradientDescentOptimizer(0.01).minimize(cross_entropy)

W_conv1 = weight_variable([5, 5, COLOR_CHANNELS, 32])
b_conv1 = bias_variable([32])

x_image = tf.reshape(x, [-1, IMG_SIZE, IMG_SIZE, COLOR_CHANNELS])

h_conv1 = tf.nn.relu(conv2d(x_image, W_conv1) + b_conv1)
h_pool1 = max_pool_2x2(h_conv1)

W_conv2 = weight_variable([5, 5, 32, 64])
b_conv2 = bias_variable([64])

h_conv2 = tf.nn.relu(conv2d(h_pool1, W_conv2) + b_conv2)
h_pool2 = max_pool_2x2(h_conv2)

W_fc1 = weight_variable([7 * 7 * 64, 1024])
b_fc1 = bias_variable([1024])

h_pool2_flat = tf.reshape(h_pool2, [-1, 7*7*64])
h_fc1 = tf.nn.relu(tf.matmul(h_pool2_flat, W_fc1) + b_fc1)

keep_prob = tf.placeholder(tf.float32)
h_fc1_drop = tf.nn.dropout(h_fc1, keep_prob)

W_fc2 = weight_variable([1024, NUM_CLASSES])
b_fc2 = bias_variable([NUM_CLASSES])

y_conv=tf.nn.softmax(tf.matmul(h_fc1_drop, W_fc2) + b_fc2)

cross_entropy = -tf.reduce_sum(y_*tf.log(y_conv))
train_step = tf.train.AdamOptimizer(1e-4).minimize(cross_entropy)
correct_prediction = tf.equal(tf.argmax(y_conv,1), tf.argmax(y_,1))
accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))
sess.run(tf.initialize_all_variables())

STEPS = 100 # 学習ステップ数
BATCH_SIZE = 50 # バッチサイズ
train_accuracies = []

for i in range(STEPS):
    random_seq = list(range(len(train_image)))
    np.random.shuffle(random_seq)

    for j in range(round(len(train_image)/BATCH_SIZE)):
        batch = BATCH_SIZE * j
        train_image_batch = []
        train_label_batch = []
        for k in range(BATCH_SIZE):
            train_image_batch.append(train_image[random_seq[batch + k]])
            train_label_batch.append(train_label[random_seq[batch + k]])
        train_step.run(feed_dict={x: train_image_batch, y_: train_label_batch, keep_prob: 0.5})

    # 毎ステップ、学習データに対する正答率を表示
    train_accuracy = accuracy.eval(feed_dict={
            x:train_image, y_: train_label, keep_prob: 1.0})
    print("step %d, training accuracy %g"%(i, train_accuracy))
    train_accuracies.append(train_accuracy)

新規データの順位を予想

各候補者の検証データについて学習データと同様に顔検出した後、学習済ネットワークで分類結果を予想させます。各候補者によって顔検出できた枚数が異なるので、ミス:2点、準ミス:1点、その他:0点として平均点を算出して、最終的な比較をすることにしました。

例えばAさんの画像5枚を顔検出して3枚が顔検出し、「ミス(1st)」「ミス(1st)」「準ミス(2nd)」と判定された場合、Aさんのスコアは5/3点になります。

ミス東大2016を予想させてみる

ここでは、既に結果が出ているミス東大2016を予想させてみることで、提案手法の性能を評価してみます。

学習データ

  • 東大ミスコン2008, 2010, 2011, 2012年
    • ミス: 46枚、準ミス: 39枚、その他: 230枚

検証データ

  • 東大ミスコン2016年
    • 候補者5人分✕5枚

学習の様子

ミニバッチ処理で学習させた際のTraining Accuracyの推移を以下に示します。順調に学習できていると分かります。横軸がステップ数、縦軸がTraining Accuracyです。

f:id:upura:20171105181938p:plain

評価結果と考察

CNNによる評価結果と実際の結果は以下の通りです。

f:id:upura:20171105182121p:plain

左から3人目の画像を「準ミス」「ミス」「準ミス」と判定し、総合で「ミス」だと予想しました。同様にして、左から4人目を「準ミス」と予想しました。実際の結果と比べると「準ミス」は的中、「ミス」は外すという結果になりました。そこそこ上手くいっている気がします。

本当はもっと検証データ数を増やして定量的に評価すべきだったのですが、時間がありませんでした。

ミス東大2017を予想させてみる

ここでは、11月末の駒場祭で決定するミス東大2017を予想させてみます。

学習データ

学習データに、先に使った2016年分のデータも加えます。

  • 東大ミスコン2008, 2010, 2011, 2012, 2016年
    • ミス: 48枚、準ミス: 40枚、その他: 237枚

検証データ

  • 東大ミスコン2017年
    • 候補者4人分✕5枚

評価結果

CNNによる評価結果は、以下の通りです。CNNによると、今年のミス東大はレベルが高いようです。全画像で「ミス」と判定された方が2人いたので、より枚数が多かった右端の方を「ミス」、左端の方を「準ミス」と予想したと見なしました。

f:id:upura:20171105183056p:plain

おわりに(今後の展望)

今回のハッカソンでは、CNNを用いた分類器を作成し、ミス東大2017を予想させてみました。定量的な検証が出来ていないという大きな問題点はあったのですが、恐らく話題性とプレゼン能力のおかげで、10人中2位を獲得できました。

あとは11月24~26日に開催される東大・駒場祭で結果が的中するのを祈るのみです。

misscolle.com

今後の展望

まずはデータセットの拡充と、提案手法の定量的な検証が最重要事項です。また別次元での課題として、非ニューラルネットワーク手法での調査も必要だと思います(本来はこちらから試すべきですが、ハッカソンでの惹きのためにCNNでやってしまいました)

追記(11月26日)

ミスが左から2番目、準ミスが右端だったようです。うーーん残念。