コラムの関連記事をAIで自動リストアップさせる方法

## 今回やりたいこと

Webマーケティングの中でもコンテンツマーケティングを実施されていた場合なら、ほとんどのメディアが設定しているであろう「関連記事」や「おすすめ記事」。

この記事を設定するときに、以下のような悩みを抱える方が多くいると思います。

+ 過去の記事をさかのぼって関連記事を設定するのが面倒
+ どれが最適な記事なのか判断ができない、時間がかかる
+ 昔の記事の関連記事が更新できていない

そこで、今回は簡単に関連記事を見直しできるツールをAIの力を借りて実装してみます。

## 今回の概要

まずはサイトの内容を伝えられる範囲で下記へ記載します。

+ 表示回数は月間で5万回ほど
+ 食やグルメに関するメディア
+ 現在はPV数のアップが課題になっている

## 考え方の概要

今回はオイラリー・ジャパンが出版している「推薦システム実践入門」を拝見したうえで実装をしています。ぜひ購入を検討している方はこちらから。
https://www.oreilly.co.jp/books/9784873119663/

以下では実装するにあたって、どんな方法が存在しているのか？どのような考え方でモデルを選んだのか？について解説をしていく。

### 3種類の推薦方法
推薦方法には大きく分けて3つの計算方法がある。
+ 概要推薦
新着順、安い順、人気が高い順などパーソナライズがされていない推薦方法
+ 関連アイテム推薦
「このアイテムを見ているユーザーはこんなアイテムも見ている」といった形で推薦する方法。アイテム同士の関連度をもとに推薦されるため、似たようなコンテンツが選ばれやすい。
+ パーソナライズ推薦
ユーザー一人一人のプロフィールや行動履歴をもとにして、コンテンツを推薦する方法。行動履歴や閲覧履歴をもとに計算ができるため、見逃したコンテンツも表示させやすい。

今回、Webサイトでログインシステムなどの個人を特定するツールは導入していないため、関連アイテム推薦の開発をすることになる。

### ユーザーの目的別の推薦システム
推薦システムとひとくくりにしても、メディアのコンバージョンポイントやビジネスモデルの違いで推薦システムを導入する目的は全く違ってくる。そこで、今回は導入目的を大きく4つに分けて紹介する。

+ 適合アイテム発見
ユーザーが自分の目的を達成するのに適したものを一つでもいいから見つけようとしているメディア。
例）
食べログ：自分が好きな飲食店を見つけることが目的。比較検討はするが真剣にはしない。
GoogleMap：自分が行きたい場所の複数候補をリストアップしてもらい、適切なアイテムを推薦してもらえる。
Netflix：映画やアニメなどの画像コンテンツの中で、ユーザーが次に見たいコンテンツを推薦してもらえる。

+ 適合アイテム列挙
ユーザーの目的を達成するのに適切なアイテムを、すべて比較検討して選びたいと考えているメディア。
例）
SUUMO：物件情報の写真や条件をすべて閲覧する場合が多い。多くの情報から納得した情報を選べる。
J-Plat-Pat：特許情報プラットフォーム。ユーザーが複数の特許情報を比べながら選択する。

+ アイテム系列消費
閲覧や消費する中で推薦されたアイテムそのものから価値を享受することを目的としたメディア
例）
Spotify：音楽ストリーミングサービスで音楽を次々と再生する。音楽再生によって価値をユーザーへ提供している。

+ サービス内回遊
ユーザーが利用している目的を達成するためでなく、ただアイテムを閲覧することを目的としたメディア。
例）
Airbnb：Airbnbでは「自然に囲まれた宿泊先」や「ユニークなリスティング」など、読み物コンテンツが多くある。
アットコスメ：メイクやコスメに関する記事が多く掲載されている。コンバージョンは別であるが、そのアイテムを閲覧して離脱を繰り返す。


私はそれぞれの4つについて、以下の通りに理解をしている
+ 何か商品を選んでいる
    + しっかりと比較検討する：適合アイテム列挙
    + そこまで比較検討しない：適合アイテム発見
+ 商品を選んでいるわけではない
    + 同じメディア内で複数のコンテンツを連続して消費：アイテム系列消費
    + そのアイテムだけを閲覧することが目的：サービス内回遊

