Pythonで購入履歴の共起分析(アイテム間類似度取得・クラスタリング)

Pythonで購入履歴の共起分析(アイテム間類似度取得・クラスタリング)

商品購入ログから商品間の類似度取得・クラスタリング

商品購入ログを使って商品の共起分析。
以前行った実験と同じデータを使ってより高度な前処理を行う。

前回の実験↓

今回は下記の図に示すようにデータを加工していく。



ログデータのクロス集計までは前回と一緒。

その後、

  • 共起行列
  • PPMI
  • SVD

と続く。

成果物として、

  • アイテム間のコサイン類似度表
  • アイテムのクラスタリングリスト

が得られる。

1. 環境構築

Anacondaが既にインストールされている前提。
入ってなければ以下を参考にして入れる。

以下のように「data」という名前の環境を作り、必要なライブラリをインストール。

1
2
3
4
5
conda create -n data python=3.9
conda activate data
conda install -c anaconda pandas
conda install -c conda-forge matplotlib
conda install -c anaconda scikit-learn

終わり。

2. 使用データ

手元にあった下記の購入履歴を使用。
ストラクチャーデッキを参考にしている。

yugioh_log.csv

時刻購入者商品
0:19:15城之内クリッター
1:16:21遊戯ブラック・マジシャン・ガール
2:06:38海馬融合
2:34:36遊戯ブラック・マジシャン
2:57:13海馬融合解除
4:04:33マリクサイクロン
4:38:45パンドラ死のマジック・ボックス
4:48:40海馬リビングデッドの呼び声
5:18:28城之内ハリケーン
6:33:19遊戯死者蘇生
8:46:46遊戯融合
9:40:23遊戯ブラック・ホール
9:40:30ペガサス死者蘇生
10:26:40ペガサス融合
10:54:43ペガサス大嵐
11:33:02ペガサスハリケーン
14:19:39マリク聖なるバリア −ミラーフォース−
15:44:19海馬ブラック・ホール
15:44:37城之内融合
16:29:03ペガサスクリッター
17:05:46マリク死者蘇生
17:09:09海馬死者蘇生
20:26:48城之内リビングデッドの呼び声
21:13:05海馬青眼の白龍
21:41:42城之内サイクロン
21:49:30ペガサス融合解除
21:52:27遊戯聖なるバリア −ミラーフォース−
21:59:26マリク融合
22:15:42パンドラブラック・マジシャン
23:14:51遊戯融合解除
23:17:19遊戯サイクロン
23:23:05遊戯クリッター
23:55:36海馬クリッター
23:56:50遊戯死のマジック・ボックス
23:55:56海馬青眼の白龍

3. 実験

さっきの購入履歴を使用してPythonによって実験。

3.1. クロス集計

はじめにpandasによってデータを取得。
その後pandasの関数で「商品」と「購入者」のクロス集計を行う。

前回説明したのと同じなので割愛。

1
2
3
4
5
6
7
8
9
import pandas as pd

#### CSV読み込み
loadcsv = "yugioh_log.csv"
df = pd.read_csv(loadcsv, encoding="shift-jis") # ← CSVの文字コードがutf8なら encoding="utf-8"

#### 1. クロス集計
cross = pd.crosstab(df["商品"], df["購入者"])
cross = cross.mask(cross > 0, 1) # 2回以上でも1にする

こんな感じになる↓

商品パンドラペガサスマリク城之内海馬遊戯
クリッター010111
サイクロン001101
ハリケーン010100
ブラック・ホール000011
ブラック・マジシャン100001
ブラック・マジシャン・ガール000001
リビングデッドの呼び声000110
大嵐010000
死のマジック・ボックス100001
死者蘇生011011
聖なるバリア −ミラーフォース−001001
融合011111
融合解除010011
青眼の白龍000010

3.2. 共起行列 作成

