🏧

Python+Streamlitで属性別価格感度を可視化 - Van Westendorp PSM分析 #2

に公開

📌はじめに

前回はこの手法を、価格に対する消費者の主観的な受容範囲を可視化するVan Westendorp分析を紹介しました。
しかし、年齢や価値観、購買スタイルによって、適正価格の感覚は人によって異なることが予想されます。

そこで本記事では、Van Westendorp分析を 属性に基づきグループを分けて実施し、
ターゲット別の価格感度を可視化する方法 を紹介します。

前回のおさらい:Van Westendorpとは❓

Van Westendorp Price Sensitivity Meter(PSM分析)は、
価格に対する消費者の受け止め方を可視化するための手法です。

消費者に次の4つの質問を行い、価格に関する感覚を尋ねます

  1. 「安すぎて不安になる価格(Too Cheap)」
  2. 「お買い得だと感じる価格(Cheap)」
  3. 「やや高いが許容できる価格(Expensive)」
  4. 「高すぎて買えない価格(Too Expensive)」

それぞれの回答から累積分布を描くことで、次の4つの指標を導き出せます。

指標 意味
OPP Optimal Price Point(最適価格):買いやすいと感じる価格の中心
IDP Indifference Price Point(無関心価格):価格にあまり関心がない境目
PMC Point of Marginal Cheapness(価格受容範囲の下限)
PME Point of Marginal Expensiveness(価格受容範囲の上限)

これらの交点から、「どの価格帯なら買いたいと思うか」 という 主観的な受容範囲 を視覚的に示せます。


📌本記事のゴールと分析の流れ

前回紹介したVan Westendorp分析は、全体としての「価格に対する主観的な受容範囲」を可視化できる手法です。
しかし、全体平均だけを見ても次のような疑問が残ります。

これらの疑問を解消するために、属性情報に基づくグループ分け
(例:年齢層や価値観クラスタリング)
を行い、
グループごとの価格感度の違いを比較・可視化していきます。

👉 本記事のゴールは、
ターゲット別の価格感度を比較し、価格戦略に活かせる知見を得ること です。


📌分析フロー

  1. アンケートデータを読込
  2. 年齢・性別などの属性を抽出
  3. 属性に基づいてグループ分け(例:KMeansによるクラスタリング)
  4. 各グループに対して Van Westendorp 分析を実施
  5. ブランド別・グループ別に価格感度グラフを可視化
  6. 最適価格・受容価格帯をグループ間で比較・考察

📌 完成画面(UIイメージ)

1. 分析対象のデータと前処理(簡単に)

前回と同様に、Van Westendorp 分析用のアンケートデータ(価格に関する4項目+属性情報)を使用します。

  • 価格に関する4項目
    「高すぎる」「高い」「安い」「安すぎる」

  • 属性情報の例
    「年齢層」「性別」「平均購入単価」「購入頻度」 など

これらの属性をもとにクラスタリングを行い、グループごとの価格感度を分析します。

👉 詳細なデータ構成は 前回の記事をご参照ください。


2. クラスタリングの実装

価格感度は、年齢や性別といった属性によって異なる可能性があります。
そこで、回答者の 属性情報を特徴量としてクラスタリング(例:KMeans) を行い、クラスタごとの価格感度を可視化していきます。


📌 コード解説

# ------------------------
# 0. ライブラリ
# ------------------------
import streamlit as st
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.cluster import KMeans
import plotly.graph_objects as go
from scipy.interpolate import interp1d

# ------------------------
# 1. グローバル列名
# ------------------------
GLB_AGE = '年齢'
GLB_GEN = '性別'
GLB_JOB = '職業'
GLB_CHAR = 'キャラ傾向'
GLB_FREQ = '購買頻度'
GLB_STYLE = '購入スタイル'
GLB_IMPORT = '重要視すること'
GLB_SNS = 'SNS利用時間'
GLB_AVG = '平均購入単価'

# ------------------------
# 2. Streamlit設定
# ------------------------
st.set_page_config(layout="wide")
st.title("💴 Van Westendorp PSM + クラスタリング分析アプリ")

# ------------------------
# 3. 関数定義
# ------------------------
@st.cache_data
def load_data(uploaded_file):
    return pd.read_csv(uploaded_file)

def find_intersection(y1, y2, x):
    diff = np.array(y1) - np.array(y2)
    sign_change = np.where(np.diff(np.sign(diff)) != 0)[0]
    if len(sign_change) == 0:
        return None
    i = sign_change[0]
    try:
        f = interp1d(diff[i:i+2], x[i:i+2])
        return float(f(0))
    except:
        return None

