🏧

Python+Streamlitで価格感度を可視化 - Van Westendorp PSM分析 #1

に公開

📌はじめに

価格は高すぎてもダメ、安すぎても不安。
そんな「ちょうどいい価格」を探るための手法が、Van Westendorpの価格感度メーター(PSM)分析です。

業務ではエクセルVBAで作ったことがありますが、今回はPythonとStreamlitを使い、Van Westendorp分析の基本的なUIを試作してみました。

❓Van Westendorp PSMって?❓

4つの質問で価格に対する消費者心理を測るアンケート手法です。

  • 高すぎて買いたくない価格(Too Expensive)
  • 高いがまだ許容できる価格(Expensive)
  • 安いがまだ許容できる価格(Cheap)
  • 安すぎて品質が不安になる価格(Too Cheap)

これらの回答から、価格の**「受容ゾーン」「最適価格帯」**を導き出します。


PSM : 価格感度測定 (Price Sensitivity Measurement)


📌今回のStreamlit UIでできること

  • 年齢、性別、職業、SNS利用時間など複数条件での絞り込みフィルター
  • ブランドごとのVan Westendorp PSM指標(OPP、IDP、PMC、PME)の算出と表示
  • 価格感度曲線のインタラクティブなグラフ表示(Plotlyを使用)
  • フィルター前後の指標比較表示

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

アプリの上部には年齢・性別・職業などのフィルターUIがあり、
下部に絞り込み後のPSMグラフと以下の指標が表示されます:

  • 最適価格(OPP)
  • 無関心価格(IDP)
  • 価格受容範囲下限(PMC)
  • 価格受容範囲上限(PME)

さらにその下には、フィルター適用前の全体集計結果が一覧で確認できます。


📌調査内容(仮定)

対象:18歳~30歳以下のユーザー

目的
4つの商品カテゴリについて、Van Westendorp PSMの4指標(OPP, IDP, PMC, PME)を収集・分析します。

調査対象の商品

質問項目
それぞれの商品について、「安すぎる/安い/高い/高すぎる」と感じる価格帯を、以下の範囲から選んでもらいました。


📌サンプルデータ(sample_data.csv)

カラム名 内容
ID、年齢、性別、職業 基本的なデモグラフィック情報
重要視すること、購入スタイル、生活で大切なこと 嗜好・価値観に関する質問項目
購入頻度、SNS利用時間、お気に入りブランド数 購買行動やライフスタイルの指標
平均購入単価、情報収集チャンネル、購買意思決定機関、購買意思決定期間 購買プロセスに関する行動データ
キャラ思考 AI分析によるキャラクター傾向の結果
Lumiere_too_cheap、Lumiere_cheap、Lumiere_expensive、Lumiere_too_expensive など 各商品に対する価格感度の回答データ(価格帯)

📌コード解説(psm_ui_app.py)

# ------------------------
# 0. 必要ライブラリ
# ------------------------
import streamlit as st
import pandas as pd
import numpy as np
from scipy.interpolate import interp1d
import plotly.graph_objects as go

# グローバル設定(ブランド + 列名)
glb_age = '年齢'
glb_gen = '性別'
glb_wok = '職業'
glb_cha = 'キャラ傾向'
glb_pur = '購買頻度'
glb_sty = '購入スタイル'
glb_imp = '重要視すること'
glb_sns = 'SNS利用時間'
glb_ave = '平均購入単価'

# ------------------------
# 1.Streamlit UI設定
# ------------------------
st.set_page_config(layout="wide")
st.title("💴 Van Westendorp PSM 分析アプリ")

# ------------------------
# 2.関数
# ------------------------
# 高速化
@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

0. 必要ライブラリの読み込み

  • Streamlit:WebアプリのUI作成
  • SciPy:数値補間、価格感度曲線の交点計算
  • グローバル変数:分析でよく使う列名やブランド名をとして定義

1.Streamlit UI設定

  • layout="wide":ページレイアウトを横幅広めに設定
出力例

2.関数

  • @st.cache_data: 同じファイルを繰り返し読み込むときにキャッシュして高速化
  • find_intersection(y1, y2, x):価格曲線の交差点を求める
    • 差の符号変化を検出し、線形補間で正確な交点を算出
    • ※複数交点がある場合、最初に見つかった1つを返します
    • 最適価格や受容範囲の算出に使用
