Google Analytics のデータを元に、Blog 記事 のアイテムベース協調フィルタリングレコメンドをしてみる


過去に、アイテムベースでの記事レコメンドを作成しました。Mezzanine の Blog に 関連記事 のレコメンド表示をカスタマイズする | Monotalk

ユーザーベースでのレコメンドもいつか実装してみたいと思っていたのですが、そのようなデータ集めていなかったので、データを集めることろから始め、データもそれなりに収集できてきたので、実際の実装にとりかかろうかと思います。


参考

以下、実装にあたって読んでいた記事になります。


Blog における関心事

ECサイトの商品や、レビュー等に対して、Blog における協調フィルタリングには、以下の特徴があると個人的には思っております。
* ECサイトの商品にあたるものは、Blog においては、Blog記事と考える。

  • ECサイトにおける商品購入を、Blog記事へのPageView と考える。
    現在ブログ記事への2分間のユーザの滞在をコンバージョンとして計測していますが、コンバージョンを商品購入と考えたほうがおもしろいかもしれないですが、実装が難しくなるので、一旦、Blog記事のPageViewを商品購入と考えます。

  • 直帰率は低くはない。ほどんどのユーザが1ページを閲覧し離脱する。
    そこまで、ユーザ同士で見ているページが重複することはないのかもしれません。


方針

以下の方針で進めます。
* ライブラリとしてSurprise · A Python scikit for recommender systems. を使ってみる。
* アルゴリズムが幾つかある、どれがいい感じになるのかアルゴリズムを変えて試していく。


手順

実施の手順は、以下の通りです。
* データセットの準備

  • python への取り込みと、形式変換

  • Datasetとして読み込み

  • アルゴリズムを幾つか試す。

    • SVDによる学習と予測、評価
    • KNNBasic による学習と予測、評価
    • BaselineOnly による学習と予測、評価
  • 協調フィルタリング、アイテムベースレコメンドの実装


データセットの準備

  • clientId を予めカスタムディメンションとして送付しておく
    データセットは、Google Analytics で取得したデータです。
    Google Analytics は デフォルト設定では、どのユーザーのアクセスなのかを識別することができません。
    このため、Google Analytics の cookie を クライアントID として、 カスタムディメンションとして 送信しています。
    Google Analytics の cookie をカスタムディメンションとして送付する方法は、以下の記事が参考になりました。
    2016年の新定番!ユーザーエクスプローラーをもっと活用するための簡単な方法 | 株式会社プリンシプル

  • Google Analytics Spreadsheet Add-on で、データを取得する
    google analytics spreadsheet add-on を使って、Google Analytics からデータを取得します。
    上記の使用方法は、Googleアナリティクスの分析はスプレッドシートのアドオンで全自動化しよう が参考になりました。
    レコメンドに使用するデータセットとして、以下の Metrics、Dimensions を指定しています。
    また、データの取得件数ですが、デフォルトは1000件までです。10000件までは引き上げられるので、10000件を設定しています。

    • Metrics
      • ga:pageviews
    • Dimensions
      • ga:dimension8
      • ga:pagePath

python への取り込みと、形式変換

python へのデータの取り込み、形式変換を実施します。
google スプレッドシートからのデータの取り込みには、gspread を使います。

事前準備

gspreadoauth2client をインストールします。

python3 -m pip install gspread --user
python3 -m pip install oauth2client --user
python3 -m pip install scikit-surprise --user
python3 -m pip install df2gspread --user

スプレッドシートのデータを取得する

以下のコードで、スプレッドシートのデータを取得してデータセットとして使用します。
API にアクセスするための JSON Key の発行が事前に必要になります。
APIの発行方法、gspread の使い方は以下の記事が参考になりました。
[Python] Google SpreadSheetをAPI経由で読み書きする - YoheiM .NET

形式変換

各行がユーザID、アイテムID、評価値の順番で列を持つこと、sepで各列が’ ‘(半角スペース)で区切る必要があります。
スプレッドシートから取得したデータをこの形式に変換します。

データ取得、形式変換までを行うプログラム

以下に記載します。