複数の目的に当てはまるケースもあると思いますが、大きくこの4つに目的が分かれる。今回は別にコンバージョンポイントがあり、SEOで流入してきたユーザーを想定しているため、「サービス内回遊」が関連記事を追記する目的になる。

### 推薦システムの種類
実は推薦システムは入力に使うデータとアルゴリズムにより、内容ベースフィルタリング（コンテンツベースフィルタリング）と協調フィルタリングの2種類に分けられる。それぞれの違いについて解説をする。

+ 内容ベースフィルタリング
本のタイトルや本文、作者、ジャンル、タグなどアイテムに関する情報を利用している。その情報から近いアイテムを探して推薦するシステムのこと。つまり過去ユーザーが見てきたコンテンツの情報である「ユーザープロファイル」と、コンテンツの性質を数値化した「アイテム特徴量」の2つがあって成立する計算。

+ 協調フィルタリング
サービス内でユーザーと同じような行動をした、他のユーザーが見たことのあるコンテンツを推薦するシステムのこと。例えば、ユーザーの閲覧履歴を見てアイテムの子のみを推測、その後に好みの傾向が似ているユーザーを探し出す。

それぞれの使い分けを表にすると以下のようになる。

|  | 協調フィルタリング | 内容フィルタリング |
|:-----------|:------------|:------------|
| 多様性の向上 | 〇 | × |
| ドメイン知識を扱うコスト| 〇 | × |
| コールドスタート問題への対応       | ×        |  △  |
| ユーザー数が少ないサービスにおける推薦         | × | 〇  |
| アイテム特徴の活用       | ×  | 〇      |
| 予測精度    | 〇     | △      |

つまり、予測精度や多様性の向上など品質面では協調フィルタリングの方が適切であるが、ユーザー数が少なかったり、コールドスタート問題へ対応するためには内容フィルタリングの方が適している。

※コールドスタート問題とは？
新しく登場したコンテンツについては、閲覧しているユーザー数が少ない。そのため、推薦するコンテンツが少なくなってしまうという問題。この問題を避けるためにはコンテンツの「内容」をもとに推薦することが大切

## AIモデルの実装

ここまでで推薦システムの目的や種類について解説をしてきた。では次に、実際に今回はどのようなモデルが良かったのかについて解説をしていく。

今回は以下のモデルを試してみて、どのような記事がおすすめされるのか目視で確認してみた。

| モデル | フィルタリング | 精度 |
|:-----------|------------|:------------|
| tf-idf |内容 | ○ |
| BERT| 内容 | ○ |
| word2vec | 内容 |  △ |
| LDA | 内容 | △ |
| アソシエーションルール | 協調 | × |

今回は上記のモデルを試してみたが、結論td-idfは最も効果があった。次の章ではどのようなモデルを組んだのか解説をしてみる

### tf-idf

+ tf-idfとは？
Term Frequency - Inverse Document Frequencyの略のこと。
単語の頻出度を比べて、どの単語がその文章のなかで重要なのか？重要ではないのか？を分類分けする手法。

tfは、その文章のなかで単語の出現頻度がどれくらいかを示している。idfは「いくつの文章でその単語が使われているか」を示している。

例えば「私」や「です」など、複数の文章で使われている単語は重要でないケースが多い。そのため、数少ない文章で使われている「レアな単語」だとidfの値が上昇する。

最後にtfとidfの積を取ることで、レア単語がどれくらいの頻度で表れるのか？を示す指標となる。

そして、「レア単語の頻度」を使って、文章の近さを求める。

+ モデルの実装
今回はGoogleColaboratryで実装をしてみます。
~~~python

import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

#csvファイルの読み込みなどを行うため、google colabとドライブを連携
from google.colab import drive
drive.mount('/content/drive')

#連携させたGoogleドライブからcsvファイルをdf_originへ格納
df_origin = pd.read_csv('/content/drive/MyDrive/mydata/original_data/my_content.csv')

#不要な文字はreplaceを使って削除する。そのための辞書データの作成
replace_dic ={"\n":"", "\u3000":"","\r":""}