さっきのクロス集計した表から共起行列を作成する。
共起行列について今回作成する具体例をもとに説明する。

さきほどのクロス集計では縦が「商品」、横が「ユーザー」となっていたところを、
縦、横ともに「商品」とする。

仮に縦を「商品A」、横を「商品B」とすると、【「商品A」行・「商品B」列】の値は、
クロス集計で「商品A」と「商品B」を両方購入したユーザーの数になる。

共起行列に変換することで(大抵の場合は)次元数が削減される。
今回のように商品数よりユーザー数の方が少ない場合は効果がないが、
大抵の場合はユーザー数の方がはるかに大きい。

クロス集計から共起行列への変換は、
「クロス集計」と「転置した自分自身」の内積をとることで簡単に取得できる。

1
2
3
#### 2. 共起行列
co_occurrence = cross.dot(cross.T)
co_occurrence.to_csv("yugioh_log_co_occurrence.csv", encoding="shift-jis")

こんな感じになる↓

商品クリッターサイクロンハリケーンブラック・ホールブラック・マジシャンブラック・マジシャン・ガールリビングデッドの呼び声大嵐死のマジック・ボックス死者蘇生聖なるバリア -ミラーフォース-融合融合解除青眼の白龍
クリッター42221121131431
サイクロン23111110122310
ハリケーン21200011010210
ブラック・ホール21021110121221
ブラック・マジシャン11012100211110
ブラック・マジシャン・ガール11011100111110
リビングデッドの呼び声21110020010211
大嵐10100001010110
死のマジック・ボックス11012100211110
死者蘇生32121111142431
聖なるバリア -ミラーフォース-12011100122210
融合43221121142531
融合解除31121111131331
青眼の白龍10010010010111

3.3. PPMI 計算

上記の表をそのまま分析に使うと単純に出現回数の多いアイテムがあらゆるものと高い類似性を示してしまう。
その問題に対して、より出現回数が少ないものが共起したときほど値が大きくなるように計算する手法がある。

それがPMI(Pointwise Mutual Information)。
日本語では自己相互情報量というらしい。

PMIの計算を普通に行うと共起数の少ないものが負の値になり、全く共起しないものが0になる。
これだと良くないのでPMIの負の値を全て0にしたものがPPMI(Positive Pointwise Mutual Information)

今回はPPMI計算用の関数を作成してPPMIの計算を行う。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import numpy as np

# 関数:PPMI計算
# pmi(X, i, j) = log( P(Xij) / P(Xi)・P(Xj) )
# 引数:df ⇒ 共起行列、上の式でいうところのP(Xij)
def calc_ppmi(df):
# PMI の計算
word_freq = df.sum(axis=0) # 各単語の出現回数の合計
total_word_freq = word_freq.sum() # 全単語の出現回数の合計
expected = np.outer(word_freq, word_freq) # 各単語の出現確率だけから推定した 単語A × 単語B の発生確率表
df = (df * total_word_freq) / expected # 確率なので合計値(total)で割る、分母は2回、分子は1回割るから分子に1回かける
with np.errstate(divide='ignore'): df = np.log(df) # 対数をとる log(0)のエラーは無視して後で処理
df[np.isinf(df)] = 0.0 # log(0)の結果である「-inf」を「0」とする
# PPMI の計算 負の値を0にする
df[df < 0] = 0.0
return df

#### 3. PPMI
ppmi = calc_ppmi(co_occurrence)
ppmi.to_csv("yugioh_log_ppmi.csv", encoding="shift-jis")

こんな感じになる↓