❓交点検出とは❓

Van Westendorp分析における価格感度のしきい値推定に活用されます。
特に重要な交点には以下があります:

PMC(Point of Marginal Cheapness)

Too Cheap と Not Cheap の交点

PMU(Point of Marginal Expensiveness)

Too Expensive と Not Expensive の交点

これらを基に 最適価格帯(Range of Acceptable Prices) を導きます。


# ------------------------
# 3. フィルター設定
# ------------------------    

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 glb_age in df.columns:
            min_age = int(df[glb_age].min())
            max_age = int(df[glb_age].max())
            selected_age_range = st.slider(f'🔍 {glb_age}', min_age, max_age, (min_age, max_age))
        else:
            selected_age_range = None

        # 性別フィルター
        if glb_gen in df.columns:
            gender_options = df[glb_gen].dropna().unique().tolist()
            if 'selected_gender' not in st.session_state:
                st.session_state.selected_gender = gender_options
            selected_gender = st.multiselect(
                f'🔍 {glb_gen}', options=gender_options, default=st.session_state.selected_gender
            )
            st.session_state.selected_gender = selected_gender
        else:
            selected_gender = None

        # 職業フィルター
        if glb_wok in df.columns:
            job_options = df[glb_wok].dropna().unique().tolist()
            if 'selected_jobs' not in st.session_state:
                st.session_state.selected_jobs = job_options
            selected_jobs = st.multiselect(
                f'🔍 {glb_wok}', options=job_options, default=st.session_state.selected_jobs
            )
            st.session_state.selected_jobs = selected_jobs
        else:
            selected_jobs = None

        if glb_ave in df.columns:
            min_price = int(df[glb_ave].min())
            max_price = int(df[glb_ave].max())
            selected_average_bands = st.slider(f'🔍 {glb_ave}', min_price, max_price, (min_price, max_price))
        else:
            selected_average_bands = None

        if glb_sns in df.columns:
            min_sns = int(df[glb_sns].min())
            max_sns = int(df[glb_sns].max())
            selected_sns = st.slider(f'🔍 {glb_sns}', min_sns, max_sns, (min_sns, max_sns))
        else:
            selected_sns = None

    with col2:
        # キャラ傾向フィルター
        if glb_cha in df.columns:
            char_options = df[glb_cha].dropna().unique().tolist()
            if 'selected_character' not in st.session_state:
                st.session_state.selected_character = char_options
            selected_character = st.multiselect(
                f'🔍 {glb_cha}', options=char_options, default=st.session_state.selected_character
            )
            st.session_state.selected_character = selected_character
        else:
            selected_character = None

        # 重要視すること フィルター
        if glb_imp in df.columns:
            imp_options = df[glb_imp].dropna().unique().tolist()
            if 'selected_importance' not in st.session_state:
                st.session_state.selected_importance = imp_options
            selected_importance = st.multiselect(
                f'🔍 {glb_imp}', options=imp_options, default=st.session_state.selected_importance
            )
            st.session_state.selected_importance = selected_importance
        else:
            selected_importance = None

        # 購買頻度フィルター
        if glb_pur in df.columns:
            freq_options = df[glb_pur].dropna().unique().tolist()
            if 'selected_frequency' not in st.session_state:
                st.session_state.selected_frequency = freq_options
            selected_frequency = st.multiselect(
                f'🔍 {glb_pur}', options=freq_options, default=st.session_state.selected_frequency
            )
            st.session_state.selected_frequency = selected_frequency
        else:
            selected_frequency = None

    with col3:
        if glb_sty in df.columns:
            style_options = df[glb_sty].dropna().unique().tolist()
            st.markdown(f'🔍 {glb_sty}')
        
            # セッションステート初期化
            for s in style_options:
                key_name = f"selected_style_{s}"
                if key_name not in st.session_state:
                    st.session_state[key_name] = True  # 初期は全選択状態
        
            colA, colB = st.columns(2)
            with colA:
                if st.button("✅ 全て選択"):
                    for s in style_options:
                        st.session_state[f"selected_style_{s}"] = True
            with colB:
                if st.button("❌ 全て解除"):
                    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)  # value= は不要
                if checked:
                    selected_style.append(s)
        else:
            selected_style = None

    show_lines = st.checkbox("📊 指標の補助線とラベルを表示/非表示", value=True)

    # フィルター処理(順序は意味を持たず、独立して適用されます)
    filtered_df = df.copy()
    if selected_age_range:
        filtered_df = filtered_df[filtered_df[glb_age].between(*selected_age_range)]
    if selected_gender:
        filtered_df = filtered_df[filtered_df[glb_gen].isin(selected_gender)]
    if selected_jobs:
        filtered_df = filtered_df[filtered_df[glb_wok].isin(selected_jobs)]
    if selected_character:
        filtered_df = filtered_df[filtered_df[glb_cha].isin(selected_character)]
    if selected_frequency:
        filtered_df = filtered_df[filtered_df[glb_pur].isin(selected_frequency)]
    if selected_style:
        filtered_df = filtered_df[filtered_df[glb_sty].isin(selected_style)]
    if selected_importance:
        filtered_df = filtered_df[filtered_df[glb_imp].isin(selected_importance)]
    if selected_sns:
        filtered_df = filtered_df[filtered_df[glb_sns].between(*selected_sns)]
    if selected_average_bands:
        filtered_df = filtered_df[filtered_df[glb_ave].between(*selected_average_bands)]

    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)))
    tabs = st.tabs(brands)

