ランサーズ(Lancers)エンジニアブログ > 機械学習 > サンプルデータを使ってリアルタイムレコメンデーションを作る – 2. 類似度計算 –

サンプルデータを使ってリアルタイムレコメンデーションを作る – 2. 類似度計算 –

s.t|2017年11月17日
機械学習

サンプルデータを使ってリアルタイムレコメンデーションを作る – 2. 類似度計算 –

  1. はじめに
    • 参考書
    • レコメンドとは
    • データセット
    • 特徴エンジニアリング
  2. 類似度計算
  3. Webアプリケーションとしての実装
    • Endpoint設計
    • flask × gunicorn

第1章にて、データセットの準備が整ったので、第2章ではレコメンドを実装していきます。

TF-IDF

各仕事データ間の類似度を計算するためには、各仕事データを数値化しなければなりません。当連載では、テキストを数値化するためにTF-IDFという手法を用います。
TF-IDFとは、各文書にとって重要な単語は何であるかを算出してくれる便利な計算式です。詳細な仕組みについては以下のサイトがわかりやすいです。

テキストを数値化するためには、TF-IDF以外にも色々と手法があり、単純に単語の出現数だけを考慮するCountVectorizer、1つの単語だけでなく、対象単語の前後の単語も考慮に入れる Word2Vec, Doc2Vecといった手法も存在します。
scikit-learnにはWord2Vec, Doc2Vecは実装されておらず、gensimと呼ばれるライブラリに実装されています。興味がある方はTF-IDFの代わりにWord2Vec, Doc2Vecを使って特徴を数値化してみてください。
Word2Vec
Doc2Vec

それでは、TF-IDFを使ってサンプルデータを数値化していきます。
ダウンロードしたサンプルデータと同じディレクトリにrecommend.pyを作成します。

with ~ as ~というのは、python3から導入された新しい文法で、コンテキストマネージャと呼ばれています。クラスにおけるコンストラクタとデストラクタをセットにしたようなものです。pythonはクラスにおけるデストラクタの挙動が保証されていないので、コンテキストマネージャが重宝します。

下記コードの場合だと、jsonファイルを読み込み、読み込んだオブジェクトを変数 f にバインドし、json.loadに渡しているという処理になります。with文を抜けると自動的にファイルのclose処理が実行されます。

recommend.py

import json

with open('lancers_recommend_datasets.json', 'r') as f:
    datasets = json.load(f)

サンプルデータを読み込むことができたので、TF-IDFが計算できるように形を整えます。
items()というのは、組み込み関数で、辞書のキーと値をタプル形式で返してくれる便利な関数です。サンプルデータは {id1: 'body1', id2: 'body2', id3: 'body3',...} このような形式になっているので、items()を使って各idとbodyを取り出して、items変数に格納していきます。
Pythonではidが組み込み関数として用意されているので、idxという変数名にしています。

items = {
    'id': [],
    'body': []
}

for idx, body in datasets.items():
    items['id'].append(idx)
    items['body'].append(body)
Tips.

dictのキーと値を1行で取得する方法。
リストの内包表記というテクニックを使います。

my_dict = {777: 'casino'}
key, val = ([(k, v) for (k, v) in my_dict.items()])[0]
print(key, val)
777 casino

scikit-learnではTF-IDfはTfidfVectorizerという名前で実装されています。