商品クリッターサイクロンハリケーンブラック・ホールブラック・マジシャンブラック・マジシャン・ガールリビングデッドの呼び声大嵐死のマジック・ボックス死者蘇生聖なるバリア −ミラーフォース−融合融合解除青眼の白龍
クリッター0.160.000.400.000.000.000.310.310.000.000.000.030.070.16
サイクロン0.000.650.100.000.010.190.010.000.010.000.550.130.000.00
ハリケーン0.400.101.340.000.000.000.561.250.000.000.000.270.000.00
ブラック・ホール0.000.000.000.460.120.300.120.000.120.000.000.000.160.66
ブラック・マジシャン0.000.010.000.121.160.650.000.001.160.000.310.000.000.00
ブラック・マジシャン・ガール0.000.190.000.300.650.830.000.000.650.000.500.000.000.00
リビングデッドの呼び声0.310.010.560.120.000.001.160.000.000.000.000.180.001.01
大嵐0.310.001.250.000.000.000.001.850.000.350.000.180.510.00
死のマジック・ボックス0.000.010.000.121.160.650.000.001.160.000.310.000.000.00
死者蘇生0.000.000.000.000.000.000.000.350.000.230.200.060.110.20
聖なるバリア −ミラーフォース−0.000.550.000.000.310.500.000.000.310.200.850.030.000.00
融合0.030.130.270.000.000.000.180.180.000.060.030.120.000.03
融合解除0.070.000.000.160.000.000.000.510.000.110.000.000.270.36
青眼の白龍0.160.000.000.660.000.001.010.000.000.200.000.030.361.55

3.4. コサイン類似度の計算

PPMIの結果を利用して任意の商品同士の類似度を計算できる。
この商品を買った人はこの商品も買ってますみたいなやつ。
クラスタリングだけが目的ならこれはやる必要が無い。

コサイン類似度はscikitlearnにあるライブラリを使うことで表ごとまとめて計算してくれる。

それだと若干扱いづらいのでリストに変換して出力した。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#### 4. Cosine類似度
from sklearn.metrics.pairwise import cosine_similarity
cos_sim_matrix = cosine_similarity(ppmi)
cos_sim_matrix = pd.DataFrame(data=cos_sim_matrix, index=ppmi.index, columns=ppmi.columns)

# Cosine類似度表csv出力
cos_sim_matrix.to_csv("yugioh_log_ppmi_cossim.csv", encoding="shift-jis")

# Cosine類似度リスト作成
import itertools
output_list = []
for combi in itertools.combinations(cos_sim_matrix.index, 2):
output_list.append( [ combi[0], combi[1], cos_sim_matrix[combi[0]][combi[1]] ] )
output_list.append( [ combi[1], combi[0], cos_sim_matrix[combi[0]][combi[1]] ] )

# Cosine類似度リストcsv出力
import csv
with open('yugioh_log_ppmi_cossim_list.csv', 'w', newline="") as f:
writer = csv.writer(f)
writer.writerow(["card A", "card B", "Cosine類似度"])
writer.writerows(output_list)

コサイン類似度表(最大は1)↓

商品クリッターサイクロンハリケーンブラック・ホールブラック・マジシャンブラック・マジシャン・ガールリビングデッドの呼び声大嵐死のマジック・ボックス死者蘇生聖なるバリア −ミラーフォース−融合融合解除青眼の白龍
クリッター1.000.080.920.270.000.000.740.780.000.450.000.870.540.48
サイクロン0.081.000.130.080.200.460.060.070.200.250.850.380.000.01
ハリケーン0.920.131.000.040.000.010.470.900.000.440.020.900.480.16
ブラック・ホール0.270.080.041.000.330.440.570.040.330.320.200.100.560.83
ブラック・マジシャン0.000.200.000.331.000.900.000.001.000.070.600.010.020.02
ブラック・マジシャン・ガール0.000.460.010.440.901.000.020.000.900.140.790.060.050.07
リビングデッドの呼び声0.740.060.470.570.000.021.000.210.000.240.000.600.340.85
大嵐0.780.070.900.040.000.000.211.000.000.650.030.740.690.06
死のマジック・ボックス0.000.200.000.331.000.900.000.001.000.070.600.010.020.02
死者蘇生0.450.250.440.320.070.140.240.650.071.000.340.440.820.37
聖なるバリア −ミラーフォース−0.000.850.020.200.600.790.000.030.600.341.000.210.020.02
融合0.870.380.900.100.010.060.600.740.010.440.211.000.370.29
融合解除0.540.000.480.560.020.050.340.690.020.820.020.371.000.55
青眼の白龍0.480.010.160.830.020.070.850.060.020.370.020.290.551.00