#まずは本文をリスト化させて、documentへ入れる。
#documentに格納された文章それぞれに、replace_dicの変換を行う。
document = df_origin['本文'].to_list()
for old,new in replace_dic.items():
  for i in range(len(document)):
    document[i] = document[i].replace(old,new)

# TF-IDFベクトライザーの初期化
tfidf_vectorizer = TfidfVectorizer()
# 文書のTF-IDFベクトルを計算。tfidf_vectorizer.fit_transformで文章のTF-IDFスコアを求められる。
tfidf_matrix = tfidf_vectorizer.fit_transform(document)
# コサイン類似度を計算。コサイン類似度が大きいと、その文章は近いといえる。
cosine_similarities = cosine_similarity(tfidf_matrix, tfidf_matrix)

top_similar_articles = {}
for idx in range(cosine_similarities.shape[0]):
    # 自分自身を含むため除外しつつ上位6つのインデックスを取得
    similar_indices = cosine_similarities[idx].argsort()[-7:-1][::-1]
    top_similar_articles[idx] = similar_indices
# 各文章に対して関連度の高い6つの記事を表示
for i, similar_indices in top_similar_articles.items():
    print(f"Article {i+1}: {df_origin['タイトル'][i]} - {df_origin['URL'][i]}")
    for idx in similar_indices:
        print(f"  - Similar Article {idx+1}: {df_origin['タイトル'][idx]} - {df_origin['URL'][idx]}")
    print("\n")

~~~

### BERT
+ BERTとは？
Bidirectional Encoder Representations from Transformersの略。自然言語処理へ活用できる深層学習モデルのひとつで、文章や単語の意味を数値で表すことができる。旧来のモデルでは取れなかった、「長い文章でもその文脈を読み取れる」ことが特徴。

また、その数値化された文章の「近さ」を求めることもできるため、2つの文章がどれくらい近いのか？（似ているのか）を示す指標にできる。

※おそらく「どうやって文章を数値で表すのか？」気になると思う。それは「BERT 仕組み」とGoogle検索してください。

+ モデルの実装

~~~python
# BERTのモデルとトークナイザーをロード
model_name = 'bert-base-uncased'
tokenizer = BertTokenizer.from_pretrained(model_name)
#BertTokenizerクラスでは入力分のtokenizeや辞書に関連するクラスである
#bert-base-uncasedで、事前学習された単語へ分割される。uncasedは小文字と大文字を区別しないことを示す。
#from_pretrained()でそのモデルに基づいて、トークナイズ（単語を分割）される
model = BertModel.from_pretrained(model_name)
#事前学習モデルに基づいて、単語の埋め込み表現（ベクトル表現）を生成

# モデルをGPUに移動
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model.to(device)

def get_embeddings(text_list):
    embeddings = []
    for text in text_list:
        inputs = tokenizer(text, return_tensors='pt', truncation=True, padding=True, max_length=512)
        #ここではトークナイズ（単語分割）を行っている
        #text：どの文章を分割するか指定
        #return_tensors：出力フォーマットを指定します。今回はPyTorchテンソル(torch.Tensor)を指定している。PyTorchかTensorFlowのどちらか
        #truncation：テキストがmax_lengthを超える際に、テキストを切り詰めるように指定する。モデルに入力されるテキストが許容範囲になるように
        #padding：もし指定された長さより短い倍にはパディングをしてテキストの長さを統一する。
        #max_length：テキストの最大長を指定する。
        inputs = {key: value.to(device) for key, value in inputs.items()}  # GPUに移動
        outputs = model(**inputs)
        embeddings.append(outputs.last_hidden_state.mean(dim=1).squeeze().detach().cpu().numpy())  # GPUからCPUに戻す
    return np.array(embeddings)

document_embeddings = get_embeddings(documents)

def get_top_similar_articles(embeddings, top_k=6):
    similarities = np.zeros((len(embeddings), len(embeddings)))

    for i in range(len(embeddings)):
        for j in range(len(embeddings)):
            if i != j:
                similarities[i, j] = 1 - cosine(embeddings[i], embeddings[j])

    top_similar_articles = {}
    for i in range(len(embeddings)):
        top_indices = similarities[i].argsort()[-top_k:][::-1]
        top_similar_articles[i] = top_indices

    return top_similar_articles

