0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Python×Streamlit】クラスタリング×価格感度で読み解くターゲット別の最適価格(PSM分析 #2)

Last updated at Posted at 2025-08-02

1. 前回のおさらい: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(価格受容範囲の上限)

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

前回の記事:
【Python×Streamlit】Van Westendorpで“ちょうどいい価格”を可視化してみた(PSM分析 #1)


前回はこの手法を、全体データに対して平均的に集計して分析しました。
しかし実際には、年齢や価値観、購買スタイルによって 「適正価格の感覚」にはばらつき があることが予想されます。

そこで今回は、このVan Westendorp分析を属性ごとにクラスタリングした上で適用し、
ターゲット別の価格感度を可視化する方法を紹介していきます。

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

前章では、Van Westendorp分析が「価格に対する主観的な受容範囲」を可視化できる手法であることを紹介しました。

しかし、全体平均だけを見ても、次のような疑問が残ります。

  • 年齢層によって、価格感度は変わるのでは?

  • 性別や重視するポイントによって、理想の価格に違いはあるのでは

  • 同じ製品でも、価値観の異なるグループごとに「適正価格」が違うのでは?

これらの疑問を解消するために、以下のような流れで属性情報に基づくクラスタリング分析を行い、グループごとの価格感度の違いを比較・可視化していきます。

🔄 分析フロー

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

🔄 本記事のゴール

  • 年齢・性別・その他属性をもとに顧客をクラスタリング

  • 各クラスタに対してVan Westendorp分析を適用

  • クラスタごとに「適正価格の違い」をグラフで比較

  • ブランド別のターゲティングや価格戦略の示唆を得る

本記事で紹介する分析は、すべてPython + Streamlitアプリ上で動作します。
グラフの切り替え、属性の絞り込み、クラスタ数の調整などを、インタラクティブに操作できる設計となっています。

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

image.png

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

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

  • 使用する価格項目
     「高すぎる」「高い」「安い」「安すぎる」
  • 使用する属性情報
     「年齢層」「性別」「平均購入単価」「購入頻度」など

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

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

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

💡 分析の目的

価格感度は年齢や性別などの属性によって異なる可能性があります。
そこで、アンケート回答者を属性ベースでクラスタリングし、クラスタごとの価格感度を可視化します。

🧮 フィルター設定等

👇 前回と同じコードを使用しているため、解説は省略します。

コード
import streamlit as st
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
import plotly.graph_objects as go
from scipy.interpolate import interp1d

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

@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 Exception:
        return None

uploaded_file = st.file_uploader("📂 CSVファイルをアップロード", type=["csv"])

if uploaded_file is not None:
    df = load_data(uploaded_file)
    st.markdown("#### 🔍 絞り込みフィルター")
    col1, col2, col3 = st.columns(3)
    
    with col1:
        if '年齢' in df.columns:
            min_age = int(df['年齢'].min())
            max_age = int(df['年齢'].max())
            selected_age_range = st.slider("🔍 年齢範囲", min_age, max_age, (min_age, max_age))
        else:
            selected_age_range = None
    
        # 性別フィルター
        if '性別' in df.columns:
            gender_options = df['性別'].dropna().unique().tolist()
            if 'selected_gender' not in st.session_state:
                st.session_state.selected_gender = gender_options
            selected_gender = st.multiselect(
                "🔍 性別", options=gender_options, default=st.session_state.selected_gender
            )
            st.session_state.selected_gender = selected_gender
        else:
            selected_gender = None
    
        # 職業フィルター
        if '職業' in df.columns:
            job_options = df['職業'].dropna().unique().tolist()
            if 'selected_jobs' not in st.session_state:
                st.session_state.selected_jobs = job_options
            selected_jobs = st.multiselect(
                "🔍 職業", options=job_options, default=st.session_state.selected_jobs
            )
            st.session_state.selected_jobs = selected_jobs
        else:
            selected_jobs = None
    
        if '平均購入単価' in df.columns:
            min_price = int(df['平均購入単価'].min())
            max_price = int(df['平均購入単価'].max())
            selected_average_bands = st.slider("🔍 平均購入単価", min_price, max_price, (min_price, max_price))
        else:
            selected_average_bands = None
    
        if 'SNS利用時間' in df.columns:
            min_sns = int(df['SNS利用時間'].min())
            max_sns = int(df['SNS利用時間'].max())
            selected_sns = st.slider("🔍 SNS利用時間", min_sns, max_sns, (min_sns, max_sns))
        else:
            selected_sns = None
    
    with col2:
        # キャラ傾向フィルター
        if 'キャラ傾向' in df.columns:
            char_options = df['キャラ傾向'].dropna().unique().tolist()
            if 'selected_character' not in st.session_state:
                st.session_state.selected_character = char_options
            selected_character = st.multiselect(
                "🔍 キャラ傾向", options=char_options, default=st.session_state.selected_character
            )
            st.session_state.selected_character = selected_character
        else:
            selected_character = None
    
        # 重要視すること1 フィルター
        if '重要視すること' in df.columns:
            imp_options = df['重要視すること'].dropna().unique().tolist()
            if 'selected_importance' not in st.session_state:
                st.session_state.selected_importance = imp_options
            selected_importance = st.multiselect(
                "🔍 重要視すること", options=imp_options, default=st.session_state.selected_importance
            )
            st.session_state.selected_importance = selected_importance
        else:
            selected_importance = None
    
        # 購買頻度フィルター
        if '購買頻度' in df.columns:
            freq_options = df['購買頻度'].dropna().unique().tolist()
            if 'selected_frequency' not in st.session_state:
                st.session_state.selected_frequency = freq_options
            selected_frequency = st.multiselect(
                "🔍 購買頻度", options=freq_options, default=st.session_state.selected_frequency
            )
            st.session_state.selected_frequency = selected_frequency
        else:
            selected_frequency = None
    
    with col3:
        if '購入スタイル' in df.columns:
            style_options = df['購入スタイル'].dropna().unique().tolist()
            st.markdown("🔍 購入スタイル")
    
            if "selected_style" not in st.session_state:
                st.session_state.selected_style = {s: True for s in style_options}
    
            colA, colB = st.columns(2)
            with colA:
                if st.button("✅ 全て選択"):
                    for s in style_options:
                        st.session_state.selected_style[s] = True
            with colB:
                if st.button("❌ 全て解除"):
                    for s in style_options:
                        st.session_state.selected_style[s] = False
    
            selected_style = []
            for s in style_options:
                checked = st.checkbox(s, value=st.session_state.selected_style[s])
                st.session_state.selected_style[s] = checked
                if checked:
                    selected_style.append(s)
        else:
            selected_style = None
            
    # ================
    # フィルター処理
    # ================
    filtered_df = df.copy()
    if selected_age_range:
        filtered_df = filtered_df[filtered_df['年齢'].between(*selected_age_range)]
    if selected_gender:
        filtered_df = filtered_df[filtered_df['性別'].isin(selected_gender)]
    if selected_jobs:
        filtered_df = filtered_df[filtered_df['職業'].isin(selected_jobs)]
    if selected_character:
        filtered_df = filtered_df[filtered_df['キャラ傾向'].isin(selected_character)]
    if selected_frequency:
        filtered_df = filtered_df[filtered_df['購買頻度'].isin(selected_frequency)]
    if selected_style:
        filtered_df = filtered_df[filtered_df['購入スタイル'].isin(selected_style)]
    if selected_importance:
        filtered_df = filtered_df[filtered_df['重要視すること'].isin(selected_importance)]
    if selected_sns:
        filtered_df = filtered_df[filtered_df['SNS利用時間'].between(*selected_sns)]
    if selected_average_bands:
        filtered_df = filtered_df[filtered_df['平均購入単価'].between(*selected_average_bands)]

    st.markdown(f"### フィルター後の対象者数: {len(filtered_df)}")

📨 output

image.png


🧮 クラスタリング設定

    # ======================
    # クラスタリング
    # ======================
    st.markdown("#### 🧩 クラスタリング設定")

    # クラスタリングに使う特徴量選択UI(カテゴリはラベルエンコーディング)
    candidate_features = ['年齢', '性別','職業','購買頻度','平均購入単価', '購入スタイル', '重要視すること']
    selected_features = st.multiselect("クラスタリングに使う属性を選択してください", candidate_features, default=candidate_features)

📌 st.multiselect
ユーザーがクラスタリングに使う属性を複数選択できるUIを表示。
candidate_features に指定した候補リストから選択でき、デフォルトではすべて選択。

📨 output

image.png

選択された属性はクラスタリングの入力データとして利用され、
カテゴリ変数(例:性別、職業など)は後でラベルエンコーディングを行い、数値データとして処理します。



    if len(selected_features) == 0:
        st.warning("少なくとも1つ以上の特徴量を選択してください。")
    else:
        cluster_count = st.slider("クラスタ数 (K)", 2, 10, 3)

        # 前処理:カテゴリ変数はlabel encoding(簡易版)
        from sklearn.preprocessing import LabelEncoder
        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))

