Speee DEVELOPER BLOG

Speee開発陣による技術情報発信ブログです。 メディア開発・運用、スマートフォンアプリ開発、Webマーケティング、アドテクなどで培った技術ノウハウを発信していきます!

クリスマスに機械学習で彼女ができました。


初めまして。 新卒でSpeeeに入社をして半年ちょっとが経ちました二次元エンジニアです。現在はソーシャルゲームチームのメインエンジニアとして働いています。彼女はいませんが、最近ANIMAXAT-Xを契約して非常に生活が充実しています。

もちろん表題は釣りです。釣られてしまった方は腹筋してください。

さて、画面の中、学校、会社で気になる女の子を見つけたらどうしますか。 勇気ある方はきっとその子の趣味や好きな物を調べて、それを口実に接触を試みるでしょう。 彼女にこんなのあったよ!って新しいものを提供しましょう!

・・・

そうだ!機械学習だ!機械学習で彼女ができるんだ!

という訳で、今回はアニメ好きの女の子を想定して、アニメの感想でscikit-leanを試してみました。

今回使用するもの

今回は主にpythonとscikit-learnを使ってごにょごにょ試してみたいと思います。

実行環境
  • さくらVPS 1G
  • CentOS6.4
  • Python2.7

VPSはメモリが1Gしかないので、swapを確保して無理矢理メモリエラーを回避しています。 データの保存に一部MySQLを使っていますが、完全にミスでした。ファイルベースでやるべきです。 途中pickleでシリアライズしていますが、これも無駄にデータ量が増えてしまうので好ましくありません。

scikit-learnはnumpyとscipyに依存しているため、この二つをまずインストールする必要があります。

Numpy

