過去に Blog の 記事のレコメンドをアイテムレコメンドで実装を行いました。
Mezzanine の Blog に 関連記事 のレコメンド表示を組み込んでみる | Monotalk

他のレコメンド方式でのレコメンドを実装したいという気持ちがあり、今回は、アソシエーション分析でレコメンドが実装できるか試してみます。
以下、試してみた結果を記載します。


前提

OS 、Python の version は以下の通りです。

  • OS

    % sw_vers
    ProductName:    Mac OS X
    ProductVersion: 10.13.2
    BuildVersion:   17C88
    

  • Python

    % python3 -V
    Python 3.6.2
    


Python3 のアソシエーション分析のライブラリについて

検索した限り、以下2つ見つかりました。
2つ使用してみます。


分析に使用するデータと、分析して得たい結果について

分析に使用するデータ

以下の項目を保持するTSVファイルを使用します。
* ga:dimension8
GA の clientid が設定されたカスタムディメンションになります。
* Page
閲覧ページのURL
* Date
閲覧した日付
* Pageviews
閲覧回数

分析して得たい結果について

基本的には、ページ閲覧の相関関係、「このページを見ている人はあのページも閲覧しています。」 という結果を得たいです。
協調フィルタリングでも同じような結果は得られますが、アソシエーション分析だと、前後関係の意識した結果を返却してくれます。
閲覧したURLを商品、閲覧回数を購入回数として、アソシエーション分析のINPUTとします。


pyfpgrowth を使って、アソシエーション分析(バスケット分析を行う)

必要なライブラリのインストール

pandaspyfpgrowthインストールします。

!python3 -m pip install pandas
!python3 -m pip install pyfpgrowth

TSV ファイルを読み込む

TSVファイルを読み込み、pyfpgrowth の INPUTの形式に加工します。

import pandas as pd
df = pd.read_table("GA_dataset.tsv")
# client_id と日付の昇順でソート
df = df.sort_values(by=["ga:dimension8", "Date"], ascending=True)
# データ件数が多く後続の計算に時間を擁するのでデータを減らす
df = df[1:3000]

pyfpgrowth の input の形式に変換

pyfpgrowth は、input として、2次元のリストが必要になります。 pandas の datatable を加工して、2次元のリストを作成します。

transactions = []
strs = []
previous_client_id = ""
for idx in df.index:
    elems = df.ix[idx]
    client_id = elems["ga:dimension8"]
    if(previous_client_id != ""):
        if (previous_client_id != client_id):
            transactions.append(strs)
            strs = []
            strs.extend(((elems["Page"] + ",") * elems["Pageviews"]).rstrip(',').split(','))
        else:
            strs.extend(((elems["Page"] + ",") * elems["Pageviews"]).rstrip(',').split(','))
    else:
        strs.extend(((elems["Page"] + ",") * elems["Pageviews"]).rstrip(',').split(','))
    previous_client_id = client_id
if strs:
    transactions.append(strs)

pyfpgrowth でアソシエーション分析を実行

import pyfpgrowth
patterns = pyfpgrowth.find_frequent_patterns(transactions, 3)
rules = pyfpgrowth.generate_association_rules(patterns, 0.7)

parttens には、組み合わせが、何回登場するかを示す。
辞書が設定されます。

patterns