def apply_filters(df):
    """セッションステートに基づきデータをフィルタリング"""
    filtered = df.copy()

    # 数値スライダー系
    if GLB_AGE in df.columns and "selected_age_range" in st.session_state:
        filtered = filtered[filtered[GLB_AGE].between(*st.session_state["selected_age_range"])]
    if GLB_SNS in df.columns and "selected_sns" in st.session_state:
        filtered = filtered[filtered[GLB_SNS].between(*st.session_state["selected_sns"])]
    if GLB_AVG in df.columns and "selected_average_bands" in st.session_state:
        filtered = filtered[filtered[GLB_AVG].between(*st.session_state["selected_average_bands"])]

    # カテゴリマルチセレクト系
    for col, key in [(GLB_GEN,"selected_gender"), (GLB_JOB,"selected_jobs"),
                     (GLB_FREQ,"selected_frequency"), (GLB_CHAR,"selected_character"),
                     (GLB_IMPORT,"selected_importance")]:
        if col in df.columns and key in st.session_state:
            filtered = filtered[filtered[col].isin(st.session_state[key])]

    # 購入スタイル
    if GLB_STYLE in df.columns:
        style_options = df[GLB_STYLE].dropna().unique().tolist()
        selected_style = [s for s in style_options if st.session_state.get(f"selected_style_{s}", True)]
        if selected_style:
            filtered = filtered[filtered[GLB_STYLE].isin(selected_style)]

    return filtered

0. ライブラリ

  • Streamlit:WebアプリのUIを構築
  • scikit-learn(KMeans, StandardScaler, LabelEncoder):クラスタリングと前処理
  • SciPy:補間処理による価格感度曲線の交点計算
  • Plotly:グラフ描画

👉 可視化・分析・UIまで一気通貫で作れる組み合わせになっています。


1. グローバル列名

アンケートの属性列(年齢・性別など)を 定数としてまとめて定義 しています。

  • 後の処理で「文字列を直書き」しなくて済む
  • 列名が変わったときも修正箇所が一箇所で済む
  • 可読性・保守性が上がる

2. Streamlit設定

UIまわりは最初にしっかり整えておきます。

  • st.set_page_config(layout="wide")
    グラフや比較結果を並べやすくするために、横幅いっぱいに表示。
  • st.title("💴 Van Westendorp PSM + クラスタリング分析アプリ"):タイトルを設定
出力例


3. 関数定義

アプリの「頭脳」にあたる部分です。

  • @st.cache_data:同じファイルを繰り返し読み込むときにキャッシュして処理を高速化。

  • def find_intersection(y1, y2, x)
    2本の曲線(例:Too Expensive と Cheap の曲線)の交点を補間して計算する仕組み。

    • 差分の符号が変化する場所を検出
    • その区間を補間して「正確にどの価格で交わるか」を算出

➡️ これらを組み合わせることで、Van Westendorp 分析における
OPP / IDP / PMC / PME(主要な交点指標) を計算することができます。


