【機械学習】WordPressでより高精度な関連記事を表示させたい【Python】

最終更新日:

一応国立理系大学情報系学部学生の管理人ですが、最近自然言語処理の授業や機械学習の授業を受けていたりして、これブログ運営に活かせたりしないかなとぼんやり考えていました。

現在、BableTechではアクセスしてくださった方が次にどんな記事を読む傾向にあるのかというのを記録し、それを集計しておすすめの記事を提案していたりします。(高校生の時に適当に作ったやつですが) ただこれだと精度があまり良くないので、今回は「関連記事の表示」を自然言語処理や機械学習を駆使してより高精度にできるようにしたいと思います。

ただ、まだ管理人はそれほど知識がないので、本当に初歩的なものになってしまいそうです…笑

環境を整理

こういった開発を進めるにあたって、やっぱり最初に環境について整理しておくのは大事ですね。現在のBableTechの環境を軽く記載しますと、

  • Xserver(レンタルサーバー), CentOS(管理者権限無) , Anaconda3
  • WordPress(MySQL , PHP)

といった感じです。形態素分析などの自然言語処理をするならやはりPythonが好ましいかと思いますので、PythonをXserverに導入したいわけですが、管理者権限がないのでAnaconda3を導入しました。

Anaconda3だと機械学習のライブラリが標準で搭載されていたりするのでこういったときに非常に使い勝手が良いですね。ただ、他にもサーバーを使ってあらゆる処理をさせたいと考えているので、おそらくXserver(レンタルサーバー)はやめて、10月くらいからXserver(VPS)に移行します。

そしてWebサイトの環境についてですが、WordPressを使っています。WordPressは基本的にPHPで動作し、記事などの情報はMySQL(MariaDB)データベースを利用しているみたいです。

早速やってみる

行き当たりばったりな感じになってしまいそうですが、早速実装してみます。

投稿の内容を取得する

まず最初に既存の投稿の内容を取得してそれを形態素分析してTF-IDF辞書を作成する必要があるわけですが、投稿の内容をどうやって取得しようといった感じですよね。WordPress関数の「get_posts」とかを使ってもいいわけですが、その場合PHPを経由する必要が出てくると思います。今回は基本的にすべての処理をPythonで行いたいと思っているので、直接WordPressのデータベースにPythonからアクセスして投稿の内容を取得してみたいと思います。

Xserverで新しくMySQLユーザーを作り、WPデータベースへのアクセス権を付与して、phpMyAdmin等で確認してみました。少しでも中をいじくってしまったらWordPressが正常に動作しなくなりそうで怖いですね…

WordPressのデータベースには様々なテーブルがありましたが、その中にわかりやすく「wp_posts」というテーブルがあり、中を確認してみるとしっかりと記事内容まで格納されていました。

この時初めて知ったのですが、WordPressでは画像とかプラグインのコンテンツとかもすべて(広義での)投稿と定義されていて、それぞれ投稿IDが付与されていますね。

ただ、テーブルのカラムを見ていると「post_type」という属性があり、そこで「attachment」や「post」などの属性値で投稿の区別をつけていました。今回は、「post」のみ取得する感じになりますね。そして「post_content」というカラムを見てみたのですが、ここには投稿本文のHTMLコードが格納されていました。ブロックエディターにおけるブロックの種類なんかも、コメントアウトで説明されていますね。

これはほんの一部ですね。ちゃんと確認はしていませんが、冷静に考えてショートコードってHTMLをレンダリングするときにWordPressの関数によって自動的に展開されるものですから、データベースに格納されているのは展開前のショートコードかと思います。ただ、ショートコードで挿入するもの自体、当サイトでは文字がそこまで多いわけではないので今回はショートコードの部分は削りたいと思います。

それでは早速PythonからMySQLにつないで投稿を取得してみます。ここではPyMySQLというライブラリを使ってみることにしましょう。あと、先ほどWPデータベースへのアクセス権限を付与していたユーザーは削除し、データベース接続の際はwp-config.phpから確認したユーザー名とパスワードを使うことにしました(セキュリティのため)

import pymysql

#投稿を取得していく

#mysql接続
db = pymysql.connect(user="ユーザー",passwd="パスワード",host="localhost",db="データベース名")
cur = db.cursor()

#投稿を取得するクエリ
query = "SELECT ID,post_content,post_title FROM wp_posts WHERE post_type = 'post'"
cur.execute(query)
post_list = cur.fetchall()
print(post_list)

そしたらちゃんと出力されました。まぎれもなくHTMLコードですね。

ただ、表とかの文字は要らないと考えたので、この中からpタグのテキストだけ抽出してみることにします。

from bs4 import BeautifulSoup