{('/blog/django-templatesyntaxerror-xxx-is-not-a-registered-tag-library-must-be-one-of/',): 3,
 ('/blog/django-templatesyntaxerror-xxx-is-not-a-registered-tag-library-must-be-one-of/',
 ('/blog/Block-multiple-requests-of-Wicket-Ajax/',
  '/blog/category/wicket/',
  '/blog/category/wicket/',
  '/blog/category/wicket/?page=2',
  '/blog/invisible-markup-on-wicket/',
  '/blog/using-resource-on-wicket-application/',
  '/blog/wicket-javalangruntimeexception-could-not-deserialize-object-from-byte-エラーについて考える/'): (('/blog/apache-wicketでrestapiを使う/',
   '/blog/search-resutls-wicket-models/',
   '/blog/wicket-about-wicketheader-items/'),
  1.0),
 ('/blog/Block-multiple-requests-of-Wicket-Ajax/',
  '/blog/apache-wicketでrestapiを使う/',
  '/blog/category/wicket/',
  '/blog/category/wicket/?page=2',
  '/blog/search-resutls-wicket-models/',
  '/blog/wicket-javalangruntimeexception-could-not-deserialize-object-from-byte-エラーについて考える/'): (('/blog/wicket-about-wicketheader-items/',),
  5.0),
 ...}

rules キーとして、ページの組み合わせのTaple を指定すると、レコメンド結果が返却されます。

rules.get(('/blog/Block-multiple-requests-of-Wicket-Ajax/', '/blog/apache-wicketでrestapiを使う/','/blog/category/wicket/?page=2', '/blog/category/wicket/?page=2', '/blog/category/wicket/?page=2', '/blog/wicket-scriptタグ-を-body-閉じタグの直前に出力する/'))

(('/blog/category/wicket/',
  '/blog/search-resutls-wicket-models/',
  '/blog/wicket-about-wicketheader-items/',
  '/blog/wicket-javalangruntimeexception-could-not-deserialize-object-from-byte-エラーについて考える/'),
 1.0)

試してみた感想

  • PageView 数を商品購入数的な扱いにはしないほうがいい感じになるかもしれない。
    1つのページのPageView数が多い (1ページを何回も見て、他のページも少し見る)というclient_id が多数なので、PageView数を商品購入数的な扱いにすると、いい感じの結果にならない気がしました。回数が多くても1回とするか、多少係数で補正したほうが、いいのかもしれません。

  • 1つのページだけ閲覧したclient_idは除外する
    1ページだけを見て離脱するclient_idがほとんどなので、1ページだけのclient_id は除外しておくのが、計算に時間もかからずよさそうです。

  • そもそも上手くいっているのか、わからない
    確率が、1.0を超えるので、ちょっとうまくいっているのかわかりませんでした。
    閲覧数は、1より大きくなるので、その影響でしょうか?


orange3-associate を使ってアソシエーション分析を行う

必要なライブラリのインストール

orange3-associateインストールします。

!python3 -m pip install orange3
!python3 -m pip install orange3-associate

csvファイルの作成

basketいう拡張子で、入力ファイルとするcsvファイルを作成します。

lines = []
line = ""
previous_client_id = ""
for idx in df.index:
    elems = df.ix[idx]
    client_id = elems["ga:dimension8"]
    if(previous_client_id != ""):
        if (previous_client_id != client_id):
            lines.append(line)
            line = ""
            line = line + ((elems["Page"] + ",") * elems["Pageviews"]).rstrip(',')
        else:
            line = line + ((elems["Page"] + ",") * elems["Pageviews"]).rstrip(',')
    else:
        line = line + ((elems["Page"] + ",") * elems["Pageviews"]).rstrip(',')
    previous_client_id = client_id
if (line != ""):
    lines.append(line)

file = open("dataset.basket","w")
for line in lines:
    file.write(line + "\n")

orange3-associate で アソシエーション分析を実行

import Orange
from orangecontrib.associate.fpgrowth import *
tbl = Orange.data.Table('dataset.basket')

---------------------------------------------------------------------------

ValueError                                Traceback (most recent call last)

<ipython-input-58-2ce7644e98cc> in <module>()
      1 import Orange
      2 from orangecontrib.associate.fpgrowth import *
----> 3 tbl = Orange.data.Table('dataset.basket')


~/Library/Python/3.6/lib/python/site-packages/Orange/data/table.py in __new__(cls, *args, **kwargs)
    212                 return cls.from_url(args[0], **kwargs)
    213             else:
--> 214                 return cls.from_file(args[0])
    215         elif isinstance(args[0], Table):
    216             return cls.from_table(args[0].domain, args[0])


~/Library/Python/3.6/lib/python/site-packages/Orange/data/table.py in from_file(cls, filename, sheet)
    606         reader = FileFormat.get_reader(absolute_filename)
    607         reader.select_sheet(sheet)
--> 608         data = reader.read()
    609 
    610         # Readers return plain table. Make sure to cast it to appropriate


~/Library/Python/3.6/lib/python/site-packages/Orange/data/io.py in read(self)
    899 
    900         X, Y, metas, attr_indices, class_indices, meta_indices = \
--> 901             _io.sparse_read_float(self.filename.encode(sys.getdefaultencoding()))
    902 
    903         attrs = constr_vars(attr_indices)


Orange/data/_io.pyx in Orange.data._io.sparse_read_float()


ValueError: b'dataset.basket':17:21: invalid value

basketデータの形式でエラーになりました。
対象行の文字列は以下の通りです。

/translate_c?depth=1&hl=en&prev=search&rurl=translate.google.com&sl=ja&sp=nmt4&u=https://www.monotalk.xyz/blog/google-apps-script-で電子署名をする/&usg=ALkJrhhsVbHe1cSGBqRWm5dF2OEu936DqQ
=?& 等が含まれると上手く処理できなさそうなので、文字列を除外して再度csvを作成します。

lines = []
line = ""
previous_client_id = ""
for idx in df.index:
    elems = df.ix[idx]
    client_id = elems["ga:dimension8"]
    if(previous_client_id != ""):
        if (previous_client_id != client_id):
            lines.append(line.rstrip(',').replace("?","").replace("=","").replace("&",""))
            line = ""
            line = line + ((elems["Page"] + ",") * elems["Pageviews"])
        else:
            line = line + ((elems["Page"] + ",") * elems["Pageviews"])
    else:
        line = line + ((elems["Page"] + ",") * elems["Pageviews"])
    previous_client_id = client_id
if (line != ""):
    lines.append(line.rstrip(',').replace("?","").replace("=","").replace("&",""))

file = open("dataset.basket","w")
for line in lines:
    file.write(line + "\n")

import Orange
from orangecontrib.associate.fpgrowth import *
tbl = Orange.data.Table('dataset.basket')

今度は上手く読み込めました。

ルール と、信頼度の計算、出力

実装は、Python でアソシエーション分析 - Orange3-Associate - なんとなくな Developer のメモ参考にしました。
いうか、まるっと拝借させて頂きました。

def decode_onehot(d):
    items = OneHot.decode(d, tbl, mapping)
    # ContinuousVariable の name 値を取得
    return list(map(lambda v: v[1].name, items))

X, mapping = OneHot.encode(tbl)
# 2 は組み合わせごとの発生回数、2件以上のものを取得する
itemsets = dict(frequent_itemsets(X, 2))

# アソシエーションルールの抽出
# 信頼度(確信度)が0.7以上のものを抽出する
rules = association_rules(itemsets, 0.7)
for P, Q, support, confidence in rules:
    lhs = decode_onehot(P)
    rhs = decode_onehot(Q)
    print(f"lhs = {lhs}, rhs = {rhs}, support = {support}, confidence = {confidence}")

lhs = ['/blog/Default-PMD-Rules-In-Github-Repositories/', '/blog/Default-FindBugs-Rules-In-Github-Repositories/'], rhs = ['/blog/lombokのfindbugs警告を抑制する/'], support = 2, confidence = 1.0
lhs = ['/blog/lombokのfindbugs警告を抑制する/', '/blog/Default-PMD-Rules-In-Github-Repositories/'], rhs = ['/blog/Default-FindBugs-Rules-In-Github-Repositories/'], support = 2, confidence = 1.0
lhs = ['/blog/python-folinum-を使い都道府県の夫婦年齢差をプロットする/', '/blog/python-folium-指定できる-地図の-タイル-について/'], rhs = ['/blog/python-folium-で都内の公園にまつわる情報を地図上に描画する/'], support = 3, confidence = 1.0
....
lhs = ['/blog/category/wicket/'], rhs = ['/blog/apache-wicketでrestapiを使う/'], support = 2, confidence = 1.0

LHS、RHS について

LHS、RHS というのが何なのかよくわからず調べてみました。
LHS は ルール左辺の条件部 (antecedent: left-hand-side or LHS)
RHS は ルール右辺の結論部 (consequent: right-hand-side or RHS) 表す言葉です。
アソシエーション分析(1)説明がわかりやすかったです。

発生回数ではなく、確率で計算する

def decode_onehot(d):
    items = OneHot.decode(d, tbl, mapping)
    # ContinuousVariable の name 値を取得
    return list(map(lambda v: v[1].name, items))

X, mapping = OneHot.encode(tbl)
# .001 は組み合わせごとの発生確率、0.1%以上のものを取得する
# 結果が出力されるのが、0.1%以下でのみ出力されました。
itemsets = dict(frequent_itemsets(X, .001))

# アソシエーションルールの抽出
# 信頼度(確信度)が0.7以上のものを抽出する
rules = association_rules(itemsets, 0.7)
for P, Q, support, confidence in rules:
    lhs = decode_onehot(P)
    rhs = decode_onehot(Q)
    print(f"lhs = {lhs}, rhs = {rhs}, support = {support}, confidence = {confidence}")

lhs = ['/blog/python-folinum-を使い都道府県の夫婦年齢差をプロットする/', '/blog/python-folium-指定できる-地図の-タイル-について/'], rhs = ['/blog/python-folium-で都内の公園にまつわる情報を地図上に描画する/'], support = 3, confidence = 1.0
lhs = ['/blog/htmlcompressor-maven-plugin-を使ってhtml-を圧縮する/'], rhs = ['/blog/jar-ファイル作成時にminify-maven-plugin-を使ってcssjavascript-を圧縮結合する/'], support = 3, confidence = 0.75
lhs = ['/blog/django-templatesyntaxerror-xxx-is-not-a-registered-tag-library-must-be-one-of/'], rhs = ['/blog/get-single-result-on-django-model-filter/'], support = 3, confidence = 1.0

リフト値 の計算

stats = rules_stats(rules, itemsets, len(X))
for s in sorted(stats, key = lambda x: x[6], reverse = True):

    lhs = decode_onehot(s[0])
    rhs = decode_onehot(s[1])

    support = s[2]
    confidence = s[3]
    lift = s[6]

    print(f"lhs = {lhs}, rhs = {rhs}, support = {support}, confidence = {confidence}, lift = {lift}")

リフト値の出力はありませんでした。 そもそもの出現確率が低すぎるのかもしれません。

pyfpgrowth と、orange-associate3 の比較

それぞれ使ってみた感想を記載します。

  • データ量が多いとパフォーマンスが結構つらい
    20000万レコード以上で処理した際、結果がpyfpgrowth jupyter上での応答がなくなり、処理対象のデータ件数を3000件にしました。
    それなりに長い間使っているMACですが、データをもっと絞るなりしないと、実際には使えないかもしれません。

  • 処理結果は、orange-associate3 のほうがそれらしい結果が出ている
    処理結果を見る限りは、orange-associate3ほうがそれらしい結果が出ていました。
    処理結果から推測すると、以下のような動作をしていそうに思いました。

    • orange-associate3 は、商品の重複カウントは、考慮していないが、pyfpgrowth重複カウントを考慮していそう。
    • orange-associate3 は、商品の順序性を考慮していないが、pyfpgrowth商品の順序性を考慮していそう。

再度分析する際のデータ加工について

再度実施する際は、以下の点を考慮してデータの加工をしたいと思います。

  • PageView数ではなく、ユーザー数、セッション数をカウントする。
  • 2ページ以上の閲覧がないユーザーは除外する。
  • Page閲覧時の滞在時間が長いユーザーのみ抽出する。

以上です。

コメント

カテゴリー