📌 st.warning
ユーザーが何も特徴量を選択しなかった場合に警告を表示。
選択があれば、クラスタ数を指定するスライダーUIが表示される。

📌 LabelEncoder
カテゴリ変数(例:「男性」「女性」)を 0, 1, 2,... の整数に変換。

📨 output

image.png

機械学習モデルは文字列を扱えないため、数値に変換する必要があります。
この処理により、KMeansが扱いやすい数値データになります。


        # 標準化
        scaler = StandardScaler()
        X_scaled = scaler.fit_transform(X)

        # KMeansクラスタリング
        kmeans = KMeans(n_clusters=cluster_count, random_state=42)
        clusters = kmeans.fit_predict(X_scaled)

        filtered_df['cluster'] = clusters

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

📌 StandardScaler()
特徴量を平均0、分散1に変換しスケールを揃えることで、クラスタリングの偏りを防ぐ。

📌 KMeans()
指定したクラスタ数でデータをグループ化し、各データにクラスタ番号を割り当る。

📌 DataFrame['cluster']
クラスタリング結果のクラスタ番号を元データに新しい列として追加。

📌 st.markdown() / st.write()
Streamlitでクラスタリング結果の見出しと、ID・選択属性・クラスタ番号のテーブルを表示。

📨 output

image.png


        # クラスタ人数分布の下に追加
        st.markdown("#### クラスタ内容")
    
        num_clusters = filtered_df['cluster'].nunique()
        
        num_features = ['年齢', 'SNS利用時間', '平均購入単価']
        cat_features = ['性別', '職業', '購買頻度',"重要視すること","購入スタイル", 'キャラ傾向']

        rows = []
        
        for c in range(num_clusters):
            cluster_df = filtered_df[filtered_df['cluster'] == c]
            n = len(cluster_df)
            
            row = {"クラスタ": c, "人数": n}
            
            # 数値特徴量の平均値
            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)

