レコメンデーション・機械学習でビジネス課題を解決! 技術編

はじめに

前回のブログで私は、レコメンデーションのビジネス活用視点でブログを書かせていただきました。今回は技術編として、理論と実装について説明したいと思います。

レコメンデーションの手法と理論

今回使う手法は協調フィルタリングとコンテンツベースフィルタリングの2つです。

協調フィルタリング

協調フィルタリングとは趣味趣向が似ている別のユーザーからまだ閲覧していないコンテンツに関して口コミを聞き、そのコンテンツをレコメンデーションする手法になります。今回使用するアルゴリズムは特異値分解(SVD:Singular Value Decomposition)です。SVDはある行列を特異値分解定理により3つの行列の積に分解し、以下の式で表されます。



{\displaystyle X=U\Sigma V^{T}}


ここで𝑋は任意の𝑛×𝑚行列です。𝑈は𝑛×𝑘行列、𝑉は𝑘×𝑚行列で、𝑈と𝑉は直行行列です。また、Σは𝑘×𝑘の対角行列であり、この対角成分を特異値と言います。これらを視覚化すると図1になります。

図1. SVDの可視化


今回の記事のレコメンデーションでの例では、各記事(コンテンツ)に対し各ユーザーの閲覧履歴を行列で表したデータセットが𝑋です。𝑋 は𝑛人のユーザーが存在し、𝑚個の記事があることを表しています。𝑈はユーザーに関する情報を、𝑉は記事に関する情報をもっている行列です。Σは対角成分に特異値が大きい順にならんでいます。この特異値とは潜在的な変数の重要度とみなすことができます。つまり、最も大きな特異値は最も重要な潜在的変数となります。
このΣから特異値が大きい順にd番目を選択し、それ以外の特異値が小さいものは重要度が低いので、d+1番目以降の行列を無視します。残った行列Σ_dとそれに対応した行列U_dV_dの積により元の行列に近似した行列\tilde Xを計算します。この計算によるイメージとしては、まだ見ていない記事に対して趣味趣向が似た別のユーザーからオススメ度を入力してもらっているデータセット状態です。この計算したオススメ度の数値を高いコンテンツをオススメすることでレコメンデーションすることができます。この一連の流れがSVDを利用したレコメンデーションの仕組みです。

図2. SVDによる近似計算のイメージ

コンテンツベースフィルタリング

コンテンツベースフィルタリングとはユーザーが見たコンテンツ(今回の場合は記事)と似ているものをレコメンデーションする手法です。今回の例ではタイトルの単語を使用してコンテンツの近さを計算してレコメンデーションを行います。以下ではタイトル=文書として読み進めてください。この文書の近さを計算するためには何かしらの値が必要です。その値を求めるアルゴリズムに今回はTF-IDF(Term Frequency-Inverse Document Frequency)を使用します。TF-IDFとは文書内の単語の頻出頻度とその単語が文書全体における希少性を考慮した値です。詳しくTF、IDFと順に説明します。※補足

TF(Term Frequency:単語の出現頻度)

ある文書内のある単語を抽出し、その単語の「登場する割合」を計算した値のことを意味します。

{
\displaystyle
\begin{equation}
TF(t_i,d_j )=\frac{文書d_j に登場する単語t_i の数}{文書d_j に登場する全ての単語の数}
\end{equation}
}

このTFは高頻度の単語は重要であることを意味します。
単純にTFのみを使用してコンテンツの特徴を捉えた値として使用すると、例えば「私」、「あなた」といったその文書を特徴付ける単語でないのに、出現回数が多い単語が高い数値になるであろうと考えられます。そこで次に説明するIDFにより、特定の文書に出てきてその文書を特徴付ける単語に重要さを割り当てます。

IDF(Inverse Document Frequency:逆文書頻度)

全文書数の内、ある単語が含まれている割合の逆数をlogとって計算したものです。

{
\displaystyle
\begin{equation}
IDF(t_i )=\log⁡\frac{全文書の数}{単語t_i が含まれる文書の数}
\end{equation}
}