オプションについて

  • max_df: 全体の文書において、特定の単語が指定したパーセント以上出現した場合に、その単語を無視するというパラメータ。例えば、max_df=0.5とした場合、全部で10,000の文書があるとして、5000の文書に「携帯」という単語が出現していれば、「携帯」という単語はボキャブラリーを構築するにあたり、無視するということになります。floatとintで指定することができます。floatの場合はパーセントを表し、intの場合は具体的な単語の数になります。
  • min_df: max_dfの反対で、 全体の文書において、特定の単語が指定したパーセント以下しか出現しない場合は、その単語を無視するということになり、floatとintで指定することができます。
  • max_features: 文書全体におけるTF値の高い単語のみをボキャブラリーとして使用します。TF(Term Frequency)は、文書内での単語の出現頻度を表し、たくさん出てくる単語ほど重要という意味になります。
  • stop_words: ボキャブラリーを構築するにあたり、具体的に無視したい単語を指定します。list形式で無視したい単語を渡します。日本語のstop wordはこちらが有名です。
  • analyzer: stop_wordsを使うのであれば、wordを指定します。
  • ngram_range: 前後何単語をボキャブラリーとして構築するかを指定します。(1, 1)はユニグラム、(2, 2)はバイグラム, (3, 3)はトリグラムと呼ばれます。(1, 3)は1-3単語の組み合わせを考慮するという意味になります。バイグラム以上にすると精度は上がるのですが、特量が何倍にもなって、計算が遅くなり、また、過学習しがちなので、(1, 1)で十分だと思われます。
from sklearn.feature_extraction.text import TfidfVectorizer

tfidf = TfidfVectorizer(
    max_df=0.5,
    min_df=5,
    max_features=1280
    # stop_wordsは例えばこのような形で指定します。
    stop_words=['あり', 'なし', 'その他'],
    analyzer='word',
    ngram_range=(1, 1)
)

# tfidf.fit_transform(items['body'])のようにまとめて実行する
# こともできますが、TF-IDFの計算結果を使い回すので、あえて分けて
# 実行しています。
tfidf_fit = tfidf.fit(items['body'])
tfidf_transform = tfidf.transform(items['body'])

print(tfidf_transform.shape)
>>(10000, 1280)

max_featuresでTF値の高い特徴(文字)上位1,280のみをボキャブラリー構築に使用すると指定したので、当然ながら10,000の文書に対してボキャブラリーの数、つまり、特徴量(次元)は1,280となっています。

10,000も文書があるのにたったの1,280単語しか使わないのかと不思議に思われる方もいらっしゃるかもしれませんが、無駄な特徴が多いとむしろ精度が下がります。例えば、max_featuresを設定せずに計算したTF-IDF値を学習データとして用いると、そこそこ精度が落ちます。また、特徴量が多すぎると過学習してしまうので、特徴量はできる限り少ない方が望ましいです。

max_featuresを1,280にしている理由は、別の機会に説明させていただきます。

さらにこの1,280の特徴量を128次元まで圧縮します。

次元圧縮

テキストを特徴量に用いると特徴量が増大しがちなので、次元圧縮が重要になります。
KernelPCA, LSA, Random Projectionが次元圧縮の手法として有名どころです。
– KernelPCAはLSAと同じ精度が出るが、処理コストが高すぎる。処理が終了するのに何十分もかかる。
– Random Projectionは速いが、精度がKernelPCA、LSAと比べて10%低かった。
– LSAはKernelPCAとほぼ同じ精度でRandom Projectionより少し遅いくらい。

それぞれ試してみたところ、LSAが速度と精度のバランスが最も良かったです。LSA: Latent Semantic Analysisは、検索の分野でよく使われるので、LSI: Latent Semantic Indexingと呼ばれたりもします。

こちらの論文にRandom ProjectionとLSAについての言及があります。

scikit-learnでは、LSAはTruncatedSVDという名前で実装されています。

当連載で次元数を128としている理由は2つあります。
1. 公式ドキュメントにオススメは100、と書いてある。
For LSA, a value of 100 is recommended.
2. 「情報推薦システム入門 -理論と実践-」 こちらの書籍に、「実験の結果から、次元は128以上にしてもさほど変わらなかった」という記述がある。

実際に、次元を100, 200, 500, 1000で試したところ、大して精度は変わりませんでした。なお、1000次元にすると処理の完了までにだいぶ時間がかかるようになります。
サンプルデータを使って色々試してみてください。

  • n_componentsが次元数を表しています。
  • random_stateというのは、TruncatedSVDに限らずscikit-learnの様々なクラスで実装されているパラメータで、結果を固定するために使います。
from sklearn.decomposition import TruncatedSVD

lsa = TruncatedSVD(n_components=128, random_state=0)