# ------------------------
# 4. CSVアップロード
# ------------------------
uploaded_file = st.file_uploader("📂 CSVファイルをアップロード", type=["csv"])
if uploaded_file is not None:
    df = load_data(uploaded_file)
    st.markdown("---")
    st.markdown("#### 🔍 絞り込みフィルター")

    # ------------------------
    # 5.全フィルター解除ボタン
    # ------------------------
    if st.button("🚿 全フィルター解除"):
        if GLB_AGE in df.columns:
            st.session_state["selected_age_range"] = (int(df[GLB_AGE].min()), int(df[GLB_AGE].max()))
        if GLB_SNS in df.columns:
            st.session_state["selected_sns"] = (int(df[GLB_SNS].min()), int(df[GLB_SNS].max()))
        if GLB_AVG in df.columns:
            st.session_state["selected_average_bands"] = (int(df[GLB_AVG].min()), int(df[GLB_AVG].max()))
        for col, key in [(GLB_GEN,"selected_gender"), (GLB_JOB,"selected_jobs"),
                         (GLB_FREQ,"selected_frequency"), (GLB_CHAR,"selected_character"),
                         (GLB_IMPORT,"selected_importance")]:
            if col in df.columns:
                st.session_state[key] = df[col].dropna().unique().tolist()
        if GLB_STYLE in df.columns:
            for s in df[GLB_STYLE].dropna().unique().tolist():
                st.session_state[f"selected_style_{s}"] = True
        #st.experimental_rerun()

    # ------------------------
    # 6. フィルターUI
    # ------------------------
    col1, col2, col3 = st.columns(3)

    # col1: 年齢・性別・職業・平均購入単価・SNS
    with col1:
        if GLB_AGE in df.columns:
            st.session_state.setdefault("selected_age_range", (int(df[GLB_AGE].min()), int(df[GLB_AGE].max())))
            st.session_state["selected_age_range"] = st.slider(f"🔍 {GLB_AGE}",
                                                               int(df[GLB_AGE].min()),
                                                               int(df[GLB_AGE].max()),
                                                               st.session_state["selected_age_range"])
        if GLB_GEN in df.columns:
            st.session_state.setdefault("selected_gender", df[GLB_GEN].dropna().unique().tolist())
            st.session_state["selected_gender"] = st.multiselect(f"🔍 {GLB_GEN}",
                                                                 df[GLB_GEN].dropna().unique().tolist(),
                                                                 default=st.session_state["selected_gender"])
        if GLB_JOB in df.columns:
            st.session_state.setdefault("selected_jobs", df[GLB_JOB].dropna().unique().tolist())
            st.session_state["selected_jobs"] = st.multiselect(f"🔍 {GLB_JOB}",
                                                               df[GLB_JOB].dropna().unique().tolist(),
                                                               default=st.session_state["selected_jobs"])
        if GLB_AVG in df.columns:
            st.session_state.setdefault("selected_average_bands", (int(df[GLB_AVG].min()), int(df[GLB_AVG].max())))
            st.session_state["selected_average_bands"] = st.slider(f"🔍 {GLB_AVG}",
                                                                    int(df[GLB_AVG].min()),
                                                                    int(df[GLB_AVG].max()),
                                                                    st.session_state["selected_average_bands"])
        if GLB_SNS in df.columns:
            st.session_state.setdefault("selected_sns", (int(df[GLB_SNS].min()), int(df[GLB_SNS].max())))
            st.session_state["selected_sns"] = st.slider(f"🔍 {GLB_SNS}",
                                                          int(df[GLB_SNS].min()),
                                                          int(df[GLB_SNS].max()),
                                                          st.session_state["selected_sns"])

    # col2: キャラ傾向・重要視すること・購買頻度
    with col2:
        for col, key, label in [(GLB_CHAR,"selected_character",GLB_CHAR),
                                (GLB_IMPORT,"selected_importance",GLB_IMPORT),
                                (GLB_FREQ,"selected_frequency",GLB_FREQ)]:
            if col in df.columns:
                st.session_state.setdefault(key, df[col].dropna().unique().tolist())
                st.session_state[key] = st.multiselect(f"🔍 {label}", df[col].dropna().unique().tolist(),
                                                       default=st.session_state[key])

    # col3: 購入スタイル
    with col3:
        if GLB_STYLE in df.columns:
            style_options = df[GLB_STYLE].dropna().unique().tolist()
            st.markdown(f"🔍 {GLB_STYLE}")
            for s in style_options:
                st.session_state.setdefault(f"selected_style_{s}", True)
            c1, c2 = st.columns(2)
            with c1:
                if st.button("✅ 全て選択", key="style_all_select"):
                    for s in style_options: st.session_state[f"selected_style_{s}"] = True
            with c2:
                if st.button("❌ 全て解除", key="style_all_clear"):
                    for s in style_options: st.session_state[f"selected_style_{s}"] = False
            # 個別チェックボックス
            selected_style = []
            for s in style_options:
                key_name = f"selected_style_{s}"
                checked = st.checkbox(s, key=key_name)
                if checked:
                    selected_style.append(s)

4. CSVアップロード

  • st.file_uploader : ユーザーが手元のCSVファイルをアップロードできるUIを提供できます。
  • アップロードされたファイルは、そのままデータフレームとして読み込まれ、分析の入力データとして利用可能です。
出力例


5. 全フィルター解除ボタン

  • st.button("🚿 全フィルター解除") : ボタンを押すと、すべてのフィルター条件を初期状態にリセットできます
  • 数値系属性(年齢、SNS利用時間、平均購入単価):最小値〜最大値に戻す
  • カテゴリ系属性(性別、職業、購買頻度、キャラ傾向、重要視すること):
    → 全選択状態に戻す
  • 購入スタイル :すべてTrue(有効)に戻す

