この記事は CrowdWorks Advent Calendar 2017 の23日目の記事です。
クラウドワークスでは、2017年初頭頃から少しずつではありますが機械学習の手法を取り入れ始めています。まだ数は少ないですが、実サービスに組み込まれたものもあります。その陰で、「実験的にやってみたけど上手く行かなかった」という試みも数々あり、死屍累々な状況。本エントリーでは、上手く行ったものも行かなかったものも含めて、この一年で試してみて印象に残ったあれこれを紹介しようと思います。
その前に、これを書いている僕自身のスペックを明らかにしておきます。
- 機械学習については素人
- いちおう Coursera の Machine Learning のコースは修了した
- サンプルを動かしたりしたことはあるけど、実地で使ったことはない
- 数学方面のアカデミックなバックグラウンドなし
- 法学部出身です
- 関心は持っていて勉強はしたが、独学
という感じの人間が機械学習を用いて自社サービスに蓄積されたデータから何か価値を引っ張り出せないかと奮闘した記録です。似たような境遇の人にとって何かの参考になれば。
おことわり
以下、いくつかコードが出てきますが、「やってみたけど上手く行かなかった」というモノが含まれています。むしろ「こういう感じで実装してみたけどダメだった例」だと考えてもらった方が良いと思います。予めご了承の程を。
仕事案件をクラスタリングしてみよう
動機
クラウドワークスに投稿される案件には、カテゴリを指定することができます。たとえばウェブデザインの仕事とかブログ記事作成の仕事のような感じですね。カテゴリを適切に指定しておけば、特定カテゴリをウォッチしているクラウドワーカーさんの目にとまりやすくなると期待できます。
ところが、事はそう簡単ではなくて、見当違いのカテゴリが指定されてしまう場合もあります。たとえばウェブデザインに関するブログ記事を書いてほしいという案件はウェブデザインとブログ記事作成どちらのカテゴリを選択すれば良いでしょうか?
依頼したいことは記事作成なのでブログ記事作成を選ぶと良さそうですが、ウェブデザインを手がけているクラウドワーカーさんたちは記事作成カテゴリの案件はあまりウォッチしていなくて、誰も応募してくれないかもしれません。かと言って、ウェブデザインのカテゴリに記事作成の依頼が紛れ込んでしまうのも、なんだか場違いな気もします。
あくまでも運営側の立場からの個人的な意見としては、こういう場合は何をする仕事なのかに注目してブログ記事作成カテゴリを選んでもらうのが適切かなと思うのですが、実際にはウェブデザインが選択されていることもありますし、同一の内容をウェブデザインと記事作成両方のカテゴリで出せばよくね?と考える人もいるようです。気持ちは分かる。
というような次第で、案件に設定されているカテゴリはさておき、本来はどのカテゴリが指定されるのが良かったのだろうか?ということを知りたいという需要がありました。
仮説
同じカテゴリに属する案件は、その依頼文面の傾向が似ているのではないかという仮説を立ててみました。ウェブデザインの依頼であれば、どういったテイストのサイトを作りたいのかとか、要求されるスキル (Photoshop や Illusrator が使えるとか) 、納入の形式などが書かれているでしょうし、ブログ記事作成であれば文字数の目安とか文体の指定などが書かれているかもしれません。
それらの、似た特徴を持つ内容が書かれている案件を集めたものをグループ化すれば同じカテゴリに入るべき案件が見えてくるのではないか、ということを考えました。
作戦
そうなると、似たような特徴を持つ文面というのはどうやって判断すれば良いだろうかということが問題になりました。似ている、ということは距離が近いと言い換えることができます。ということで、
- 案件の文面を Doc2Vec に食わせてベクトル化して、
- できたベクトルの集合を k-means でクラスタリングする
という方法で行ってみることにしました。
実装
案件の情報をデータベースから取ってきた後、テキスト情報を形態素解析して単語リストにしたものを作った上で、次のような感じの処理でベクトル化を行います。
from gensim.models.doc2vec import Doc2Vec
from gensim.models.doc2vec import TaggedDocument, LabeledSentence
# 中略: DB アクセスとか形態素解析とかしてます
sentences = []
# docs は案件の文面の集合 (形態素解析して語のリスト化済み)
# d[2] が文面に含まれる語のリスト,
# d[0] は案件の識別子
for d in docs:
sentence = LabeledSentence(words=d[2], tags=["job_%s" % d[0]])
sentences.append(sentence)
model = Doc2Vec(alpha=.025, min_alpha=.025, min_count=1)
model.build_vocab(sentences)
for epoch in range(10):
model.train(sentences)
model.alpha -= 0.002
model.min_alpha = model.alpha
gensim の Doc2Vec 実装だと、生成されたモデルオブジェクト (上のコードだと model
) の docvecs
にそれぞれのドキュメントのベクトルが入っているので、これを k-means に渡してクラスタリングします。
from sklearn.cluster import KMeans
kmeans_model = KMeans(n_clusters=num_clusters, max_iter=500).fit(model.docvecs)
あとは、戻ってきた kmeans_model.label_
にどのクラスタに分類されたのかが入っているので、それと元々の案件情報をマージすれば、結果を目視で確認できる状態になります。
結果
微妙でした。
いくつかのクラスタには、文面が全く同じ案件群が集まっていました。この点では上手くいっているようにも見えるのですが、それ以外のクラスタを見ると、「これらはなぜこのクラスタに集まってきたのであろうか」という基準がまったく読めない雑多な集合になっており、人の目から見て上手く行ったのかどうかの判断がつかない状態になっていました。パラメータをいろいろ変えて試行錯誤してみましたが、あまり改善は見られず。
また、そもそも指定しているクラスタ数が適切なのかどうかもよく分からないという問題もあります。これについては、エルボー法というものが参考になると聞いて試してみました。クラスタ数を1から順に増やしつつクラスタ内誤差平方和 (SSE) を計測を求めてその推移をプロットするとグラフが折れ曲がるポイントが現れ、そこが最適なクラスタ数と分かるというものです。試してみたところ、非常になだらかなグラフが出てきてしまい、頓挫。
感想
クラスタリングの観点ではパッとしない結果になりましたが、一方で Doc2Vec を使えば似た文面を見分けるということは容易にできるということは再確認できました (まぁ、元々そういうことを可能にするためのモデルなので当然とは言えますが) 。たとえば、文面がほとんど同一の案件をひとまとめにするみたいなことをやりたくなった時に役に立ちそうだな、という印象を持っています。
そもそも、Doc2Vec で出てくるベクトルをそのまま k-means に食わせて何か意味のあるモノが出てくるんだろうか?というところからして、理論的な裏付けもなく「とりあえずやってみて結果を見てみる」ぐらいの勢いで試してみたわけですが、バックグラウンドをちゃんと理解していないと、出てきた結果を適切に評価するのも難しいなーという、感想を持ちましたです。
案件を分類してみよう
動機
投稿される案件は、クラウドワーカーさんから応募があって成約に至るものもあれば、残念ながらそうはならないものもあります。この違いがどこから来るのかが分かれば、投稿されたばかりの案件が成約するかどうかを予想することができそうです。
成約に至るポイントが分かれば、どういう内容にすれば応募・契約されやすいのかをアドバイスするといったこともできそうです。現状では同じことを人力で行うコンシェルジュというサービスを提供しているのですが、
仮説
過去の案件データと、それらが成約したかどうかはデータベースに情報があります。この情報を教師データとして学習し、新規案件を判定する二値分類問題と捉えれば良さそうだと考えました。
作戦
ここで問題になるのは、アルゴリズムは何を使うかと、入力となる特徴量は何にするかということです。
まず、特徴量の選定については、思いついたものを片っ端から入れてみようという (傍目には乱暴に見えるであろう) 作戦に出ました。
その一方で、使うアルゴリズムは LightGBM を選んでみました。なぜこれを選んだかというと、Kaggle 界隈にも出入りしている知人からの受け売りなのですが、そこそこ高速に動いて精度も良いらしいと聞いていたので試してみたかったというのがきっかけでした。その知人曰く、とにかく思いついたデータを全て特徴量として突っ込めと (ちょっと意訳) 。
実際に選び出した特徴量はたとえば、
- 案件のカテゴリ
- プロジェクト形式 or タスク or コンペのうちどれか
- 予算額
- 募集人数
のような、それっぽい (納得感のある) 種類のものから、
- 案件が投稿された曜日
- 案件が投稿された時刻 (0〜23)
- 文面の文字数
みたいな「これって意味あるのかな?」と自分でも首を傾げるようなものも敢えて放り込んでみました。
これらを特徴量、正解ラベルは成約したかどうかを表す数値 (0 or 1) として教師データ (およびテストデータ) を作ります。件数的には教師データが30万件、テストデータが10万件ほどです。
実装
LightGBM は Python 版を使いました。学習と判定をしている部分はこんな感じです。
import lightgbm as lgb
# 中略: データソースから X_train, y_train, X_test, y_test というデータを作る
lgb_train = lgb.Dataset(X_train, y_train)
lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)
params = {
'task': 'train',
'boosting_type': 'gbdt',
'objective': 'binary',
'metric': {'binary_logloss'},
'learning_rate': 0.05,
'num_leaves': 255,
'min_data_in_leaf': 1,
'num_iteration': 200,
'verbose': 0
}
gbm = lgb.train(params,
lgb_train,
num_boost_round=100,
valid_sets=lgb_eval,
early_stopping_rounds=10)
y_pred = gbm.predict(X_test, num_iteration=gbm.best_iteration)
y_pred = np.round(y_pred).astype(int)
results = y_pred == y_test
correct = len(list(filter(lambda p: p, results)))
wrong = len(list(filter(lambda p: not p, results)))
print("Correct: {}, Wrong: {}".format(correct, wrong))
結果
正解率は85%前後でした。改善の余地はありそうですが、まずまずという印象です。パラメータを微調整して試行錯誤してみましたが、あまり変化は見られませんでした。
案件を分類してみよう (その2)
動機
ユーザーサポートを担当している Onoyoshi による「開発メンバーとユーザーサポートが一緒にサービス浄化して年間アワードをもらった話」を開発メン側から見た話です。
上記エントリーで紹介されている通り、今年の GW 頃をピークに悪質案件の問題が深刻になりつつありました。当時は、案件が投稿されると文面中に NG ワードが入っていないかどうかをチェックする仕組みは導入されていましたが、これだけでは NG 案件を全てカバーすることはできておらず、公開されている案件を一件ずつ人力でパトロールしてはダメと判断したものを公開停止にするなどの作業を行っていましたが、人も時間も有限であるため、数が増えてくると人力に頼るのは現実的ではなくなってきます。
そこで、人力ではなくテクノロジーの力で解決する方法はないだろうかという話になります。
仮説
不適切な投稿が所構わず投げ込まれるという現象はクラウドワークスに始まったことではなく、至るところで目にします。俗にスパムと呼ばれますね。そのスパムの代表例と言えばメール、メールのスパム対策と言えばベイジアンフィルタ、というわけで案件の文面をベイジアンフィルタに食わせて二値分類すれば悪質案件を自動で見分けることができるのではないかと考えました。
作戦
幸いにして (?) 教師データは豊富にあります。過去に人力で公開停止にされた情報は全て記録されているので、これを元に「この文面の案件は悪質と判定された or されなかった」というデータが準備できます。
実装
データベースから過去の案件の文面と、公開停止記録を取ってきて、テキストについては形態素解析などを用いて適切な形に整え、ベイジアンフィルタを学習させたモデルを用意、新規に投稿された案件を判定するようにします。
この学習と判定のプロセスは、クラウドワークス本体を置いているものとは別のサーバーで行うことにしました。サービスを動かしている Rails のプロセスの中に組み込むのは適切ではないですし、学習の処理などは高負荷になる可能性もあるので、同じサーバーに同居するのもやめた方が良いだろうという判断です。
というわけで、判定結果をサービス本体にあるデータに反映する (= 悪質と判定されたものを公開停止にする) ための API を本体側に設置、判定プロセスからこの API を呼び出すという流れにしました。
また、これと合わせてユーザーサポートのスタッフ向けに管理画面を提供、もし公開停止にされていたものが実は悪質案件ではなかった場合には速やかに停止解除できる仕組みを整えました。これによって、誤爆してしまった場合のフォローが可能になります。
結果
悪質案件の捕捉率は9割を超える結果となりました。この結果を受けてベイジアンフィルタによる悪質案件検出は正式採用になり、現在も稼働を続けています。
また、自動化の恩恵でユーザーサポートの負荷が大幅に改善されました。この功績によって社内のアワードで表彰されることになったのは、上で紹介した Onoyoshi によるエントリーでも紹介されている通りです。
その後
期待値コントロール
機械学習に取り組み始めた頃は、自分たちの経験値も足りていかなったこともあって、本業の合間の「20%ルール」的な時間を活用するなどして実験を続けていました。ここまでに紹介したものは、そのような活動の中で試したことの例です。
そのような形で活動している間は特にシビアな目標設定を要求されることもなく、「何か当たりが出ればラッキー」ぐらいの比較的お気楽な感じでよかったのですが、ベイジアンフィルタによる悪質案件判定が「当たった」ことを契機に、「どうやら機械学習を使うと良いことがあるらしい」と社内の人達の見る目が変わってきます。
もちろん、それは評価されているということなので喜ぶべきことではあるのですが、反面で以前ほどお気楽でいられなくなったのも事実で。期待値コントロール難しいです><
プロジェクト発足
そんな中、案件・クラウドワーカーのマッチングを実現・改善するプロジェクトが発足し、僕もその一員として参加することに。機械学習や協調フィルタリングなども視野に入れつつ、推薦システム的なものを構築するのがゴールになります。同じプロジェクトチームの @yo-iida がプロダクトオーナーの立場からプロジェクト立ち上げ当初のあれこれを綴っていますので、良ければそちらも合わせてお読みいただければ。
このエントリーで
いろいろと手探りでやってきた中で見えてきたのは、ユーザーが求めているものは機械学習によるレコメンドエンジンではないということです。もっといえばユーザーにとって裏側に機械学習が使われているかどうかはどうでもいいということです。
と触れられているように、機械学習などのテクノロジーはユーザーに価値を届けるための手段の一つであるという認識はチーム内でも合意が取れているのですが、そうは言いつつも機械学習的なアプローチもいろいろ試行錯誤して、再び「当たり」を発見するための試みもまた大事なことだと考えます。
というようなことも踏まえて、目下のところは機械学習的なアプローチを素早く試して結果を得る→それを踏まえて次の一手を考えるということを速い周期で回すための土台となるインフラを構築しています。
たとえば、案件のテキストを形態素解析するようなことは頻繁に必要になるので予め前処理済みのデータとして保存しておくとか、複数のアルゴリズムを並行で運用しつつ各アルゴリズムがどの程度の価値を生み出しているのかを区別して計測できるようにする仕組みであったり。
このあたりは説明し始めるとそれだけで1エントリー分の分量になるので、また別の機会に。来年のアドベントカレンダーで書こうかなw