先日、Mezzanine の Blog に レコメンドを表示するため、
Mezzanine の Blog に 関連記事 のレコメンド表示を組み込んでみる | Monotalk
に記載したdjango command を作って実行するようにしてみました。
TODO
としていた部分の機能追加、修正を施してみたので、
やったことを記載します。
やったこと
-
関連の薄い記事の足切り コサイン類似度を元に、ベスト5 を関連記事として出力しますが、
足切り処理を実装していなかったため、
独立した記事の場合、関連記事に関係性が薄い記事が表示されていました。
閾値を設定して、足切りを行うようにしました。 -
タイトル、カテゴリー等も評価してレコメンドしたい
記事本文のみの類似度をもとにレコメンドを表示していましたが、
WordPress のプラグインなどを見ていると、タイトル、カテゴリー等の類似度も評価に含めていたので、
そのあたりを評価できるようにしました。
参考
作成したDjango コマンド
以下、前回作成したプログラムを修正しました。
recommend_blog_post.py
# -*- coding: utf-8 -*-
from __future__ import print_function
from django.core.management.base import BaseCommand
from mezzanine.blog.models import BlogPost
from mezzanine.blog.models import BlogCategory
from BeautifulSoup import BeautifulSoup
from markdown import markdown
import MeCab
import HTMLParser
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.feature_extraction import text
import numpy as np
from django.db import transaction
from logging import getLogger
logger = getLogger(__name__)
additional_stop_words = frozenset([
'xxxx',
'xxxxx',
'xxxxxx',
'xxxxxxxx',
'xxxxxxxxx',
'xxxxxxxxxx',
'xxxxxxxxxxxx',
'xxxxxxxxxxxxxx',
'xxxxxxxxxxxxxxx',
'xxxxxxxxxxxxxxxxxxxxxxxx',
'xxxxxxxxxxxxxxxxxxxxxxxxx',
])
stop_words = text.ENGLISH_STOP_WORDS.union(additional_stop_words)
# 各類似度の重みづけ
title_weight = 0.2
content_weight = 0.6
categories_weight = 0.2
# 足切りする類似度閾値
truncation_threshold = 0.1
from logging import getLogger
logger = getLogger(__name__)
class Command(BaseCommand):
help = "My recommend blog post command."
def handle(self, *args, **options):
logger.info(__name__ + " START")
documents = []
titles = []
blogs = []
for blog_post in BlogPost.objects.published():
source = blog_post.content
# blog title
titles.append(str(blog_post.title.encode('utf-8')))
# blog contents
html = markdown(source)
text = ''.join(BeautifulSoup(html).findAll(text=True))
htmlParser = HTMLParser.HTMLParser()
unescaped_text = htmlParser.unescape(text)
documents.append(str(unescaped_text.encode('utf-8')))
blogs.append(blog_post)
documents_cs_array = cosine_similarity(self.__vecs_array(documents), self.__vecs_array(documents))
title_cs_array = cosine_similarity(self.__vecs_array(titles), self.__vecs_array(titles))
categories_array = self.__create_categories_array(blogs)
summary_cs_array = (title_cs_array * title_weight) + (documents_cs_array * content_weight) + (categories_array * categories_weight)
with transaction.atomic():
for blog in blogs:
blog.related_posts.clear()
for i, cs_item in enumerate(summary_cs_array):
blog = blogs[i]
print("[" + blog.title + "],[" + str(blog.id) + "]")
cs_dic = {}
for j, cs in enumerate(cs_item):
if round(cs - 1.0, 5) != 0: # 同じ文書同士は省きます
cs_dic[blogs[j]] = cs
index = 0
for k, v in sorted(cs_dic.items(), key=lambda x: x[1], reverse=True):
if v < truncation_threshold:
continue
print("\t" + str(k) + " : " + str(v))
blog.related_posts.add(k)
index += 1
if index == 5:
break
logger.info(__name__ + " END")
def __vecs_array(self, documents):
docs = np.array(documents)
vectorizer = TfidfVectorizer(
analyzer=self.__get_words,
stop_words="|",
min_df=1,
token_pattern='(?u)\\b\\w+\\b')
vecs = vectorizer.fit_transform(docs)
return vecs.toarray()
def __get_words(self, text):
out_words = []
tagger = MeCab.Tagger("-Ochasen -d /usr/lib64/mecab/dic/mecab-ipadic-neologd")
tagger.parse('')
node = tagger.parseToNode(text)
while node:
word_type = node.feature.split(",")[0]
if word_type in ["名詞"]:
word = node.surface.decode('utf-8')
if word not in stop_words:
out_words.append(node.surface.decode('utf-8'))
node = node.next
return out_words
def __create_categories_array(self, blogs):
blogs_index_dict = {}
for i, blog in enumerate(blogs):
blogs_index_dict.update({blog.id : i})
categories_array = np.zeros((len(blogs),len(blogs)))
for category in BlogCategory.objects.all():
blogposts = category.blogposts.all()
for outer_blog in blogposts:
if outer_blog.status != 2:
continue
x = blogs_index_dict.get(outer_blog.id)
for inner_blog in blogposts:
if inner_blog.status != 2:
continue
y = blogs_index_dict.get(inner_blog.id)
if x == y:
continue
categories_array[x, y] = categories_array[x, y] + 1
scaler = MinMaxScaler(feature_range=(0, 0.99))
categories_array = scaler.fit_transform(categories_array)
return categories_array + np.identity(len(blogs))
説明
カテゴリ、タイトル、記事本文の類似度の重み付けについて
タイトル 0.2
、
本文 0.6
カテゴリ 0.2
としました。
この辺り負の値を設定したりすると、現状実装だと、うまく動かない気がします。
合計値1.0
になるようにしないといけないところとかはかっこ悪いです。
### 各類似度の重みづけ
title_weight = 0.2
content_weight = 0.6
categories_weight = 0.2
足切りする類似度の閾値
0.1
としました。
# 足切りする類似度閾値
truncation_threshold = 0.1
同一カテゴリーの記事類似度を示す行列の作成方法について
タイトル、本文はコサイン類似度は sklearn の cosine_similarity
を使って、算出していますが、
同一カテゴリーの記事類似度は、文字列の類似度では計算できないので、
以下のようなルールで算出することにしました。
- ルール
1.類似度の最大値は同じ記事の場合、1 として、その他 最大値 0.99 とする。
2.複数の同一カテゴリに所属する記事はより類似度が高いと判断、2つカテゴリが同一の記事は、1つカテゴリ同一の記事よりも2倍類似度が高いとする。
ルールを踏まえて、処理の流れは以下のようになりました。
- 処理の流れ
- Blog投稿リストのindex と、Blogのid を紐づけた辞書を作成する。
Numpy.zeros()
でBlog投稿リスト数分の要素を持つ、2次元配列を作成する。- ブログのカテゴリの数分ループ、同一カテゴリに所属する記事に1を足す。
MinMaxScaler
を使って、最小値0
、最大値0.99
で正規化。- 同一の類似度を1とするため、正規化後の行列に、単位行列 を足す。
def __create_categories_array(self, blogs):
blogs_index_dict = {}
for i, blog in enumerate(blogs):
blogs_index_dict.update({blog.id : i})
categories_array = np.zeros((len(blogs),len(blogs)))
for category in BlogCategory.objects.all():
blogposts = category.blogposts.all()
for outer_blog in blogposts:
# ドラフト状態のブログ記事は除外
if outer_blog.status != 2:
continue
x = blogs_index_dict.get(outer_blog.id)
for inner_blog in blogposts:
# ドラフト状態のブログ記事は除外
if inner_blog.status != 2:
continue
y = blogs_index_dict.get(inner_blog.id)
# 同一記事は処理しない
if x == y:
continue
categories_array[x, y] = categories_array[x, y] + 1
# MinMaxScaler で正規化
scaler = MinMaxScaler(feature_range=(0, 0.99))
categories_array = scaler.fit_transform(categories_array)
return categories_array + np.identity(len(blogs))
作成する過程で、勉強なったこと
-
Numpyの行列計算が頗る便利、そのまま足せるし、掛け算/割り算もできる。
-
MinMaxScaler は、feature_range で最大値、最小値が指定できる。
-
Numpy.zeros() と、Numpy.identity() とか、Numpy は痒いところに手が届いている気がする。
あとは、mezzanine の画面上から設定値を指定できるようにするのと、
英語くらいは対応したら、plugin にできるかもと。。
以上です。
コメント