👉 UIをいろいろ触っても「すぐ戻せる安心感」があると、ユーザーが試行錯誤しやすくなります。

出力例


6. フィルターUI

データの属性ごとに3カラムで整理してUIを構成しています。

  • col1:年齢、性別、職業、平均購入単価、SNS利用時間

    • st.slider:スライダー設定
    • st.multiselect:ユーザーがクラスタリングに使う属性を複数選択可能
  • col2:キャラ傾向、重要視すること、購買頻度

    • st.multiselect:複数選択でクラスタを制御
  • col3:購入スタイル

    • st.checkbox:スタイルごとに個別ON/OFF
    • 「✅ 全て選択」「❌ 全て解除」ボタンで一括制御可能

👉 この構成により、ユーザーは「条件を組み合わせてターゲットを絞り込み → そのグループで価格感度を分析」できるようになっています。

出力例


選択された属性はクラスタリングの入力データに使われます。
カテゴリ変数(例:性別・職業など)は後でラベルエンコーディングして数値に変換し、クラスタリングに投入されます。


    # ------------------------
    # 7.フィルター適用
    # ------------------------
    st.markdown("---")
    filtered_df = apply_filters(df)
    st.markdown(f"#### <フィルター後の対象者数: {len(filtered_df)} 人>")

    # ------------------------
    # 8.KMeansクラスタリング設定
    # ------------------------
    st.markdown("### 🧩 クラスタリング設定")
    candidate_features = [GLB_AGE, GLB_GEN,GLB_JOB,GLB_FREQ,GLB_AVG, GLB_STYLE, GLB_IMPORT]
     #candidate_features = ['年齢', '性別','職業','購買頻度','平均購入単価', '購入スタイル', '重要視すること']
    selected_features = st.multiselect("クラスタリングに使う属性を選択してください",
                                       candidate_features, default=candidate_features)

    if len(selected_features) == 0:
        st.warning("少なくとも1つ以上の特徴量を選択してください。")
    else:
        cluster_count = st.slider("クラスタ数 (K)", 2, 10, 3)
        X = filtered_df[selected_features].copy()
        for col in X.columns:
            if X[col].dtype == 'object' or X[col].dtype.name == 'category':
                le = LabelEncoder()
                X[col] = le.fit_transform(X[col].astype(str))
        X_scaled = StandardScaler().fit_transform(X)
        kmeans = KMeans(n_clusters=cluster_count, random_state=42)
        clusters = kmeans.fit_predict(X_scaled)
        filtered_df['cluster'] = clusters

        # ------------------------     
        # 9.クラスタ概要表示
        # ------------------------     
        st.markdown(f"#### クラスタリング結果(K={cluster_count})")
        st.write(filtered_df[['ID'] + selected_features + ['cluster']])

        # クラスタ別サマリ
        num_clusters = filtered_df['cluster'].nunique()
        num_features = [GLB_AGE, GLB_SNS, GLB_AVG]
        cat_features = [GLB_GEN, GLB_JOB, GLB_FREQ,GLB_IMPORT,GLB_STYLE, GLB_CHAR]
        #num_features = ['年齢', 'SNS利用時間', '平均購入単価']
        #at_features = ['性別', '職業', '購買頻度',"重要視すること","購入スタイル", 'キャラ傾向']

        rows = []
        for c in range(num_clusters):
            cluster_df = filtered_df[filtered_df['cluster']==c]
            row = {"クラスタ":c, "人数":len(cluster_df)}
            for f in num_features:
                if f in cluster_df.columns:
                    row[f"{f}平均"] = round(cluster_df[f].mean(),2)
            for f in cat_features:
                if f in cluster_df.columns:
                    top_val = cluster_df[f].value_counts(normalize=True).idxmax()
                    top_ratio = cluster_df[f].value_counts(normalize=True).max()
                    row[f"{f}(最多)"] = f"{top_val} ({top_ratio:.1%})"
            rows.append(row)
        summary_df = pd.DataFrame(rows)
        st.dataframe(summary_df)

7. フィルター適用

  • apply_filters(df):UIで指定した条件に従ってデータを絞り込み
  • st.markdown(f"... {len(filtered_df)} 人>")
    絞り込み後の対象者数を表示し、サンプル数の確認可能です。

👉 サンプル数を意識しながらクラスタリングできるのは、実務でありがちな「データが少なすぎて解釈が危うい」状況を防ぐ工夫です。