IDFはある単語自体の希少性を計算しており、一般的な単語の重みを小さくし、珍しい単語の重みを大きくします。

TF-IDF

上記で説明したTFとIDFを掛け合わせたものがTF-IDFになります。
{
\displaystyle
TF\text{-}IDF(t_i,d_j )=TF(t_i,d_j ) \times IDF(t_i )
}

実装

AML(Azure Machine learning)でコードを実行しました。

実装環境

  • リージョン:centralus
  • コンピューティングインスタンス:Standard_DS12_v2
  • ノートブック環境:JupyterLab
  • カーネル:Python 3.8 - Pytorch and Tensorflow

(numpy:1.22.2, pandas:1.4.0, scikit-learn:1.0.2, scipy:1.8.0,matplotlib:3.5.1, azureml-core:1.38.0)

データ

MIND: Microsoft News Recommendation Dataset | Kaggleから取得し、事前にAMLのデータセットに登録しました。
なお、使用したデータは「MINDsmall_train」ディレクトリ配下のデータです。

  • behaviors.tsv(ユーザーの行動履歴)
  • news.tsv (コンテンツの情報)

※データセットの登録方法はこちらをご参照ください
スタジオ UI を使用してデータ ストレージに接続する - Azure Machine Learning | Microsoft Docs

実装コード

ライブラリのインストール・インポート
pip install nltk

上記ではコンピューティングインスタンスを作成した時点でインストールされていないライブラリをインストールします。
以下のような出力が表示されます。「Successfully installed nltk-X.X regex-XXXX.XX.XX」が表示されればインストール完了です(X部分はバージョン)。

# 必要なライブラリのインポート
import numpy as np
import pandas as pd
import math
import random
import scipy
from scipy.sparse import csr_matrix
import sklearn
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from scipy.sparse.linalg import svds
from sklearn.preprocessing import MinMaxScaler
from nltk.corpus import stopwords
import matplotlib.pyplot as plt
from azureml.core import Workspace, Dataset
# ストップワード除去するためにダウンロード
import nltk
nltk.download('stopwords')

コードを実行するとパッケージのダウンロードとアンジップの結果が表示されます。

データのロード
# Azure ML環境
from azureml.core import Workspace, Dataset
# AMLのデータセットに上のデータを使用するためワークスペースにアクセスする
subscription_id = 'ここにサブスクリプションIDを入れて下さい'
resource_group = 'ここにAMLのリソースグループを入れて下さい'
workspace_name = 'ここにAMLのワークスペース名を入れて下さい'

workspace = Workspace(subscription_id, resource_group, workspace_name)
# ニュースデータの読み込み
dataset = Dataset.get_by_name(workspace, name='ここにデータストアにnews.tsvを登録したときの名前を入れてください')
df_news =dataset.to_pandas_dataframe()
# 列名の設定
df_news.columns = ['news_id',
"category",
"subcategory",
"title",
"abstract",
"url",
"title_entities",
"abstract_entities "]
# 行数、列数の表示
print(df_news.shape)
# データを表示
df_news.head()

ニュースデータの(行数、列数)と先頭5行目までのデータが表示されます。

# ユーザーの行動履歴データの読み込み
dataset = Dataset.get_by_name(workspace, name='ここにデータストアにbehaviors.tsvを登録したときの名前を入れてください')
df_behaviors =dataset.to_pandas_dataframe()
# 列名の設定
df_behaviors.columns = ['impression_id', 'user_id', 'time', 'history', 'impressions']
# 行数、列数の表示
print(df_behaviors.shape)
# データを表示
df_behaviors.head()

ユーザーの行動履歴データの(行数、列数)と先頭5行目までのデータが表示されます。

データ加工
# スペース区切りをカンマ区切りのリスト型に変換してPandasデータフレームの新しい列に代入
df_behaviors['history_list'] = df_behaviors['history'].str.split(' ')
# この後の作業のため、history_countが欠損している行に空のリストを代入
df_behaviors.loc[df_behaviors['history_list'].isnull(),'history_list']=df_behaviors['history_list'].apply(lambda x:list())
# 欠損値に対する穴埋め確認
print(df_behaviors.isnull().sum())