top_similar_articles = get_top_similar_articles(document_embeddings)

# 各文章に対して関連度の高い6つの記事を表示
for i, similar_indices in top_similar_articles.items():
    print(f"Article {i+1}:")
    for idx in similar_indices:
        print(f"  - Similar Article {idx+1}")
    print("\n")

~~~

### word2vec 
+ word2vecとは？
これも単語をベクトル変換し、ベクトルをもとにして単語同士の意味の近さを示すモデルのこと。ついでにBERTはword2vecを発展させた結果になります。
word2vecでは、特定の単語をマスキングしたうえで周辺の単語からマスキングされた単語を推測します。単語を推測するときにはベクトル表示で示されます。
その単語のベクトルの平均を取ることで、文章の意味をベクトルで表示できるようになります。

+ モデルの実装

~~~python
from gensim.models import Word2Vec
from nltk.tokenize import word_tokenize

df = df_origin

df["tokens"] = df["本文"].apply(word_tokenize)

# Word2Vecモデルの学習
model = Word2Vec(sentences=df["tokens"], vector_size=100, window=5, min_count=1, workers=4)

from sklearn.metrics.pairwise import cosine_similarity
import numpy as np

# アイテムのベクトルを取得
def get_item_vector(index, model, df):
    tokens = df.loc[index, 'tokens']
    vector = np.mean([model.wv[token] for token in tokens if token in model.wv], axis=0)
    return vector

# 各アイテムのベクトルを計算
item_vectors = {index: get_item_vector(index, model, df) for index in df.index}

# コサイン類似度を計算
item_ids = list(item_vectors.keys())
item_matrix = np.array(list(item_vectors.values()))
similarity_matrix = cosine_similarity(item_matrix)


# 類似度の高いアイテムを取得する関数
def get_similar_items(index, similarity_matrix, item_ids, df, top_n=6):
    item_idx = item_ids.index(index)
    similar_indices = np.argsort(similarity_matrix[item_idx])[::-1][1:top_n+1]
    similar_items = [(item_ids[i], df.loc[item_ids[i], 'タイトル'], df.loc[item_ids[i], 'URL']) for i in similar_indices]
    return similar_items

# 結果を表示する関数
def display_similar_items(df, similarity_matrix, item_ids, top_n=6):
    for index in df.index:
        similar_items = get_similar_items(index, similarity_matrix, item_ids, df, top_n)
        print(f"関連記事 for '{df.loc[index, 'タイトル']}':")
        for sim_item_id, title, url in similar_items:
            print(f"  - {title} ({url})")
        print()

# 全ての記事に対して類似記事を表示
display_similar_items(df, similarity_matrix, item_ids)

~~~


### LDA

+ LDAとは？
Latent Dirichlet Allocationの略で、大量のテキストデータを分析し、そのトピックを自動抽出するモデルのひとつ。この「トピックを抽出するモデル」をトピックモデリングとも言う。

クラスタリングのモデルにはk-meansもある。k-meansは「1つのトピックに分類される」という考えだが、LDAは「複数のトピックに分類される可能性がある」ことを前提としたモデルである。

例えば、「パリオリンピックで活躍した水泳選手による経済効果は1億円だ」という文章の場合には、
k-meansでは「オリンピック」など、1つのカゴリーにしか分類できない。
一方でLDAなら「オリンピック・水泳・経済」の3つのトピックが含まれるという判断ができる

+ 仕組み

1. 何個のトピックに分類するのかを決める
2. 文章を単語ごとに分割する
3. どの文章に何の単語が出てきたのかを示す表を作る
4. その表をもとにして、文章がどのトピックに分類されるのかを示す

+ モデルの実装

~~~python
import pandas as pd
import nltk
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation
from sklearn.metrics.pairwise import cosine_similarity
import MeCab
import re

# punktデータのダウンロード
nltk.download('punkt')
nltk.download('stopwords')

# データの読み込み（適宜、パスを指定してください）
df = pd.read_csv('/content/drive/MyDrive/goo-dy/original_data/0720_iimono-content.csv')

