記事のレコメンドについて考えてみた2016年の夏


今年もこの時期がやってきましたね。
ベーシック Advent Calendar 2016の4日目は@s-moriがつとめます。
今更感しかありませんが、夏のできごとのお話です。

はじめに - 記事のレコメンドについて -

記事のレコメンドって難しいですよね。
ユーザにとって関連する(似てそうだから見る)記事と、記事を書く側が「これも見て!」と思う記事は異なる場合も多いので、何を正解とするのか、判断が難しいところだと思います。
手動で設定できるようになっていれば、書く側が設定できるのである程度満たせるところもありますが、記事が増えてきたときに網羅的に見て設定することはコストがかかります。

「自動的に関連記事取得して出してくれたら嬉しいなァ」ということで、ゴソゴソやってみた記録です。

レコメンド実装

レコメンド実装にあたって考慮したことは以下3点です。

  • 専門的な単語も分解されずに単語として取れるように
  • 関連具合の計算、関連記事として表示するときに速度への影響を最小限にする
  • ユーザとライター双方から見てある程度の「あ、なんか関連していそう…?」を叶える

専門的な単語は分解されないように

テキストそのままでは利用が難しいので、MeCabで単語に分割してから処理をします。
扱った記事が専門的な単語を含むこともあり、MeCabをそのまま使うと以下のように分割されてしまいます。

$ mecab
3C分析
3   名詞,数,*,*,*,*,*
C   名詞,一般,*,*,*,*,*
分析  名詞,サ変接続,*,*,*,*,分析,ブンセキ,ブンセキ
EOS

そこで、ユーザ辞書を追加して取得したい単語も取得できるようにします。
辞書の追加方法は省略しますが、MeCabの[単語の追加方法]に記載があります。
諸々作業をして、作成したユーザ辞書を追加した結果がこちら。

$ mecab
3C分析
3C分析    名詞,用語,Webマーケティング用語,*,*,*,3C分析,サンシーブンセキ,サンシーブンセキ,3C分析
EOS

無事、分解されずにとれました。

速度への影響を最小限にする

更新性のある記事なので、それに合わせてレコメンドも変わって欲しいです。
また、いつまでも古い記事をレコメンドすることもいかないので、記事が追加されたときに、追加された記事を考慮したレコメンドになって欲しいです。
となると都度計算が必要になるわけですが、ゴリゴリやっていると時間がかかります。
せっかくなので、その計算結果を取得する際の速度も…。

これらを満たすため、Redisを利用しました。
Redisのソート済みセット型を使うと、データを入れたときにソートもしてくれるので、取得してからソートのような処理がいらなくなります。便利。
参考:Redisでアクセスランキングを実装

計算した結果の保存、取り出し

redis = Redis.new(host: REDIS_HOST)

## ex. 記事ID1と記事ID5の記事の関連度が0.5の場合
# 記事ID1のリストには"記事ID5との関連度が0.5"として保存
# 記事ID5のリストには"記事ID1との関連度が0.5"として保存
redis.zadd "記事ID1の関連記事リスト", 0.5, 5
redis.zadd "記事ID5の関連記事リスト", 0.5, 1

## ex. 記事ID1と記事ID7の記事の関連度が0.04の場合
# 記事ID1のリストには"記事ID7との関連度が0.04"として保存
# 記事ID7のリストには"記事ID1との関連度が0.04"として保存
redis.zadd "記事ID1の関連記事リスト", 0.04, 7
redis.zadd "記事ID7の関連記事リスト", 0.04, 1

# 関連記事を関連度の降順で10件取得(記事IDが返る)
redis.zrevrangebyscore "記事ID1の関連記事リスト", 1, 0, limit: [0, 10]
#=>["5", "7"]

あとは取得したIDの記事を関連として表示するだけです(場合によっては公開されている記事かのチェックが必要)

Redisに保存するデータはレコメンドの計算結果レコメンド計算に使う"単語"と"出現頻度のリストの2つです。
都度計算することを考えると、その度に2記事分の単語&頻度を取得するわけにはいかないので、単語と頻度のリストもRedisに保存、取り出すようにします。

ある程度の「あ、なんか関連していそう…?」を叶える

難しいところです。
これまでもタイトルやタグを元にしたレコメンドの実装を行ったりもしたのですが、「まあ…関連するといえばするかも…?」のようにどれもパッとせずに終わっていました。

  • タイトル
    含まれる文字数が少ないので、テキストを比較する場合も単語で比較する場合も難しい
  • タグ
    タグをつける側に依存するところもあるので、タグをつける段階で整理されていないと難しい

タイトルもタグもダメなら、ここはやはり本文に活躍してもらいましょう。

ざっくりとした処理の流れ

  1. MeCabを使って単語に分割する
  2. 分割した単語の名詞だけ&1回以上使われている単語をリスト化する
    単語と出現頻度のリストを作成
  3. 全記事分計算処理をし直す
    Redisに保存してあるリストはRedisから取得
  4. Redisに結果を保存する

2のところでTF-IDF等を使って特徴語を取れていればよかったかなあとこっそり思っていますが、ひとまず出現回数で一部単語を除外しています。
参考:特徴抽出と TF-IDF

