Mezzanine の Blog に 関連記事 のレコメンド表示をカスタマイズする


先日、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倍類似度が高いとする。

ルールを踏まえて、処理の流れは以下のようになりました。

  • 処理の流れ
    1. Blog投稿リストのindex と、Blogのid を紐づけた辞書を作成する。
    2. Numpy.zeros()でBlog投稿リスト数分の要素を持つ、2次元配列を作成する。
    3. ブログのカテゴリの数分ループ、同一カテゴリに所属する記事に1を足す。
    4. MinMaxScaler を使って、最小値0、最大値0.99 で正規化。
    5. 同一の類似度を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 にできるかもと。。

以上です。

コメント