[Python]機械学習で作ったストライク判定機で、好みの女性との素敵な出会いを実現する[Tinder]

# はじめに
こんにちは。Qiita初投稿・エンジニア転職を目指し勉強中の営業マン([@kuro_bkk](http://...))です。
学習のアウトプット発信の一環として本記事を投稿します。未熟者故、至らぬ点やつっこみ所が満載かと存じますが、暖かくご指摘・コメントいただければ幸いです。
# 目的
マッチングアプリTinderで好みのタイプにだけ自動的にいいねをつけて素敵な出会いを実現する。
# Tinderとは
> Tinder(ティンダー)は、Facebookを利用し、位置情報を使った出会い系サービスを提供するアプリケーションソフトウェア、「デートアプリ」で、相互に関心をもったユーザー同士の間でコミュニケーションをとることを可能にし、マッチしたユーザーの間でチャットすることができるようにするもの。

> [Wikipediaより引用](https://ja.wikipedia.org/wiki/Tinder)

Tinderは世界中で流行しているマッチングアプリです。
なんと世界190以上の国で利用されており、1日あたり2600万組のマッチが成立しているそうです。(2015年時点)

本記事の内容に関わるのでざっくりとした使い方を説明します。


* 自身の近くにいるTinderユーザーがあるアルゴリズムに基づいて表示される。
* 写真をみて、気に入ったらLIKE(いいね)、気に入らなければNOPEに振り分ける。
* 相互にいいねを送りあうと「マッチ」が成立し、Tinderを通してチャットができる。

他にも追加機能があるようですが、基本となるのはこの流れです。

※Tinderを利用できる年齢は18歳以上です。

# やったこと
1. 写真を収集
2. 写真から顔部分だけを抽出、好みのタイプ（ストライク）か否か分類
3. 機械学習で自分の好みを判定するストライク判定機を作成
4. ストライク判定機を用いてTinderで好みのタイプのみを自動でいいねする

言語はPython、環境はGoogle Colaboratoryを使用しました。

## Tinderから写真を収集
判定機を作るために顔写真をできるだけたくさん集め、あらかじめストライクorボールに分類する（ラベルをつける）必要があります。

まず思いついたのは画像検索を用いて芸能人の顔写真を大量にスクレイピングする方法です。
しかし、同じ人物の画像を大量に集める場合においてこの方法は効果的ですが、今回のように異なる人物の顔写真をたくさん集める場合はひと工夫必要になります。
また、集める顔写真に偏りがあってもいけません。顔立ちが整った芸能人ばかりを収集してしまうと判定が厳しくなりすぎる可能性もあります。

バッティングセンターでど真ん中直球だけを練習してもしょうがない、実戦の生きた球で練習(学習)することが大事と考え、結局顔写真もTinderから集めました。

<br>
Pynderというライブラリを利用することでTinderを操作できます。通常使用では位置情報を自由に更新できないそうですが、Pynder経由であれば位置情報も設定できるようです。
準備するものはFacebookで認証済のTinderアカウントだけでOKです。

<br>
Pynderの設定など、これらの記事を参考にさせていただきました。ありがとうございます:bow_tone1:

* [【Python】TinderのAPIを使って、青山大学周辺で遠隔ナンパする - Qiita](https://qiita.com/kkdmgs110/items/5f68ebef4afe230f75de)
* [TinderAPIで女の子の顔写真を集めて、加工アリorナシを自動で判定してみた | Aidemy Blog](https://blog.aidemy.net/entry/2018/07/05/172157)
* [[Python] Tinder APIでPythonを使って自動マッチングマシンを作ってみた - 筋肉エンジニアの備忘録](https://workout-engineer.com/python-tinder-api/)
* [PythonでTinder APIを使ってネトストとサイバーナンパ師やってみた|Review of My Life](https://review-of-my-life.blogspot.com/2017/06/pythontinder-api-cyber-hockup.html)

<br>
試しに渋谷駅付近にいるユーザーの名前、年齢、写真のURLを表示してみましょう。
緯度・経度はこちらのサイトから調べることができます。

* [Geocoding - 住所から緯度経度を検索](https://www.geocoding.jp/)

<br>

<details><summary>クリックしてコード展開</summary><div>

```python
import pynder

#facebookのアクセストークン入力
session = pynder.Session('your facebook access token')

#緯度と経度を入力
LAT = 35.658034
LON = 139.701636

#位置情報を更新
session.update_location(LAT, LON)

#ユーザーを取得
users = session.nearby_users()

for user in users:
  print(user.name)
  print(str(user.age)+'歳')
  photos = user.photos
    
  for photo in photos:
    #形式が場合によってwebpとjpgになるのでjpgにする
    if photo.endswith(".webp"):
      photo = photo.replace(".webp",".jpg")
    print(photo)
```
</div></details>

<br>

結果はこちら。
![result1.png](https://qiita-image-store.s3.amazonaws.com/0/329479/ffe4b220-eb7d-da5f-194f-fd86e024fe60.png)


一度に情報を取得できる人数の上限がわからないのですが、Tinder側の制限なのか、表示させている途中に不規則にエラーが出て勝手に止まりました。エラーを調べるとライブラリ側のバグ？という可能性もあるようで、細かい仕様はわかりませんでした。エラーを避けるには必要に応じてカウンターを設けて少な目の人数でストップさせてください。

実はこの時、海外にいたのですがきちんと日本人の名前が表示されたので(モザイクしていますが)位置情報の更新を含め問題はなさそうです。
短時間に連続してアクセスすると制限がかかるようなので、取得できない場合は時間を置いてみましょう。
## 顔を含む写真だけをピックアップする
顔が写っていない写真は判定機作成の上で必要ないので、写真を保存する前に顔の有無を判定します。

顔認識といえばOpenCVが手軽に利用できますが、顔でない部分に反応したりと精度があまりよろしくなかったのでMicrosoftのFace APIを使用しました。
もちろん、工夫次第でOpenCVでも精度をあげられるとは思いますがFace APIが非常に便利で使いやすかったです。(後の工程である顔部分の抽出についてはOpenCVを使用しました。)

* [Face API - 顔認識ソフトウェア | Microsoft Azure](https://azure.microsoft.com/ja-jp/services/cognitive-services/face/)

<br>
顔写真抽出についてはこちらの記事が大変参考になりました。ありがとうございます:bow_tone1:

* [夏季休暇だったのでAzureのFaceAPIを使ってみた - Qiita](https://qiita.com/y4u0t2a1r0/items/b1a69de8104fa23f19a2)
* [PythonによるWeb API入門（2）顔認識の機械学習APIを利用する｜Dai｜note](https://note.mu/daikawai/n/n522215b3b3ec?magazine_key=mf629e9cefc1b)

<br>
取得したURLをFace APIに渡します。1つ以上の顔が認識されるとその位置情報を返してくれます。たとえば4人が同一画像に写っていても、きちんと4人分の顔の位置情報を返してくれる優れものです。

この顔認識をさきほど取得したTinderのプロフィール画像で試してみます。

<br>

<details><summary>クリックしてコード展開</summary><div>

```python
import pynder
import requests
import json

def detectFace(imageUrl):
  result = requests.post(
        'https:// your endpoint /detect',
        headers = {
            'Ocp-Apim-Subscription-Key': 'your subscription key'
        },
        json = {
            'url': imageUrl
        }
    )

    return json.loads(result.text)

for user in users:
  print('-----------')
  print(user.name)
  print(str(user.age)+'歳')
  photos = user.photos
    
  for photo in photos:
    if photo.endswith(".webp"):
      photo = photo.replace(".webp",".jpg")
      
    #顔を検出
    faces = detectFace(photo)
    if len(faces) > 0:
      print(str(len(faces)) + '個の顔を検出')
      for face in faces:
        print(face['faceRectangle'])
    else:
      print('顔が写っていません')
```
</div></details>

<br>

結果はこちら。
![result2.png](https://qiita-image-store.s3.amazonaws.com/0/329479/47aad617-f5b0-b01a-6c04-455263b771d8.png)

無事に認識できており、顔の位置情報が得られました。

驚いたことに、顔(正面)が写っている写真をアップしている人が少ないことがわかりました。
体感では顔が写っている写真は取得総数の半分以下でした。後ろ姿や、顔が認識できない横顔だけの写真。そのほかにもおしゃれレストランでのランチ（食べ物だけの）写真や風景だけの写真など、マッチング目的と考えると大切な情報が抜け落ちた写真が多かったです。

そのため、顔写真取得にかなりの時間を要してしまいましたが根気強く何度も繰り返して合計500人分ほどのデータを準備しました。

## 写真から顔部分を抽出して保存

1つ以上の顔を認識できた写真に対して顔部分のみを抽出して保存します。

Face APIから返ってくる生データを使うと本当に顔面部分だけが切り取られてしまい、分類が困難だった(顔の輪郭も判断材料である)ため生データの範囲を上下左右20%広げ、さらに正方形になるように抽出しました。

<br>

<details><summary>クリックしてコード展開</summary><div>

```python
import pynder
import requests
import json
import cv2
from PIL import Image
import io
import numpy as np

def face_position(face):

  x = face['faceRectangle']['left']
  y = face['faceRectangle']['top']
  h = face['faceRectangle']['height']
  w = face['faceRectangle']['width']

  #幅、高さのうち、長い辺で正方形にする
  l = h if h > w else w
  
  #顔の輪郭も含めるため範囲を20%拡大
  x = int(x - (0.1 * l))
  y = int(y - (0.1 * l))
  l = int(1.2 * l)
  
  return x,y,l
  
def crop_face(imageUrl,x,y,l):

  #pillowによる画像の読み込み
  file =io.BytesIO(urlopen(imageUrl).read())
  img = Image.open(file)

  #openCVで加工するためnumpy配列に変換
  imgArray = np.asarray(img)

  #openCVでトリミング
  imgArrayCropped = imgArray[y:y+l, x:x+l]

  #RGBへ変換
  imgArrayCropped = cv2.cvtColor(imgArrayCropped, cv2.COLOR_BGR2RGB)
  
  return imgArrayCropped

for user in users:
  photos = user.photos
    
  for photo in photos:
  if photo.endswith(".webp"):
    photo = photo.replace(".webp",".jpg")

  faces = detectFace(photo)
  if len(faces) > 0:
    print(str(len(faces))+'個の顔を検出')
    for face in faces:
      #顔の位置を取得
      left, top, length = face_position(face)

      #切り取る
      cropped_face = crop_face(photo,left,top,length)

      #保存
      cv2.imwrite('your file name' + '.jpg', cropped_face)

  else:
    print('顔が写っていません')

```
</div></details>

<br>

緯度、経度を変更して大都市を周遊しながら何度も実行した結果がこちら。(保存先のフォルダのスクショです)
![Result3.png](https://qiita-image-store.s3.amazonaws.com/0/329479/6de35afc-3b94-a6b3-555b-78865406d3ae.png)

アバターやイラストといった非人間の顔を誤認識してしまいますが、分類時に削除します。
私が実行した範囲では全く顔でない部分(背景など)を顔と認識したものはありませんでした。
逆に、顔の大部分が隠れていたり、見切れている顔なども認識していてFace APIの精度の高さに驚きました。
## ストライクorボールに分類（ラベル付け）

収集した顔写真を自分の好みかどうかで分類していきます。一人一人の顔を見ながら手動で該当するフォルダへ移動していきます。
はたから見たら夜な夜な大量の顔写真をフォルダ分けする怪しい人になってしまいましたが勉強のためです。

後ほど説明するKerasのImageDataGeneratorで扱いやすいよう下記のようなフォルダ構成にしておきます。

```
face_images // train // strike // train_strike_1.jpg,...
                     // ball   // train_ball_1.jpg,...

            // test  // strike // test_strike_1.jpg,...
                     // ball   // test_ball_1.jpg,...
```

学習データとテストデータは8:2の割合でランダムに分けました。

## ストライク判定機の作成
### データ拡張
学習データ(写真の枚数)が少ないのでデータ拡張が必要です。

KerasのImageDataGeneratorを使うことで指定した範囲でランダムに写真を加工してくれ、データを無限に水増しできます。
今回は回転、左右反転、水平移動、垂直移動、拡大縮小の5種類の加工をしました。


flow_from_directoryというメソッドを使うことでデータ拡張と同時にサブディレクトリ名（strike/ball）から自動的にラベルをつけてくれます。

<br>

<details><summary>クリックしてコード展開</summary><div>

```python
from keras.preprocessing.image import ImageDataGenerator

train_datagen = ImageDataGenerator(
                rescale=1./255,
                shear_range=0.1,
                zoom_range=0.1,
                rotation_range=10,
                height_shift_range=0.1,
                width_shift_range=0.1)

test_datagen = ImageDataGenerator(rescale=1./255)

train_generator = train_datagen.flow_from_directory(
                  '/face_images/train',
                  target_size=(64,64),
                  batch_size=16,
                  class_mode='categorical')

validation_generator = test_datagen.flow_from_directory(
                  '/face_images/test',
                  target_size=(64,64),
                  batch_size=16,
                  class_mode='categorical')

```
</div></details>

<br>

target_sizeとbatch_sizeはデータサイズによって調整が必要です。

今回は2クラス分類なのでclass_modeはbinaryでもよいですが、3クラス以上の識別を後ほど試してみようと思ってcategoricalのままにしてしまっていました。特に深い意味はありません。

<br>
Kerasの公式ドキュメントが参考になります。

* [画像の前処理 - Keras Documentation](https://keras.io/ja/preprocessing/image/)

### 転移学習で判定機作成
転移学習とは偉大なる先人たちが構築した学習モデルを応用するという方法です。
今回はVGG16という学習モデルをベースに、~~畳み込み層をちょこっとくっつけてオリジナルの判定機を作成しました。~~全結合層を再学習させます。
(追記)
@peaceiris さんからコメントいただき、表現が不適切だったので一部変更しました。

もちろん、試行錯誤しながらパラメータのチューニングを実施しています。

<br>

<details><summary>クリックしてコード展開</summary><div>

```python
from keras import optimizers
from keras.applications.vgg16 import VGG16
from keras.layers import Dense, Dropout, Flatten, Input, Activation
from keras.models import Model, Sequential
from keras.callbacks import CSVLogger

input_tensor = Input(shape=(64,64, 3))
vgg16 = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)

top_model = Sequential()
top_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
top_model.add(Dense(128))
top_model.add(Activation('relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(128))
top_model.add(Activation('relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(128))
top_model.add(Activation('relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(2))
top_model.add(Activation('softmax'))

model = Model(inputs=vgg16.input, outputs=top_model(vgg16.output))

for layer in model.layers[:15]:
  layer.trainable = False
    
model.compile(loss='categorical_crossentropy',
              optimizer=optimizers.Adam(lr=1e-5),
              metrics=['accuracy'])

csv_logger = CSVLogger('your file name' + '.csv')
history = model.fit_generator(
              train_generator,
              steps_per_epoch=16,
              epochs=100,
              validation_data=validation_generator,
              validation_steps=16,
              callbacks=[csv_logger])

model.save('your file name' + '.h5')

```
</div></details>

<br>

エポック数は100回に設定して、処理時間はおよそ1時間でした。

学習結果はこちら。

![Result4.png](https://qiita-image-store.s3.amazonaws.com/0/329479/9d23aeac-d31b-6319-21d9-d5fe9118c742.png)

(実際はMatplotlibで出力しながら作業していましたが、投稿用に見やすく加工するのが面倒だったのでSpread sheetでグラフを作りました:sweat_smile:)

Epoch数がすすむにつれ正答率が徐々に上がり、損失も下がっているので、学習そのものはできていそう。
しかし70Epochあたりから損失が上昇して、正答率も若干下がってきているのでこのあたりから過学習していそう。
途中で学習を打ち切るEarlyStopping機能がKerasにあるので、それを使えばよかったかもしれないが、今回はこのままいきます:bow_tone1:

<br>

最終的な正答率はおよそ75から80%になりました。これはすなわち用意したデータに関しては、ストライクゾーンに飛んできた球をきちんとストライクと判定できた確率が75から80%ということ。

ためしに何人かの有名人をこのモデルで判定してみました。

<br>

<details><summary>クリックしてコード展開</summary><div>

```python
from keras.models import load_model

#保存したモデルを読み込む
model = load_model('your file name' + '.h5')

imageUrl = 'your image URL'

faces = detectFace(imageUrl)

if len(faces) > 0:
  for face in faces:
    #顔部分を切り取る
    left, top, length = face_position(face)
    imgArray = crop_face(imageUrl,left,top,length)
    imgArray = cv2.cvtColor(imgArray, cv2.COLOR_BGR2RGB)
    
    #PIL形式にしてリサイズ
    img = array_to_img(imgArray)
    img_resized = img.resize((64,64))

    #再びArrayに戻して正規化
    x = img_to_array(img_resized) / 255
    x = np.expand_dims(x, axis=0)
  
    pred = model.predict(x)

    #判定結果
    percent = int(round(pred[0][1]*100))
    print('ストライクの確率 : ' + str(percent) + '%')
    
else:
  print('顔が写っていません')

```
</div></details>

<br>

結果

* 広瀬すずさんのストライク確率 99%:hugging:
* 広瀬アリスさんのストライク確率 64%:blush:
* 女芸人Aさんのストライク確率 2%:frowning2:
* 安倍首相のストライク確率 1%:kissing:

もしTinderで広瀬姉妹があらわれても逃さないということで最低限の仕事はできているようなので良しとします。

他にもいろんな人の写真を試しましたが、大きな問題はなさそう？好みのタイプという直感を数値化しているのでいまいち評価の仕方がわかりません。
同一人物でも写真の写り方で大きくストライク確率が変化してしまいました。しかし確認した範囲では、写り具合で95%が30%になったりと判定が覆るほどの変化はなかったように思えます。

<br>

無事に(？)判定機が作成できたので次はいよいよ判定に基づいて自動いいねをしていきます。

## 好みのタイプを自動でいいねする
画像取集のパートですでに基本となるTinderの操作は実施済みです。
その時のコードほとんどそのままで判定部分とLIKEをする部分を追加します。
ストライクの確率が50%を超えるといいねをするように設定してみました。

<br>

<details><summary>クリックしてコード展開</summary><div>

```python
session = pynder.Session('your facebook access token')

#緯度と経度を入力
LAT = 'your location'
LON = 'your location'

#位置情報を更新
session.update_location(LAT, LON)

#ユーザーを取得
users = session.nearby_users()

#モデルを読み込む
model = load_model('your file name' + '.h5')

for user in users:
  print('-----------')
  print(user.name)
  print(str(user.age)+'歳')
  photos = user.photos

  for photo in photos:
    if photo.endswith(".webp"):
      photo = photo.replace(".webp",".jpg")

    #顔を検出
    faces = detectFace(photo)
    
    if len(faces) > 0:      
      for face in faces:
        left, top, length = face_position(face)
        imgArray = crop_face(photo,left,top,length)
        imgArrayRGB = cv2.cvtColor(imgArray, cv2.COLOR_BGR2RGB)

        img = array_to_img(imgArrayRGB)
        img_resized = img.resize((64,64))

        x = img_to_array(img_resized) / 255
        pitch = np.expand_dims(x, axis=0)

        pred = model.predict(pitch)
        strike_percent = int(round(pred[0][1]*100))

        if strike_percent > 50:
          print('ストライク!!!')

          #いいねして画像保存
          user.like()
          cv2.imwrite('your file name', imgArray)
        else:
          print('ボール')
    else:
      print('顔が写っていません')

```
</div></details>


# 実際にやってみた
場所は東京の繁華街をいくつか指定、とりあえず3人ストライク判定が出るまでやってみました。

**ストライク判定1人目**
![strike1.png](https://qiita-image-store.s3.amazonaws.com/0/329479/c9c9c756-60cf-9edc-ea80-64ece334673a.png)

**ストライク判定2人目**
![strike2.png](https://qiita-image-store.s3.amazonaws.com/0/329479/392939d5-989c-c041-dca2-153e972388a2.png)

**ストライク判定3人目**
![strike3.png](https://qiita-image-store.s3.amazonaws.com/0/329479/bba2933f-ea56-64ef-4d71-bb7302fa7184.png)

ストライク判定になった方はみなさんかわいくて素敵な方です！感動！！！
ただ自身の好みかどうかと言われるとよくわからない...

<br>

さらに大きな問題があり、今の設定ではボール判定の数が非常に多いです。
ストライクの確率が50%を上回ったらいいねをする設定にしましたが、いいねした数は検出された顔のうち、10%以下でした。前述したように、そもそもアップされている顔写真も少ないので、この状態ではかなり辛口な気がします。

出会いの質を取るか量を取るか、難しい問題ですね。
実際の野球でも、ボール球をどんどん振って良い成績を残す選手もいますし、選球眼が売りの選手もいます。
自身の置かれている状況を考え、程よい値を設定することが鍵となりそうです。時間の経過とともに閾値が下がっていってもおもしろそうですね。

<br>

これにて目的達成です。あとは向こうからのアプローチを待つのみです。

# さいごに

長々とお付き合いいただきありがとうございました。

機械学習はデータ集めや前処理が大切で、作業の中でいちばん時間を割くなんて言葉を聞いたことがあります。その言葉通り、顔写真の収集が大変でした。効率が悪いと感じたので改善の余地ありですね。フリーで使える日本人の顔データセットってあるのでしょうか。

学習データが少なすぎたのでなんちゃって機械学習になってしまった感があります。とはいえ、書籍を読みながら学習を進めるよりよっぽど考えながら、かつ楽しみながら手を動かせたのはよかったです。

恥ずかしくてなかなか異性へ積極的にアプローチができないかたも多くいらっしゃると思います。「判定機がストライクをだした」という事実があれば精神的にも楽に行動できるかもしれません。

出会いをお求めのエンジニアのみなさま、ぜひ試してみてください。

**みなさまに素敵な出会いがありますように☆**

おわり