#それぞれの投稿について処理
for (post_id,post_content,post_title) in post_list:

    soup = BeautifulSoup(post_content, "html.parser")
    content_p_list = [p.get_text() for p in soup.find_all('p')]
    content_p_list.append(post_title)

BeautifulSoupを使いました。pタグの中身を抽出+タイトルを結合して、文字列のリストを作りました。確認したところ、いい感じにほとんどのショートコードが排除されていたのでショートコードのことはもう考えずに続行します。

TF-IDFリストを作成

それでは続いて形態素分析をしてTF-IDFリストを作成してみましょう。ここでは「SudachiPy」という形態素分析器を使ってみます。

import pymysql
from bs4 import BeautifulSoup
from sudachipy import tokenizer
from sudachipy import dictionary
import re
import math

#tokenizerの設定
tokenizer_obj = dictionary.Dictionary().create()
mode = tokenizer.Tokenizer.SplitMode.C

#mysql接続
db = pymysql.connect(user="ユーザー",passwd="パスワード",host="localhost",db="データベース名")
cur = db.cursor()

#投稿を取得するクエリ
query = "SELECT ID,post_content,post_title FROM wp_posts WHERE post_type = 'post'"
cur.execute(query)

post_list = cur.fetchall()

tfidf_dic = {}

#それぞれの投稿について処理
for (post_id,post_content,post_title) in post_list:

    soup = BeautifulSoup(post_content, "html.parser")
    content_p_list = [p.get_text() for p in soup.find_all('p')]
    content_p_list.append(post_title)

    tf_dic = {}
    word_sum = 0

    for each_paragraph in content_p_list:
        for each_token in tokenizer_obj.tokenize(each_paragraph,mode):

            kind = each_token.part_of_speech()[0]
            if re.search(r'^助動?詞$',kind):
                continue
            
            word = each_token.normalized_form()
            word = re.sub('[\n  \t!"#$%&\'\\\\()*+,-./:;<=>?@[\\]^_`{|}~「」〔〕“”〈〉『』【】&*・()$#@。、?!`+¥%]','',word)

            if word in tf_dic:
                tf_dic[word] = tf_dic[word] + 1
                word_sum += 1
            elif word:
                tf_dic[word] = 1
                word_sum += 1
    
    for each_word,each_num in tf_dic.items():
        
        if each_word in tfidf_dic:
            tfidf_dic[each_word].update({post_id:each_num / word_sum})
        else:
            tfidf_dic[each_word] = {post_id:each_num / word_sum}             
            
post_sum = len(post_list)

#tfidfリストを完成させていく
for each_word in tfidf_dic:
    this_idf = math.log2(post_sum / (len(tfidf_dic[each_word]))) #idfの値

    for each_post in tfidf_dic[each_word]:
        this_tf = tfidf_dic[each_word][each_post]
        this_tfidf = this_tf * this_idf

        tfidf_dic[each_word][each_post] = this_tfidf

print(tfidf_dic)

こんな感じで適当に作ってみました。ストップワード(除外語)をしっかり設定していなかったりとかなり適当な感じですが、ひとまずはこれで許容しましょう。軽く説明しておくと、助詞・助動詞や記号などの文字は除外して投稿ごとに単語出現数リストを作って、それをもとにTFリストを作り、最後にIDFも算出して、TFとIDFの積を格納しているTF-IDFリストを作っている感じですね。

この後紹介する機械学習のライブラリ等を使えばもっと簡単にTF-IDFリストを作れるみたいですが、なんか授業でこんな感じで一から作らされたので、その勢いで作ってみました。

2023/05/08追記:同じトークンだとしても活用されていたりして形が変わっているときに違うトークンとして認識させないように原形でトークンを保存するようにしていますが、トークンIDというものを扱った方が圧倒的にデータサイズが少なくて済むし良いかもしれないです。

主成分分析を行う

このままそれぞれの投稿同士のコサイン類似度を求めていっても良いのですが、単語数が多すぎて数万次元ベクトルの内積の計算×数百万回という絶望的な処理が必要になってしまうのと、ある程度大まかにクラス分けも行いたいという思いがありまして、ここではまず主成分分析をしたいと思います。 それぞれの投稿のTF-IDF情報の特徴をなるべく残しながら次数を下げていく機械学習の処理ですね。

#主成分分析
post_tfidf_df = pd.DataFrame(tfidf_dic).fillna(0)

pca = PCA(n_components=10)
new_tfidf = pca.fit_transform(post_tfidf_df.iloc[:,:].values)

ここではsklearn,panda等を使ってみました。こんなにも簡単に主成分分析ができるなんて… すごいですね。ここではコンポーネントの数を10にしてみました。