# インデックスをitem_idに設定
df.set_index('Unnamed: 0', inplace=True)

# 日本語のストップワードのリスト
japanese_stopwords = set([
    "の", "に", "は", "を", "た", "が", "で", "て", "と", "し", "れ", "さ", "ある",
    "いる", "も", "する", "から", "な", "こと", "として", "いく", "や", "れる",
    "など", "ない", "なっ", "それ", "これ", "あれ", "ここ", "そこ", "あそこ"
])

# MeCabの初期化
mecab = MeCab.Tagger("-Owakati")

# 前処理: テキストのクリーニング
def preprocess_text(text):
    # 日本語の文章をトークン化
    tokens = mecab.parse(text).strip().split()
    # ストップワードの除去
    tokens = [token for token in tokens if token not in japanese_stopwords]
    return ' '.join(tokens)

df['clean_text'] = df['本文'].apply(preprocess_text)

# ベクトライザーの初期化
vectorizer = CountVectorizer(max_df=0.95, min_df=2)

# 文書-単語行列の生成
dtm = vectorizer.fit_transform(df['clean_text'])

# LDAモデルのトレーニング
lda = LatentDirichletAllocation(n_components=10, random_state=42)
lda.fit(dtm)

# トピック分布の計算
topic_distributions = lda.transform(dtm)

# コサイン類似度の計算
similarity_matrix = cosine_similarity(topic_distributions)

# 類似度の高いアイテムを取得する関数
def get_similar_items(index, similarity_matrix, item_ids, df, top_n=6):
    item_idx = item_ids.index(index)
    similar_indices = np.argsort(similarity_matrix[item_idx])[::-1][1:top_n+1]
    similar_items = [(item_ids[i], df.loc[item_ids[i], 'タイトル'], df.loc[item_ids[i], 'URL']) for i in similar_indices]
    return similar_items

# 結果を表示する関数
def display_similar_items(df, similarity_matrix, item_ids, top_n=6):
    for index in df.index:
        similar_items = get_similar_items(index, similarity_matrix, item_ids, df, top_n)
        print(f"関連記事 for '{df.loc[index, 'タイトル']}':")
        for sim_item_id, title, url in similar_items:
            print(f"  - {title} ({url})")
        print()

# 全ての記事に対して類似記事を表示
item_ids = list(df.index)
display_similar_items(df, similarity_matrix, item_ids)
~~~



### アソシエーションルール
+ アソシエーション分析とは？
あるAの事象が起こった時に、Bの事象も一緒に起こるといったルールのことをアソシエーションルールという。

webページの例でいうと、とあるユーザーがAページを見たあとにBページを見たとする。すると、他のユーザーもAページを見たあとには、Bページを見る可能性が高いと言える。

上記は1ユーザーだけだが、同一セッションのなかでAページの他に
・Bページを見たユーザー : 100
・Cページを見たユーザー : 20
・Dページを見たユーザー : 500
となっている場合を考える。
この時、Aページに対してはDページを「おすすめコンテンツ」として出すべきだろう。

このルールを使って協調フィルタリングによる推薦システムを構築できる。

※アソシエーションとは、共通の目的を持つ人々が作る集団のこと

+ アソシエーション分析の評価方法
アソシエーション分析では主に3つの指標を使って、分析した結果の評価を行う

1.支持度（Support）
すべてのコンテンツのなかで、該当の複数コンテンツが選ばれる確率のこと。今回で言えばAページとBページが一緒に見られたセッション数÷セッション数の合計

この支持度が高いということは、他のコンテンツよりも同じタイミングで見られるケースが多いと言える。

例えば、
① AページとBページを一緒に見たSS数 : 450
② AページとCページを一緒に見たSS数 : 50
の場合に支持度は
① 0.9　②0.1
となる。

この場合には、AページにはBページを見せるべきと言えるだろう。

ただし、多くの記事がある場合には「SS数は少ないニッチな記事」があるはず。この数値だと全体のSS数が大きくなるとニッチな記事の関連記事はどれが良いか比較がしにくい。そこで次の指標を使う。