📌 DataFrame.nunique()
クラスタ番号のユニーク数を取得し、クラスタ数を決定。

📌 for c in range(num_clusters):
各クラスタごとにデータ抽出と集計処理をループで実施。

📌 mean()
数値特徴量(例:年齢、SNS利用時間、平均購入単価)の平均を計算。

📌 value_counts(normalize=True)
カテゴリ特徴量の最頻値とその割合(%)を算出し、クラスタの代表的な特徴を把握。

📌 pd.DataFrame(rows)
集計結果をデータフレームにまとめ、一覧表示できる形に整える。

📌 st.dataframe()
集計したクラスタごとの特徴(人数、平均値、最多カテゴリ)をStreamlit上で表形式で表示。

📨 output

image.png


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

        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)])

📌 labels = [...]
Van Westendorp分析で使用する4つの価格感度ラベルをリストで定義。

📌 brands = sorted(set(...))
データのカラム名からブランド名を抽出し、重複を排除してアルファベット順に並べたリストを作成。

📌 st.tabs()
クラスタごとに切り替え可能なタブを作成し、分析結果の表示を見やすくする。

📨 output

image.png


        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 = []  # 各ブランドのPSM指標を保存するリスト
        
                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)]

📌 for cluster_id, tab in zip(...)
クラスタ番号と対応するタブを同時にループ処理し、クラスタ別の分析画面を表示。

📌 with tab:
各クラスタのタブ内に処理を限定し、内容を分けて表示。

📌 st.markdown()
クラスタごとの見出しを表示。

📌 filtered_df[filtered_df['cluster'] == cluster_id]
指定したクラスタのデータのみを抽出。