この後モデル構築するため、準備として'history_list'列の追加と空の場合に空のリストを入れることで、以下の出力で'history_list'の欠損値の数が0であることを確認します。

# historyの要素数の長さを新しい列に入れる
df_behaviors['history_count'] = df_behaviors['history_list'].apply(len)

後の処理で閲覧回数を条件として抽出するための列を追加します。

サンプリング
# 今回はテストとして5000件抽出
df_behaviors = df_behaviors.sample(n=5000,random_state=42)
print('df_users_with_enough_interactions size:{}'.format(df_behaviors.shape))

抽出したデータサイズ(行数、列数)が表示されます。

# 閲覧履歴が5件以上かつユーザーのレコードが2件以上あるユーザーのデータのみ抽出
print('# users: %d' % len(df_behaviors.groupby('user_id')))
df_users_with_enough_interactions = df_behaviors[(df_behaviors['history_count'] >= 5)&\
        (df_behaviors['user_id'].isin(df_behaviors['user_id'].value_counts()[df_behaviors['user_id'].value_counts()>=2].index))\
            ].reset_index()
print('# users with at least 5 interactions: %d' % len(df_users_with_enough_interactions.groupby('user_id')))

ここではモデル構築時のコールドスタート問題を回避するため、記事閲覧回数が5件以上、かつモデルを評価するためデータが2件以上あるユーザーのデータを抽出します。出力結果の上は抽出前のユーザー数、下が抽出後のユーザー数です。

トレーニング用、テスト用データの分割
df_interactions_train, df_interactions_test = train_test_split(df_users_with_enough_interactions,
                                   stratify=df_users_with_enough_interactions['user_id'], 
                                   test_size=0.50,
                                   random_state=42)

print('# interactions on Train set: %d' % len(df_interactions_train))
print('# interactions on Test set: %d' % len(df_interactions_test))

分割後のトレーニング用とテスト用データの件数が表示されます。

分割後のデータ加工
# データ加工用関数

def convert_df_vertical(df_interactions):

    # impressionsを縦持ちに変換し名前をevent_strengthとする
    df_interactions_upper = pd.merge(df_interactions,\
    df_interactions[["impressions"]].assign(impressions=df_interactions[["impressions"]].\
    impressions.str.split(" ")).explode('impressions').impressions.str.split("-", expand=True),
    left_index=True, right_index=True).rename(columns={0: 'news_id', 1: 'event_strength'})

    # インデックスの振り直し
    df_interactions_upper.reset_index(inplace=True, drop=True)

    # データの型変換
    df_interactions_upper['event_strength'] = df_interactions_upper['event_strength'].astype('int')

    # impressionされて見た記事を2点に変換する
    df_interactions_upper['event_strength'] = df_interactions_upper['event_strength'] *2

    # 結合するため不要な列を削除する
    df_interactions_upper.drop(columns=['history','history_count','history_list', 'impressions','index'],inplace=True)

    # history_listを縦持ちに変換する
    df_interactions_lower=df_interactions.explode('history_list').reset_index().drop(columns='index').rename(columns={'history_list': 'news_id'})

    # 過去見た記事を1点にする
    df_interactions_lower['event_strength'] = 1
    df_interactions_lower.drop(columns=['history','history_count','impressions','level_0'], inplace=True) 

    return pd.concat([df_interactions_upper,df_interactions_lower]).sort_values('impression_id').reset_index(drop=True)

ここではインプレッションされて、ユーザーが意図して閲覧した記事に2倍の重み付けと、ユーザーの閲覧記事とインプレッションした記事毎に縦持ちに展開する関数を定義しています。

# トレーニング用データのデータ加工
print('# interactions on Train set: {}'.format(df_interactions_train.shape))
df_interactions_train = convert_df_vertical(df_interactions_train)
print('# interactions on Train set: {}'.format(df_interactions_train.shape))
df_interactions_train.head()

