🈴

PythonでL9直交表を使ったコンジョイント分析#2 – 効用値計算編

に公開

本記事はコンジョイント分析連載の第2回です。

第1回 PythonでパパッとL9直交表作成してコンジョイント分析#1 – 準備編
第2回 PythonでL9直交表を使ったコンジョイント分析#2 – 効用値計算編 ← 今回
第3回 Excelでできる!コンジョイント分析#3 – 購買確率シミュレーション編 ← 次回予定
第4回 Python でコンジョイント分析#4 - 購買確率 Streamlit編 ← 予定


📌はじめに

今回は 「直交表を用いたコンジョイント分析」 を、実際のデータを使って試してみます。
コンジョイント分析を使うと、製品やサービスのどの要素が消費者の選好に影響しているかを定量的に明らかにできます。

直交表の作り方を知りたい方は、前回の記事もご覧ください。

❓コンジョイント分析とは❓

製品やサービスの複数の属性(価格、デザイン、機能など)が
消費者の選好にどのように影響するかを数値で明らかにする手法です。

例えば、以下を調べることができます:

  • どの属性が購買意思決定にどれくらい効いているか
  • 消費者が何を重視しているか
  • どの組み合わせの商品がより好まれるか
❓OLS(最小二乗法)とは❓

OLS(Ordinary Least Squares)は、回帰分析の代表的な手法です。
誤差(二乗和)が最も小さくなるように係数を決める方法で、売上予測や特徴量の影響度分析など、多くの場面で使われます。

今回のコンジョイント分析でも、このOLSを使って各属性の影響を推定します。

❓効用値(part-worth)とは❓

各属性や水準に対して、ユーザーがどの程度好むかを数値化したものです。

属性 水準 効用値の傾向 解説
バッテリー容量 大容量 高い効用値(プラス) 大容量の方が好まれるため効用値が高くなる
価格 高価格 低い効用値(マイナス) 高価格は敬遠されやすく効用値が低くなる

効用値は 相対的な好みの強さ を示す指標です。
単体では意味を持ちませんが、製品ごとの効用値を合計することで、どちらがより好まれるかを判断できます。


📌調査内容(仮定)

90名の被験者に対して、直交表に基づき作成した 9種類の商品パターン を提示しました。
各パターンについて、被験者には 「どの程度購入したいか」 を5段階で評価してもらいました。

評価 数値(変換後) 意味
欲しい 2 強い購入意向
やや欲しい 1 やや購入したい
どちらでもない 0 ニュートラル
たぶん買わない -1 やや購入拒否
絶対に買わない -2 強い購入拒否

📌サンプルデータ

本記事で使用するデータは 2種類 です。

📜 orthogonal_table.csv

分析に使う 商品パターン(属性の組み合わせ) をまとめたファイルです。

📜 調査データ.csv

被験者の属性情報と、各商品パターンに対する評価スコアをまとめたファイルです。

📜 調査データ.csv の構成

内容
A列 ID(被験者ごとのユニークID) 1, 2, 3, ...
B〜E列 性別・年齢・職業・収入などの属性情報 男性, 30代, 会社員, 400万
F列 所有するOS(ブランド)情報 iPhone / Android / Galaxy M51
G〜O列 直交表のパターン(0〜8)に対する評価データ 2, 1, 0, -1, -2 ...

📌フォルダ構成

├─ 1_flow/
│ └─ conjoint_analysis_l9.py   # Python実行スクリプト
├─ 2_data/
│ ├─ orthogonal_table.csv      # L9直交表
│ └─ 調査データ.csv             # 調査用アンケートデータ
├─ 3_output/                   # 出力用フォルダ(実行時に自動作成)

📌環境

python3.x
jupyter lab


📌コード解説

#============================================
# 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)

1. ディレクトリ・ファイル・変数設定

  • データ格納用のフォルダと出力フォルダを定義
  • 出力ファイル名(効用値を保存するCSV)を準備
  • 分析に使う列名(ID, OS, 質問番号など)をグローバル変数で管理

2. 直交表読み込み & 縦持ち化

  • orthogonal_table.csv読み込む(文字コードに応じて自動切り替え)
  • attributes = df_design.index.tolist(): 属性名の一覧を取得
  • df_design.T.reset_index(): 横持ち(wide形式)→ 縦持ち(long形式)へ変換
  • glb_QA_NO: 列を整数型にして、扱いやすくする