# TF-IDFの場合と同じく、lsa.fit(tfidf_transform)のように
# まとめて実行することもできますが、計算結果を使い回すので、
# あえて分けて実行しています。
lsa_fit = lsa.fit(tfidf_transform)
lsa_transform = lsa.transform(tfidf_transform)

これでデータセットの前処理が完了しました。

コサイン類似度

本連載では、アイテムの特徴を利用して、類似するアイテムを推薦します。「情報推薦システム入門 -理論と実践-」 こちらの書籍に詳細について記載がありますが、アイテムの特徴を利用した類似度の計算方法はコサイン類似度という計算手法が最も精度が高いです。本連載でもコサイン類似度を使って推薦を実装します。

scikit-learnではコサイン類似度はズバリcosine_similarityという名前で実装されています。
cosine_similarityは、第一引数と第二引数間の類似度を全て計算します。
サンプルデータは10,000件あるので、10,000件それぞれに対して10,000件の類似度を計算します。

from sklearn.metrics.pairwise import cosine_similarity

cos_sim = cosine_similarity(lsa_transform, lsa_transform)

print(cos_sim.shape)
>> (10000, 10000)

cosine_similarityは返り値をnumpyのndarrayという型で返します。

numpy.ndarray方には、argsortというメソッドが用意されています。argsortは、降順に並べ替えた結果の配列のインデックスを返します。

# 1000番目のデータを表示します
print(items['body'][1000])

# 1000番目のデータと類似するデータ上位8件を取得します
sim_items_idx = cos_sim[1000].argsort()[:-9:-1]

# 1番目は自分自身になるので2番目以降を対象とします
for idx in sim_items_idx[1:]:
    print('------------------------------------')
    # 1000番目のデータと類似するデータの先頭60文字を表示します
    print(items['body'][idx][0:60])


$ python recommend.py
急募 モバイルサイト 製作 募集 依頼 目的 概要 当社 フレッツ 大手 固定 通信回線 取り次ぎ 受注 獲得 モバイル リニューアル 運び 現在 運用 サイト 元 モバイルサイト リニューアル 募集 検討 基準 モバイル 市場 参入 成果 モバイル 市場 モバイルサイト 企画 製作 運営 経験 優遇 モバイル 展開 検索連動型広告 展開 希望 デザインソースファイル ファイル 一式 サンプル 製作 実績 モバイルサイト お知らせ 参考 サイト
------------------------------------
急募 モバイルサイト 製作 募集 依頼 目的 概要 当社 フレッツ 大手 固定 通信回線 取り次ぎ 受注 獲得 モバイル
------------------------------------
層 ターゲット カラーコンタクトレンズ モバイルサイト 製作 層 ユーザー ターゲット おしゃれ コンタクトレンズ カラ
------------------------------------
クイズ 作家 募集 依頼 目的 概要 現在 弊社 モバイル 韓国語 学習 コンセプト モバイル クイズ サイト 企画 ク
------------------------------------
モバイル 連動 共同購入 サイト 製作 依頼 目的 概要 参考 有名 共同購入 サイト サイト 構築 検討 基準 お客様
------------------------------------
求人 サイト 企画 作成 モバイル 薬剤師 看護師 求人 サイト リニューアル 依頼 目的 概要 既存 薬剤師 求人 サ
------------------------------------
携帯用 ホームページ 作成 依頼 先日 募集 正直 値段 折り合い イメージ 感じ シンプル 感じ ドメイン 使用 携帯
------------------------------------
モバイル 既存 モバイルサイト お呼び 依頼 目的 概要 既存 モバイルサイト 商品 一覧 商品 詳細 部分 合計 ファ

ランサーズでは同じ内容の仕事データが作られることが少なくないで、全く同じ内容のデータがレコメンドされても気にしないでください。

「モバイルサイトの製作」に類似するデータがレコメンドされました。

コサイン類似度の使い方は以上の通りです。

バイナリデータ

