先日、
Mezzanine の
に
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
同一カテゴリーの 記事類似度を 示す 行列の 作成方法に ついて
タイトル、cosine_similarity
を
同一カテゴリーの
以下のような
- ルール 1.類似度の
最大値は 同じ 記事の 場合、 1 と して、 その 他 最大値 0.99 とする。
2.複数の同一カテゴリに 所属する 記事は より 類似度が 高いと 判断、 2つ カテゴリが 同一の 記事は、 1つ カテゴリ同一の 記事よりも 2倍類似度が 高いとする。
ルールを
- 処理の
流れ - Blog投稿リストの
index と、 Blogの id を 紐づけた 辞書を 作成する。 Numpy.zeros()
でBlog投稿リスト数分の 要素を 持つ、 2次元配列を 作成する。 - ブログの
カテゴリの 数分ループ、 同一カテゴリに 所属する 記事に 1を 足す。 MinMaxScaler
を使って、 最小値 0
、最大値 0.99
で正規化。 - 同一の
類似度を 1とするため、 正規化後の 行列に、 単位行列 を 足す。
- Blog投稿リストの
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 は 痒い ところに 手が 届いている 気が する。
あとは、
英語くらいは
以上です。
コメント