💡ポイント

  • Long形式に変換することで、OLS回帰に使いやすい形 になる
  • 属性名やパターン番号は、このあと効用値の推定や可視化で利用する
出力例


#============================================
# 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')

3. 調査データ読み込み & 縦持ち化

  • 調査データ.csv読み込む(文字コードに応じて自動切り替え)
  • a_cols = [...] : QA列(QA_0~QA_8)のリストを作成
  • pd.melt() : 横持ち(wide形式) → 縦持ち(long形式) に変換
  • QA_番号 : 列を整数型に変換して後続の結合に備える
出力例

補足
  • QA の部分は データの列名と完全に一致させる必要 があります
  • a_cols で作っているのは、単に 処理対象の列名リスト です
  • この列名リストを melt に渡すことで、横持ちの QA_0~QA_8 の列が縦持ちに変換されます

4. データ結合

  • 縦持ちに変換した 調査データ (df_melt) と直交表の 属性情報 (df_long) を結合
  • on=glb_QA_NO:共通の質問番号でマージ
  • how='left':調査データを基準に全件保持

💡ポイント

  • 被験者の評価値と商品パターンの属性情報が1つのデータフレームに揃う
  • この形にしておくと、そのままOLS回帰分析(効用値算出) に投入できる

#============================================
# 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')

5. 全体効用値の推定

  • 直交表から すべての属性水準 を抜き出してリスト化
  • dict.fromkeys(...)) : 重複削除し穴なしの水準リストを準備
  • これにより「あるOSには水準が欠けている」場合でも、最終表の列を揃えやすくなる
❓穴アリと穴ナシの効用値とは?❓

コンジョイント分析でダミー変数を作って回帰するとき、
すべての水準を使うか/1つを基準に落とすか で結果の形が変わります。

📌 穴アリ(基準水準あり)

  • pd.get_dummies(..., drop_first=True) のように 1つの水準を落とす方法
  • 落とした水準が「基準」となり、残りの水準は その基準との差分(相対効用値) として推定されます
  • 例:
    • 価格属性が「安い/高い」の場合
    • 「安い」を基準に落とす
    • 推定結果「高い = -1.2」 → 「高いの効用は安いに比べて -1.2」

👉 メリット

  • 多重共線性を回避でき、推定が安定する
  • 「基準に対してどれくらい効用が違うか」が直感的にわかる

📌 穴ナシ(基準なし、全水準を含める)

  • pd.get_dummies(..., drop_first=False) のように 全水準を入れる方法
  • 各水準に効用値が割り当てられるが、平均や合計がゼロに近づくように調整される
  • 値の絶対的な意味は弱く、水準間の相対的な大小で解釈する

👉 メリット

  • 表の列がそろいやすい(欠けがなくて見やすい)
  • OS別や個人別で比較する際に便利

📌 どっちを使えばいい?

  • 理論的に正しいのは穴アリ(基準水準あり)
     → 多重共線性を避け、相対効用値として解釈しやすい
  • 実務や可視化では穴ナシもよく使う
     → 全OSや全個人で効用値の表を横並びで比較できる

💡今回の記事では「穴ナシ」で計算しています。
これは「OSごとの効用値を表に揃えて比較しやすくする」ためです。

ただし理論的には「穴アリ(基準を落とす)」の方が正統派なので、
分析の目的に応じて使い分けてください。


6. 関数