2.信頼度（Confidence）
あるコンテンツを見た時に、他のコンテンツも一緒に見る確率のこと。今回で言えばAページとBページを両方見たSS数÷Aページだけを見たSS数となる。

この割合が高いということは、Aページを見た人のほとんどがBページも一緒に見ているということになる。

例えば、
AページのSS数 : 250
AページとBページを一緒に見たSS数 : 200
の場合に信頼度は 0.8 となる。

この場合には、AページにBページを見せるべきと言えるだろう。

この値だと確かにAとBの関連性は図りやすくなる。ただ、注意したいのはBページが大人気ページだった場合だ。人気ページということは、BページのSS数は必然的に多くなる。つまり、AページとBページを一緒に見たSS数も多くなり、信頼度が上がる。そこで次は人気度の指標を抜きにして、関連度が高い記事を探す

3.リフト値（Lift）
リフト値は、コンテンツAを見たユーザーのうちBも一緒に見たユーザーの割合（信頼度）が、コンテンツBのみ見たユーザーの割合よりもどれくらい大きいかを示した指標。

例えば
全ページのSS数 : 100
AページのみSS数 : 80
BページのみSS数 : 20
AページとBページを一緒に見たSS数 : 40
の場合、
Aの信頼度 : 0.5
全SSのうちBページのみSSした割合 : 0.2
リフト値 : 2.5
となる。

リフト値が1を越すとAとBには強い関連性があると言えるだろう。

先ほど提示した「人気ページか否かを判定」する方法は全体のSS数に占める、BページのSSの割合となる。リフト値が1を越すというのは、人気ページ度合いよりも、AとBを一緒に見る度合いの方が大きいとなる。

+ 今回の分析方法
Python環境だけでは分析ができないため、Bigqueryも活用しながら分析を行う。
具体的には以下のフローとなる
・Bigqueyでga_session_idとpage_locationを取得
・Pythonでga_session_idごとにpage_locationでアソシエーション分析をする

+ モデルの実装

~~~python
df_origin_aso = pd.read_csv('/content/drive/MyDrive/file/original_data/originaldata.csv',dtype = {'user_pseudo_id':'object'})
df = df_origin_aso
df.drop_duplicates(subset=["session_id","page_location"],inplace=True)
#まずはBigQueryで抽出した情報をdfへ入れる。その後に重複があれば削除。

df_1 = df.groupby(["session_id","page_location"])["event_date"].count()
df_2 = df_1.unstack().reset_index().fillna(0).set_index("session_id")
# セッションIDとpage_locationごとにグループ分けし、その数を数える。
# その後に、インデックスをリセット、nullを0で埋める、そしてsession_idを新たなインデックスとして登録する。
# df_2は、インデックスにsession_id、カラムにページURL、値としてPV数を取っている。

basket_df = df_2.apply(lambda x:x>0)
# もしdf_2の値が1以上であれば、True異なればFlaseを記載している
#アソシエーション分析をするためには、この形式にする必要がある。

# ライブラリの読み込み
from mlxtend.frequent_patterns import apriori
from mlxtend.frequent_patterns import association_rules

# アプリオリによる分析
freq_items1 = apriori(basket_df, min_support = 0.0001, 
    use_colnames = True)

# 結果確認
display(freq_items1.sort_values('support', 
    ascending = False).head(10))

# itemset数確認
print(freq_items1.shape[0])

# アソシエーションルールの抽出
a_rules1 = association_rules(freq_items1, metric = "lift",
    min_threshold = 5000.0)

# リフト値でソート
a_rules1 = a_rules1.sort_values('lift',
    ascending = False).reset_index(drop=True)

# 結果確認
display(a_rules1.head(50))

# ルール数確認
print(a_rules1.shape[0])
~~~

## 結論 

上述の通り、今回はtf-idfがよい分類をできていたように感じる。
アソシエーション分析についてはデータ量が少なく、ニッチな記事ばかりだったので支持度がかなり低かった。また推薦記事が見つからないケースもあり断念。
一方でtf-idfは
・スイーツに関する記事：スイーツのランキング、コンビニスイーツ
・和菓子に関する記事：羊羹のランキング、令和和菓子の記事
など、関連度の高い記事が紹介されていた。