コサイン類似度リスト(一部抜粋)↓

card Acard BCosine類似度
ブラック・マジシャン死のマジック・ボックス1
死のマジック・ボックスブラック・マジシャン1
クリッターハリケーン0.9234974389
ハリケーンクリッター0.9234974389
ブラック・マジシャンブラック・マジシャン・ガール0.9029729248
ブラック・マジシャン・ガールブラック・マジシャン0.9029729248
ブラック・マジシャン・ガール死のマジック・ボックス0.9029729248
死のマジック・ボックスブラック・マジシャン・ガール0.9029729248
ハリケーン融合0.8996990148
融合ハリケーン0.8996990148
ハリケーン大嵐0.8973861356
大嵐ハリケーン0.8973861356
クリッター融合0.8687967339
融合クリッター0.8687967339
サイクロン聖なるバリア −ミラーフォース−0.8519482014
聖なるバリア −ミラーフォース−サイクロン0.8519482014
リビングデッドの呼び声青眼の白龍0.8489787168
青眼の白龍リビングデッドの呼び声0.8489787168
ブラック・ホール青眼の白龍0.8318634432

3.5. SVD

SVDという手法で次元削減を行う。
基本的に次元数が多いと特徴が捉えづらく分析に不向き。

今回の例では商品数が14と少ないので必要ないが次元数が多い時は有効。
本当は1000次元から100次元にしたりとがっつり削減する。

1
2
3
4
5
#### 5. SVD
from sklearn.decomposition import TruncatedSVD
svd = TruncatedSVD(n_components=5) # 目標の次元数 5
svd_array = svd.fit_transform(ppmi)
svd_df = pd.DataFrame(data=svd_array, index=ppmi.index, columns=list(range(5)), dtype='float') # columnsの定数はさっきの次元数

3.6. クラスタリング

クラスタリングは前回やったので説明しない。

1
2
3
4
5
6
7
8
9
10
11
12
#### 6. クラスタリング(SpectralClustering)
from sklearn import cluster
from scipy.sparse import csr_matrix

# クラスタリング実行
csr = csr_matrix(svd_df) # スパースマトリクスに変換
spectral_clustering = cluster.SpectralClustering(n_clusters=5, affinity='nearest_neighbors', n_init=100, assign_labels='discretize')
predict_s = spectral_clustering.fit_predict(csr)

# クラスタリング結果csv出力
result = pd.DataFrame({"class Spectral":predict_s}, index=svd_df.index)
result.to_csv("yugioh_log_ppmi_svd_class.csv", encoding="utf-8")

クラスタリングの結果↓

商品class
クリッター0
サイクロン2
ハリケーン4
ブラック・ホール3
ブラック・マジシャン3
ブラック・マジシャン・ガール3
リビングデッドの呼び声4
大嵐1
死のマジック・ボックス2
死者蘇生0
聖なるバリア −ミラーフォース−2
融合0
融合解除1
青眼の白龍1

3.7. 結果

それらしい結果は出た。
ちゃんと動いている。

PPMIによって出現頻度の大小に関わらず類似度を計算できるようにしたが、そもそも出現頻度に大きな偏りがない。
共起行列・SVDによって次元数の削減を目論んだが、そもそも次元数が小さい。

多分もっと大きなデータで実験したら差が出てくる。

4. コード

全部まとまった状態のコード

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import pandas as pd
import numpy as np