3.フィルター設定

3-1.ファイルアップロード

  • st.file_uploader("CSVファイルをアップロード"):ユーザーがCSVをアップロード可能にする
出力例

3-2.データ読み込み

  • load_data(uploaded_file):アップロードされたCSVをDataFrameとして読み込み

3-3.絞り込みUI

  • st.columns(3):3分割カラムレイアウト
  • st.slider():数値範囲(年齢、平均購入単価、SNS利用時間など)を選択
  • st.multiselect():複数選択(性別、職業、キャラ傾向、重要視すること、購買頻度など)
  • st.checkbox():購入スタイルの個別選択
  • st.button():全選択/全解除の操作
  • ユーザーの選択は st.session_state に保存されるため、画面再描画後も保持されます
出力例

3-4.グラフ:補助線の表示切り替え

  • show_lines:補助線の表示・非表示をチェックボックスで操作できます
    • True:補助線とラベルを表示
    • False:非表示
出力例

3-5.フィルター処理

  • filtered_df = df.copy():元データをコピーして絞り込み
  • DataFrame.between(*range):スライダーで指定した範囲のデータを抽出
  • DataFrame.isin(list):複数選択で該当データを抽出
  • 条件は独立して適用され、順序に意味はありません
  • 最終的に、選択条件に沿ったデータのみ filtered_df に残り、グラフや分析はその対象で実行されます
❓「✅ 全て選択」「❌ 全て解除」❓


選択肢(ワード)をすべて表示して選びやすくするためです。
他の項目とUIが異なるのは意図的なデザインです。

3-6.タブ表示

  • st.tabs(brands):ブランドごとにタブを作成
    • 列名から "too_cheap" などのキーワードを含む部分を探し、ブランド名を自動抽出しています
    • 各タブにブランド別のPSM指標やグラフを表示
出力例


    # ------------------------   
    #  4.PSM分析とグラフ表示
    # ------------------------   
    results = []
    num_people = filtered_df.shape[0]
    for tab, brand in zip(tabs, brands):
        with tab:
            brand_cols = [f"{brand}_{lbl}" for lbl in labels if f"{brand}_{lbl}" in filtered_df.columns]
            df_brand = filtered_df[filtered_df[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')))

            if show_lines:
                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')
            )

            # 結果表示
            col_info, col_graph = st.columns([1, 2])

            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,
                    "OPP": opp,
                    "IDP": idp,
                    "PMC": pmc,
                    "PME": pme
                })

                summary_df = pd.DataFrame(results)
                brand_row = summary_df[summary_df["ブランド"] == brand]
                st.dataframe(brand_row.style.format({col: "{:.0f}" for col in brand_row.columns if col != "ブランド"}))

            with col_graph:
                st.plotly_chart(fig, use_container_width=True)

4.PSM分析とグラフ表示