from oauth2client.service_account import ServiceAccountCredentials
def download_as_df():
    from df2gspread import gspread2df as g2d
    # key_file 以下の指定方法だと、notebook と同じディレクトリにあるキーファイルを取得しています。
    key_file = "spreadsheet_api_key.json"
    scope = ['https://spreadsheets.google.com/feeds']
    credentials = ServiceAccountCredentials.from_json_keyfile_name(key_file, scope)    
    # 1brCpWvk2uofc3MEt-ASb2cuZ-u8Zmx-ICxSTltlbVBQ はスプレッドシートのID なのでそれぞれ取得対象のスプレッドシートで変わります。
    df = g2d.download("1brCpWvk2uofc3MEt-ASb2cuZ-u8Zmx-ICxSTltlbVBQ", wks_name="ユーザーの行動レポート 201710", col_names=True, row_names=False, credentials=credentials, start_cell = 'A15')
    df = df.sort_values(by='ga:dimension8') 
    return df
df = download_as_df()
df
ga:dimension8ga:pagePathga:pageviews
01001125006/blog/cent-os-69-に-memcached-をインストールログの設定まで実施する/1
11001125006/blog/rundeck-job-をエクスポートする/1
21001401202/blog/rundeck-job-をエクスポートする/1
31001564022/blog/Several-queries-implemented-with-QueryDsl/1
4100158393.2/blog/python-markdown-の出力フォーマット-をsublime-markd...1
51002298796/blog/pycharm-terminal-からpythonスクリプトを実行できるようにする/1
61003971493/blog/macos-el-capitan-に-elasticsearch-を-インストー...1
71004220920/blog/Try-Japanese-translation-of-rule-of-Sona...1
81004368328/blog/intellij-ideaでpom-の-依存関係をグラフ表示する/1
91004496170/blog/postgresql-connection-to-database-failed...1
101004962317/blog/jackson-orgcodehausjacksonとcomfasterxmlj...1
111005044973/blog/spring-boot-での-サブコマンドsubcommandsの実装案/1
121005605181/blog/pycharm-terminal-からpythonスクリプトを実行できるようにする/1
131005723713/blog/TEMPLATE_DEBUG-on-Djnago1.8/1
141005723713/blog/django-18からsettingspyのtemplatesが非推奨になって警...1
151005988329/blog/python-requests-post-リクエスト送信時にheader-を設定する/1
16100608157.2/blog/djangomezzanine-template内で-google-tag-ma...2
171006325468/blog/nonencoded-querystring-on-python-requests/1
181006695896/blog/jackson-orgcodehausjacksonとcomfasterxmlj...1
191006695896/blog/javalangruntimeexception-comfasterxmljac...1
201006725276/blog/Try-Japanese-translation-of-rule-of-Sona...1
21100810717.2/blog/search-resutls-wicket-models/1
221008449136/blog/google-モバイルサイト認定を取得してみました/1
23100854833.2/blog/java-url文字列からクエリストリングを取得/1
24100854833.2/blog/pep8wraning-do-not-assign-a-lambda-expre...1
25100865557.2/blog/Try-Japanese-translation-of-rule-of-Sona...1
261008987097/blog/mezzanine_create_sitemap/1
271009170940/blog/python-で-markdownファイルをplain-text-に変換する/1
281011239371/blog/google-apps-script-でスプレッドシートの列の値を取得する/1
291011359939/blog/Verifying-the-vulnerability-of-blogs-bui...1
............
4879994573930.2/blog/macos-el-capitan-に-elasticsearch-を-インストー...1
4880994841234.2/blog/top-without-b/1
4881995360462.2/blog/Try-Japanese-translation-of-rule-of-Sona...1
4882995370005.2/blog/google-search-console-の-キーワードの共起ネットワーク図を...4
4883995797997.2/blog/review-musuinabe/1
4884995905549.2/blog/spring-boot-での-サブコマンドsubcommandsの実装案/3
4885995996577.2/blog/mezzanineのpagedownにcodehiliteを設定する/1
4886996280209.2/blog/jackson-orgcodehausjacksonとcomfasterxmlj...3
4887996299315.2/blog/google-spread-sheet-の-複数のシートのデータをスクリプトで統...1
4888997078995.2/blog/sonarqube-owasp-dependency-check-plugin-...1
4889997425962.2/blog/google-apps-script-でスプレッドシートの列の値を取得する/1
4890997953452.2/blog/spring-boot-での-サブコマンドsubcommandsの実装案/1
4891998219780.2/blog/usage-of-upsert-java-mongodb-driver/1
4892998284256.2/blog/google-analytics-v4-api-java-pageview-pe...3
4893998433927.2/blog/Try-static-analysis-of-Python-using-Sona...1
4894998528418.2/blog/java-url文字列からクエリストリングを取得/1
4895998630797.2/blog/rundeck-job-をエクスポートする/2
4896998803816.2/blog/pmd-rulesetの一覧java/1
4897998803816.2/blog/sonarqube-squids106-標準出力はログ出力に使用すべきではありません/1
4898998803816.2/blog/sonarqube-web-api-を-python-から実行する/1
489999885062.15/blog/Several-queries-implemented-with-QueryDsl/1
4900998887381.2/blog/get-single-result-on-django-model-filter/1
4901999543280.2/blog/typeerror-builtin_function_or_method-obj...1
4902999606506.2/blog/jackson-orgcodehausjacksonとcomfasterxmlj...5
4903999606506.2/blog/javalangruntimeexception-comfasterxmljac...2
4904999899749.2/blog/mac-os-siera-に-superset-をインストールする/1
4905amp-golB4KRZeIM8NHKnbMPeWQ/6
4906amp-golB4KRZeIM8NHKnbMPeWQ/?page=21
4907amp-golB4KRZeIM8NHKnbMPeWQ/blog/web-サイトの診断ツール-sonar-のcliを使ってみる/1
4908amp-golB4KRZeIM8NHKnbMPeWQ/ja/blog/sitespeedio-coach-の-chrome-plugin-の使い方/1