トレーニング用データの加工前後のデータ件数とデータ加工関数を実行した結果のデータが表示されます。

# テスト用データのデータ加工
print('# interactions on Test set: {}'.format(df_interactions_test.shape))
df_interactions_test = convert_df_vertical(df_interactions_test)
print('# interactions on Test set: {}'.format(df_interactions_test.shape))
df_interactions_test.head()

テスト用データの加工前後のデータ件数とデータ加工関数を実行した結果のデータが表示されます。

評価関数の作成
# 評価時にuser_idをインデックスにすることで検索速度を上げる
df_users_with_enough_interactions_indexed=pd.concat([df_interactions_train,df_interactions_test],sort=True).sort_values('impression_id').reset_index(drop=True).set_index('user_id')
df_interactions_train_indexed = df_interactions_train.set_index('user_id')
df_interactions_test_indexed = df_interactions_test.set_index('user_id')

ここでは特に出力表示はありませんが、評価表のデータのインデクスを設定しています。

def get_items_interacted(user_id, df_interactions):
    """
    ユーザーが過去に閲覧したニュースを取得する関数(重複を除いた状態で返す)
    """
    interacted_items = df_interactions.loc[user_id]['news_id']
    return set(interacted_items if type(interacted_items) == pd.Series else [interacted_items])
#Top-N accuracy metrics consts
EVAL_RANDOM_SAMPLE_NON_INTERACTED_ITEMS = 100