出力例


8. KMeansクラスタリング設定

このステップでは、クラスタリングの前処理と設定を行います。

  • 特徴量の選択
    ユーザーがクラスタリングに使う属性を st.multiselect で柔軟に指定可能
  • クラスタ数 (K)
    スライダーで2〜10の範囲を選択。試行錯誤しやすくする設計
  • 前処理
    • LabelEncoder:カテゴリ変数を数値化(性別や職業など)
    • StandardScaler:スケーリングして特徴量のスケール差を吸収
  • KMeans実行
    各回答者にクラスタ番号を付与し、filtered_df['cluster'] に格納

👉 文字列を数値に変換したり正規化したりする工程は、クラスタリングを正しく機能させるための「下ごしらえ」です。
UIから選択できる仕様にしてあるので、ユーザーは 「どの属性でグループが分かれるか」 を直感的に試せます。

出力例


9. クラスタ概要表示

クラスタリングの結果を「どんなグループに分かれたか」という視点でまとめます。

  • 個別確認

    • st.write で各回答者の属性とクラスタ番号を表形式で表示
    • どの回答者がどのクラスタに属するかを確認可能
  • クラスタ別サマリ

    • 数値属性(例:年齢、SNS利用時間、平均購入単価):平均値を計算
    • カテゴリ属性(例:性別、職業、購買頻度):最頻カテゴリ+比率を表示
    • st.dataframe(summary_df) によってクラスタごとの特徴を俯瞰できる

👉 このステップで「クラスタ0は20代女性が中心」「クラスタ1はSNS利用時間が長い層」といったターゲット像が浮かび上がります。

出力例


        # ------------------------
        # 10.クラスタ別 PSM分析
        # ------------------------
        st.markdown("---")
        st.markdown("#### 📊 クラスタ別 Van Westendorp PSM分析")

        show_lines = st.checkbox("📊 指標の補助線とラベルを表示/非表示", value=True, key="show_lines_checkbox")
               
        labels = ['too_cheap', 'cheap', 'expensive', 'too_expensive']
        brands = sorted(set(col.split('_')[0] for col in df.columns if any(lbl in col for lbl in labels)))
        cluster_tabs = st.tabs([f"Cluster {i}" for i in range(cluster_count)])

        for cluster_id, tab in zip(range(cluster_count), cluster_tabs):
            with tab:
                st.markdown(f"##### Cluster {cluster_id} の分析")
                df_cluster = filtered_df[filtered_df['cluster']==cluster_id]
                results = []

                for brand in brands:
                    brand_cols = [f"{brand}_{lbl}" for lbl in labels if f"{brand}_{lbl}" in df_cluster.columns]
                    df_brand = df_cluster[df_cluster[brand_cols].notnull().any(axis=1)]
                    if df_brand.empty:
                        st.warning(f"{brand} のデータがありません。")
                        continue

                    price_data = {label: df_brand[f"{brand}_{label}"].dropna().astype(int).values
                                  for label in labels if f"{brand}_{label}" in df_brand.columns}
                    valid_arrays = [arr for arr in price_data.values() if len(arr) > 0]
                    if not valid_arrays:
                        st.warning("有効な価格データがありません。")
                        continue

                    all_prices = np.arange(min(np.concatenate(valid_arrays)),
                                           max(np.concatenate(valid_arrays))+1000, 100)
                    n = len(df_brand)
                    cumulative = {
                        'too_cheap':[np.sum(price_data.get('too_cheap',[])>=p)/n for p in all_prices],
                        'cheap':[np.sum(price_data.get('cheap',[])>=p)/n for p in all_prices],
                        'expensive':[np.sum(price_data.get('expensive',[])<=p)/n for p in all_prices],
                        'too_expensive':[np.sum(price_data.get('too_expensive',[])<=p)/n for p in all_prices]
                    }

                    opp = find_intersection(cumulative['cheap'], cumulative['expensive'], all_prices)
                    idp = find_intersection(cumulative['too_cheap'], cumulative['too_expensive'], all_prices)
                    pme = find_intersection(cumulative['cheap'], cumulative['too_expensive'], all_prices)
                    pmc = find_intersection(cumulative['expensive'], cumulative['too_cheap'], all_prices)

                    # グラフ生成
                    fig = go.Figure()
                    fig.add_trace(go.Scatter(x=all_prices, y=np.array(cumulative['too_cheap'])*100,
                                             name='Too Cheap', line=dict(color='blue')))
                    fig.add_trace(go.Scatter(x=all_prices, y=np.array(cumulative['cheap'])*100,
                                             name='Cheap', line=dict(color='green')))
                    fig.add_trace(go.Scatter(x=all_prices, y=np.array(cumulative['expensive'])*100,
                                             name='Expensive', line=dict(color='orange')))
                    fig.add_trace(go.Scatter(x=all_prices, y=np.array(cumulative['too_expensive'])*100,
                                             name='Too Expensive', line=dict(color='red')))

                    for val, name, color in zip([opp,idp,pme,pmc],
                                                ['OPP','IDP','PME','PMC'],
                                                ['purple','black','magenta','cyan']):
                        if val and show_lines:  # ←ここでチェック
                            fig.add_vline(x=val, line_dash='dash', line_color=color)
                            fig.add_annotation(x=val, y=50, text=name, showarrow=False, textangle=90,
                                               font=dict(color=color,size=12), bgcolor='white')

                    fig.update_layout(title=f"{brand} - PSM分析",
                                      xaxis_title="価格(円)",
                                      yaxis_title="累積比率(%)",
                                      height=400,
                                      hovermode="x unified",
                                      xaxis=dict(tickformat='d'))

                    # 表示
                    col_plot, col_info = st.columns([3,1])
                    with col_plot:
                        st.plotly_chart(fig, use_container_width=True)
                    with col_info:
                        st.markdown(f"**{brand} の該当人数:{df_brand.shape[0]}人**")
                        st.write(f"📌 OPP(最適価格): {round(opp) if opp else '計算不可'} 円")
                        st.write(f"📌 IDP(無関心価格): {round(idp) if idp else '計算不可'} 円")
                        st.write(f"📌 PMC(下限): {round(pmc) if pmc else '計算不可'} 円")
                        st.write(f"📌 PME(上限): {round(pme) if pme else '計算不可'} 円")

                    results.append({"ブランド":brand,"該当人数":df_brand.shape[0],
                                    "最適価格(OPP)":round(opp) if opp else None,
                                    "無関心価格(IDP)":round(idp) if idp else None,
                                    "価格受容範囲下限(PMC)":round(pmc) if pmc else None,
                                    "価格受容範囲上限(PME)":round(pme) if pme else None})

                # クラスタ内全ブランド表
                if results:
                    st.markdown("---")
                    st.markdown("#### クラスタ内ブランド別 PSM指標一覧")
                    df_result = pd.DataFrame(results)
                    st.dataframe(df_result.style.format({col:"{:.0f}" for col in df_result.columns if col!="ブランド"}))])