4-1. データ準備

  • price_data = {...}:各ラベル(Too Cheap, Cheap, Expensive, Too Expensive)の価格配列を作成
  • valid_arrays = [...]:有効な価格データのみ抽出
  • all_prices = ...:全価格帯を100円刻みで生成

4-2. 累積比率計算

  • cumulative[...]:各価格ラベルの累積比率を計算
    • Too CheapCheap : 指定価格以上の割合
    • ExpensiveToo Expensive : 指定価格以下の割合
    • ※PSM分析の定義に基づいた計算方法です

4-3. 交点計算

Van Westendorp分析で重要な4つの価格閾値(交点)

  • opp:最適価格(OPP)
  • idp :無関心価格(IDP)
  • pme:受容範囲上限(PME)
  • pmc:受容範囲下限(PMC)
  • find_intersection(...): 2つの累積比率曲線の交点を計算。心理的価格閾値(例:PMC、PMEなど)を求める。
補足
  • OPP:Not Cheap(cheap曲線)と Expensive の交点
  • IDP:Too Cheap と Too Expensive の交点
  • PME:Not Cheap(cheap曲線)と Too Expensive の交点
  • PMC:Expensive と Too Cheap の交点

4-4. グラフ作成

  • go.Figure():Plotlyで折れ線グラフを作成
  • fig.add_trace():各価格ラベルの線を追加
  • fig.add_vline()fig.add_annotation():補助線とラベルを表示(show_lines=True の場合)
  • fig.update_layout():タイトル、軸ラベル、高さ、ツールチップなどを設定

4-5. 結果表示

  • st.columns([1, 2]):情報とグラフを左右に表示
  • results.append({...}):ブランド別のPSM指標を辞書として保存
  • summary_df = ...:全ブランドの集計表を作成(ここではブランド単位で抜き出して表示しています)

左カラム

  • ブランド名と回答人数表示
  • st.markdown / st.write:4つの心理的価格閾値(OPP, IDP, PMC, PME)をラベル付きで表示
    • 価格は丸めて整数表示、計算不可の場合は「計算不可」と表示
  • st.dataframe():表形式でも確認可能
  • results.append({...}):ブランド別のPSM指標を辞書としてリストに保存
  • summary_df = ...:全ブランドの集計表を作成
出力例:左

右カラム

  • st.plotly_chart(fig):インタラクティブグラフ表示
出力例:右

出力例:全体

指定したフィルター条件に応じて、各指標が再計算され、グラフがリアルタイムに更新されます。

グラフ右上のアイコンから、画像のダウンロードや拡大表示などの操作が可能です。


    # ------------------------ 
    # フィルター前の計算結果(別集計)
    # ------------------------ 
    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("---")
    st.markdown("#### 👇フィルター前 ブランド別 PSM 指標一覧")
    summary_df_before = pd.DataFrame(results_before_filter)
    st.markdown(f"**調査人数:{len(df)}人**")
    st.dataframe(summary_df_before.style.format({col: "{:.0f}" for col in summary_df_before.columns if col != "ブランド"}))

5.フィルター前の集計表示

5-1. データ準備

  • results_before_filter = []:ブランド別PSM指標を格納するリスト
  • price_data:ブランドごとに列を抽出した有効な価格データを格納
  • valid_arrays:有効データがあるか確認
  • all_prices = np.arange(...):全価格帯を100円刻みで生成

5-2. 累積比率計算と交点算出

  • cumulative[...]:累積比率を計算
  • opp, idp, pme, pmc = ...:心理的価格閾値(OPP, IDP, PME, PMC)を計算
  • 結果を results_before_filter.append({...}) に保存
  • ※フィルター前なので、全体傾向を把握できます

5-3. 表示

  • st.markdown("#### 👇フィルター前 ブランド別 PSM 指標一覧"):タイトル表示
  • st.markdown(f"**調査人数:{len(df)}人**"):総人数表示(フィルター後の人数とは異なる)
  • st.dataframe(summary_df_before.style.format(...)):表形式でブランド別PSM指標を表示
    • 価格は整数表示
    • ブランド名列はそのまま表示
出力例

フィルター適用前の指標なので、フィルター適用後と比較が可能です。
全体の傾向を把握するのに役立ちます。。