4909 rows × 3 columns

Datasetとして読み込み

変換したデータをデータセットとして読み込みます。

from surprise import Reader, Dataset
reader = Reader(line_format='user item rating', sep=' ')
dataset = Dataset.load_from_df(df, reader=reader)

アルゴリズムを幾つか試す

Surprise · A Python scikit for recommender systems. には、レコメンドを行うアルゴリズムが幾つかあるようなので、 アルゴリズムを変えながら結果がどう変化するのかを確認していきます。 * SVDによる学習と予測 * KNNBasic による学習と予測
* BaselineOnly による学習と予測

SVDによる学習と予測、評価

学習と予測

from surprise import SVD
model = SVD()

# 全てのデータを使って学習
trainset = dataset.build_full_trainset()
model.train(trainset)

# 評価値を予測する
prediction = model.predict(uid="1001125006", iid="/blog/cent-os-69-に-memcached-をインストールログの設定まで実施する/")
print('Predicted rating(User: {0}, Item: {1}): {2:.2f}'
        .format(prediction.uid, prediction.iid, prediction.est))
Computing the msd similarity matrix...
Done computing similarity matrix.
Predicted rating(User: 1001125006, Item: /blog/cent-os-69-に-memcached-をインストールログの設定まで実施する/): 1.20

評価

予測の評価指標 である RMSEMAE を算出します。 RMSEMAE の値が何かは全くわかりませんでしたが、 レコメンドつれづれ ~第3回 レコメンド精度の評価方法を学ぶ~ - Platinum Data Blog by BrainPad に説明が記載されており、参考になりました。

from surprise import evaluate

# 学習データとテストデータを4分割
dataset.split(n_folds=4)

# 平方平均二乗誤差と平均絶対誤差の算出
result = evaluate(model, dataset, measures=['RMSE', 'MAE'])
result
Evaluating RMSE, MAE of algorithm SVD.

------------
Fold 1
RMSE: 1.4291
MAE:  0.5485
------------
Fold 2
RMSE: 1.2848
MAE:  0.5540
------------
Fold 3
RMSE: 0.8470
MAE:  0.5086
------------
Fold 4
RMSE: 1.1087
MAE:  0.5223
------------
------------
Mean RMSE: 1.1674
Mean MAE : 0.5333
------------
------------





CaseInsensitiveDefaultDict(list,
                           {'mae': [0.54846225701436846,
                             0.55402829594829694,
                             0.50855895245752036,
                             0.52225277635798917],
                            'rmse': [1.4290612954440325,
                             1.2848327554774843,
                             0.84697789666245271,
                             1.1086690225537881]})

あまり、精度が高くないでしょうか?
よくかわっておらずですが、先に進めます。

KNNBasic による学習と予測、評価

SVD > KNNBasic に変更すると、使えます。便利です。

from surprise import KNNBasic
model = KNNBasic()

# 全てのデータを使って学習
trainset = dataset.build_full_trainset()
model.train(trainset)

# 評価値を予測する
prediction = model.predict(uid="1001125006", iid="/blog/cent-os-69-に-memcached-をインストールログの設定まで実施する/")
print('Predicted rating(User: {0}, Item: {1}): {2:.2f}'
        .format(prediction.uid, prediction.iid, prediction.est))