class ModelEvaluator:

    def get_not_interacted_items_sample(self, user_id, sample_size, seed=42):
        """
        ユーザーが閲覧したことがないニュースを取得する関数
        """
        interacted_items = get_items_interacted(user_id, df_users_with_enough_interactions_indexed)
        all_items = set(df_news['news_id'])
        non_interacted_items = all_items - interacted_items

        random.seed(seed)
        non_interacted_items_sample = random.sample(list(non_interacted_items), sample_size)
        # 閲覧したことのないニュースからサンプルサイズ分(ここでは100)データをランダム抽出
        """
        non_interacted_items_sample = random.sample(non_interacted_items, sample_size)
        python 3.9でwarning
        set型がサポートされなくなるwarningがでるので、list型に変換
        """
        return set(non_interacted_items_sample)

    def _verify_hit_top_n(self, item_id, recommended_items, topn):
        # 何件レコメンデーションした中であたったか        
            try:
                index = next(i for i, c in enumerate(recommended_items) if c == item_id)
            except:
                index = -1
            hit = int(index in range(0, topn))
            return hit, index

    def evaluate_model_for_user(self, model, user_id):
        # Getting the items in test set
        interacted_values_testset = df_interactions_test_indexed.loc[user_id]
        # インタラクトしたが閲覧していないものを排除(閲覧したものを答えとするため)
        interacted_values_testset = interacted_values_testset[interacted_values_testset['event_strength']!=0]
        if type(interacted_values_testset['news_id']) == pd.Series:
            person_interacted_items_testset = set(interacted_values_testset['news_id'])
        else:
            person_interacted_items_testset = set([int(interacted_values_testset['news_id'])])  
        interacted_items_count_testset = len(person_interacted_items_testset) 

        # Getting a ranked recommendation list from a model for a given user
        # モデルが予測したレコメンデーション数値が高い上位n件のリストを取得(記事とその評価値)
        person_recs_df = model.recommend_items(user_id, 
                                               items_to_ignore=get_items_interacted(user_id, 
                                                                                    df_interactions_train_indexed), 
                                               topn=10000000000)

        hits_at_5_count = 0
        hits_at_10_count = 0
        # For each item the user has interacted in test set
        for item_id in person_interacted_items_testset:
            #Getting a random sample (100) items the user has not interacted 
            #(to represent items that are assumed to be no relevant to the user)
            non_interacted_items_sample = self.get_not_interacted_items_sample(user_id, 
                                                                          sample_size=EVAL_RANDOM_SAMPLE_NON_INTERACTED_ITEMS, 
                                                                          #seed=item_id%(2**32))
                                                                          seed=42)

            # Combining the current interacted item with the 100 random items
            # 閲覧したことのある1件と閲覧したことのない100件(ランダム)を結合
            items_to_filter_recs = non_interacted_items_sample.union(set([item_id]))

            # Filtering only recommendations that are either the interacted item or from a random sample of 100 non-interacted items
            # レコメンデーションモデルが予測したデータフレームから先の閲覧したことのある1件と閲覧したことのない100件どちらか当てはまるものを抽出
            valid_recs_df = person_recs_df[person_recs_df['news_id'].isin(items_to_filter_recs)]                    
            valid_recs = valid_recs_df['news_id'].values
            # Verifying if the current interacted item is among the Top-N recommended items
            # インタラクションしたことのある記事がレコメンデーションモデルでリスト化した中のトップNに何個あるか
            hit_at_5, index_at_5 = self._verify_hit_top_n(item_id, valid_recs, 5)
            hits_at_5_count += hit_at_5
            hit_at_10, index_at_10 = self._verify_hit_top_n(item_id, valid_recs, 10)
            hits_at_10_count += hit_at_10

        #Recall is the rate of the interacted items that are ranked among the Top-N recommended items, 
        #when mixed with a set of non-relevant items
        recall_at_5 = hits_at_5_count / float(interacted_items_count_testset)
        recall_at_10 = hits_at_10_count / float(interacted_items_count_testset)

        # ユーザー毎の各メトリックを構造化
        person_metrics = {'hits@5_count':hits_at_5_count, 
                          'hits@10_count':hits_at_10_count, 
                          'interacted_count': interacted_items_count_testset,
                          'recall@5': recall_at_5,
                          'recall@10': recall_at_10}
        return person_metrics

    def evaluate_model(self, model):
        #print('Running evaluation for users')
        people_metrics = []
        for idx, user_id in enumerate(list(df_interactions_test_indexed.index.unique().values)):
            #if idx % 100 == 0 and idx > 0:
            #    print('%d users processed' % idx)
            person_metrics = self.evaluate_model_for_user(model, user_id)  
            person_metrics['_user_id'] = user_id
            people_metrics.append(person_metrics)
        print('%d users processed' % idx)

        detailed_results_df = pd.DataFrame(people_metrics) \
                            .sort_values('interacted_count', ascending=False)
        
        global_recall_at_5 = detailed_results_df['hits@5_count'].sum() / float(detailed_results_df['interacted_count'].sum())
        global_recall_at_10 = detailed_results_df['hits@10_count'].sum() / float(detailed_results_df['interacted_count'].sum())
        
        global_metrics = {'modelName': model.get_model_name(),
                          'recall@5': global_recall_at_5,
                          'recall@10': global_recall_at_10}    
        return global_metrics, detailed_results_df
    
model_evaluator = ModelEvaluator()    

上記2つのコードで評価関数を作成します。

協調フィルタリングモデルの作成
# ここでは行にユーザー列に各ニュース記事をもつ行列を作成し、閲覧等した場合に対照のセルに値が入ります(ほとんどゼロ=疎な行列:スパース)
df_users_items_pivot_matrix = df_interactions_train.pivot_table(index='user_id', 
                                                          columns='news_id', 
                                                          values='event_strength', # 集計対象
                                                          aggfunc=np.sum).fillna(0) # 集計デフォルトは平均

df_users_items_pivot_matrix.head(10)

ここでは協調フィルタリング(SVD)のモデルを作るため、行をユーザー、列をニュース記事のidとして、ピボットテーブル化を行います。

#スパースマトリックスの作成
# 行列の値を取得
users_items_pivot_matrix = df_users_items_pivot_matrix.values
# ユーザーidの取得
users_ids = list(df_users_items_pivot_matrix.index)
# スパースマトリックスの作成
users_items_pivot_sparse_matrix = csr_matrix(users_items_pivot_matrix)
# ユーザー・アイテムスパースマトリックスを分解するための因子数
NUMBER_OF_FACTORS_MF = 100
# 元のユーザーアイテムスパースマトリックスの行列分解
U, sigma, Vt = svds(users_items_pivot_sparse_matrix, k = NUMBER_OF_FACTORS_MF)