📌 results = []
ブランドごとのPSM指標を格納するリストを初期化。

📌 [f"{brand}_{lbl}" for lbl in labels if ...]
そのクラスタ内に存在するブランドのPSM関連カラム名をリスト化。

📌 df_cluster[df_cluster[brand_cols].notnull().any(axis=1)]
ブランドのPSMデータが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)
        

📌 df_brand.empty
ブランドのデータが空の場合、処理をスキップして警告を表示。

📌 price_data = {...}
価格感度ラベル(too_cheap, cheap, ...)に対応するブランドの価格データの価格配列を作成。

📌 valid_arrays = [...]
値が入っている配列だけを抽出し、有効なデータの有無をチェック。

📌 np.arange(...)
有効なすべての価格を統合して最小~最大の範囲を取り、100円刻みで等間隔の価格帯(all_prices)を生成。
→ PSMのグラフ横軸(価格帯)。

📌 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)
        

📌 cumulative = {...}

各価格帯に対して以下の4曲線の**累積比率(累積パーセンテージ)** を算出

too_cheap: 「この価格以上で“安すぎる”と感じた人の割合」
cheap: 「この価格以上で“安い”と感じた人の割合」
expensive: 「この価格以下で“高い”と感じた人の割合」
too_expensive: 「この価格以下で“高すぎる”と感じた人の割合」

この処理では、all_prices(100円刻みの価格帯)にわたって、それぞれの割合を計算しています。
累積比率を取ることで、交点の探索がしやすくなります。

📌 find_intersection(...)

4本の累積曲線の交点から、以下の`Van Westendorp指標`を算出

OPP(Optimal Price Point)
→ 「“安い”と感じる人の割合」と「“高い”と感じる人の割合」が等しい価格帯
IDP(Indifference Price Point)
→ 「“安すぎる”と感じる人」と「“高すぎる”と感じる人」の割合が等しい価格帯
PME(Point of Marginal Expensiveness)
→ 「“安い”と感じる人」と「“高すぎる”と感じる人」の交点
PMC(Point of Marginal Cheapness)
→ 「“高い”と感じる人」と「“安すぎる”と感じる人」の交点

📨 output

image.png

これらは、価格設定の参考になる指標として、ダッシュボードなどで可視化されることが多いです。


                    # グラフ生成
                    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')))

📌 go.Figure() / add_trace()
Plotlyでグラフオブジェクトを生成し、各累積曲線を色分けして描画。

📨 output

image.png

   for val, name, color in zip(
                        [opp, idp, pme, pmc],
                        ['OPP(最適)', 'IDP(無関心)', 'PME(上限)', 'PMC(下限)'],
                        ['purple', 'black', 'magenta', 'cyan']
                    ):
                        if val:
                            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')
                    )
        

📌 for val, name, color in zip(...)
Van Westendorpの4つの指標(OPP, IDP, PME, PMC)に対して、それぞれ色分けした縦線とラベルをグラフに追加。

📌 fig.add_vline()
各指標の価格に縦線(点線)を引き、位置を強調。

📌 fig.add_annotation()
指標名を縦方向に注釈として表示し、色と位置を対応させて可読性を高める。

📨 output

image.png

📌 fig.update_layout()
タイトル、軸ラベル、サイズ、高さ、ホバーモード(カーソルに応じた値の統一表示)を設定して、全体を整える。


                    # グラフと指標を横並びで表示
                    col_plot, col_info = st.columns([3, 1])
                    with col_plot:
                        st.plotly_chart(fig, use_container_width=True)
                    with col_info:
                        st.markdown("#### 👇 指標")
                        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
                    })

📌 st.columns([3, 1])
→ グラフ(左)と指標(右)を3:1の割合で横並びに表示。視覚的にメリハリをつけて、主要なグラフに重点を置きつつ補足情報を右側に配置。

📌 st.plotly_chart(fig, use_container_width=True)
Plotlyで生成したグラフをカラム幅いっぱいに表示し、見やすさを最大化。

📌 st.markdown / st.write
指標値と該当人数をテキストで明示し、読者が数値的な解釈をしやすいように整理。
OPPなどが None の場合に 計算不可 と出す。