📌UIを使ってみる

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

PS C:\Users\user> cd C:\Users\user\が保存されてるフォルダ
PS C:\Users\user\aa.pyが保存されてるフォルダ> streamlit run psm_ui_app.py
  • ブラウザが起動、CSVアップロード画面が表示される
  • アップロード後、インタラクティブなPSM分析結果を確認可能


📉価格感度分析例:Celesta(n=90)

指標 金額 意味 解説
最適価格(OPP) 6,571円 消費者が最も納得して購入しやすい価格 「高すぎず安すぎず」で、最も選ばれやすいと推定される価格
無関心価格(IDP) 7,000円 高くも安くも感じない“心理的な中心点” この価格を境に「高い」「安い」という意識が変わる。安心感があり、違和感なく受け入れられる価格
価格受容範囲下限(PMC) 5,912円 これより安いと品質が不安に感じられるライン 「安すぎて逆に怪しい」と思われる限界価格
価格受容範囲上限(PME) 7,600円 これより高いと「高すぎる」と感じるライン 購入意欲が大きく下がる価格帯の上限

価格の意味とポイント

  • 安心して売れる価格帯:5,912円~7,600円
  • 違和感なく受け入れられる価格:7,000円(IDP)前後
  • 戦略的に一番売れやすい価格:6,571円(OPP)

まとめ

  • OPP(6,571円)を販売価格に設定するのがおすすめ
  • IDP(7,000円)より少し低めに設定すると納得感と売れやすさのバランスが良い
  • 価格受容範囲(5,912円~7,600円)の範囲内で戦略的に販売可能
補足
価格 消費者の反応
5,912円以下 安すぎて不安
6,571円 販売価格として最も納得されやすい
7,000円 高くも安くも感じない心理的真ん中
7,600円以上 高くて購入意欲が下がる
❓最も大事な無関心価格(IDP)とは❓

IDP(無関心価格)とは

  • 消費者が「高くも安くも感じない」心理的にちょうど良い価格
  • この価格帯だと購入意欲が下がりにくく、安心して選んでもらいやすい
❓無関心価格(IDP)と適正価格の違いとは❓
用語 目的 視点
IDP 消費者が受け入れやすい価格 感覚・印象
適正価格 企業・市場に妥当な価格 論理・利益

使い分けの例

シーン IDPが重要 適正価格が重要
顧客の“印象”を重視した価格戦略
原価・利益・競合を考慮した価格設定
新商品の受け入れやすさを調べたい
長期的に利益を確保したい

📌フィルター活用のポイント

フィルター機能を活用すると、単に「全体の傾向」を見るだけでなく、属性ごとの細かな価格心理も可視化できます。
これにより、より効果的な価格戦略やプロモーション計画を立てることが可能です。

  • 選択したフィルターはタブを切り替えても保持される
    → 他のブランドのグラフや指標も同じ条件で確認可能

📉Celesta


📉Lumiere

タブを切り替えても、選択した条件が反映されます


フィルターで確認できること

意味・目的 詳細説明
ターゲット層の理解が深まる 年齢、性別、職業などで絞り込むことで、そのグループ特有の価格感覚や購買意欲の違いがわかる。
マーケティング戦略の精緻化 若年層向け・女性向け・特定職業向けなど、属性別に最適な価格設定や訴求ポイントを検討できる。
商品改善や新商品の開発に活用 特定層が感じる「高い・安い」の基準や違和感を把握し、商品価値や価格帯を調整する材料になる。
販売施策の効果検証 フィルターで絞った層に対して価格戦略を変えた場合の反応を予測しやすくなり、効果的な施策立案が可能になる。

📌まとめ

今回の記事では、Van WestendorpのPSM分析をPython+Streamlitで再現し、
アップロードした調査データに対してブランド別の価格感度を可視化できるUIを構築しました。

価格曲線の交点から「無関心価格(IDP)」や「価格受容域(PMC~PME)」を求める手法を、実務でも活用できる形で実装しています。

次回は、性別・年齢などの属性情報やユーザーの反応傾向をもとにクラスタリングを行い、
各クラスタごとにどのような価格受容性の違いがあるかを明らかにしていく予定です。


参考リンクについて

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

Discussion