ここまでで計算したTF-IDF, LSAの計算結果は新規データを受け付けた際に使い回すので保存しておく必要があります。
Pythonには、オブジェクトをバイナリ形式に変換するpickleという非常に便利なモジュールが同梱されているのでこれを使います。バイナリにすることで計算済みのTF-IDF, LSAをデータベースに保存することができます。本連載ではRedisに保存します。

Redisとの通信量を減らすために、pickleで変換したデータをgzipで圧縮した上で、Redisに保存します。
pythonには、gzip, bzip2, xzの3パターンの圧縮方式が同梱されています。gzipを採用した理由はこちらを参考にしてください。

pythonからRedisへの接続はこちらのモジュールを使います。また、Redisとの通信回数を減らすためにパイプラインという機能を使います。パイプラインは、クエリーをまとめてRedisに送る機能です。Webシステムのようなリアルタイム性を追求するシステムを構築する場合は、DBとの接続回数を減らすことは処理速度を速くする上で非常に重要になります。特にRedisは、処理全体の大半が通信時間で占められる傾向があります。Redis自体の処理はほぼ一瞬で終わります。

計算済みTF-IDF, LSAデータは、Redisにstring型で保存します。

import pickle
import gzip
import redis

tfidf_pickle = pickle.dumps(tfidf_fit, pickle.HIGHEST_PROTOCOL)
lsa_pickle = pickle.dumps(lsa_fit, pickle.HIGHEST_PROTOCOL)

tfidf_gzip = gzip.compress(tfidf_pickle, compresslevel=9)
lsa_gzip = gzip.compress(lsa_pickle, compresslevel=9)

r = redis.StrictRedis()

# transactionをTrueにすることでトランザクション処理にすることができます。
# 今回はトランザクション処理にする必要がないので、Falseにします
# withはpython特有の文法であるコンテキストマネージャと呼ばれるものです。
with r.pipeline(transaction=False) as pipe:
    pipe.set('tfidf:pickle', tfidf_gzip)
    pipe.set('lsa:pickle', lsa_gzip)

    # まとめて実行します
    pipe.execute()

Redisへの保存が完了したので、あとで使い回すことができるようになりました。

圧縮を導入 – どの方式を採用するか –

結論: gzip compress_level: 9

Pickleでバイナリ化した1.3MBのデータをgzip, bzip2, xzで圧縮したときの一覧
圧縮率は1-9まで選択することができ、9が最も圧縮率が高いです。圧縮率が高いほど元に戻す時の時間が掛かりそうだと直感的には思うのですが、そんなことはなく、どの圧縮率でも元に戻す時間は同じだったので、圧縮率9を使わない理由はないです。
圧縮後のサイズと元に戻す際の時間を考慮すると、gzip compress_level: 9 が最もバランスがよかったのでgzip compress_level: 9 を採用します。

Format Size Compress Time Decompress Time
gzip compress_level: 9 0.758 MB 0.47 sec 0.02 sec
gzip compress_level: 8 0.761 MB 0.31 sec 0.02 sec
gzip compress_level: 7 0.766 MB 0.16 sec 0.02 sec
gzip compress_level: 6 0.768 MB 0.13 sec 0.02 sec
gzip compress_level: 5 0.776 MB 0.11 sec 0.02 sec
gzip compress_level: 1 0.805 MB 0.08 sec 0.02 sec
bzip2 compress_level: 9 0.759 MB 0.45 sec 0.13 sec
bzip2 compress_level: 8 0.762 MB 0.45 sec 0.13 sec
bzip2 compress_level: 7 0.748 MB 0.45 sec 0.13 sec
bzip2 compress_level: 6 0.748 MB 0.46 sec 0.13 sec
bzip2 compress_level: 5 0.753 MB 0.47 sec 0.13 sec
bzip2 compress_level: 1 0.796 MB 0.45 sec 0.13 sec
lzma format=FORMAT_XZ 0.633 MB 0.73 sec 0.09 sec

1.はじめにへ戻る
3.Webアプリケーションとしての実装へ続く

Posted by Shigeomi Takada(takada.shigeomi@lancers.co.jp)