コサイン類似度行列を作成

sim = cosine_similarity(new_tfidf)

similarity_list = {}
for each_post in range(len(sim)):
    this_post_id = post_list[each_post]
    similarity_list[str(this_post_id)] = {}

    for each_target_post in range(len(sim[each_post])):
        if sim[each_post][each_target_post] > 0.9 and each_post != each_target_post:
            target_post_id = post_list[each_target_post]
            similarity_list[str(this_post_id)][str(target_post_id)] = sim[each_post][each_target_post]

続いてcosine_similarityというめっちゃ便利な関数で類似度行列を作りつつ、その中から類似度が90%以上のものだけを抽出して投稿IDとかも付与したリストを作りました。なんかすごく遠回りなことしている気がしますが、まぁ結果ちゃんと類似度の高い記事が抽出できていたのでよしとしましょう。

流れとしては、これをJSON形式にしてサーバー上に保管し、各投稿を表示する際にそのJSONを参照して関連記事を取得して記事内に表示するといった感じですね。そしてこの類似度JSONに関しては一日に一回、閲覧者が少ない時間帯でcronで自動実行するようにしようかと思っています。

ちなみにこの一連の処理ですが、Xserverが高性能すぎて、記事数が700くらいあるのに10秒くらいでサクッと終わっちゃいますね。すごすぎる…

関連記事を表示機能を作る

続いて、投稿にアクセスしたときに一番下に関連記事を表示する機能を作ります。 投稿にアクセスしたときに投稿IDをJavaScriptの変数に代入→ページロード完了後に非同期Ajax通信でPHPにPOSTして返り値として関連記事表示DOMを取得→投稿の一番下に挿入という流れでいきます。

//類似度行列を取得
$similarity_list = file_get_contents("~/post_similarity.json"); //類似度行列ファイル
$similarity_list = json_decode($similarity_list,true);

$this_similarity_list = array_key_exists($post_id,$similarity_list) ? $similarity_list[$post_id] : array();

arsort($this_similarity_list);
$max = 5;
$i = 0;

foreach($this_similarity_list as $each_post => $each_score){
    if($i >= $max){break;}
    $thumbnail_url = get_the_post_thumbnail_url($each_post,"medium");
    $title = get_the_title($each_post);
    $url = "https://bablishe.com/?p={$each_post}";
    
    $relative_DOM .= "<li><a href='{$url}'><img src='{$thumbnail_url}' alt='投稿のサムネイル画像' loading='lazy'></a>";
    $relative_DOM .= "<a class='post_title' href='{$url}'>{$title}</a></li>";
    $i++;
}

$random_post_id = array_rand($similarity_list,1); //ランダムな記事も

$thumbnail_url = get_the_post_thumbnail_url($random_post_id,"medium");
$title = get_the_title($random_post_id);
$url = "https://bablishe.com/?p={$random_post_id}";

$relative_DOM .= "<li><a href='{$url}'><img src='{$thumbnail_url}' alt='投稿のサムネイル画像' loading='lazy'></a>";
$relative_DOM .= "<a class='post_title' href='{$url}'>{$title}</a></li>";

$result_data_set = array("pp_DOM"=>$result_DOM,"rp_DOM"=>$relative_DOM);


echo json_encode($result_data_set);

このPHPファイル内でWordPressをロードしているので、投稿IDから投稿の情報が取得できる関数が使えます。現在表示している投稿のIDをPOSTで送ってもらって、それを類似度行列ファイルと照合して類似度記事リストを取得→WP関数で投稿情報を取得しながらDOM生成といった感じですね。

関連記事だけが出てくるのもあれなので、最後にあえてランダム記事も投入しました。思いのほかランダムで表示された記事には興味がひかれたりするんですよね、たぶん…

done(function (data) {
    data = JSON.parse(data);
    jQuery("ul#sidebar_popular_list").html(data["pp_DOM"]);
    
  });

JavaScript側では出力されたJSONデータをでコードしてそのままHTML出力するというシンプルな処理になっています。

あとはCSSを整えて、完成しました。この記事は以下の記事の関連記事達です。

【jQuery】スクロールに追尾してくれる便利な目次を作る【WordPress】

全く関係ないARM MacBookの記事が紛れ込んでいますが、例によってこれがランダムで選ばれた記事ですね。 ちょっと解説が少なくなってしまいましたが、今後も機械学習とブログを融合させたようなシステム等をたくさん作っていきたいと思います。


関連記事

    人気記事

    じゅんき
    BableTech再整備中です10月頃にまた本格的に始動する見込みです。ちなみに管理人20歳の情報系大学生です。忙しいです(泣)

    記事内用語

    詳細ページ