📌 results.append({...})
各ブランドごとのPSM指標を記録し、あとで集計表(st.dataframe()など)として表示するための下準備。
にも役立つ。

このresults は、あとで pd.DataFrame(results) にして表でまとめるための準備です。
指標を一覧にすることで、後からグラフ化したりダウンロード可能にしたりと、アプリの応用がしやすくなります。


🧮 各クラスタ内に全ブランド指標

                # 🔽 各クラスタ内の全ブランドまとめ表(PSM指標一覧)
                if results:
                    st.markdown("### 📊 クラスタ内ブランド別 PSM指標 一覧")
                    df_result = pd.DataFrame(results)
                    st.dataframe(df_result.style.format({col: "{:.0f}" for col in ["OPP", "IDP", "PMC", "PME"]}))
                else:
                    st.info("このクラスタには有効なPSMデータがありません。")

📌 st.columns([3, 1])
グラフ表示部分を幅広く、指標表示部分を狭めた2カラム構成にすることで、視認性を向上

📌 st.plotly_chart(fig, use_container_width=True)
PSM分析の累積グラフを、横軸共有・画面幅フィットで表示。

📌 st.markdown(f"...該当人数...")
ブランドごとの回答者数を明示。クラスタ規模の参考になる。

📌 st.write(f"...各指標...")
各PSM指標(OPP, IDP, PMC, PME)を丁寧に表示。**未算出時は「計算不可」**と表示。

📌 results.append({...})
ブランド別PSM指標を収集し、後続のDataFrame化や可視化に備える。

📨 output

image.png

※該当人数:タブで選択中のクラスタに属する回答者の人数


🧮 全ブランド指標(フィルター前)

      # ---- 全体集計(フィルター前)----
        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)

📌 results_before_filter = []
全体データに対するPSM分析結果を格納する空リストの初期化。

📌 for brand in brands:
全ブランドについてループを回し、個別にPSM分析を実施。

📌 brand_cols = [f"{brand}{lbl}" for lbl in labels if f"{brand}{lbl}" in df.columns]
対象ブランドにおけるPSMラベル(too_cheap, cheap, expensive, too_expensive)に該当する列名を抽出。

📌 df_brand = df[df[brand_cols].notnull().any(axis=1)]
いずれかの価格感度項目に回答があるデータのみを抽出し、分析対象とする。

📌 price_data = {...}
欠損値を除き、各ラベルの価格データを整数配列でまとめる。

📌 valid_arrays = [...]
有効なデータが1件でもある配列のみを判定用に抽出し、なければスキップ。

📌 all_prices = np.arange(...)
有効価格範囲の最小値~最大値までを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 != "ブランド"}))
        

📌 cumulative = {...}
4つの価格評価ラベルごとに、価格軸に対して累積比率(該当価格以上または以下と答えた割合)を計算。

📌 find_intersection(...)
累積比率曲線の交点を求め、Van Westendorp法の主要指標(OPP、IDP、PME、PMC)を算出。

📌 results_before_filter.append({...})
算出した指標をブランドごとに辞書形式でまとめ、後で一覧表示や分析に活用。

📌 st.markdown("---")
区切り線を挿入し、UIを見やすく整える。

📌 st.expander(...)
折りたたみ可能なエリアに全ブランドの全体集計のPSM指標を表示。

📨 output

image.png

📌 st.markdown(f"全体調査人数:{len(df)}人")
全体の有効回答者数を表示。

📌 st.dataframe(...style.format(...))
ブランド名以外の指標は整数表示に整形。

📨 output

image.png


🧮 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価格感度モデルに基づくインタラクティブな分析結果が確認できます。

📨 output
image.png


5. 全体平均との比較・考察

5-1. フィルター前のPSM指標(全体平均)

image.png
全体のPSM結果を基準として、クラスタごとの価格感覚の違いを見ていきます。

ちょっと余談

image.png

image.png

クラスタリング設定にはキャラ傾向はいれてませんが、クラスタリングするときれいに傾向が出るのがおもしろいですね。
👉 実は、購買行動と性格傾向には潜在的な関係があるのかもしれません。


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

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

各クラスタは、価格に対する感度や価値観が大きく異なることが分かりました。

特にクラスタ1・2のように価格に寛容かつ個性や映えを重視する層は、
プレミアムラインのターゲットとして有望です。