10.クラスタ別 PSM分析

クラスタリングしたグループごとに Van Westendorp の価格受容曲線 を描きます。
タブ切り替えでクラスタを選び、それぞれのブランドについて「価格感度」を確認できます。

  • タブ構成st.tabs([f"Cluster {i}" ...]) によって、各クラスタごとに独立した画面を用意
  • 分析対象
    • Too Cheap / Cheap / Expensive / Too Expensive の 4系列を取得
    • 累積分布を計算し、曲線の交点から OPP / IDP / PMC / PME を導出
  • UI工夫
    • st.checkbox によって交点の補助線やラベルの ON/OFF を切替可能
    • 各ブランドの人数や指標値を横に表示して「解釈しやすさ」を優先
累積比率の考え方
  • too_cheap:この価格以上で「安すぎる」と感じる人の割合
  • cheap:この価格以上で「安い」と感じる人の割合
  • expensive:この価格以下で「高い」と感じる人の割合
  • too_expensive:この価格以下で「高すぎる」と感じる人の割合

➡️ これら4曲線の交点から PSM の代表的な指標を計算します。

Van Westendorp指標の意味
  • OPP(Optimal Price Point):安いと感じる割合と高いと感じる割合が交わる点
  • IDP(Indifference Price Point):安すぎると感じる割合と高すぎると感じる割合が交わる点
  • PMC(下限):高いと感じる人と安すぎる人の交点
  • PME(上限):安いと感じる人と高すぎる人の交点
  • Plotly:価格受容曲線を描画し、交点を補助線で可視化
  • st.checkbox("📊 指標の補助線とラベルを表示/非表示") :補助線やラベルのON/OFF切替可能