# 関数:PPMI計算
# pmi(X, i, j) = log( P(Xij) / P(Xi)・P(Xj) )
# 引数:df ⇒ 共起行列、上の式でいうところのP(Xij)
def calc_ppmi(df):
# PMI の計算
word_freq = df.sum(axis=0) # 各単語の出現回数の合計
total_word_freq = word_freq.sum() # 全単語の出現回数の合計
expected = np.outer(word_freq, word_freq) # 各単語の出現確率だけから推定した 単語A × 単語B の発生確率表
df = (df * total_word_freq) / expected # 確率なので合計値(total)で割る、分母は2回、分子は1回割るから分子に1回かける
with np.errstate(divide='ignore'): df = np.log(df) # 対数をとる log(0)のエラーは無視して後で処理
df[np.isinf(df)] = 0.0 # log(0)の結果である「-inf」を「0」とする
# PPMI の計算 負の値を0にする
df[df < 0] = 0.0
return df

#### CSV読み込み
loadcsv = "yugioh_log.csv"
df = pd.read_csv(loadcsv, encoding="shift-jis") # ← CSVの文字コードがutf8なら encoding="utf-8"

#### 1. クロス集計
cross = pd.crosstab(df["商品"], df["購入者"])
cross = cross.mask(cross > 0, 1) # 2回以上でも1にする

#### 2. 共起行列
co_occurrence = cross.dot(cross.T)
co_occurrence.to_csv("yugioh_log_co_occurrence.csv", encoding="shift-jis")

#### 3. PPMI
ppmi = calc_ppmi(co_occurrence)
ppmi.to_csv("yugioh_log_ppmi.csv", encoding="shift-jis")

#### 4. Cosine類似度
from sklearn.metrics.pairwise import cosine_similarity
cos_sim_matrix = cosine_similarity(ppmi)
cos_sim_matrix = pd.DataFrame(data=cos_sim_matrix, index=ppmi.index, columns=ppmi.columns)

# Cosine類似度表csv出力
cos_sim_matrix.to_csv("yugioh_log_ppmi_cossim.csv", encoding="shift-jis")

# Cosine類似度リスト作成
import itertools
output_list = []
for combi in itertools.combinations(cos_sim_matrix.index, 2):
output_list.append( [ combi[0], combi[1], cos_sim_matrix[combi[0]][combi[1]] ] )
output_list.append( [ combi[1], combi[0], cos_sim_matrix[combi[0]][combi[1]] ] )

# Cosine類似度リストcsv出力
import csv
with open('yugioh_log_ppmi_cossim_list.csv', 'w', newline="") as f:
writer = csv.writer(f)
writer.writerow(["card A", "card B", "Cosine類似度"])
writer.writerows(output_list)

#### 5. SVD
from sklearn.decomposition import TruncatedSVD
svd = TruncatedSVD(n_components=5) # 元の次元数が少ないから5にしてるけど本当は1000次元から100次元に削減したりする
svd_array = svd.fit_transform(ppmi)
svd_df = pd.DataFrame(data=svd_array, index=ppmi.index, columns=list(range(5)), dtype='float') # columnsの定数はさっきの次元数

#### 6. クラスタリング(SpectralClustering)
from sklearn import cluster
from scipy.sparse import csr_matrix

# クラスタリング実行
csr = csr_matrix(svd_df) # スパースマトリクスに変換
spectral_clustering = cluster.SpectralClustering(n_clusters=5, affinity='nearest_neighbors', n_init=100, assign_labels='discretize')
predict_s = spectral_clustering.fit_predict(csr)

# クラスタリング結果csv出力
result = pd.DataFrame({"class Spectral":predict_s}, index=svd_df.index)
result.to_csv("yugioh_log_ppmi_svd_class.csv", encoding="utf-8")

5. 参考

PPMIの計算方法について
https://stackoverflow.com/questions/58701337/how-to-construct-ppmi-matrix-from-a-text-corpus

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×