6-1. 効用値を OS 別に計算する関数

  • df_merged:OS ごとに分割し、それぞれOLSで効用値を推定
  • 推定結果を「全水準リスト」で補完(欠けは0)
  • `列順を整理して1つのDataFrameにまとめる

💡ポイント

  • OSごとの効用値を比較でき、表形式で見やすく揃う

6-2. ダミー列作成関数

  • カテゴリデータ(OSや価格など)を 0/1 のダミー変数に展開
  • OLS回帰に投入するために必須の処理
❓なぜダミー変数を作るの❓

機械学習や統計モデルは 文字列カテゴリを直接扱えない ので、
「赤/青/緑」 → [1,0,0] / [0,1,0] / [0,0,1] のように数値化して渡します。

コンジョイント分析でも、属性水準をダミー変換することで
「どの水準がどれだけ効用を持つか」を数値で推定できるようになります。

❓get_dummies() の落とし穴:多重共線性とは❓

pd.get_dummies() を使うと、カテゴリ変数が複数の列に展開されますが、
すべての水準を含めると「多重共線性」のリスクがあります。

✅ 対策:drop_first=True を使う
1つの水準を「基準」として削除することで、共線性を回避できます。

pd.get_dummies(df[attributes], drop_first=True)

🧠 この設定は、「基準水準に対して、他の水準がどれだけ効用を持つか」を示す
相対効用値の解釈にもつながります。


6-3. 効用値推定関数

  • X:ダミー列(属性水準)
  • y:評価値(被験者の回答)
  • sm.OLS(y, X):回帰を実行し、各水準の効用値(part-worth)を算出

💡ポイント

  • ここで得られるのは 「どの属性水準がどれくらい好まれるか」 を表す数値。
  • OS別や個人別の集計にも再利用できる。
❓OLS(最小二乗法)とは❓

「説明変数の数」>「サンプル数」のような状況では、OLS は失敗しやすくなります。

💥 主な原因

  • 行列のランクが足りない(多重共線性)
  • 係数が推定できない(行列の逆行列が計算できない)
  • エラー例:Singular matrix(特異行列)

✅ 対策案

  1. Ridge回帰(L2正則化) に切り替える
    特にカテゴリが多く、ダミー変数が増えている場合に安定します。
from sklearn.linear_model import Ridge
model = Ridge(alpha=1.0)
model.fit(X, y)

🧠 モデルが失敗しても慌てずに、上記のような対応を検討してみてください。


7. OS(ブランド)別効用値の計算

  • estimate_util_by_OS() を呼び出し、OSごとの効用値を計算
  • 結果は、OSごとに各属性水準の効用値が並んだ表になります
出力例

💡ポイント

  • OS別に効用値を算出することで、ブランドごとのユーザーの好みの傾向を把握できる
  • 例えば「iPhoneユーザーはバッテリー大容量をより重視」などの比較が可能

各 OS(ブランド)ごとに回帰分析を行ったことで
属性水準ごとの効用値(=好まれ度) を一覧で比較できるようになりました。

これにより、どの属性がどのブランドユーザーに好まれているかを
数値で可視化できます。



#============================================
# 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')

8. ID別効用値(穴ナシ)

  • df_mergedIDごとにグループ化し、各被験者の効用値を推定
  • 穴ナシ:全水準をそろえ、欠けている水準は 0 で補完
  • 所有情報(OSなど)をマージして列順整理
  • 最終的に個々の被験者の効用値が1行ずつ揃った表 が作れる
出力例

効用値が似た値になる理由と注意点

1. なぜ似た値になるのか

直交表 L9 の構造

  • 各属性水準は均等に出現するように設計されているため、回帰計算上は「平均値がゼロになる」傾向が強くなります。

OLSでの効用値算出

  • ダミー変数にして回帰すると、水準間の差分が効用値として表れる
  • そのため、同じ属性の水準が複数の被験者に対して「0に近い値」になったり、プラス・マイナスが入れ替わるパターンがよく出ます。

2. 典型的なパターン

  • iPhone / Android / Galaxy のようなブランドで「1つがプラス、残りがマイナス」の分布が出やすい
  • バッテリーや画面サイズでも、好まれる水準はプラス、他はマイナスに相対評価される
  • 小さい分散の効用値は、サンプル数が少ないことや回答のばらつきの少なさからも起こります

3. 注意点

  • 「効用値が同じように見える=バグ」ではありません
  • 全体効用値の合計がゼロになるように正規化されていることが多く、このため ±0.25~0.5 といった値が並ぶことがあります
  • 個別被験者の値は、あくまで 相対的な好みの差 を示しています

💡まとめ

  • この表の値は、穴ナシ効用値(全水準揃え) として自然な範囲
  • 被験者間で似た傾向が出ているのは、データ構造や回帰処理上の特徴
  • もし「よりバラつきが欲しい」場合は、サンプル数を増やすか、回答のばらつきを大きくしたシミュレーションが有効

👉 各ユーザーごとの効用値が一目でわかります。
誰がどの属性を好むかがわかるので、パーソナライズされた嗜好分析に活用可能です。
また、この効用値を使えば、嗜好が似たユーザーグループをクラスタ分析で抽出することもできます。


#============================================
# 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()

9. OS別効用値のグラフ

  • warnings.catch_warnings():表示される警告を抑制

💡 ポイント

  • 棒グラフで属性ごとの効用値を OS別に比較できる
  • ブランドによる消費者の選好の違いを直感的に確認可能
出力例


#============================================
# 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()

10. ID別効用値ヒートマップ

  • cmap='coolwarm':効用値のプラス・マイナスが色で分かる
  • center=0 :ゼロを中立色にして、好まれる/好まれないを直感的に判別
    個人差の可視化やクラスタリング前の探索的分析に最適
出力例
  • 「このグループはバッテリー重視」「このグループは価格敏感」など、傾向が一目でわかる
  • クラスタリング前の探索的分析にも使える
  • 商品企画やマーケティング戦略の参考データとして活用可能

11. 折れ線グラフでブランドごとの差を確認

同じデータを使って、エクセルで折れ線グラフ化してみます。

💡 ポイント

  • 棒グラフは全体の傾向を把握するのに適している
  • 折れ線グラフはブランドごとのパターンの違いを追いやすい
  • グラフの形で「ブランド間の差」「属性の評価傾向」が直感的に理解可能

🔍 ブランド別効用値から読み取れること

  1. ブランドロイヤルティの存在

    • iPhoneユーザーはiPhoneを、AndroidユーザーはAndroidを高く評価
    • 自分のブランドへの好意が効用値に反映され、ブランドロイヤルティの強さを確認できる
  2. バッテリー容量の評価

    • 3000mAhは全体的にマイナス評価
    • 4000〜6500mAhはプラス評価、特に6500mAhが高め
    • ブランドによって評価の幅が異なるため、「単純に大容量がベスト」とは限らない
  3. 画面サイズの分化

    • 6インチは全ブランドで低評価
    • 5インチ(携帯性重視)と6.8インチ(視認性重視)で評価が分かれる
    • 「小型派」「大型派」の二極化が見られ、製品ラインナップ戦略に活用可能
  4. 価格の影響

    • 13万円は全体で高評価、6万円も安定してプラス
    • 24万円は全ブランドで低評価、高価格による抵抗感が強い
    • ブランド別で差があるため、ターゲット別戦略に注意

✨ 考察まとめ

  • ブランドロイヤルティが購買行動に大きく影響
  • バッテリー、画面サイズ、価格は「他ブランドへの乗り換えを促す条件」として機能
  • 特に画面サイズと価格は分かりやすい分岐点で、商品ラインナップ戦略に活かせる
  • 高価格帯は避けられるため、プレミアムモデル投入は慎重に判断

👉 棒グラフで全体傾向を把握し、折れ線グラフでブランドごとの差を追う
→ 「ユーザー全体の価値観」と「ブランドやセグメントごとの違い」を整理するのに最適


📌全体可視化まとめ

本記事で作成した可視化をまとめると、以下のような使い分けができます。

1. OS別効用値の棒グラフ

  • ブランドごとの属性水準の効用値を比較可能
  • 「iPhoneユーザーはバッテリー大容量を重視」など傾向が見える
  • 欠けている水準も0で補完されているため、表の列が揃いやすい

2. ID別効用値ヒートマップ

  • 個々の被験者の好みを色で可視化
  • 正の効用値は赤、負の効用値は青で直感的に把握
  • クラスタリング前の探索的分析や、パーソナライズ戦略の検討に最適

3. ブランド別折れ線グラフ

  • 全体傾向は棒グラフで把握
  • ブランドごとのパターンの違いは折れ線グラフで追いやすい
  • ブランドロイヤルティ、バッテリー・画面サイズ・価格の差を直感的に理解可能

✨ 全体可視化まとめ

  • 棒グラフ:全体傾向を把握
  • ヒートマップ:個人ごとの傾向を視覚化
  • 折れ線グラフ:ブランドごとの差を追う

これらを組み合わせることで、ユーザー全体の傾向とブランド・個人ごとの差を同時に理解可能です。


📌まとめ

今回は、Pythonでのコンジョイント分析を通して、
製品の各属性がユーザーの評価にどのように影響しているか を可視化・分析しました。

なかなか使う機会は少ないかもしれませんが、

  • 属性ごとの効用値を定量的に把握できる
  • ブランド別や個人別の嗜好の違いを視覚化できる
  • 商品企画やマーケティング戦略の参考になる

…といった点で、データ分析や意思決定に役立つ手法です。


次回の記事では、購買確率(choice probability) の計算と解釈について解説する予定です。
これにより、効用値から「どの商品が実際に選ばれやすいか」を予測できるようになります。
https://zenn.dev/swatchp/articles/00091c758cf102
https://zenn.dev/swatchp/articles/18c4615a44fa26


参考リンクについて

GitHubリポジトリ
本記事で紹介したコードやサンプルデータはこちらで公開しています。

Discussion