一方、クラスタ0・3は価格感度が高く、バリュー志向が強い層であり、
コスパ訴求型の戦略がフィットします。

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

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

クラスタごとの特徴に応じて「価格設定」だけでなく「訴求メッセージ」や「販売チャネル」も最適化することで、限られた予算でも高い費用対効果が見込めるマーケティング戦略が構築できます(たぶん)。

🧭 クラスタ0を考察

image.png

image.png

✔ 価格傾向まとめ
全ブランドで「最適価格」が6,600〜6,940円で収束 → コスパの見極めに敏感。

無関心価格(IDP)が比較的低く、価格に対する反応がシビア。

受容範囲(PMC〜PME)は平均で 約2,000円程度の幅。他クラスタより狭い可能性あり。

🧭 クラスタ0向けマーケティング示唆

項目 内容
価格戦略 上限7,000円以下に収まる価格設定(例:6,500円)
訴求軸 「長く使える」「壊れにくい」「必要十分」など、堅実・実用性の価値訴求
チャネル 家電量販店のチラシ・ECサイトのセール枠・価格比較サイトなど、価格重視層が集まる場所
販促コピー例 「この価格でこの機能?コスパ最強!」「ムダを省いたちょうどいい選択」

📌 実用性を重視し、価格に敏感な堅実派
クラスタ0に対しては
高機能・高価格なモデルよりも、「必要最低限で価格を抑えたモデル」 が刺さります。

また、チラシや価格比較サイトなど「価格を基準に探す導線」 でのアピールが効果的です。
購入スタイルや性格傾向(「まじめ」「金額を決めて購入」)を踏まえると煽り文句よりも、信頼感あるトーンが求められます。

5-2. フィルター後PSM指標

🧭 ① 購入スタイルで衝動買いしそうなタイプに絞ります

image.png

🧭 ② クラスタリング設定から購入スタイルをチェックアウトし、クラスタ数を3にします

image.png

🧭 クラスタ内容

image.png

🧭 クラスタ0 のPSM とフィルター前のPSMの比較

image.png

🧭 考察

フィルター後のVan Westendorp分析では、全体集計に比べて最適価格帯が一様に下がる傾向が見られました。

「衝動買いしそう」と予測されるクラスタでも、価格の現実的な制約は無視できず、高すぎる価格設定は購買機会を逃すリスクがあります。

つまり「衝動買いしそう」とされる層でも、価格には現実的な上限が存在し、高価格帯では購買機会を失うリスクがあることが示唆されます。

🧭 このことから分かること

① 「衝動買いしそうな人」でも、価格には現実的な制約がある
アンケート上「欲しいものはいくらでも買う」と回答する一方で、PSM分析では全ブランドの希望価格が低下。

購買意欲の高さと実際に出せる価格は別物。可処分所得が購買行動の大きな制約となっている。

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

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

② ライフステージ(学生・若手社会人)が価格感度に影響
多くが学生や就職したばかりの層で、収入が限られている。そのため、高価格帯のブランド展開は、この層にとってハードルが高く、購入を諦めてしまう可能性がある。

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

③ PSM単独では見えない「人物像」や「態度変容」までクラスタリングで把握可能
価格好みの背景にある生活背景や価値観を理解することで、価格設定の理由や訴求ポイントをより具体的に設計できる。

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

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

態度は以下の3つの要素でできていると言われます(ABCモデル):

  • Affect(感情):好き・嫌い
  • Behavior(行動):買う・買わない
  • Cognition(認知) :知っている・知らない、理解している

「態度変容」は、これらのうちいずれかが変わることを指します。

この分析で価格は単なる数値指標ではなく、ターゲットの生活背景や価値観、購買行動に深く根差した要素 であることが明らかになりました。

したがってPSMとクラスタリングの組み合わせは、より実態に即した現実的かつ効果的なマーケティング戦略立案に不可欠 と言えます。

6. まとめ

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

つまり、
「誰がどんな価格で買いたいか」をちゃんと理解すれば、売り方もグッと上手くいく!
そんなことが見えてくる分析でした。

クラスタ0は絞り込み後で11人しかいなかったので、PSMの結果と考察はあくまで参考程度で。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?