プロクラシスト

今日の寄り道 明日の近道

自サイトの『関連記事』を自動で探すやつを作った(バスケット分析)


スポンサーリンク

こんにちは、ほけきよです。

以前、SEO対策ということで、内部リンクの可視化についての記事を書きました。

コレだけでも、十分に参考になるのですが、ほら、ひとつ気になることがないですか?

  • 孤島に浮かぶ記事はどうやって補足すればええんや

これ考えるのは人間のお仕事ですよね。

….

けどやっぱりめんどくさい!!

というわけで、自動化しましょうね。

記事の類似度を出す

記事の類似度を出す方法って、いろいろあるんですよ。大別すると二種

  • 記事の文章内容から類似度算出
  • 読者の行動から類似度算出

今回は二つ目『読者の行動から類似度算出』方式でやります。*1

バスケット分析

「おむつとビール」の法則って聞いたことないですか?オムツと一緒にビールが買われるっていうアレです。

情報マネジメント用語辞典:おむつとビール(おむつとびーる) - ITmedia エンタープライズ

これに使われるのがバスケット分析

「同じ組み合わせで買われたことが多いもの同士は関連が深いだろう」 という考えに基づいています。

たとえば、ここにAさん、Bさん、Cさん、Dさんがいるとします。 それぞれ下図のような商品を買いました。

これを、縦軸を顧客、横軸を商品にとります。すると、各商品が誰に買われたかがわかります。

ちなみに、この手の表は婚活アプリにも使われているよ! 好みの傾向が似ている人がいいなと思う女の子を紹介するようにできているんだ!*2

各商品において、どれが似ているかを数学的に計算して、 顧客の購買履歴から商品の関連性を算出してやろうということです。

いわゆる『共起性』を見るのです。より詳しくはこちらの記事でどうぞ!

バスケット分析をブログに応用

この考えをブログに使ってみたのが今回のアレ。

  • 商品が記事
  • 顧客は読者
  • 購入の有無はブクマ

スパム的なブクマカやファンは中身よりほかの目的でブックマークしている可能性があるので、その影響が薄くなるように、少しだけ細工をしたものを使っています。(下図)