Numpy(http://www.numpy.org/)とは、数値計算を効率的に行うためのライブラリです。Pythonで行列やベクトルの演算をサポートしてくれる上に、C言語で実装されているため、超高速に実行することができます。通常はCPythonと組み合わせて利用し、最大限パフォーマンスを向上させるようですが、今回は特に高速性を求めていないので、Pythonはそのまま使用しました。

ちなみに、NumpyはBLASという数値計算APIを呼び出すため、BLASの実装によってパフォーマンスが異なります。 無料で使用できる高速なものにOpenBLAS(http://www.openblas.net/)というものがあるので、是非導入してみてください。ちなみにMacの場合homebrewから簡単に入れられます。

Scipy

Scipy(http://www.scipy.org/)は科学計算用数値解析ライブラリです、要は面倒ないろいろな計算処理が入ってるやつです。今回はの用途ではあまり意識する必要が無いので、詳しくはGoogle先生

scikit-learn

今回のメインであるscikit-learn(http://scikit-learn.org/stable/)は、機械学習に特化したライブラリです。分類、回帰、クラスタリング、交差検定などなど機能的にはRと大差なく、様々な便利系実装もあります。 結構公式のチュートリアルやサンプルコードが充実しているので、眺めてみると良いと思います。

結果を表示するためにmatplotlibもあると良いです。小生の能力では、高次元ベクトルをいい感じに次元圧縮してグラフにすることはかなり厳しいです。視覚的に捉えられる限界の3次元まで落とせばいいのでしょうか。

その他には文章を形態素解析するためにMeCabを使っています。Pythonはいろんなミドルウエアが使えて非常にフットワークが軽いです。

特徴量抽出

それでは準備が整ったので、こちらに用意しておいた4.4万件のアニメ感想データを使います。 データは

{ “title” : “ソードアートオンライン”, “text”:”めっちゃ面白くて死ぬかと思った。アスナ俺の嫁!” }

という非常にシンプルになっています。 方針としては、bag-of-wordsを参考にしています。全文章をMeCab形態素解析を行い、単語(「助詞、助動詞、記号」を除いたもの)を並べてベクトル化します。このベクトルの各単語に対して、作品毎に出現数を投票してゆき、最終的に作品毎の特徴量としました。

f:id:bino98ty:20180104174015p:plain

上画像中の右側の数字がベクトルになります。

#coding:utf-8 #日本語を扱うおまじない
import MeCab
import pickle

except_features = ["助詞", "助動詞", "記号"] #除外種類
word_dic = {} #{"単語":{"word":"単語", "index":"index番号(行列の要素index)"}} 分割した単語を保存

tagger = MeCab.Tagger("-Ochasen") #MeCabの準備
impressions = DBSession.query(ImpressionSource).all() #DBから感想情報を取得
index = 0
for row in impressions:
    text = row.text.encode(u"utf-8") #unicodeからutf-8にエンコードしないとエラーになります
    node = tagger.parseToNode(text) # 形態素解析の実行

    while node:
        type = node.feature.split(",")[0] #nodeのfeatureには「名詞,接尾,人名,*,*,*,さん,サン,サン」の形式で結果が入っているので","で分割する
        if not type in except_features and node.surface: #空文字と除外要素のチェック
            if not word_dic.get(node.surface): #既に出現した要素か確認
                #まだ出現していない要素の場合は追加
                word_dic[node.surface] = {u'index':index, u'word':node.surface}
                index += 1
        node = node.next #次の形態素に進める

output_f = open('words_dic.dat', 'w') #一旦ファイルに保存。
output_f.write(pickle.dumps(word_dic, protocol=2))
output_f.close()

 

node = tagger.parseToNode(text)
# node.surface : 表層、そのままのテキスト
# node.feature : 品詞

このnodeオブジェクトは参照になっているので、一度コピーしてあげないと、node.nextを読んだ時点で消滅してしまうので注意が必要です。

ここまでで、一旦全文章から単語の一覧を手に入れた事になります。次に各作品の特徴量を作ります。

方針
  • 一回全部の感想それぞれに対して特徴ベクトルを作成
  • 各タイトル毎に特徴ベクトルを要素ごとに足す
  • 感想数の差でのばらつきを軽減するため、正規化する
#毎回使うパッケージのimportは割愛します。

import numpy as np #numpyのimport
words_dic = pickle.loads(open('words_dic.dat', 'r').read()) #保存しておいたものを復活
feature_length = len(words_dic) #全単語数を取得

tagger = MeCab.Tagger("-Ochasen") #MeCabを使う準備
impressions = DBSession.query(ImpressionSource).all() #DBから感想情報を取得
for row in impressions:
    feature = np.zeros(feature_length) # zerosでベクトルの初期化
    text = row.text.encode(u"utf-8") #unicodeからutf-8へ
    node = tagger.parseToNode(text) # 形態素解析の実行

    while node:
        type = node.feature.split(",")[0]
        if not type in except_features and node.surface: #この辺のフィルタはさっきと同じです。
            if words_dic.get(node.surface):
#先ほど取得した全単語辞書から要素indexを取得して、特徴量ベクトルに投票
                feature[words_dic[node.surface][u'index']] += 1
        node = node.next

    #あらかじめ、今回用意したテストデータのタイトル一覧をDBに作成して置きました。
    title_obj = DBSession.query(AnimeTitle).filter(AnimeTitle.id ==row.title_id).first()
    #感想を特徴量つきでinsert
    impression = new AnimeImpression(title_obj.id, row.text, len(row.text), pickle.dumps(feature, protocol=2))
    DBSession.add(impression)
    DBSession.commit()
np.zeroを使ってベクトルの初期化を行う事ができます。ex. np.zero(4) => [0, 0, 0, 0]

それぞれの感想に対しての特徴ベクトルを作る事ができました。 この状態では一つの感想に対して複数の特徴ベクトルがあるので、まとめます。

#毎回使うパッケージのimportは割愛します。
#words_dic(全単語辞書)のファイルからのロードも完了しているものとします。

feature_length = len(words_dic) #全単語数の取得
anime_titles = DBSession.query(AnimeTitle).all() #DBから全アニメタイトルを取得
for row in anime_titles:
    features = np.zeros(feature_length) #タイトルの特徴ベクトルを初期化
    anime_impressions = DBSession.query(AnimeImpression).filter(AnimeImpression.title_id == row.id).all() #該当タイトルの感想を取得
    for impression in anime_impressions:
        features = features + pickle.loads(impression.feature) #各感想の投票数を足し込む
    if np.linalg.norm(features) == 0: #長さ0のベクトルははじく
        break
    features = features / np.linalg.norm(features) #正規化します
    #特徴量を保存します
    DBSession.query(AnimeTitle).filter_by(AnimeTitle.id == row.id).update({"feature":pickle.dumps(features, protocol=2)})
    DBSession.commit()
np.linalg.normを使う事で、ベクトルの長さを取得することができます。

全部のデータをMySQLから一気に引っ張るとデータ量が多すぎてしまうので、1作品ずつ取得しています。 単純にベクトルを各要素で足し込んで、最後に正規化をして各作品毎の感想数の差の影響を減らしています。

特徴量的に最も似ているものを探す

それでは作成した特徴量を用いて、似ている作品を探しましょう。 残念ながら特徴量が似ている=彼女が好きなものとは限りません。ご注意ください。 2つの特徴量の類似度を出す方法として、良く用いられるのは「コサイン類似度」や「相関係数」です。文章では「jaccard係数」が良く使われます。

コサイン類似度はベクトルの距離に関係するものですが、jaccardは2集合の中にどれだけ同じものが含まれているのかが指標になります。

今回作成した特徴量ではjaccardを適用するためには一手間必要なので、前者2つを適用してみました。

・コサイン類似度の算出

def cos(v1, v2):
    return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
np.dot(a, b)で行列aと行列bの内積を求める事ができます。

・相関係数の算出

np.corrcoef(v1, v2)[0][1]
np.corrcoef(a, b)を使うと行列a,bの相関係数を取得する事ができます。 np.corrcoef(a, b) [1     (aとbの相関係数), (aとbの相関係数)     1] a,bに応じて結果の行列はn*nとなります。

corrcoefを使えば、複数ベクトルに対する相関係数を一気に演算できますが、今回2500タイトルを一気に求めると重くなってしまったので、一個ずつ比較しています。 また、DBから一気にオブジェクトをフェッチすると特徴ベクトルのデータ量が多すぎて死んでしまうので、一つずつ取得しています。

#毎回使うパッケージのimportは割愛します。
#words_dic(全単語辞書)のファイルからのロードも完了しているものとします。
#feature_lengthには全単語数が入っています

user_title = DBSession.query(AnimeTitle).filter(AnimeTitle.id ==82).first() #ソードアートオンラインのタイトルデータを取得
user_feature = pickle.loads(user_title.feature']) #タイトルの特徴量を取得
titles = np.zeros(2523) #全タイトル分の配列を用意。ここに類似度を入れていきます

anime_titles = DBSession.query(AnimeTitle).with_entities(AnimeTitle.id).all() #DBからタイトルのidのみを取得
id_list = [] #titlesのindexとタイトルidの順序を保持する
index = 0
for row in anime_titles:
    id_list.append(row.id)
    anime_title = DBSession.query(AnimeTitle).with_entities(AnimeTitle.feature).filter(AnimeTitle.id == row.id).first()
    titles[index] = cos(pickle.loads(anime_title.feature), user_feature) #コサイン類似度の場合はこちら
    # titles[index] = np.corrcoef(pickle.loads(anime_title.feature), user_feature)[0][1] #相関係数の場合はこちら
    index += 1

#ここまでで全タイトルに対する類似度が求まります。
print '一番近いのは?'
z = zip(id_list, titles) #id_listとtitlesをひとまとめのリストにします
result = sorted(z,key=lambda x:x[1],reverse=True) #類似度の高い順に並べ替えを行います
for ele in result[0:10]: #上位10件の情報を表示します
    title = DBSession.query(AnimeTitle).with_entities(AnimeTitle.title).filter(AnimeTitle.id == ele[0]).first()
    print str(ele[0]) + ':' + str(ele[1]) #[id] : [類似度] で表示
    print title.title #タイトルを表示

ソードアートオンラインで行った結果

コサイン類似度 相関係数

順序的に同じ結果になってしまいました。 ざっと出た中で僕の独断と偏見で、一番近いのは禁書のような気がします。 感想という面だと、

ソードアート・オンライン 面白かった」「ご都合主義」「世界観」「かわいい」など
シュタインズゲート 面白い」「意味が分からない」「世界観」「原作」など
進撃の巨人 楽しめた」「よくわからん」「原作」など

基本的に好意見+世界観的な単語が集中しているようです。 感想だけを比べれば確かに似ているのかもしれませんが、進撃の巨人は違うんじゃないかと思うので、残念な結果になってしまいました。

Affinity Propagationで適当クラスタリング

とりあえずクラスタリングをしてみます。 クラスタリングアルゴリズムで最も有名なものはk-meansだと思われます。ただ、k(分けるクラスタ数)を決定するのが今回のような場合には見当がつきません。 そんなときに便利なのが、 Affinity Propagation (http://stanag4172.m13.coreserver.jp/document/AffinityPropagation.rev2.pdf

例のごとく、解説はリンクかGoogle先生に聞いていただく方が良いかと。最も大きな特徴はあらかじめクラスタ数を決めておく必要がないことです。

#毎回使うパッケージのimportは割愛します。
#words_dic(全単語辞書)のファイルからのロードも完了しているものとします。
#feature_lengthには全単語数が入っています

from sklearn.cluster import AffinityPropagation

X = np.zeros((2523, feature_length)) #入力に使う行列です
anime_titles = DBSession.query(AnimeTitle).with_entities(AnimeTitle.id).all() #DBからタイトルのidのみを取得
id_list = [] #上と同じくtitlesのindexとタイトルidの順序を保持する
index = 0
for row in anime_titles:
    id_list.append(row.id)
    anime_title = DBSession.query(AnimeTitle).with_entities(AnimeTitle.feature).filter(AnimeTitle.id == row.id).first()
    X[index] = pickle.loads(anime_title.feature) # 行列Xに特徴ベクトルを追加していきます。
    index += 1

# Compute Affinity Propagation
af = AffinityPropagation().fit(X) #クラスタリングを実行します。
cluster_centers_indices = af.cluster_centers_indices_ #各クラスタのセンター要素indexが入っています。
labels = af.labels_ #各要素のクラスタindexが入っています。

n_clusters_ = len(cluster_centers_indices)
for k in range(n_clusters_):
    cluster_center_id = id_list[cluster_centers_indices[k]] #クラスタセンター
    anime_title = DBSession.query(AnimeTitle).with_entities(AnimeTitle.id, AnimeTitle.title).filter(AnimeTitle.id == cluster_center_id).first()
    print anime_title.title

    class_members = labels == k #クラスタkに含まれるものを取得します
        for x in id_list[class_members]:
            anime_title = DBSession.query(AnimeTitle).with_entities(AnimeTitle.id, AnimeTitle.title).filter(AnimeTitle.id == x).first()
            print u' ' + anime_title.title #クラスタに属しているもの

実行結果

喰霊 -零-

あの日見た花の名前を僕達はまだ知らない。

劇場版 魔法少女まどか☆マギカ [後編] 永遠の物語

魔法少女リリカルなのは The MOVIE 2nd A's

リトルバスターズ!

サマーウォーズ

  • サカサマのパテマ 展開が面白い
  • AURA ~魔竜院光牙最後の闘い~ 永遠の中二病
  • 龍-RYO-
  • デスビリヤード

機動戦士ガンダムSEED

...

ガンダムクラスタの正確さにはびっくりしました。ガンダムという固有名詞が必ず出てくるからなんでしょうね。 100%ではないにせよ、同じシリーズのものは大体(コイントスするよりは正確に)同じクラスタに属する結果となりました。

今回作成した特徴量では、同じ作品という訳ではなく、感想から雰囲気似ている作品をうまく出せればいいと思っていましたが、そううまくは行かないようです。次はアニメみたいな物だとハッキリ傾向の見えそうな、画像データからやってみたいです。

LinearSVMで分類

ここまでの結果で、気になるあの子に突撃すると痛い目を見ます。(-人-)ナムー ここまで扱っていたものはあくまでも特徴量の世界で、数字の世界で近いものを列挙しているに過ぎません。 僕が適当につけた特徴が似ているからといって、気になるあの子に好みに似ている訳ではありません。

さて、彼女を作るためにやるべきことは分類です。 あの子の好き、嫌いという2つに分類を行うことで、彼女が好きなものに分類する事ができます。

scikit-learnにはいくつか分類器が実装されています。 (http://scikit-learn.org/stable/modules/svm.html#svm-kernels)

今回は線形分類器をつかってみたいと思います。 scikit-learnで線形分類器を使う方法は、SVCカーネルをlinear指定する方法と、専用のlinearSVCを使う方法があります。 今回はどちらのクラスに分類されるかの確度も利用したかったので、SVCカーネル指定をする方法を選択しました。 linearSVCを使うと並列計算がサポートされているため、高速に実行できます。

from sklearn.svm import SVC
# from sklearn.svm import linearSVC #linearSVCの場合

data = [(9, 1),(22, 1),(21, 1),(23, 1),(27, 1),(35, 1),(44, 1),(62, 1),(72, 1),(74, 1),(77, 1),(82, 1),(85, 1),(94, 1),(122, 1),
(127, 1),(128, 1),(133, 1),(147, 1),(220, 1),(343, 1),(444, 1),(545, 1),(1906, 1),(2523, 0),(2453, 0),(2188, 0),(2137, 0),(2116, 0),
(2063, 0),(2004, 0),(1991, 0),(1915, 0),(1874, 0),(1816, 0),(1544, 0),(1409, 0),(1410, 0),(1381, 0),(1287, 0),(1287, 0),(1106, 0),
(949, 0),(847, 0),(649, 0),(161, 0),(71, 0),(10, 0),(1, 0)]

data_train = np.zeros((len(data), 42278))  #訓練データ 42278は全単語数です
label_train = np.zeros(len(data)) #訓練ラベル

#まずは教師データを作成します。
for i, (id, value) in enumerate(data):
    title = DBSession.query(AnimeTitle).with_entities(AnimeTitle.feature).filter(AnimeTitle.id == id).first()
    data_train[i] = pickle.loads(title.feature) #特徴量
    label_train[i] = value #ラベル1(好き)or0(嫌い)

# estimator = LinearSVC(C=1.0) #linearSVCの場合
estimator = SVC(C=1.0,kernel='linear', probability=True)   #probabilityをTrueにする事でpredict_proba(そのクラスに対する確度)が使えるようになります。
estimator.fit(data_train, label_train) #学習

# Xは上のクラスタリングの時作ったものをファイルに保存してloadして使いました。
# 全タイトルの特徴ベクトルが入った行列です。

# label_predict = estimator.predict(X) #linearSVCの場合クラスタリングされた結果が入ります
predict_proba = estimator.predict_proba(X) #どちらのクラスに入るかの確度が入っています。例えば[4.4,5.6]の場合、クラスタ0に含まれる可能性は4.4くらい、クラスタ1に含まれる可能性は5.6くらいとなります。これがXの要素数分配列になっています。

predict_title = []
z = zip(id_list, predict_proba)
result = sorted(z,key=lambda x:x[1][0],reverse=True) #好きクラスに入る確度順に降順
for ele in result[0:10]: #10個表示
    title = DBSession.query(AnimeTitle).with_entities(AnimeTitle.title).filter(AnimeTitle.id == ele[0]).first()
    predict_title.append({'title' : title.title})
    print predict_title

用意した教師データ

好き 嫌い

結果

△[ 0.08129715 0.91870285]あの日見た花の名前を僕達はまだ知らない。

三段階評価 [嫌い指数 好き指数] タイトル
[ 0.09706002 0.90293998] 夏目友人帳
[ 0.10001443 0.89998557] DARKER THAN BLACK -流星の双子-
[ 0.10060422 0.89939578] 翠星のガルガンティア
[ 0.10097523 0.89902477] コードギアス 反逆のルルーシュ R2
[ 0.10200571 0.89799429] けいおん!!
[ 0.10486834 0.89513166] PSYCHO-PASS サイコパス
[ 0.10930628 0.89069372] ストライクウィッチーズ2
[ 0.10984763 0.89015237] ef - a tale of melodies.
[ 0.11330064 0.88669936] カレイドスター
[ 0.11615339 0.88384661] 輪るピングドラム
[ 0.11673622 0.88326378] 魔法少女まどか☆マギカ
[ 0.1171157 0.8828843] フラクタル
× [ 0.11758565 0.88241435] Angel Beats!
[ 0.1190454 0.8809546] WORKING'!! 第2期
[ 0.12214211 0.87785789] ちはやふる2
× [ 0.12430989 0.87569011] 戦姫絶唱シンフォギアG
[ 0.12690015 0.87309985] 俺の妹がこんなに可愛いわけがない。 (第2期)
[ 0.1288463 0.8711537] のんのんびより
[ 0.1296455 0.8703545] DARKER THAN BLACK -黒の契約者-
[ 0.13250616 0.86749384] 花咲くいろは

13/20で僕の好みのアニメが出てきました。 この結果だけだと結構いい感じですが、いずれも人気タイトルばかりなのが気になります。 やはり、感想数が多い作品ほど、含まれる単語数が多くなってしまうため、自然と類似度が高くなってしまうのでしょうか。 マニアックなものをレコメンドしようとするともうひとひねり必要そうです。

検定

今回は省略しますが、scikit-learnでは検定も行うことができます。

estimator.score(train_data, correct_data)

という感じで正当性を求めることができます。 詳しくは以下が分かりやすかったです。 scikit-learnでCross Validation http://qiita.com/Lewuathe/items/09d07d3ff366e0dd6b24 検定を重ねて、パラメータをチューニングしてゆく事で、教師データに対しては結構いいところまで上げる事ができますが、 教師データ以外のデータが来た時に散々な結果となってしまうことがままあります。(過学習

最後に

今回説明したこと。

  • クリスマスに彼女が作りたいので、機械学習にトライ
  •  → 見切り発車
  • アニメの感想データから特徴ベクトルを作成
  •  → bag-of-wordsを参考に作成
  • コサイン類似度と相関係数を用いて、似ている作品を探す
  •  → ほとんど似ていない作品が見つけられる
  • Affinity Propagationを用いて、クラスタリング
  •  → 圧倒的ガンダム。あてずっぽうよりは良さそう
  • 線形分類を用いてあの子にアタック準備
  •  → 好き、嫌いで好みで分類
  • 彼女募集中 ← いまここ

最後まで読んでいただきありがとうございました。

線形分類の教師データに使用した好きなアニメに合致する女性は、是非お問い合わせください! タイトルが嘘じゃなくなるので、よろしくお願いします!!

おまけ

デカいデータを扱う

大きなデータを扱う場合には、ファイルベースでやる方がいい気がします。こまめに保存して置く事で、重めの処理を何度も行わなくて済みます。 今回最大で4.4万(感想数)×4.2万(総単語数)のデータを扱う可能性がありました。正直やり方を工夫してこんな大きなデータを扱わないようにすべきですが、仮に扱う場合、np.saveを使って保存を行います。Pythonではシリアライズ時にpickleを利用する事が多いですが、pickleは一度すべてメモリに乗っけてからディスクに書き込みます。そのため、大体MemorryErrorとなります。こんな時にはnp.saveを使うことでメモリの浪費をさける事ができます。また、pickleでは無駄にデータサイズが大きくなってしまいます。

並列実行する

scikit-learnの中にはn_jobsを指定することで並列実行を行うことができます。k-meansとか あとはParallelとdelayedを使って並列化できます。