本当に巨乳顔なんてないのだろうか？

# はじめに

Courseraの [Machine Learning](https://www.coursera.org/learn/machine-learning) という講座を修了したので、自分で一から何か機械学習プロジェクトに取り組んでみようと思ったのが本記事のきっかけです。

とは言ってもテーマが思いつかずQiitaを漁ってたところ、 [ディープラーニングで顔写真から巨乳かどうかを判別してみる (うまくいったか微妙)](https://qiita.com/summer4an/items/db0124eee8103c1d3b85) という記事を見つけました。微妙に終わった理由の一つ「そもそも巨乳顔なんてものはない？」に、本当にそうなのか？と思い、自分でトライしてみることにしました（勝手にすみません）。

# おことわり

先行記事のコメントで巨乳／貧乳という表現はデリカシーに欠けるとあったので、本記事では機械学習の用語を使い、巨乳の方を**陽性クラス**、そうでない方を**陰性クラス**と呼ぶことにします。

またジェンダー論を持ち出している方もいますが、他の方のコメにもあるように個人的にもそこまでかしこまった場ではないと思っているのでトライしてみました。

# 先行記事の要約

- 陽性クラスの画像およそ2500枚、陰性クラスの画像およそ1700枚を用意
- ディープラーニングで学習させて正確度（accuracy）53.4%を得た
- 未知の画像（陽性：84枚、陰性：81枚）を使い予想させてみると、陰性を陽性と誤判定するケースが多かった

# 分類問題の性能指標について

先行記事では**正確度（accuracy）**のみ見ていますが、分類問題の性能を測定する指標としては陽性クラスの正解率を表す**適合率（precision）**、分類器が正しく分類した陽性インスタンスの割合を表す**再現率（recall）**、適合率と再現率を一つにまとめた**F値（F<sub>1</sub> score）**などもあります。

詳しくは [Python: 機械学習で分類問題のモデルを評価する指標について](https://blog.amedama.jp/entry/2017/12/18/005311) を参考にして欲しいのですが、解く問題によってどの指標を使うのかが変わります。

本記事では正確度も測定しますが、陽性／陰性インスタンスの出現頻度が異なる場合などにも評価しやすいF値も測定します。

# データの準備

プログラムを書く前に、まずはプログラムに学習させるデータを準備します。準備はデータ収集とデータの前処理の二段階あります。

## データ収集

訓練データとして画像を集める必要がありますが、今回は [機械学習用の画像を集めるのにicrawlerが便利だった](https://qiita.com/tkt989/items/84c6581dfa1d9a42dc2d) で紹介されていたクローラーを使いました。100枚／人の画像をダウンロードし、そこから別人の画像や下向きの画像などを取り除く作業が入ります。最終的には30〜40枚／人になりました。

続いて集めた画像から顔部分のみを抽出します。最初はOpenCVを使って顔検出するプログラムを自作したのですが、少しでも傾いたりしてると検出できず、検出精度をあげる術を知らなかったので最終的には [Face++ Detect API](https://www.faceplusplus.com/face-detection/) を利用しました。

最終的に集まった画像は陽性クラスが2239枚、陰性クラスが2366枚です（ちなみにネットの情報をもとにDカップ以上を陽性としています）。

## データ前処理

集めた画像はサイズがバラバラのため統一する必要があります。サイズは幾つでも構わないのですが、今回は28×28にしました（Courseraの手書き数字認識の課題で扱った画像が28x28だったので、本記事でも28x28にしました）。

また画像の各ピクセルは0〜255の値ですが、機械学習アルゴリズムの多くは入力の数値属性のスケールが大きく異なると性能を発揮できないことが知られています。そのため数値属性のスケールを統一する必要があります。scikit-learnではスケール変換の手段として、入力を正規化するMinMaxScaler, 標準化するStandardScalerの二つの変換器を利用できます。入力値が特定の範囲（例えば0〜1）に収まることを前提としているアルゴリズムを使う場合はMinMaxScalerを使います。一方そのような制約がない、または外れ値の影響を抑えたい場合はStandardScalerを使います。本記事で使用するSVMは特定の範囲に収まっていることを前提としたアルゴリズムではないため、StandardScalerを使用します。

## データ準備に使用したプログラム

顔の検出にはfacepp.pyを、検出した顔のリサイズにはim-resize.pyを使用しました。ちなみにfacepp.pyはFace++のサイトにあるサンプルコードをベースに作成しましたが、python2じゃないと動かないです。

```facepp.py
# -*- coding: utf-8 -*-
import urllib2
import urllib
import time
import sys, os
sys.path.append(os.pardir)
import glob
import json
import os
from time import sleep

http_url = 'https://api-us.faceplusplus.com/facepp/v3/detect'
key = "Your API Key"
secret = "Your Secret Key"

# ./dataset/train/original/ はダウンロードした画像を格納しているディレクトリ
# originalの下に original/ほげ子, original/ふが子, ..., のような感じで
# 人物ごとのディレクトリを作成し、その中に各人物の画像を格納しています。
image_path = './dataset/train/original/'

# 以下のように各行に人物と分類（陽性=1, 陰性=0）を記載したテキストファイル
# ほげ子 0
# ふが子 1
# ...
train_txt = open('./dataset/train/train.txt', 'r')
for p in train_txt:
  p = p.rsplit()[0]
  files = glob.glob(image_path + p + '/*')

  # dataset/train/face/ は抽出した顔画像を格納するディレクトリ
  # faceの下に face/ほげ子, face/ふが子, ..., のような感じで
  # 人物ごとのディレクトリを作成し、その中に各人物の顔画像を格納しています。
  if not os.path.exists('./dataset/train/face/' + p):
    os.makedirs('./dataset/train/face/' + p)

  for f in files:
    sleep(1)
    boundary = '----------%s' % hex(int(time.time() * 1000))
    data = []
    data.append('--%s' % boundary)
    data.append('Content-Disposition: form-data; name="%s"\r\n' % 'api_key')
    data.append(key)
    data.append('--%s' % boundary)
    data.append('Content-Disposition: form-data; name="%s"\r\n' % 'api_secret')
    data.append(secret)
    data.append('--%s' % boundary)
    fr=open(f,'rb')
    data.append('Content-Disposition: form-data; name="%s"; filename="co33.jpg"' % 'image_file')
    data.append('Content-Type: %s\r\n' % 'application/octet-stream')
    data.append(fr.read())
    fr.close()
    data.append('--%s--\r\n' % boundary)

    http_body='\r\n'.join(data)
    #buld http request
    req=urllib2.Request(http_url)
    #header
    req.add_header('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
    req.add_data(http_body)
    try:
      #post data to server
      resp = urllib2.urlopen(req, timeout=5)
      #get response
      qrcont=resp.read()
      json_dict = json.loads(qrcont)
      faces = json_dict['faces']
      if len(faces) == 1:
        face_rectangle = faces[0]['face_rectangle']
        (x, y, w, h) = (face_rectangle['left'], face_rectangle['top'], face_rectangle['width'], face_rectangle['height'])
        os.system('python im-resize.py ' + str(x) + ' ' + str(y) + ' ' + str(w) + ' ' + str(h) + ' ' + f)

    except urllib2.HTTPError as e:
      print(e.read())
```

```im-resize.py
from PIL import Image
import json
import sys

def save_im_path(im_path):
  return im_path.replace('original', 'face')

def crop_im_and_save(x, y, w, h, im_path):
  im = Image.open(im_path)
  im_crop = im.crop((x, y, x + w, y + h))
  im_crop.resize((28, 28), Image.LANCZOS).convert('L').save(save_im_path(im_path))

args = sys.argv
crop_im_and_save(int(args[1]), int(args[2]), int(args[3]), int(args[4]), args[5])
```

以下のコマンドで実行します。

```
$ /usr/local/bin/python2 facepp.py
```

# 学習と評価

以上で準備は終了し、モデルを作成する作業に入ります。とは言っても機械学習用のライブラリscikit-learn（バージョン: 0.20.dev0）を使うだけなのでそんなに難しいことはないです。

```small_or_big_claffifier.py
import os
import numpy as np
import scipy.io as sio
import matplotlib.pyplot as plt
from sklearn import svm
from sklearn.metrics import confusion_matrix
from sklearn.metrics import precision_recall_curve
from sklearn.naive_bayes import GaussianNB
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import ShuffleSplit
from sklearn.model_selection import GridSearchCV
from sklearn.model_selection import learning_curve
from sklearn.model_selection import cross_val_score
from sklearn.model_selection import train_test_split
from sklearn.model_selection import cross_val_predict


# ==================== データの構築 ====================
def get_train_data():
  """
  訓練データを取得する関数
  読み出したデータの8割を訓練用、2割を評価用に分割する
  """
  datainfo = sio.loadmat('./dataset/train/train.mat')
  X = datainfo['X']
  y = datainfo['y']
  X_train, X_test, y_train, y_test = \
      train_test_split(X, y, test_size=0.2, random_state=42)
  return (X_train, y_train), (X_test, y_test)

(X_train, y_train), (X_test, y_test) = get_train_data()

def normalize(a):
  """
  データを標準化する関数
  """
  std_scaler = StandardScaler()
  return std_scaler.fit_transform(a.astype(np.float64))

def do_data_clansing(X_train, X_test, y_train, y_test):
  """
  データの前処理をする関数
  標準化、データの並べ替えをする
  """
  X_train, X_test = normalize(X_train), normalize(X_test)
  shuffle_index = np.random.permutation(len(X_train))
  X_train, y_train = X_train[shuffle_index], y_train[shuffle_index]
  y_train, y_test = np.reshape(y_train, (-1)), np.reshape(y_test, (-1))
  return X_train, X_test, y_train, y_test

X_train, X_test, y_train, y_test = do_data_clansing(X_train, X_test, y_train, y_test)


# ==================== 最適パラメータの探索 ====================
def do_grid_search(X, y):
  """
  最適パラメータを探索する関数
  """
  param_grid = {
      'C': [0.001, 0.01, 0.1, 1, 10, 100, 1000],
      'gamma' : [0.001, 0.001, 0.01, 0.1, 1, 10, 100] }
  grid_search = GridSearchCV(svm.SVC(), param_grid, cv=5, scoring='f1')
  grid_search.fit(X, y)
  print('Test set score: {}'.format(grid_search.score(X_test, y_test)))
  print('Best parameters: {}'.format(grid_search.best_params_))
  print('Best cross-validation: {}'.format(grid_search.best_score_))

# 最適パラメータは一度求めたら再計算は不要
# do_grid_search(X_train, y_train)


# ==================== モデルの構築 ====================
clf = svm.SVC(kernel='rbf', C=1, gamma=0.001)


# ==================== 学習 ====================
clf.fit(X_train, y_train)


# ==================== 性能評価 ====================
accuracy, precision, recall = cross_val_score(clf, X_train, y_train, cv=3, scoring='f1')
print('accuracy: {0}, precision: {1}, recall: {2}'.format(round(accuracy * 100, 1), round(precision * 100, 1), round(recall * 100, 1)))

y_train_pred = cross_val_predict(clf, X_train, y_train, cv=3)
conf_mx = confusion_matrix(y_train, y_train_pred)
print(conf_mx)


# ==================== 予測 ====================
def get_pred_data():
  """
  予測データを取得する関数
  """
  datainfo = sio.loadmat('./dataset/pred/pred.mat')
  X = datainfo['X']
  return X

X_pred = get_pred_data()
X_pred_normalized = normalize(X_pred)

print(clf.predict(X_pred_normalized))


# ==================== 結果の可視化 ====================
def plot_learning_curve(estimator, title, X, y, ylim=None, cv=None, n_jobs=1, train_sizes=np.linspace(.1, 1.0, 5)):
  """
  学習曲線を描画する関数
  """
  plt.figure()
  plt.title(title)

  if ylim is not None:
    plt.ylim(*ylim)

  plt.xlabel("Training examples")
  plt.ylabel("Score")
  train_sizes, train_scores, test_scores = learning_curve(
      estimator, X, y, cv=cv, n_jobs=n_jobs, train_sizes=train_sizes)
  train_scores_mean = np.mean(train_scores, axis = 1)
  train_scores_std = np.std(train_scores, axis = 1)
  test_scores_mean = np.mean(test_scores, axis = 1)
  test_scores_std = np.std(test_scores, axis = 1)
  plt.grid()

  plt.fill_between(
      train_sizes, train_scores_mean - train_scores_std,
      train_scores_mean + train_scores_std, alpha=0.1,
      color="r")
  plt.fill_between(
      train_sizes, test_scores_mean - test_scores_std,
      test_scores_mean + test_scores_std, alpha=0.1,
      color = "g")
  plt.plot(
      train_sizes, train_scores_mean, 'o-', color="r",
      label="Training score")
  plt.plot(
      train_sizes, test_scores_mean, 'o-', color="g",
      label="Cross-validation score")

  plt.legend(loc="best")
  return plt

X = np.concatenate((X_train, X_test), axis=0)
y = np.concatenate((y_train, y_test), axis=0)

title = "Learning Curves (SVM, RBF kernel, gamma=0.001)"
# SVC is more expensive so we do a lower number of CV iterations:
cv = ShuffleSplit(n_splits=10, test_size=0.2, random_state=0)
estimator = svm.SVC(gamma=0.001)
plot_learning_curve(estimator, title, X, y, (0.0, 1.01), cv=cv, n_jobs=4)
plt.show()
```

以下のコマンドで実行します。

```
$ python small_or_big_classifier.py
```

正確度、適合率、再現率がそれぞれ62.6%, 64.2%, 63.7%です。決して良い値とは言えませんね。

# 分析

誤分類が多かった原因を分析します。原因としては例えば以下のものが思い浮かびます。

1. データ数が足りない

2. 特徴量が足りない（または多い）

3. 特徴が適切でない

4. 入力情報が間違ってる
    - 自身のカップを公表している方は少ないので、今回はネットでの推測値を採用しました。この情報に誤りがある場合（特に陽性と陰性の境界値での誤りがある場合）は誤分類に繋がります。

5. ハイパーパラメータが最適ではない

例えば1.が原因の場合は画像の追加、2.は画像サイズの見直し、3.は適切な特徴の追加、4.は使用するデータを公表している方のみに絞る、5.はハイパーパラメータの再調整などすれば性能改善します。ただしどの対策もそこそこ労力がかかります。対策したけど効果がイマイチだった！では悲しいので、何が真の原因なのかをデータから分析します。

## 混同行列（confusion matrix）

混同行列（confusion matrix）とは陽性のインスタンスが陰性のインスタンスに分類された（またはその逆の）回数を数えるものです。

|            | 陰性（予想） | 陽性（予想） |
|------------|:--------:|:---------:|
|**陰性（正解）**|   923    |    859    |
|**陽性（正解）**|   617    |    1285   |

上の表から分かる通り**陰性を陽性と誤分類するケースが多い**です。これは**陰性クラスの中に実際は陽性の方が混じっている**といった可能性が考えられます（混ざった理由は推測ミス、整形や豊胸などが考えられます）。訓練データはカップを公表している方のみに絞ることで本問題は解決できますが、実際に公表している方は少数のため、モデルを訓練するのに十分なデータを集めることができないといった新たな問題が生まれます。

## 学習曲線

学習曲線とは訓練セットのサイズの関数として訓練セットと検証セットの性能をプロットしたものです。これによりモデルが未学習なのか過学習なのかを知ることができます。

訓練セットとは？検証セットとは？の方は [Python: パラメータ選択を伴う機械学習モデルの交差検証について](https://blog.amedama.jp/entry/2018/07/23/084500) を参照してください。

![learning-curve.png](https://qiita-image-store.s3.amazonaws.com/0/28856/adbc988c-5f1f-708a-6083-d45c8f7bb90a.png)

訓練セットと検証セットの差が大きいので**過学習**な状態にありそうです（検証セットの性能がサチってる感じもするのですが...）。この場合訓練データを増やすことで性能が改善しますが、検証セットの性能がサチってる感があるので大きな改善効果は見込めないような気がしています。

# まとめ

先行記事が微妙に終わったとした原因の一つ「そもそも巨乳顔なんてものはない？」に、本当にそうなのか？と思い、自分でトライしてみましたが同じく微妙な感じで終わってしいました。

微妙に終わった原因は、陰性クラスの中に実際は陽性の方が混じっている、つまり訓練データの正解ラベルに誤りがあるからと思っています。もしそうであれば巨乳顔はあるだろうし、そうでなければそんなものはないということになるんでしょうね。