これの内積を用いて、関連度を抽出しています!

  • バスケット分析で類似度を算出
  • なにもかもブクマする人よりは、特定のジャンルだけブクマする人を重視
  • 類似度が高い=関連記事として表示
  • コピペで一発!記事抽出コード

    今までどおりpythonを使って、記事抽出のコードを書きました。下記コードをコピペして、related_article.pyとかで保存してください。 *3

    コードをみる
    #coding: utf-8
    from bs4 import BeautifulSoup
    import urllib
    from urllib import request
    import csv
    from argparse import ArgumentParser
    import json
    import numpy as np
    from sklearn import manifold
    import matplotlib.pyplot as plt
    
    def extract_urls(root_url):
        """
        トップページを指定すると、ブログ内に存在するurlをすべて抜き出してくれる
        """
        is_articles = True
        page = 1
        urls = []
        titles = []
        # writer = csv.writer(f, lineterminator='\n') # 改行コード(\n)を指定しておく
        while is_articles:
            try:
                html = request.urlopen("{}/archive?page={}".format(root_url, page))
            except urllib.error.HTTPError as e: 
                # HTTPレスポンスのステータスコードが404, 403, 401などの例外処理
                print(e.reason)
                break
            except urllib.error.URLError as e: 
                # アクセスしようとしたurlが無効なときの例外処理
                print(e.reason)
                break
            soup = BeautifulSoup(html, "html.parser")
            articles = soup.find_all("a",class_="entry-title-link")
            for article in articles:
                titles.append(article.text)
                urls.append(article.get("href"))
            if len(articles) == 0:
                # articleがなくなったら終了
                is_articles = False
            page += 1
        return titles, urls
    
    def get_bookmarks(url):
        """
        はてブ情報を取得
        """ 
        data = request.urlopen("http://b.hatena.ne.jp/entry/json/{}".format(url)).read().decode("utf-8")
        try:
            info = json.loads(data.strip('(').rstrip(')'))
        except:
            info = json.loads(bytes(data).strip(b'(').rstrip(b')'), "r")
        try:
            return info["bookmarks"]
        except:
            return 0
    
    def make_matrix(urls, save):
        users = []
        for url in urls:
            bookmarks = get_bookmarks(url)
            if bookmarks != 0:
                for bookmark in bookmarks:
                    user = bookmark["user"]
                    if user not in users:
                        users.append(user)
        # bookmark_matrix作成
        M = np.zeros((len(urls),len(users)))
        for i, url in enumerate(urls):
            bookmarks = get_bookmarks(url)
            if bookmarks != 0:
                for bookmark in bookmarks:
                    j = users.index(bookmark["user"])
                    M[i][j] += 1
        # 保存したければする
        if save:
            with open("data.csv","w") as f:
                writer = csv.writer(f, lineterminator='\n')
                a = ["USER"]
                a.extend(urls)
                writer.writerow(a)
            MT = M.T
            for i, user  in enumerate(users): 
                m = [user]
                m.extend(MT[i])
                with open("data.csv","a") as f:
                    writer = csv.writer(f, lineterminator='\n')
                    writer.writerow(m)
        # ブクマ一回勢を消す
        MT = M.T
        # print(MT.shape)
        MT_filter = []
        for e in MT:
            if e.sum() > 1:
                e /= e.sum()
                MT_filter.append(e)
        MT_filter = np.array(MT_filter)
        M_filter = MT_filter.T
        print(M_filter.shape)
        return M_filter
    
    def calc_dist(M_filter,alpha=0.05):
        confidences = np.zeros( (len(M_filter),len(M_filter)) )
        for i, article0 in enumerate(M_filter):
            for j, article1 in enumerate(M_filter):
                a0a1 = np.zeros(len(article0))
                for l,(u0, u1) in enumerate(zip(article0, article1)):
                    a0a1[l] = u0*u1
                if article0.sum() == 0 or i==j:
                    confidences[i][j] = 0
                else:
                    confidences[i][j] = a0a1.sum()#/article0.sum() 
        # symmetricな計量の場合
        dist = 1-np.power(confidences,0.04)
        return dist
    
    if __name__ == '__main__':
        parser = ArgumentParser()
        parser.add_argument("-u", "--url", type=str, required=True,help="input your url")
        parser.add_argument("-r", "--rank", type=int, required=True,help="input num of related articles")
        parser.add_argument("-s", "--save_matrix", action="store_true", default=False, help="save matrix default:False")
        parser.add_argument("-m", "--mds", action="store_true", default=False, help="show MSD scatter default:False")
        args = parser.parse_args()
        save = args.save_matrix
        mds = args.mds
        n = args.rank
        titles, urls = extract_urls(args.url)
        alpha = 0.05
        # userリスト作成
        M_filter = make_matrix(urls, save=False)
        # csvにする(url編)
        with open("related_articles_url.csv","w") as f:
            writer = csv.writer(f, lineterminator='\n')
            a = ["original"]
            a.extend(range(n))
            writer.writerow(a)
        # csvにする(タイトル編)
        with open("related_articles_title.csv","w") as f:
            writer = csv.writer(f, lineterminator='\n')
            a = ["original"]
            a.extend(range(n))
            writer.writerow(a)
        
        confidence = np.zeros(len(M_filter))
        for i, article0 in enumerate(M_filter):
            for j, article1 in enumerate(M_filter):
                a0a1 = np.zeros(len(article0))
                for k,(u0, u1) in enumerate(zip(article0, article1)):
                    a0a1[k] = u0*u1
                if article0.sum() == 0:
                    confidence[j] = 0
                else:
                    confidence[j] = a0a1.sum()/article0.sum() 
            index = confidence.argsort()[::-1]
            print(titles[i],":",urls[i])
            related_article_url = [urls[i]]
            related_article_title = [titles[i]]
            related_num = ["#"]
            # 追加
            for i in index[1:n]:
                related_article_url.append(urls[i])
                related_article_title.append(titles[i])            
                related_num.append(confidence[i])
                print("\t",confidence[i],titles[i],":",urls[i])
            with open("related_articles_url.csv","a") as f:
                writer = csv.writer(f, lineterminator='\n')
                writer.writerow(related_article_url)
                writer.writerow(related_num)
            with open("related_articles_title.csv","a") as f:
                writer = csv.writer(f, lineterminator='\n')
                try:
                    writer.writerow(related_article_title)
                except:
                    continue
                writer.writerow(related_num)
        
    
        # confidence heatmap作る
        if mds:
            dist = calc_dist(M_filter, alpha)
            mds  = manifold.MDS(n_components=2, dissimilarity="precomputed")
            pos = mds.fit_transform(dist)
            plt.scatter(pos[:,0], pos[:,1], marker="x", alpha=0.5)
            plt.show()

    実行は下記コマンドです。保存場所で実行しましょう

    python related_article.py -u http://www.procrasist.com -r 10 -s -m
    • -u 自分のURLを入れる
    • -r 関連記事を何個まで出すか
    • -s 関連記事リストを保存するかどうか
    • -m 2次元にマッピングして表示してみるかどうか

    各記事に対してタイトル、URLが関連度の大きい順に表示されます。 また、実行結果がcsvに保存されるようになっています

    • data.csv : 顧客と商品のマトリックス(上で表示しているようなやつ)
    • related_article_url.csv : 関連記事のURLリスト
    • related_article_title.csv : 関連記事のタイトルリスト*4
    実行結果をみる
    ...
    1ヶ月書き続ければやっぱりぶち上がるの?ブログ月報(´ε` ) : http://www.procrasist.com/entry/2016/10/31/200000
             0.171028210043 ブログは一年続くの?読者数は?2万件のはてなブログで分析する : http://www.procrasist.com/entry/blog-analyzer
             0.149092859153 帰省・上京の新幹線の中で聞きたい曲9選 : http://www.procrasist.com/entry/2016/12/30/200000
             0.0273836825332 【ブログ運営報告】前月比3倍!得意領域に力を入れた、やってて良かった7ヶ月目。 : http://www.procrasist.com/entry/7th-month
             0.0258411085419 【運営報告】バズとGoogle様と5ヶ月目の私 : http://www.procrasist.com/entry/5th-month
             0.0238548574645 【コードで一発】ブログ最適化/SEO対策で面倒なことは全てPythonにやらせよう : http://www.procrasist.com/entry/python-blog-optimization
             0.0238548574645 DeepLearning系ライブラリ、Kerasがあまりにも便利だったので使い方メモ : http://www.procrasist.com/entry/2017/01/07/154441
             0.0175295395488 最近のyoutuberが鉄球を熱しすぎな件について : http://www.procrasist.com/entry/2016/12/04/200000
             0.0165658732392 【成人式】真のヤンキーとは?ヤンキーのなり方を考えてみる! : http://www.procrasist.com/entry/2017/01/09/234532
             0.0165658732392 【武井壮最強説】武井壮が本当に『百獣の王』だと思うワケ : http://www.procrasist.com/entry/takeiso
    【書評】『金持ち父さん貧乏父さん』新社会人への『お金』の講義 : http://www.procrasist.com/entry/2016/10/29/000000
             0.217741935484 ブログは一年続くの?読者数は?2万件のはてなブログで分析する : http://www.procrasist.com/entry/blog-analyzer
             0.217741935484 ダリ展行ってきた。天才過ぎた作品タイトルランキング : http://www.procrasist.com/entry/dali
             0.217741935484 勇者ヨシヒコが始まったので、メレブの今までの呪文を振り返ってみる! : http://www.procrasist.com/entry/2016/10/08/170000
             0.00477897252091 ライブが最高!Fall Out Boy (FOB) の紹介とオススメ曲! : http://www.procrasist.com/entry/2016/11/07/200000
             0.00477897252091 毎日のパソコン生活を快適にする事前設定集 : http://www.procrasist.com/entry/pctips
             0.00477897252091 1ヶ月書き続ければやっぱりぶち上がるの?ブログ月報(´ε` ) : http://www.procrasist.com/entry/2016/10/31/200000
             0.00477897252091 Pentatonixという最強のプロアカペラ集団。おすすめ曲は? : http://www.procrasist.com/entry/2016/11/01/200000
             0.00477897252091 整体師が教えてくれた4つの肩こり・目のかすみ対策 : http://www.procrasist.com/entry/2016/11/04/200000
             0.00477897252091 【ハイスタ, NOB, ...】青春を彩るメロコア・パンクロックバンド達 : http://www.procrasist.com/entry/2016/11/06/200000
    ...

    なお、これらの類似度を元に、2次元空間にマッピングすることもできます*5*6。こんな感じ!

    f:id:imslotter:20170924120048g:plain

    僕の場合は、

    • ブログ系の記事
    • 日記的な雑記
    • テクノロジー系

    でブクマしてくれる方がちょっとずつ違うんだなぁっとわかったりしました!

    まとめ

    いかがでしたか? ブックマーク数がある程度ある人は、こういう分析方法も有効だと思います!

    試してみたいけど環境が作れない&なんかエラー出るって人は声かけてくださいな。可能な限り対応します。

    よし!これで関連記事抽出もできたし、あとはリンクをはるだk...( ˘ω˘)スヤァ

    *1:記事内容から類似度算出も実装してるんですが、Windowsでするには面倒(mecabwindowsに入れるのがゴリ面倒)なものになったので、先に2つ目から紹介します。

    *2:協調フィルタリングと言います。

    *3:そのうちこの機能もall in oneのほうに入れておきます。
    【コードで一発】ブログ最適化/SEO対策で面倒なことは全てPythonにやらせよう - プロクラシスト

    *4:特殊な文字コードが入っている場合は保存されません。仕様です。

    *5:多次元尺度法を使ってます。

    *6:グラフに関しては詳しくはhokekiyooのGithubページの実装をご覧ください。以前記事にも書いています。
    ipywidgetsとbokehで『jupyter』の更なる高みへ 【インタラクティブなグラフ描画】 - プロクラシスト

    PROCRASIST