Computing the msd similarity matrix...
Done computing similarity matrix.
Computing the msd similarity matrix...
Done computing similarity matrix.
Predicted rating(User: 1001125006, Item: /blog/cent-os-69-に-memcached-をインストールログの設定まで実施する/): 1.00

評価

from surprise import evaluate

# 学習データとテストデータを4分割
dataset.split(n_folds=4)

# 平方平均二乗誤差と平均絶対誤差の算出
result = evaluate(model, dataset, measures=['RMSE', 'MAE'])
result
Evaluating RMSE, MAE of algorithm KNNBasic.

------------
Fold 1
Computing the msd similarity matrix...
Done computing similarity matrix.
RMSE: 1.1240
MAE:  0.5434
------------
Fold 2
Computing the msd similarity matrix...
Done computing similarity matrix.
RMSE: 1.4066
MAE:  0.5866
------------
Fold 3
Computing the msd similarity matrix...
Done computing similarity matrix.
RMSE: 0.9164
MAE:  0.5322
------------
Fold 4
Computing the msd similarity matrix...
Done computing similarity matrix.
RMSE: 1.2546
MAE:  0.5644
------------
------------
Mean RMSE: 1.1754
Mean MAE : 0.5566
------------
------------





CaseInsensitiveDefaultDict(list,
                           {'mae': [0.5433624807357964,
                             0.58657066818605086,
                             0.53216262890423915,
                             0.56439259979812473],
                            'rmse': [1.1239719739149885,
                             1.4066485670168258,
                             0.91640801231542368,
                             1.2545908278867675]})

SVD と似たような値になりました。

BaselineOnly による学習と予測、評価

学習と予測

from surprise import BaselineOnly
model = BaselineOnly()

# 全てのデータを使って学習
trainset = dataset.build_full_trainset()
model.train(trainset)

# 評価値を予測する
prediction = model.predict(uid="1001125006", iid="/blog/cent-os-69-に-memcached-をインストールログの設定まで実施する/")
print('Predicted rating(User: {0}, Item: {1}): {2:.2f}'
        .format(prediction.uid, prediction.iid, prediction.est))
Estimating biases using als...
Computing the msd similarity matrix...
Done computing similarity matrix.
Predicted rating(User: 1001125006, Item: /blog/cent-os-69-に-memcached-をインストールログの設定まで実施する/): 1.25

評価

from surprise import evaluate

# 学習データとテストデータを4分割
dataset.split(n_folds=4)

# 平方平均二乗誤差と平均絶対誤差の算出
result = evaluate(model, dataset, measures=['RMSE', 'MAE'])
result
Evaluating RMSE, MAE of algorithm BaselineOnly.

------------
Fold 1
Estimating biases using als...
RMSE: 1.2686
MAE:  0.5409
------------
Fold 2
Estimating biases using als...
RMSE: 1.3285
MAE:  0.5450
------------
Fold 3
Estimating biases using als...
RMSE: 1.2432
MAE:  0.5573
------------
Fold 4
Estimating biases using als...
RMSE: 0.8520
MAE:  0.5008
------------
------------
Mean RMSE: 1.1731
Mean MAE : 0.5360
------------
------------





CaseInsensitiveDefaultDict(list,
                           {'mae': [0.54090417491621035,
                             0.54504647468273115,
                             0.55726726538797222,
                             0.50077004102283584],
                            'rmse': [1.2685778535486407,
                             1.3284861599445619,
                             1.2431791543957929,
                             0.85204905872498649]})

協調フィルタリング、アイテムベースレコメンドの実装

ユーザベースよりも、個人的に実装が簡単そうに思えたアイテムベースでの実装を試みます。
ユーザー間ではなく、アイテム間での類似度が得られれば、アイテムベースでのレコメンドができるかと思い、Surprise · A Python scikit for recommender systems. のドキュメントを確認したところ、sim_option に以下のように指定すると、アイテムの類似度が得られそうなので、オプションを指定して実行してみます。

sim_options = {'name': 'cosine',
               'user_based': False  # compute  similarities between items
               }
algo = KNNBasic(sim_options=sim_options)

SVD はsim_optionsの指定ができなかったので、KNNBasic をアルゴリズムとして指定します。

また、Surprise が返すデータは、レコメンドに用いるには少し扱いづらかったので、データ加工用のfunction を作成して、PageURL をキー、評価の降順にソートされた Taple のリストをValue として持つ辞書を作成するようにしました。