3の関連度の計算には、Cos類似度を使ってみることにしました。
参考:ruby で短い文章の cos類似度を計算してみる / 東京伊勢海老通信
   TF-IDF Cos類似度推定法

Cosに限らず計算方法はいろいろあるので、探してみると楽しいです。
こちらの記事が見ていてとても参考になりました。
【レコメンド】内容ベースと協調フィルタリングの長所と短所・実装方法まとめ

余談

xx2vec

当初は本文のテキストもしくはタイトルをつかって、doc2vecやword2vecでできないかなあ、と思っていました。
途中まではいけそうな雰囲気もあったのですが、

  • 計算に時間がかかる
  • メモリが足りずにエラーになる
  • 追加学習の方法がわからない

という問題が発生し、今回は見送りました。
追加学習の方法が一番わからず、他の人はどうやっているんだ…?という疑問を未だ持ち続けています。

ベクトル化

こういうところを触っていると、すごくカジュアルに「テキストのベクトル化」とか出てきます。
ただ、自分なんかは数学出来ない部類の人間なので 言いたいことはわかるけどつまりプログラム的に何がどうなってベクトルになるの のように思うこともあるわけです。数学は好きなんですけどね…。

通常のベクトルだとこんな感じかな…というイメージはつきます。

vector2 = [1, 1, 0, 1, 1, 0]
vector1 = [1, 1, 0, 0, 0, 1]

# 2つの配列を要素としたベクトルを生成
vec2 = Vector.elements(vector2, copy = true)
#=> Vector[1, 1, 0, 1, 1, 0]
 vec1 = Vector.elements(vector1, copy = true)
#=> Vector[1, 1, 0, 0, 0, 1]

これをテキストに置き換えた場合、同じように数字だけの配列を作って、それを要素とするベクトルを生成すると考えると、以下のようなイメージ。

# テキストを構成する単語とその出現頻度を、テキストをベクトル化した際の要素と考える
# 状況によっては2回以上出てくる単語のみに制限する場合も
xx = {"雨": 1, "に": 2, "も": 2, "負け": 2, "ず": 2, "風": 1}
yy = {"雪": 1, "に": 2, "も": 2, "夏": 1, "の": 1, "暑": 1, "さ": 1, "負け": 1, "ぬ": 1}

# MEMO:xxとyyの和集合は`|`でとれる
xx.keys | yy.keys
# => [:雨, :に, :も, :負け, :ず, :風, :雪, :夏, :の, :暑, :さ, :ぬ]

# xxとyyをベクトル化
# 和集合と照らし合わせて、xxとyyそれぞれkeyとして持っていれば1、なければ0の配列を作る
xx_vec = [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0] # {"雨": 1, "に": 1, "も": 1, "負け": 1, "ず": 1, "風": 1, "雪": 0, "夏": 0, "の": 0, "暑": 0, "さ": 0, "ぬ": 0}
yy_vec = [0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1] # {"雨": 0, "に": 1, "も": 1, "負け": 1, "ず": 0, "風": 0, "雪": 1, "夏": 1, "の": 1, "暑": 1, "さ": 1, "ぬ": 1}
=begin
# xx_vec生成イメージ
xx_vec = []
sum_words = xx.keys | yy.keys
sum_words.each do |word|
  xx_vec.append( xx.has_key?(word) ? 1 : 0 )
end
=end

# 出現回数を考慮した場合
xx_vec2 = [1, 2, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0] # {"雨": 1, "に": 2, "も": 2, "負け": 2, "ず": 2, "風": 1, "雪": 0, "夏": 0, "の": 0, "暑": 0, "さ": 0, "ぬ": 0}
yy_vec2 = [0, 2, 2, 1, 0, 0, 1, 1, 1, 1, 1, 1] # {"雨": 0, "に": 2, "も": 2, "負け": 1, "ず": 0, "風": 0, "雪": 1, "夏": 1, "の": 1, "暑": 1, "さ": 1, "ぬ": 1}
=begin
# xx_vec2生成イメージ
xx_vec2 = []
sum_words = xx.keys | yy.keys
sum_words.each do |word|
  xx_vec2.append( xx[word] || 0 )
end
=end

# xx_vec2、yy_vec2を要素とするベクトルを生成
# console等で試すと`NameError: uninitialized constant Vector`になるので、その際はは`require 'matrix'`が必要
vecX = Vector.elements(xx_vec2, copy = true)
#=> Vector[1, 2, 2, 2, 2, 1, 0, 0, 0, 0, 0, 0] # 「雨にも負けず風にも負けず」のベクトル
vecY = Vector.elements(yy_vec2, copy = true)
#=> Vector[0, 2, 2, 1, 0, 0, 1, 1, 1, 1, 1, 1] # 「雪にも夏の暑さにも負けず」のベクトル

さいごに

本当は「機械学習で実装しましたドヤァ」みたいなことをやってみたかったのですが、力不足が身に沁みました。沁みすぎるぐらい。
ですが、レコメンド実装でxx2vec関係やベクトル、他の様々な手法にも触れたので、学びは多かったと思います。
自分にできることの範囲を超えたところ(かつ興味がある分野)は、200%ハマってばかりになりますが、なんだかんだ楽しいところが多いと思うのです。

この実装の結果を踏まえて改善とかもやってみたいなぁ…と密かに企んでいます。