Mezzanine の Blog に 関連記事 のレコメンド表示を組み込んでみる


Blog に レコメンド表示機能を組み込んでみたくなり、
実装した結果を記載します。

Mezzanine の Plugin があればそれを使うのですが、そんなことはなくて、 自前実装してみました。


前提

以下の環境で実行しています。

  • OS
    CentOS release 6.9 (Final)

  • Python Version
    Python 2.7.8

  • Package (必要そうなものだけ抜粋)
    Django (1.10.6)
    Mezzanine (4.2.3)


ブログ記事のレコメンドについて

レコメンドについて少し調べた限りだと、
協調フィルタリング を使うレコメンドと、コンテンツベースでのレコメンド、
それらの組み合わせがでのレコメンドがあるようです。
協調フィルタリング については正直ブログを見ている人達は、認証なしユーザーになるので、
どうしたらいいのかよくわからずのため、1
コンテンツベースでのレコメンドを実装する方向で実装方法を調べていきました。
レコメンドの方式についての説明は、以下の記事がわかりやすかったです。

[1].ユーザーにログインしてもらうわけではないので、Google Alaytics の情報とかを使って、
実装していくですかね?


python で、コンテンツベースレコメンドを実装する方法

コンテンツベースの実装を調べてると、だいだい以下の流れで実装できることが、
わかりました。

  1. 文書を、scikit-learnTfidfVectorizer を使って、ベクトル化し、文書の特徴を抽出する。
    TfidfVectorizer を使う際は、文章から単語を抽出するanalyzer関数を指定する必要があり、
    analyzer関数は、Mecab を使って実装する。 Mecab の辞書は、mecab-ipadic以外に、mecab-ipadic-NEologdがあり、それも使用すると、
    新語も認識できるようになる。

  2. 1.でベクトル化した結果の Cos類似度 を求める。 Cos類似度が1に近い文書ほど、類似度が高い。
    Cos類似度 も scikit-learncosine_similarity 関数を使うと求めることができる。

以下、記事が参考になりました。


自ブログにおける独自の関心事

自ブログはCMS は、Mezzanine で、mezzanine-pagedown というマークダウンプラグインを使用して、
記事を作成しています。
そのような背景?から、以下2点を実装する必要があります。

  1. Markdown から、テキスト部を抽出する
    当ブログは、mezzanine-pagedown というmarkdown pluginを使っているのですが、
    これは、blogの本文として、markdown そのものを、本文としてDBに設定し、
    画面表示時に、filter でHTMLに変換しています。
    このDBに設定されたmarkdownから本文を抽出する必要があります。

  2. 関連記事の登録をする
    Mezzanine には、デフォルトで、関連投稿 の手動登録機能があり、
    この機能で登録した投稿は、blog_blogpost_related_posts というテーブルに登録されます。
    Cos類似度 の高い関連記事は、このテーブルに登録します。2
    このテーブルからデータを取得して、表示するテンプレートは既に用意されているので、
    データさえ入ってしまえば、画面表示部を実装するのは不要です。

[2].特に今までこの機能使ってなかったので、DELETE & INSERT で登録するように実装してしまいました。


レコメンド実装に前提で必要となる ライブラリのインストール

以下、作成したDjango コマンドを動かすのに必要となります。
コマンドを動作させる場合は、実行してください。

  • mecab と、mecab-ipadic-NEologd のインストール
    mecab-ipadic-NEologd の CentOS rpm ファイルがありますので、
    それを使いました。2015年モノが落ちてきましたので、Github の README に記載がある通り、
    気まぐれ更新なのだと思われます。
    mecab-ipadic-neologd/README.ja.md at master · neologd/mecab-ipadic-neologd

    sudo rpm -ivh http://packages.groonga.org/centos/groonga-release-1.1.0-1.noarch.rpm
    sudo yum install mecab mecab-devel mecab-ipadic
    curl -L https://goo.gl/int4Th | sh
    

  • 以下、ライブラリのインストールが必要です。

    # mecab-python
    pip install mecab-python
    # scikit-learn
    pip install scikit-learn
    # numpy
    pip install numpy
    # scipy
    pip install scipy
    


作成したDjango コマンド

以下、作成した Django コマンドになります。
記事が200 くらいで、1分30秒程度で動作しました。

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 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)


class Command(BaseCommand):
    help = "My recommend blog post command."

    def handle(self, *args, **options):
        logger.info(__name__ + " START")

        documents = []
        blogs = []

        # 1. BlogPostから、本文を取得、markdownから本文を抽出
        for blog_post in BlogPost.objects.published():
            source = blog_post.content
            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)

        # 2. TfidfVectorizer を使って、ベクトル化、Cos類似度を求める
        cs_array = cosine_similarity(self.__vecs_array(documents),
                                     self.__vecs_array(documents))

        # 3. 関連投稿の全削除、Cos類似度の高い上位5件を登録
        with transaction.atomic():
            # 3-1. 関連投稿の全削除
            for blog in blogs:
                blog.related_posts.clear()

            # 3-2. Cos類似度の高い上位5件を登録
            for i, cs_item in enumerate(cs_array):
                blog = blogs[i]
                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):
                    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 = []
        # mecab-ipadic-neologd をカスタム辞書として指定
        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')
                # stopwordを弾く
                if word not in stop_words:
                    out_words.append(word)
            node = node.next
        return out_words

説明

  • Markdownから、Textを抽出する方法
    直接抽出する関数はなさそうで、一度HTMLに変換後に、BeautifulSoupでHTMLからテキストを抽出するようにしました。
    python3 での実装方法は、以下にまとめましたので、必要であれば参照してください。
    python で MarkDownファイルをPlain Text に変換する | Monotalk

  • TfidfVectorizer の使い方と、Cos類似度の高い上位5件 の取得方法について
    ほぼ、Cos類似度とDoc2Vecってどっちが良いの? - Qiita そのままです。
    StopWordを弾くため、mecab-ipadic-neologd を辞書として使用するため、words関数を少し変更しました。
    Markdown の記述や、プログラム内の記述で変な文字列が抽出されていたので、そのあたりをStopWordとして登録しています。
    実際のStopWordの記述はもっと多いです。

  • 関連投稿の削除、登録について
    Mezzanine の BlogPost には、related_posts というManyToManyField がありこのフィールドに対して操作すれば、
    関連投稿の登録削除ができます。


実装した感想とTODO

  • 感想
    一応動くものはできました。最初StopWordとか無い状態で結果の確認をしてみましたが、
    それっぽいものが関連投稿として抽出できているように思いました。
    ただ、カテゴリーとして数が少ないものは、とんちんかんな結果となるため、
    あまり関連度が低いものは足切りして、表示しないなどの考慮は必要になるかと。

  • TODO

    • パラメータによる重みづけ WordPress の 関連投稿Plugin等を見ていると、タイトルに対しての類似度、カテゴリー等を含めて、
      重みづけをして関連投稿として表示したりする機能があったりするので、
      そのような機能を参考に、パラメータ調整できるようにしてみたいです。
    • 5件以上関連投稿が登録される場合がある
      ManyToManyField だからか、複数の記事から関連投稿として抽出される記事は、自分自信の関連投稿を含めると、 5件以上登録される場合があって、そのあたりのロジックを調整したいなと思ったりします。

もっと実装に時間かかるかと思いましたが、それっぽく動作するものは、1日かからずに作ることができました。
以上です。


追記

続きをMezzanine の Blog に 関連記事 のレコメンド表示をカスタマイズする | Monotalk
に書きました。TODO の一部を実装しましたので、そちらもご参照ください。

コメント