ここでSVDによる行列分解を行います。

# 分解後の各変数のサイズ確認
print('U:',U.shape)
print('Vt:',Vt.shape)
sigma = np.diag(sigma)
print('sigma:',sigma.shape)
all_user_predicted = np.dot(np.dot(U, sigma), Vt) 
all_user_predicted

分解した各行列からもとの行列を再編します。結果として得られた行列は推定した値が入っており、レコメンデーションに利用します。

# 予測値の正規化
all_user_predicted_norm = \
    (all_user_predicted - all_user_predicted.min()) / (all_user_predicted.max() - all_user_predicted.min())

# 再構成された行列をPandasのデータフレームに戻す
df_cf_preds = pd.DataFrame(all_user_predicted_norm, columns = df_users_items_pivot_matrix.columns, index=users_ids).transpose()
df_cf_preds.head(10)

以下のように推定した値が表示されます。

class CFRecommender:
    
    MODEL_NAME = 'Collaborative Filtering'
    
    def __init__(self, cf_predictions_df, items_df=None):
        self.cf_predictions_df = cf_predictions_df
        self.items_df = items_df
        
    def get_model_name(self):
        return self.MODEL_NAME
        
    def recommend_items(self, user_id, items_to_ignore=[], topn=10, verbose=False):
        # Get and sort the user's predictions
        sorted_user_predictions = self.cf_predictions_df[user_id].sort_values(ascending=False) \
                                    .reset_index().rename(columns={user_id: 'rec_strength'})

        # Recommend the highest predicted rating movies that the user hasn't seen yet.
        df_recommendations = sorted_user_predictions[~sorted_user_predictions['news_id'].isin(items_to_ignore)] \
                               .sort_values('rec_strength', ascending = False) \
                               .head(topn)

        if verbose:
            if self.items_df is None:
                raise Exception('"df_news" is required in verbose mode')

            df_recommendations = df_recommendations.merge(self.items_df, how = 'left', 
                                                          left_on = 'news_id', 
                                                          right_on = 'news_id')[['rec_strength', 'news_id', 'title', 'url', 'category']]


        return df_recommendations
    
cf_recommender_model_demo = CFRecommender(df_cf_preds, df_news)

ここでは協調フィルタリングのデモ用モデルを作成します。

print('Evaluating Collaborative Filtering (SVD Matrix Factorization) model...')
cf_global_metrics, df_cf_detailed_results = model_evaluator.evaluate_model(cf_recommender_model_demo)
print('\nGlobal metrics:\n%s' % cf_global_metrics)
df_cf_detailed_results.head(10)

ここではモデルの評価をrecall@nにより行いました。
評価の意味についてはこちらのRecall(再現率)が詳しいのでご参照いただければと思います。

コンテンツベースフィルタリングモデルの作成
# ストップワードを取り除く(今回は英語の記事のため'english')
stopwords_list = stopwords.words('english')

# ユニグラム、バイグラムで構成され、ストップワードを含まないベクトルサイズが最大5000とする
vectorizer = TfidfVectorizer(analyzer='word',
                     ngram_range=(1, 2),
                     min_df=0.003, 
                     max_df=0.5,
                     max_features=5000,
                     stop_words=stopwords_list)
#newリストを取得
news_ids = df_news['news_id'].tolist()

# ニュースタイトルのTF-IDF値を計算したscipy.sparse.csr.csr_matrixを取得
tfidf_matrix = vectorizer.fit_transform(df_news['title'])

ここではTF-IDFを作成するための設定をしています。

def get_news_profile(news_id):
    """ニュースタイトルのTF-IDF値を取得する関数"""
    idx = news_ids.index(news_id)
    news_profile = tfidf_matrix[idx:idx+1] 
    return news_profile

def get_news_profiles(ids):
    """個別のニュースタイトルのTF-IDFを結合する関数"""
    item_profiles_list = [get_news_profile(x) for x in ids]
    item_profiles = scipy.sparse.vstack(item_profiles_list)
    return item_profiles