出力例


        # ------------------------
        # 11.フィルター前ブランド別PSM集計(全体)
        # ------------------------
        results_before_filter = []
        for brand in brands:
            brand_cols = [f"{brand}_{lbl}" for lbl in labels if f"{brand}_{lbl}" in df.columns]
            df_brand = df[df[brand_cols].notnull().any(axis=1)]
            if df_brand.empty:
                continue

            price_data = {label: df_brand[f"{brand}_{label}"].dropna().astype(int).values
                          for label in labels if f"{brand}_{label}" in df_brand.columns}
            valid_arrays = [arr for arr in price_data.values() if len(arr) > 0]
            if not valid_arrays:
                continue

            all_prices = np.arange(min(np.concatenate(valid_arrays)),
                                   max(np.concatenate(valid_arrays))+1000, 100)
            n = len(df_brand)
            cumulative = {
                'too_cheap':[np.sum(price_data.get('too_cheap',[])>=p)/n for p in all_prices],
                'cheap':[np.sum(price_data.get('cheap',[])>=p)/n for p in all_prices],
                'expensive':[np.sum(price_data.get('expensive',[])<=p)/n for p in all_prices],
                'too_expensive':[np.sum(price_data.get('too_expensive',[])<=p)/n for p in all_prices]
            }

            opp = find_intersection(cumulative['cheap'], cumulative['expensive'], all_prices)
            idp = find_intersection(cumulative['too_cheap'], cumulative['too_expensive'], all_prices)
            pme = find_intersection(cumulative['cheap'], cumulative['too_expensive'], all_prices)
            pmc = find_intersection(cumulative['expensive'], cumulative['too_cheap'], all_prices)

            results_before_filter.append({"ブランド":brand,"OPP":opp,"IDP":idp,"PMC":pmc,"PME":pme})

        st.markdown("---")
        with st.expander("📋 フィルター前 ブランド別 PSM 指標一覧(全体集計)", expanded=False):
            st.markdown(f"**全体調査人数:{len(df)}人**")
            summary_df_before = pd.DataFrame(results_before_filter)
            st.dataframe(summary_df_before.style.format({col:"{:.0f}" for col in summary_df_before.columns if col!="ブランド"}))

11. フィルター前ブランド別PSM集計(全体)

このステップでは、フィルターやクラスタリングを適用する前の全体データを使って、ブランド別の価格感度を把握します。

  • ブランドごとの OPP / IDP / PMC / PME を算出
  • 全体調査人数も表示して、サンプル規模を確認可能
出力例


📌 UIを使う

ターミナル(PowerShellなど)を開き、psm_ui_app.py を保存したフォルダに移動して以下のコマンドを実行します。

PS C:\Users\user> cd C:\Users\user\aa.pyが保存されてるフォルダ
PS C:\Users\user\aa.pyが保存されてるフォルダ> streamlit run psm_ui_app2.py

数秒でブラウザが起動し、CSVファイルをアップロードする画面が表示されます。

アップロードが完了すると、Van Westendorp価格感度モデルに基づくインタラクティブな分析結果が確認できます。


📌比較・考察

1️⃣全体平均のPSM指標(フィルターなし)

全体のPSM結果を基準として、クラスタごとの価格感覚の違いを比較します。


各クラスタの特徴と価格観の傾向(例)

クラスタ 構成・特徴 価格観・傾向 マーケティング示唆
0
金額にシビアな堅実派
(n=26)
- 男性・会社員中心
- 購買頻度:中
- 購入スタイル:金額を決めて購入
- 価格レンジに敏感
- 下限・最適価格がやや高め
- コスパ重視訴求
- バリューモデルの訴求が効果的
1
高価格でも映え重視の学生
(n=31)
- 女性・学生中心
- SNS利用時間が長い
- 「いくらでも買う」派が多い
- 価格に寛容だが、映え・共感重視
- 上限価格が高め
- デザイン/映え重視訴求
- SNSプロモーションが有効
2
今っぽさ重視の高単価会社員
(n=11)
- 男性・会社員中心
- 「今っぽい」を重視
- 衝動買い傾向あり
- 平均購入単価が最も高い
- 受容価格レンジが広い
- プレミアム訴求
- 限定モデル・トレンド展開で単価UP狙い
3
価格重視のナチュラル志向学生
(n=22)
- 女性・学生中心
- 購買頻度:低め
- 金額を決めて購入
- 下限・最適価格が全体より低め
- 価格に非常に敏感
- 低価格施策/学割
- 明確な価格メリットの提示が効果的

クラスタ別 マーケティング戦略整理表

