Pythonでコンジョイント分析に挑戦
マーケティング戦略や施策を検討する際、その第一歩として、自社の顧客を深く理解することが重要となります。
ここ数年のAI・データサイエンティストブームもあってか、属性・行動データの分析を顧客理解の主たる手段として位置付けているケースをしばしば目にします。しかしながら、限られた属性・行動データだけではわかり得ないことも多く、データ分析だけで顧客に関する理解を深めるのは簡単ではありません。
属性・行動データの分析を超え、より顧客の真意を明らかにできるような方法がないか探していたとき、コンジョイント分析(Conjoint analysis)のことを知りました。深く顧客を理解するという点では、デプスインタビューやエスノグラフィーなどの手法には劣るとは思いますが、従来のデータ分析を超える洞察を得られると感じています。
ということで前置きが長くなりましたが、コンジョイント分析の概要とPythonでの分析方法、分析結果の解釈について簡単にまとめていきたいと思います。ちなみに、2020年12月18日現在、Pythonでコンジョイント分析を実装したという日本語の記事はこれが初です!(たぶん)
コンジョイント分析とは
Albertさんに聞いてみました。
コンジョイント分析とは、最適な商品コンセプトを決定するための代表的な多変量解析を用いた分析方法で、個別の要素を評価するのではなく、商品全体の評価(全体効用値)することで、個々の要素の購買に影響する度合い(部分効用値)を算出する手法です
この文章だけで理解するのは難しそうなので、具体的な例をあげて説明します。
ノートパソコンを購入するときのことを考えてみましょう。 購入に際して検討するポイントはある程度決まっているかと思います。価格、ブランド、CPU性能、メモリ、ディスク容量、重さなどがありそうです。
それらの要素を使い、被調査者から以下のようなアンケートを収集します。
以下に示すノートパソコンについて、10点満点で評価してください。
- No.1 60,000円, メモリ8GB, SSD256GB
- No.2 70,000円, メモリ4GB, SSD1TB
- No.3 50,000円, メモリ8GB, SSD128GB
・・・
このようなアンケートを複数人から収集することで、製品・サービスそのもの対する評価と、製品・サービスを構成する要素との関係性をデータとして集めることができます。
コンジョイント分析では、アンケートで得られた消費者の製品・サービスそのものに対する評価を通じて、製品・サービスが有する各要素の購買への影響度・重要度を明らかにします。
たとえば「消費者にとっては価格が一番重要で、二番目はメモリだった」というように、消費者が製品・サービスに対して何を重視しているを分析結果から客観的に説明することができます。
なぜコンジョイント分析か
このような回りくどいアンケートを取らなくても、製品・サービスの各要素に対して個別に評価してもらってもよさそうにも思えます。
しかしこれでは、実際に購買を検討する際に発生するようなトレードオフ(たとえば、価格とディスク容量)を考慮した評価は難しいため、実際の購買と整合しないような精度の低い回答となってしまうおそれがあります。
これに対しコンジョイント分析で用いるアンケートでは、実際に店頭やネットで商品を選ぶときのようなシチュエーションを再現することによって、よりリアリティのある回答を得られることができるのです。
使用するデータ
前述の通り、被調査者から収集したアンケートが必要となります。
このとき問題となるのが各要素の組合せです。
仮に、ノートパソコンに関する質問項目を「価格、メモリ、ディスク容量」の三つとし、各要素のパターンが高い/低い(大きい/小さい)の二択だったとしましょう。
この場合、単純に考えると2×2×2で8パターンを被調査者に質問する必要が出てきます。8パターンならまだ許せますが、要素・パターンが増えるにつれ、回答数が膨大な数になり、すべてに回答してもらうことが難しくなりそうです。
これに対処するための手法が直交表です。
詳細の説明は省きますが、直交表の考え方に合わせて質問項目を設定することで、すべての組合せを質問しなくても、より少ない質問数で各要素について分析することが可能となります。
先ほどのノートパソコンの例では4通りだけ質問すればよいので、回答を得ることは容易にできそうですね。
Pythonによる実装
実務でコードと触れることが少ない私ですが、Pythonはギリギリ触れるので、Pythonでコンジョイント分析にトライしてみたいと思います。
データ
今回、ノートパソコンの価格、メモリ・ディスクのスペックに対して10点満点で評価してもらったという体で私が作成した回答データを使用します。
こういう感じのデータです。
回答者はprice, memory, storageの組合せに対してrating(評価)を付けています。
分析手法
今回は10点満点の評価を目的変数とするため、重回帰分析によって分析を行います。偏回帰係数やp値などの分析結果を確認する必要があるため、それらを出力できるstatsmodelsというライブラリを使用します。
重回帰分析の部分だけ抜き出すとこのようなコードになります。シンプルですね。
今回、各説明変数は2択となるため、分析にあたりpandasのget_dummiesで説明変数をダミー変数に変換する必要があります。このとき、多重共線性による問題を避けるため、オプションでdrop_first=Trueを設定するようにしましょう。
多重共線性の問題については以下の記事で簡単にまとめているのでご覧ください。 wannko5296.hatenablog.com
簡単に言うと、説明変数間の相関が大きい場合、偏回帰係数(目的変数に対する寄与度)の大きさや正負が不安定となり、分析結果の解釈が困難となる問題のことです。
ちなみに、今回実装したソースコードはこちらにあります。ご参考までに。
分析結果の解釈
さて、分析結果を見ていきましょう。
ダミー変数化の際に各要素の選択肢を一つ削除したため、他方の選択肢のみ表示されています。 ※なお、削除した方の偏回帰係数は0として解釈します。
coef(coefficient)は偏回帰係数を表しており、ある説明変数が1増加した場合に目的変数に対してどれだけ変化をもたらすかを示します。メモリ(memory_8GB)を例にとると、他の説明変数が一定としたとき、メモリが8GBだと評価が2.6点も上がるということになります。
ちなみに、5人分の回答を入力した際、メモリ重視な人を何人か想定して記入したので、これはある意味予想通りの結果です。
P>|t|はP値のことで、説明変数の目的変数に対する影響が統計的に有意であるかを判断するデータとなります。有意水準5%としたとき、メモリ(memory_8GB)のP値は0.000<0.05なので、メモリは評価に影響を及ぼす要素であると判断することができます。
P値についても以前記事を書いたので見てみてください。 wannko5296.hatenablog.com
一応グラフも載せておきます。
偏回帰係数がマイナスのものがあれば左側にもバーが伸びる予定だったのですが... 今回はすべて偏回帰係数が正であるため右側に伸びた普通のグラフになっています。
この分析結果を見ると、評価に対してもっとも寄与度が大きいのがメモリ、次いでストレージということがわかります。
価格については今回の分析では有意とならなかったため赤色で表現しています。なお、この場合はあくまで今回の分析では有意とならなかったというだけですので、「価格は評価には影響しない」と言い切ることはできません。
今回は要素の数と選択肢が少ないため、分析しなくてもある程度わかるような結果となりましたが、それらをより複雑にすることで、直感的にはわかり得ないような顧客の購買要因やトレードオフが見えてくるかもしれませんね。
おわりに
この記事ではコンジョイント分析の概要とPythonでの分析について簡単に説明しました。
アンケートが必要なのでちょっと手間はかかりますが、属性・行動データを分析するだけでは見えてこないような洞察が得られると思います。機会があればぜひやってみてください。
なお、「Pythonでマーケティングサイエンス(コンジョイント分析編)」(キリッ と書いていますが、続編の予定はありませんんのでご了承ください。
参考資料
- Conjoint Analysis: A simple python implementation
- How to Do Conjoint Analysis in python | Arie P Sutiono
- エクセルでコンジョイント - マーケティングテクノロジー株式会社
- コンジョイント分析についての考察
- コンジョイント分析とは | データ分析基礎知識
- 学習意欲向上にむけた講義形態の違いによる選好の分析
- Rでコンジョイント分析
- コンジョイント分析 | CBR消費者行動研究所
参考:ソースコード
import matplotlib.pyplot as plt import numpy as np import pandas as pd import statsmodels.api as sm import seaborn as sns plt.style.use('bmh')
df = pd.read_csv('./conjoint/conjoint_sample.csv')
df.head()
ans_id | price | memory | storage | rating | |
---|---|---|---|---|---|
0 | 1 | 60,000 | 4GB | 128GB | 3 |
1 | 1 | 60,000 | 8GB | 512GB | 9 |
2 | 1 | 80,000 | 4GB | 512GB | 5 |
3 | 1 | 80,000 | 8GB | 128GB | 7 |
4 | 2 | 60,000 | 4GB | 128GB | 4 |
y = pd.DataFrame(df['rating'])
x = df.drop(columns=['ans_id', 'rating'])
x_dum = pd.get_dummies(x, columns=x.columns, drop_first=True)
x_dum.head()
price_80,000 | memory_8GB | storage_512GB | |
---|---|---|---|
0 | 0 | 0 | 0 |
1 | 0 | 1 | 1 |
2 | 1 | 0 | 1 |
3 | 1 | 1 | 0 |
4 | 0 | 0 | 0 |
model = sm.OLS(y, sm.add_constant(x_dum)) result = model.fit() result.summary()
Dep. Variable: | rating | R-squared: | 0.749 |
---|---|---|---|
Model: | OLS | Adj. R-squared: | 0.702 |
Method: | Least Squares | F-statistic: | 15.94 |
Date: | Thu, 17 Dec 2020 | Prob (F-statistic): | 4.60e-05 |
Time: | 11:12:48 | Log-Likelihood: | -26.635 |
No. Observations: | 20 | AIC: | 61.27 |
Df Residuals: | 16 | BIC: | 65.25 |
Df Model: | 3 | ||
Covariance Type: | nonrobust |
coef | std err | t | P>|t| | [0.025 | 0.975] | |
---|---|---|---|---|---|---|
const | 4.2000 | 0.458 | 9.165 | 0.000 | 3.229 | 5.171 |
price_80,000 | 0.2000 | 0.458 | 0.436 | 0.668 | -0.771 | 1.171 |
memory_8GB | 2.6000 | 0.458 | 5.674 | 0.000 | 1.629 | 3.571 |
storage_512GB | 1.8000 | 0.458 | 3.928 | 0.001 | 0.829 | 2.771 |
Omnibus: | 1.314 | Durbin-Watson: | 1.581 |
---|---|---|---|
Prob(Omnibus): | 0.518 | Jarque-Bera (JB): | 1.148 |
Skew: | 0.514 | Prob(JB): | 0.563 |
Kurtosis: | 2.435 | Cond. No. | 3.73 |
Warnings:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
df_res = pd.DataFrame({ 'name': result.params.keys() , 'weight': result.params.values , 'p_val': result.pvalues })
df_res.head()
name | weight | p_val | |
---|---|---|---|
const | const | 4.2 | 9.108060e-08 |
price_80,000 | price_80,000 | 0.2 | 6.683526e-01 |
memory_8GB | memory_8GB | 2.6 | 3.456625e-05 |
storage_512GB | storage_512GB | 1.8 | 1.201026e-03 |
# グラフ表示のため行追加 # ※多重共線性回避のために削除した項目は偏回帰係数0 df_add = pd.DataFrame([['price_60,000', 0, 0.05], ['memory_4GB', 0, 0], ['storage_128GB', 0, 0]], columns=df_res.columns) df_graph = df_res.append(df_add , ignore_index=True) df_graph = df_graph.drop(df_graph.index[[0]])
df_graph = df_graph.sort_values('name')
df_graph
name | weight | p_val | |
---|---|---|---|
5 | memory_4GB | 0.0 | 0.000000 |
2 | memory_8GB | 2.6 | 0.000035 |
4 | price_60,000 | 0.0 | 0.050000 |
1 | price_80,000 | 0.2 | 0.668353 |
6 | storage_128GB | 0.0 | 0.000000 |
3 | storage_512GB | 1.8 | 0.001201 |
# 前処理 df_graph['abs_weight'] = np.abs(df_graph['weight']) df_graph['color'] = ["blue" if s < 0.05 else "red" for s in df_graph['p_val']] df_graph = df_graph.sort_values(by='abs_weight', ascending=True)
# グラフ表示 xbar = np.arange(len(df_graph['weight'])) plt.barh(xbar, df_graph['weight'], color=df_graph['color']) plt.yticks(xbar, labels=df_graph['name']) plt.show()