def build_users_profile(user_id, df_interactions_indexed):
    """ユーザー毎のプロファイを取得する関数"""
    df_interactions_user_id = df_interactions_indexed.loc[user_id]
    user_item_profiles = get_news_profiles(df_interactions_user_id['news_id'])
    
    user_item_strengths = np.array(df_interactions_user_id['event_strength']).reshape(-1,1)
    # ユーザー間の評価行動の基準がことなるため、標準化する
    user_item_strengths_weighted_avg = np.sum(user_item_profiles.multiply(user_item_strengths), axis=0) / np.sum(user_item_strengths)
    user_profile_norm = sklearn.preprocessing.normalize(user_item_strengths_weighted_avg)
    return user_profile_norm

def build_users_profiles(): 
    """ユーザー毎に閲覧したことのあるニュースを取得し、そのニュースのTF-IDF値をデータフレームにまとめる関数"""
    # 全ニュースの情報と一致するnews_idを持ち、かつ閲覧したことのあるレコードのみ抽出し、インデックスをuser_idにしたデータフレームを取得
    df_interactions_indexed = df_interactions_train[(df_interactions_train['news_id'].isin(df_news['news_id'])) & \
         (df_interactions_train['event_strength']!=0)].set_index('user_id')
    user_profiles = {}
    for user_id in df_interactions_indexed.index.unique():
        # ユーザー毎に閲覧したことのあるニュースをもとにプロファイルを作成
        user_profiles[user_id] = build_users_profile(user_id, df_interactions_indexed)
    return user_profiles

ここではユーザー毎の閲覧したことのある記事のTF-IDFの値を取得する関数を定義します。

# ユーザ毎に閲覧した記事のTF-IDF値を取得
user_profiles = build_users_profiles()
len(user_profiles)

ここでは312ユーザー分のプロファイルが作成されます。
なお、以下のようなFutureWarningが表示されますが、今回はそのまま次に進んでも問題ありません。

class ContentBasedRecommender:
    
    MODEL_NAME = 'Content-Based'
    
    def __init__(self, items_df=None):
        self.item_ids = news_ids
        self.items_df = items_df
        
    def get_model_name(self):
        return self.MODEL_NAME
        
    def _get_similar_items_to_user_profile(self, person_id, topn=1000):
        # Computes the cosine similarity between the user profile and all item profiles
        # ユーザプロファイルと全記事プロファイルのコサイン類似度を計算する
        cosine_similarities = cosine_similarity(user_profiles[person_id], tfidf_matrix)
        # Gets the top similar items
        # コサイン類似度による上位1000件を取得
        similar_indices = cosine_similarities.argsort().flatten()[-topn:]
        # Sort the similar items by similarity
        # コサイン類似度が近いもので記事をソート
        similar_items = sorted([(news_ids[i], cosine_similarities[0,i]) for i in similar_indices], key=lambda x: -x[1])
        return similar_items
        
    def recommend_items(self, user_id, items_to_ignore=[], topn=10, verbose=False):
        similar_items = self._get_similar_items_to_user_profile(user_id)
        # Ignores items the user has already interacted
        # 既にインタラクションしたことがある記事は除く
        similar_items_filtered = list(filter(lambda x: x[0] not in items_to_ignore, similar_items))
        
        recommendations_df = pd.DataFrame(similar_items_filtered, columns=['news_id', 'rec_strength']) \
                                    .head(topn)

        if verbose:
            if self.items_df is None:
                raise Exception('"df_news" is required in verbose mode')

            recommendations_df = recommendations_df.merge(self.items_df, how = 'left', 
                                                          left_on = 'news_id', 
                                                          right_on = 'news_id')[['rec_strength', 'news_id', 'title', 'url', 'category']]


        return recommendations_df
    
cb_recommender_model_demo = ContentBasedRecommender(df_news)

ここでは、ユーザーが閲覧した記事からTF-IDFの値に基づきコンテンツの近さを計算するモデルを作成します。