def create_recommended_data(trainset, similarities):
    """
    データ加工用のfunction PageURL をキー、評価の降順にソートされた Taple のリストをValue として持つ辞書 を作成する
    """
    results = {}
    for index1, elems in enumerate(similarities):
        # to_raw_iid で、similarities の配列のインデックスから、item id を取得できる
        raw_id1 = trainset.to_raw_iid(index1)
        # blog 記事は url に blog を含むのでそれ以外は除外する
        if "/blog/" not in raw_id1:
            continue
        data = {}
        for index2, elem in enumerate(elems):
            # index値が同じデータは、同一記事なので、除外
            if index1 == index2:
                continue
            raw_id2 = trainset.to_raw_iid(index2)
            # blog 記事は url に blog を含むのでそれ以外は除外する
            if "/blog/" not in raw_id2:
                continue
            # 評価が0.5以下は除外する
            if elem <= 0.5:
                continue
            data.update({raw_id2 : elem})
        results.update({raw_id1 : sorted(data.items(), key=lambda x: -x[1])})
    return results;

from surprise import KNNBasic
sim_options = {'name': 'cosine',
               'user_based': False  # compute  similarities between items
               }
model = KNNBasic(k=5, min_k=1, sim_options=sim_options)
# 全てのデータを使って学習
trainset = dataset.build_full_trainset()
model.train(trainset)
# ITEM間の類似度を計算
similarities = model.compute_similarities()
recommends_data = create_recommended_data(trainset, similarities)
Computing the cosine similarity matrix...
Done computing similarity matrix.
Computing the cosine similarity matrix...
Done computing similarity matrix.
recommends_data.get("/blog/usage-of-django-compress-on-mezzanine/")
[('/blog/google-モバイルサイト認定を取得してみました/', 1.0),
 ('/blog/django-migrate-コマンドでinsert-文を実行する/', 1.0),
 ('/blog/add-configuration-on-django-compressor/', 1.0)]
recommends_data.get("/blog/404_errorpage_configration_on_wicket_dropwizard/")
[('/blog/search-resutls-wicket-models/', 1.0),
 ('/blog/control-error-message-by-component-on-wicket/', 1.0),
 ('/blog/Block-multiple-requests-of-Wicket-Ajax/', 1.0),
 ('/blog/a-child-with-id-already-exists-でダメージを受ける/', 1.0),
 ('/blog/delete-version-number-from-url-on-wicket/', 1.0),
 ('/blog/search-resutls-wicket-forms/', 1.0),
 ('/blog/apache-wicketでrestapiを使う/', 1.0),
 ('/blog/rest-api-on-wicket7.3.0/', 1.0),
 ('/blog/wicket-about-wicketheader-items/', 1.0),
 ('/blog/using-resource-on-wicket-application/', 1.0),
 ('/blog/Wicket-AjaxButton-Controls-behavior-when-an-error-occurs-onSubmit-method/',
  1.0),
 ('/blog/wicket-が-使用するjquery-の-version-を-切り替える/', 1.0),
 ('/blog/wicket-stateless-な-pagenavigator-を作る/', 1.0),
 ('/blog/wicket-条件でhtml等のリソースファイルの切り替えをする/', 1.0),
 ('/blog/usage-of-image-class-in-wicket/', 1.0),
 ('/blog/error-handling-on-wicket/', 0.94868329805051377)]

ページURL でアクセスするとレコメンド結果が返却されるようになりました。
感覚的には、そこまでは似ていないように感じるので、評価が1.0 のものが多い理由が気になります。
あ、PageView なので、1以上の値をとるかもしれないですね。

まとめ、今後実施したいこと

Google Analytics の データを用いて、アイテムベースの協調フィルタリングが実装できるか試してみました。
以下まとめと、今後実施したことを記載します。

  • まとめ

    • Surprise · A Python scikit for recommender systems. を使って、アイテムベースの協調フィルタリングの実装イメージを作ることができた。

    • アイテムベースのレコメンドは、一部のアルゴリズムで実施できる。sim_optionsuser_based を False にすると アイテムベースのレコメンドが可能。

    • スプレッッドシートのデータを Pandas の DataFrame に変換、DataFrame から レコメンドデータを作成できる。この組み合わせは、中々実装しやすい。

  • 今後実施したいこと

    • Batch処理として、ブログへ組み込む。

    • ユーザーベースの協調フィルタリングの仮実装をしてみる。

以上です。

コメント