クラスタ タイプ・特徴 推奨価格帯 訴求軸例 有効なチャネル例
0 堅実派・価格に敏感 5,000〜7,000円 - コスパNo.1
- 長持ちバッテリーで節約上手
Amazonセール
価格比較サイト広告
1 映え重視・価格に寛容な学生 13,000〜15,000円 - SNSで映える限定カラー
- 推し活にぴったりなモデル
Instagram・TikTokのインフルエンサー施策
2 トレンド志向・高価格許容な会社員 15,000円以上 - 都会的で“今っぽい”新シリーズ
- 限定・数量限定モデル
公式EC
メルマガ先行販売
限定イベント
3 学生中心・価格重視 、5,000〜6,000円 - 学生限定割引
- 買い替え応援プラン
LINE広告
大学周辺のOOH広告

参考:🔍クラスタ0を考察


クラスタ0

  • 全ブランドで「最適価格」が6,600〜6,940円で収束 → コスパ重視で価格に敏感
  • 無関心価格(IDP)が低めで、価格に対する反応がシビア
  • 受容範囲(PMC〜PME)は平均約2,000円程度で、他クラスタより狭い可能性あり
項目 内容
価格戦略 上限7,000円以下に収まる価格設定(例:6,500円)
訴求軸 「長く使える」「壊れにくい」「必要十分」など、堅実・実用性の価値訴求
チャネル 家電量販店のチラシ・ECサイトのセール枠・価格比較サイトなど、価格重視層が集まる場所
販促コピー例 「この価格でこの機能?コスパ最強!」「ムダを省いたちょうどいい選択」

2️⃣フィルターを使って絞り込み

① 購入スタイルで「衝動買いしそうなタイプ」に絞る

② クラスタリング設定から購入スタイルを省く

③ クラスタ数を3に設定

④フィルター後の情報が表示される


  • フィルター後のVan Westendorp分析では、全体集計に比べて最適価格帯が一様に下がる傾向
  • 「衝動買いしそう」と予測されるクラスタでも、価格の現実的な制約は無視できない
  • 高すぎる価格設定では購買機会を逃すリスクがある

🔍考察

① 「衝動買いしそうな人」でも、価格には現実的な制約がある
アンケートでは「欲しいものはいくらでも買う」と答える人がいても、PSM分析では全ブランドの希望価格が低めに出る傾向があります。

購買意欲が高くても、実際に支払える金額には限りがある。可処分所得が購買行動の大きな制約になっている。

❓可処分所得とは❓

可処分所得 = 収入(給与など)- 税金や社会保険料

つまり、実際に「自由に使えるお金」のことです。
生活費・趣味・買い物・貯金などに使える「自由に使えるお金」のことです。


② ライフステージ(学生・若手社会人)が価格感度に影響
多くが学生や就職したばかりで収入が限られるため、高価格帯のブランドは手が届きにくく、購入をあきらめる可能性があります。

高価格路線をとると、学生や若手社会人の取りこぼしリスクが高まる。


③ PSM単独では見えない「人物像」や「態度変容」までクラスタリングで把握可能
価格の好みの背景にある生活スタイルや価値観を理解することで、価格設定の理由やアプローチの方向性をより具体的に考えられます。

❓態度変容とは❓

ある対象(商品、ブランド、行動など)に対する「好き・嫌い」「良い・悪い」といった心の反応が変わることを指します。
広告やキャンペーンを通じて、その商品に対する態度を良い方向へ変えることが目的です。

例:
最初は「高いし微妙」と思っていたスマホブランドが、友達の勧めやCMで「かっこいいかも!」と思うようになる → 態度変容

ABCモデル(態度の3要素):

  • Affect(感情):好き・嫌い
  • Behavior(行動):買う・買わない
  • Cognition(認知):知っている・知らない、理解している
    「態度変容」は、これらのいずれかが変化することを指します。


📌 まとめ

属性ごとのグループ分け(クラスタリング)と価格感覚(PSM)を組み合わせることで、
単なる数字だけじゃなくて**「どんな人がどんな価格を好むか」** がより具体的に見えてきます。

つまり、
誰がどんな価格で買いたいか を理解することで、販売戦略やマーケティング施策の精度をぐっと高められることがわかる分析でした。

注意点
  • 今回の例では、クラスタ0は絞り込み後で11人しかいなかったため、
    PSMの結果はあくまで参考程度
  • サンプル数が少ないクラスタは、解釈に慎重さが必要です

参考リンクについて

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

Discussion