print('Evaluating Content-Based Filtering model...')
cb_global_metrics, df_cb_detailed_results = model_evaluator.evaluate_model(cb_recommender_model_demo)
print('\nGlobal metrics:\n%s' % cb_global_metrics)
df_cb_detailed_results.head(10)

作成したモデルの評価を行います。

モデル評価の比較
df_global_metrics = pd.DataFrame([cb_global_metrics, cf_global_metrics]) \
                        .set_index('modelName')
df_global_metrics

モデルの評価一覧表が表示されます。

%matplotlib inline
ax = df_global_metrics.transpose().plot(kind='bar', figsize=(15,8))
for p in ax.patches:
    ax.annotate("%.3f" % p.get_height(), (p.get_x() + p.get_width() / 2., p.get_height()), ha='center', va='center', xytext=(0, 10), textcoords='offset points')

モデルの評価の棒グラフが表示されます。

作成したモデルによるデモ
# 対照ユーザの閲覧した記事やタイトル、評価値などを取得する関数
def inspect_interactions(user_id, test_set=True):
    if test_set:
        df_interactions = df_interactions_test_indexed
    else:
        df_interactions = df_interactions_train_indexed
    return df_interactions.loc[user_id].merge(df_news, how = 'left', 
                                                      left_on = 'news_id', 
                                                      right_on = 'news_id') \
                          .sort_values('event_strength', ascending = False)[['event_strength', 
                                                                          'news_id',
                                                                          'title', 'url', 'category']].reset_index(drop=True)

デモとして各モデルによる予測と実際にユーザーが閲覧した記事の一覧を比較します。
ここでは、ユーザーが閲覧した記事一覧を取得する関数を定義しています。

# あるテストユーザーが閲覧した記事から10件データを取得
inspect_interactions('U8262', test_set=False)[['news_id','title','category']].head(10)

ユーザーが閲覧した上位10件が表示されます。

# テストとしてあるテストユーザーに対して協調フィルタリングモデルで推薦値が高い上位10件のデータを取得
cf_recommender_model_demo.recommend_items('U8262', topn=10, verbose=True)[['news_id','title','category']]

協調フィルタリングのモデルによりレコメンデーションされた上位10件の記事一覧が表示されます。
2件実際にユーザーが閲覧した記事と同じものがあることが確認できます。

# テストとしてあるテストユーザーに対してコンテンツベースフィルタリングモデルで推薦値が高い上位10件のデータを取得
cb_recommender_model_demo.recommend_items('U8262', topn=10, verbose=True)[['news_id','title','category']]

コンテンツベースフィルタリングのモデルによりレコメンデーションされた上位10件の記事一覧が表示されます。
1件実際にユーザーが閲覧した記事と同じものがあることが確認できます。

まとめ

今回は協調フィルタリングとコンテンツベースフィルタリングの理論と実装について書きました。
実装の部分については動かしてイメージしてもらうことをメインに実装しました。
モデルの実装とデモを通してどのようにデータを使い、モデルを作り、モデルの使用例について書きました。
データのサンプリング方法につてはデータを可視化してどのような特徴があり、その特徴を考慮したサンプリング方法を考える必要があるかと思います。
また、各モデル構築時のパラメータや評価方法などに関しても同様かと思いますのでその点についてはご容赦ください。

補足

TF-IDFの定義はいろいろと定義がようです。
ここでは下記の「自分で実装してみる」を参考にして記載しています。
Python: scikit-learn と色々な TF-IDF の定義について - CUBE SUGAR CONTAINER

参考

協調フィルタリング(SVD)について

SVDについて直観的な説明があります。

特異値の分解について参考にしました。
また、今回説明はしませんでしたがPCAとSVDに関連性についても述べられています。

コンテンツベースフィルタリング(TF-IDF)について

実装(コード)について

コールドスタート問題について

6.1.3 スタートアップ問題に記述されています。

投稿者プロフィール
齊藤 泰一

齊藤 泰一

クラウド事業推進本部の齊藤です。機械学習の技術検証・活用・促進などを担当しています。

執筆記事一覧