🐧
PythonでL9直交表を使ったコンジョイント分析 #2 – 効用値計算編(ファーストチョイス追加)
📌はじめに
これまでに推定した効用値(part-worth)は「数字の表」にとどまらず、実際に予測シェア(占有率)を計算するために使えます。
その一番シンプルな方法が ファーストチョイス(First Choice) です。
各回答者が「効用値が最も高いプロファイルを1つ選ぶ」と仮定して集計すると、
選択肢の人気度がすぐにわかります。
PythonでL9直交表を使ったコンジョイント分析#2 – 効用値計算編
のコードにファーストチョイスを追加してみます。
📌前回のおさらい
コード
#============================================
# 0. 必要ライブラリ
#============================================
import os
import pandas as pd
import statsmodels.api as sm
import matplotlib.pyplot as plt
import japanize_matplotlib # 日本語ラベル対応
import warnings
import sys
#============================================
# 1. ディレクトリ・ファイル・変数設定
#============================================
INPUT_FOLDER = '2_data'
OUTPUT_FOLDER = '3_output'
parent_path = os.path.dirname(os.getcwd())
input_ort_path = os.path.join(parent_path, INPUT_FOLDER, 'orthogonal_table.csv')
input_path = os.path.join(parent_path, INPUT_FOLDER, '調査データ.csv')
output_path = os.path.join(parent_path, OUTPUT_FOLDER)
os.makedirs(output_path, exist_ok=True)
# 出力ファイル名
#save_name_all = os.path.join(output_path, "効用値_全体.csv")
save_name_os = os.path.join(output_path, "効用値_OS別.csv")
save_name_id = os.path.join(output_path, "効用値_ID別.csv")
# グローバル変数(列名など)
glb_ID ='ID'
glb_QA ='QA'
glb_OS ='OS'
glb_QA_NO = f"{glb_QA}_番号"
glb_OS_ha ='所有'
#============================================
# 2. 直交表読み込み & 縦持ち化
#============================================
try:
df_design = pd.read_csv(input_ort_path, index_col=0, encoding="utf-8")
except UnicodeDecodeError:
df_design = pd.read_csv(input_ort_path, index_col=0, encoding="cp932")
# 属性名の一覧を取得
attributes = df_design.index.tolist()
# 列→行に変換(long形式)
df_long = df_design.T.reset_index().rename(columns={'index': glb_QA_NO})
df_long[glb_QA_NO] = df_long[glb_QA_NO].astype(int)
#============================================
# 3. 調査データ読み込み & 縦持ち化
#============================================
try:
df_survey = pd.read_csv(input_path, encoding="utf-8")
except UnicodeDecodeError:
df_survey = pd.read_csv(input_path, encoding="cp932")
# QA列(QA_0~QA_8)をリスト化
qa_cols = [f'{glb_QA}_{i}' for i in range(len(df_design.columns))]
# wide形式 → long形式へ変換
df_melt = df_survey.melt(
id_vars=[glb_ID, glb_OS_ha],
value_vars=qa_cols,
var_name=glb_QA,
value_name='評価値'
)
df_melt[glb_QA_NO] = df_melt[glb_QA].str.replace(f'{glb_QA}_', '', regex=False).astype(int)
#============================================
# データ結合
#============================================
df_merged = pd.merge(df_melt, df_long, on=glb_QA_NO, how='left')
#============================================
# 5. 全体効用値の推定
#============================================
all_levels = []
for attr in attributes: # attributes = ['OS','バッテリー','画面サイズ','価格']
levels = df_design.loc[attr].dropna().unique().tolist()
all_levels.extend(levels)
all_levels = list(dict.fromkeys(all_levels)) # 重複削除
#============================================
# 6.関数
#============================================
# 効用値を OS 別に計算する関数
def estimate_util_by_OS(df_merged, attributes, glb_OS, all_levels):
"""
df_merged: 評価値付きデータ
attributes: ['OS','バッテリー','画面サイズ','価格'] 等
glb_OS: 'OS'
all_levels: 直交表から取得した全水準リスト(横持ちdf_designから作成済み)
戻り値: OS別の穴ナシ効用値 DataFrame
"""
manufacturer_utilities = []
for manu, group in df_merged.groupby(glb_OS):
coef = estimate_util(group, attributes) # ダミー列形式
coef_full = pd.Series(index=all_levels, dtype=float) # 全水準で穴ナシ準備
for col in coef.index:
val = col.split('_',1)[-1] if '_' in col else col
coef_full[val] = coef[col]
coef_full[glb_OS] = manu # OS列追加
manufacturer_utilities.append(coef_full)
df_manufacturer_util = pd.DataFrame(manufacturer_utilities)
# 列順整理
priority_cols = [glb_OS]
ordered_cols = [c for c in all_levels if c in df_manufacturer_util.columns]
other_cols = [c for c in df_manufacturer_util.columns if c not in priority_cols + ordered_cols]
final_cols = priority_cols + ordered_cols + other_cols
df_manufacturer_util = df_manufacturer_util[final_cols]
df_manufacturer_util = df_manufacturer_util.fillna(0) # 欠けている水準は0
return df_manufacturer_util
# ダミー列作成関数
def get_dummies(df, attributes):
"""属性水準をダミー変換"""
return pd.get_dummies(df[attributes])
# 効用値推定関数
def estimate_util(df_group, attributes, id_col=None):
X = get_dummies(df_group, attributes)
y = df_group['評価値']
res = sm.OLS(y, X).fit()
coef = pd.Series(res.params)
if id_col:
coef[id_col] = df_group[id_col].iloc[0]
return coef
#============================================
# 7. OS(ブランド)別効用値の計算
#============================================
df_manufacturer_util = estimate_util_by_OS(df_merged, attributes, glb_OS, all_levels)
df_manufacturer_util.to_csv(save_name_os, index=False, encoding='utf-8-sig')
#============================================
# 8. ID別効用値(穴ナシ)
#============================================
utility_list = []
for id_, g in df_merged.groupby(glb_ID):
coef = estimate_util(g, attributes, glb_ID) # ID情報も保持
# 全水準で穴ナシ用のSeries
coef_full = pd.Series(index=[glb_ID] + all_levels, dtype=float)
coef_full[glb_ID] = coef[glb_ID] # IDをセット
# coefの値を正しい水準にセット
for col in coef.index:
if col == glb_ID:
continue
# ダミー列名の水準部分
val = col.split('_', 1)[-1] if '_' in col else col
if val in all_levels:
coef_full[val] = coef[col]
utility_list.append(coef_full)
# DataFrame化
df_utility = pd.DataFrame(utility_list)
# 所有列をマージ(IDで結合)
df_utility = df_utility.merge(
df_merged[[glb_ID, '所有']].drop_duplicates(),
on=glb_ID,
how='left'
)
# 列順整理:ID → 所有 → 全水準
cols = [glb_ID, '所有'] + [c for c in df_utility.columns if c not in [glb_ID, '所有']]
df_utility = df_utility[cols]
# CSV出力
df_utility.to_csv(save_name_id, index=False, encoding='utf-8-sig')
#============================================
# 9. OS別効用値のグラフ
#============================================
with warnings.catch_warnings():
warnings.simplefilter("ignore")
plt.rcParams['axes.unicode_minus'] = False
df_plot = df_manufacturer_util.set_index(glb_OS).T
df_plot.plot(kind='bar', figsize=(15, 6))
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', borderaxespad=0.)
plt.title('OS(ブランド)別効用値の比較')
plt.ylabel('効用値')
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
#============================================
# 10. ID別効用値ヒートマップ
#============================================
import seaborn as sns
import matplotlib.pyplot as plt
# IDをインデックス、全水準を列にしたデータフレームを作成
df_heat = df_utility.set_index('ID')[all_levels] # 'all_levels'は全属性水準リスト
plt.figure(figsize=(15, 10))
sns.heatmap(df_heat, cmap='coolwarm', center=0, annot=False)
plt.title('ID別効用値ヒートマップ')
plt.xlabel('属性水準')
plt.ylabel('被験者ID')
plt.tight_layout()
plt.show()
📌 追加するコード
上のコードの次に追加します
#============================================
# 11. ファーストチョイスによるシェア予測
#============================================
# df_utility: ID別の効用値
utilities_list = []
# IDごとの効用値を1行取り出して処理
for id_, g in df_utility.groupby(glb_ID):
g_row = g.iloc[0]
# QAごとにカードをループ
for qa_no, card_cols in df_design.iteritems(): # df_design = orthogonal_table.csv 読み込み
card_id = qa_no # 0〜8
card_levels = card_cols.values.tolist() # このカードの各属性水準
# 水準ごとの効用を合計
utility_sum = sum([g_row[level] for level in card_levels])
utilities_list.append({
glb_ID: id_,
"card_id": card_id,
"utility": utility_sum
})
utilities = pd.DataFrame(utilities_list)
# ファーストチョイス
def first_choice(utilities):
chosen = utilities.loc[utilities.groupby(glb_ID)["utility"].idxmax()]
share = chosen.groupby("card_id").size().reset_index(name="count")
share["share"] = share["count"] / share["count"].sum()
return share
fc_share = first_choice(utilities)
print(fc_share)
# 可視化(オプション)
fc_share.plot(x="card_id", y="share", kind="bar", legend=False)
plt.ylabel("予測シェア")
plt.title("ファーストチョイスによる予測シェア")
plt.show()
11. ファーストチョイスによるシェア予測
11-1. df_utility: ID別の効用値
-
df_utility
: 回答者(ID)ごとの効用値がまとめられています - 列は属性水準名、行は回答者ごとになっています
11-2. IDごとの効用値を1行取り出して処理
-
groupby(glb_ID)
: 回答者ごとにグループ化 -
g_row = g.iloc[0]
: その回答者の効用値1行だけを取得
11-3. QAごとにカードをループ
-
df_design.iteritems()
: 列(選択肢ID)ごとに属性水準を取得 -
card_id = qa_no
: 選択肢の番号を決定 -
card_levels = card_cols.values.tolist()
: その選択肢の各属性水準をリスト化
❓選択肢とは❓
属性の組み合わせの1つ1つ を指します
分析に使う 商品パターン(属性の組み合わせ) をまとめたものです。
orthogonal_table.csv
- 1つ目の組み合わせ(iPhone + 3000mAh + 5インチ + 6万)が 1つの選択肢
- 2つ目の組み合わせが 別の選択肢
- 直交表の列1つ1つが選択肢に対応しています
つまり、「1つの製品イメージ」や「選択肢1つ分の条件の組み合わせ」です
11-4. 水準ごとの効用を合計
- 各選択肢の属性水準に対応する効用値を
g_row[level]
で取り出し、合計 - この合計が、回答者にとってのその選択肢の「総効用値」になる
11-5. ファーストチョイス
-
utilities.groupby(glb_ID)["utility"].idxmax()
: 各回答者の総効用が最大の選択肢を取得 - 選ばれた選択肢ごとに
count
とshare
(割合)を計算
出力例
card_id count share
0 0 34 0.377778
1 1 13 0.144444
2 3 9 0.100000
3 4 12 0.133333
4 5 21 0.233333
5 8 1 0.011111
考察
- 最も選ばれた選択肢は
card_id 0
(約38%) - 次に
card_id 5
(約23%)、card_id 1
(約14%)など - 選ばれなかった選択肢(2,6,7)は count = 0
誰も最大効用として選ばなかった
❓card_id 2,6,7が無いのはなぜ❓
だれも選ばなかったので、表示されませんでした
調査データの評価値 0(ニュートラル)とは別の意味です。
- 調査データ上の「0」はニュートラル評価(どちらでもない)
- ファーストチョイスの集計では選択されなかった選択肢は count = 0
- ファーストチョイスは「最も効用値が高い選択肢を1つだけ選ぶ」方法
- 各回答者は1枚しか選ばないので、評価が 0 でも、他の選択肢に効用値が高ければそちらが選ばれる
- その結果、card_id 2,6,7 のように誰も最大効用として選ばなかった選択肢は count=0 になる
⚠️ ここでの 0 は回答評価ではなく、「誰も選ばなかった選択肢」です。
📌ファーストチョイス集計のまとめ
- 効用値(part-worth)を計算
- ファーストチョイスで各回答者の最大効用の選択肢を選択
- 選択肢ごとの count を集計し、シェアを計算
- 選ばれなかった選択肢は count=0 / share=0
📌まとめ
ファーストチョイスを使うと、どの選択肢が人気か一目でわかるので
効用値や属性ごとの深堀と合わせると効果的です。
誰も選ばなかった選択肢(count=0)も、嗜好のばらつきが見えて面白いですね。
参考リンクについて
GitHubリポジトリ
本記事で紹介したコードやサンプルデータはこちらで公開しています。
Discussion