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】Van Westendorpで“ちょうどいい価格”を可視化してみた(PSM分析 #1)

Last updated at Posted at 2025-07-31

概要

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

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

❓Van Westendorp PSMって?❓
4つの質問で価格に対する消費者の心理を測るアンケート手法です。

  • 高すぎて買いたくない価格(Too Expensive)

  • 高いがまだ許容できる価格(Expensive)

  • 安いがまだ許容できる価格(Cheap)

  • 安すぎて品質が不安になる価格(Too Cheap)

この4つの回答から、価格の "受容ゾーン" や "最適価格帯" を導き出します

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

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

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

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

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

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

image.png

🧪 調査内容(仮定)

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

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

📌 調査対象の商品
image.png

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

image.png

📁 サンプルデータ

image.png

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

📶 コード解説

🧮 ライブラリの読み込みとタイトル表示

import streamlit as st
import pandas as pd
import numpy as np
from scipy.interpolate import interp1d
import plotly.graph_objects as go

st.set_page_config(layout="wide")
st.title("💴 Van Westendorp PSM 分析アプリ")

📌 st.set_page_config(layout="wide")
画面レイアウトをワイド表示に設定。
価格分析結果のグラフやテーブルが横に広く表示できる。

📨 output
image.png


🧮 データ読み込みとキャッシュ

@st.cache_data
def load_data(uploaded_file):
    return pd.read_csv(uploaded_file)

📌 @st.cache_data
Streamlitのキャッシュ機能。
同じ入力(例:同じファイル)に対しては関数の出力を再実行せずに再利用することで、

  • 毎回ファイルを再読み込みする手間を防ぎ、
  • アプリのレスポンスを高速化できる。

📌 load_data(uploaded_file)
アップロードされたCSVファイルを読み込み、データフレームとして返す。

🧮 交点計算(価格曲線の交差点を求める)

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

📌 diff = y1 - y2
2本の曲線(累積比率)の差を取ることで、交点の有無や位置を検出する準備。

📌 np.diff(np.sign(diff)) != 0
差分の符号が変わる箇所(正→負、負→正)=曲線が交差する箇所の直前のインデックスを取得。

📌 interp1d(...)
交点の前後2点を使って線形補間し、
差が0になるx座標(=曲線の交点、価格)を求める。

📌 except Exception
補間が失敗した場合(例:NaN・範囲外など)に None を返す。
また、交点が見つからない場合(sign_change が空)も None を返す安全設計。

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

PMC(Point of Marginal Cheapness)
 Too Cheap と Not Cheap の交点

PMU(Point of Marginal Expensiveness)
 Too Expensive と Not Expensive の交点

どちらも、適正価格帯の境界を判断する材料になります。

🧮 CSVファイルアップロードUI

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

📌 st.file_uploader()
ファイルアップローダー機能。

📌 type=["csv"]
CSVファイルのみ選択可能。

📌 if uploaded_file is not None…
ユーザーがファイルをアップロードしたとき、
そのファイルは uploaded_file に一時的に格納される。
この条件を満たすと、次の処理(データ読み込み・可視化など)に進める。

📨 output

image.png


この時点でのアプリ状態
1.タイトルや説明文などが表示。
2.CSVアップロードUIが表示され、ユーザーの操作を待機。
3.ファイルが選択されると、次ステップ(読み込み、集計、可視化など)へ移行。

🧮 フィルター設定

    if uploaded_file is not None:
        df = load_data(uploaded_file)
    st.markdown("#### 🔍 絞り込みフィルター")
    col1, col2, col3 = st.columns(3)

📌 if uploaded_file is not None:
ユーザーがCSVファイルをアップロードしていればload_data() 関数を使ってデータを読み込む。
📨 output
image.png

📌 st.markdown()
フィルターセクションのタイトル表示。

📨 output
image.png

📌 st.columns(3)
3分割カラムレイアウト。

📨 output
image.png

🎯col1:年齢 フィルター

        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

📌 with col1:
1列目(左側カラム)に年齢フィルターを配置。

📌 int(df['年齢'].min())
「年齢」列の最小値を整数で取得。

📌int(df['年齢'].max())
「年齢」列の最大値を整数で取得。

📌 selected_age_range = st.slider(...)
最小〜最大年齢スライダーを表示。
初期状態:全範囲が選択された状態

📌 if '年齢' in df.columns:
「年齢」列が存在する場合にのみスライダーを表示。
→ 安全性と柔軟性を担保する条件分岐。

📨 output
💡スライダーなので、マウス操作で簡単に指定できます。

image.png


🎯性別/職業フィルター

        # 性別フィルター
        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

📌 df['列名'].dropna().unique().tolist()
欠損値を除いたユニークな選択肢(カテゴリ)をリスト化。
→ セレクトボックスやチェックリストの 候補一覧として使用。

📌 st.session_state
Streamlitの状態管理機能。
ユーザーの選択内容を保持し、再実行後も選択状態を維持できる。
初回表示時は 全選択状態 に設定。

📌 st.multiselect(...)
複数選択可能なUIコンポーネント。

  • options=... で候補を指定

  • default=... で初期選択状態を指定(セッションから取得)

    📨 output
    💡チェックボックスで選択可能です。

image.png


🎯 平均購入単価・SNS利用時間フィルター

        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

📌 int(df['列名'].min()) / int(df['列名'].max())
指定列の 最小・最大値 を整数で取得。
→ st.slider() の範囲指定に使用。

📌 st.slider(...)
Streamlitの スライダーUIコンポーネント。
数値の範囲を選択させたいときに使用。

  • 第1〜3引数:最小値, 最大値, 初期値(タプル)

    📨 output
    💡スライダーなので、マウス操作で簡単に指定できます。
    image.png


🎯col2:キャラ傾向/重要視すること/購買頻度フィルター

    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

        # 重要視すること フィルター
        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 col2:
中央カラムにフィルターを配置する。

📌 df['列名'].dropna().unique().tolist()
ユニークな選択肢を欠損除外してリスト化。

📌 st.session_state (状態保持)
初期表示では すべての選択肢を有効化(全選択)。
ユーザーが選択を変更すると session_stateに反映され、再読み込み時も状態が保持される。

📨 output
💡チェックボックスで選択可能です。
image.png


🎯col3:購入スタイルフィルター

「購入スタイル(購買行動)」に関する選択肢を複数選べるようにし、
さらに「全て選択」「全て解除」などの一括操作ボタンも備えたUIを実装しています。

    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

📌 with col3:
3列目(右カラム)に配置。

📌 if "selected_style" not in st.session_state:
初回表示時に、各スタイル項目を True(チェックオン)で初期化する辞書を作成。
→ チェック状態をセッションで記憶し、再描画時に反映。

📌 colA, colB = st.columns(2)
「✅ 全て選択」「❌ 全て解除」ボタンを横並びで配置。

📌 st.button("✅ 全て選択") / ("❌ 全て解除")
クリックで全項目のチェック状態を一括更新。

📌 st.checkbox(s, value=...)
各スタイル項目をチェックボックスとして表示し、セッション状態に従ってオン/オフを制御。
チェックされた項目のみselected_styleに追加。データ抽出などに活用可能。

📨 output
💡ラジオボタンで選択可能です。
「✅ 全て選択」「❌ 全て解除」でチェック状態を一括更新できます。

image.png

❓購入スタイルだけラジオボタンなのはなぜ?❓
選択肢(ワード)をすべて表示して選びやすくするためです。
他の項目とUIが異なるのは意図的なデザインです。


🧮 グラフ 補助線の表示切り替え

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

📌 st.checkbox(...)
チェックボックスを表示。
ユーザー操作でオン/オフを切り替え可能。

📌 value=True
チェックボックスの初期状態を「オン(True)」に設定。
→ ページ読み込み直後は 補助線が表示される状態。

📌 show_lines
チェックボックスの状態(True =表示、False =非表示)を受け取る変数。
→ グラフ描画の条件分岐などに使う。

📨 output
💡補助線の表示・非表示をチェックボックスで操作できます。
image.png

image.png


🧮 データのフィルター処理(絞り込み処理)

 # フィルター処理
    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)]

📌between(start, end)
スライダーで範囲指定。
両端を含む(inclusive=Trueがデフォルト)。

📌isin
チェックボックスで選択。
(データ型:リスト形式)


🧮 ブランドごとのタブ生成とデータフィルタリング

    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)

📌labels
Van Westendorp分析で用いる価格感度指標ラベル一覧。
「あまりに安すぎる(too_cheap)」「安い(cheap)」「高い(expensive)」「あまりに高すぎる(too_expensive)」を表す。

📌brands
df のカラム名から、上記のラベルを含む列を抽出し、
ブランド名_ラベル の形式からブランド名部分のみ取り出して重複除去&ソート。
例)Sony_too_cheap, Sony_cheap, Apple_expensive の場合 → ['Apple', 'Sony']

📌tabs = st.tabs(brands)
ブランドごとのタブを作成し、切り替え表示を可能にする。

📨 output
💡タブを切り替えることで、ブランドごとのグラフを表示できます。
image.png


🧮 タブを使ったブランド別表示

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

📌results = []
後で分析結果などを格納するリスト。初期化。

📌num_people
フィルター済みデータの行数(人数などのサンプル数)。

📌for tab, brand in zip(tabs, brands):
タブとブランド名をペアでループ。
各タブ内で、そのブランドのデータを表示・分析する。

📌brand_cols
現在のブランドに対応する指標列をリスト化。
例)ブランドが "Sony" の場合、Sony_too_cheap, Sony_cheap など該当列を抽出。

📌df_brand
そのブランドの指標列に1つでもデータがある行だけ抽出。

📌notnull().any(axis=1)
複数列のうちどれか1つでも非欠損なら該当行を残す。

📌if df_brand.empty:
対象ブランドのデータが1件もなければ、警告表示し処理をスキップ。


🧮 価格データの集計と累積比率計算

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],
}

📌 price_data
各ラベル(too_cheapなど)ごとに、ブランドの該当列から非欠損値を整数型配列として抽出し辞書化。

📌 valid_arrays
price_data の中で要素数が0でない配列のみ抽出。
有効な価格データがあるか確認。

📌 if not valid_arrays
有効なデータが一つもない場合、警告を出して処理をスキップ。

📌 all_prices
有効価格データの最小値から最大値まで、100刻みの価格範囲配列を作成。
累積比率計算のX軸となる価格リスト。

📌 n
データ件数(行数)。累積比率の分母。

📌 cumulative
4つの価格感度ラベルごとに、各価格pに対する累積比率を計算。

  • too_cheap, cheap は価格以上の件数割合
  • expensive, too_expensive は価格以下の件数割合
    これによりVan Westendorp分析に必要な累積比率曲線を作成。

🧮 価格曲線の交点計算とプロット

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

📌 find_intersection(...)
2つの累積比率曲線の交点を計算。心理的価格閾値(例:PMC、PMEなど)を求める。

📌 opp, idp, pme, pmc
Van Westendorp分析で重要な4つの価格閾値(交点)。

  • opp:安すぎる(cheap)と高い(expensive)の交点
  • idp:あまりに安すぎる(too_cheap)とあまりに高すぎる(too_expensive)の交点
  • pme:安い(cheap)とあまりに高すぎる(too_expensive)の交点
  • pmc:高い(expensive)とあまりに安すぎる(too_cheap)の交点

📌 fig = go.Figure()
Plotlyでグラフを初期化。

📌 fig.add_trace(...)
各価格感度曲線(too_cheap, cheap, expensive, too_expensive)を色分けしてプロット。
縦軸は累積比率を%表示に変換(×100)。

🧮 補助線・注釈の表示とグラフレイアウト設定

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

📌 if show_lines:
ユーザーが補助線表示チェックボックスをオンにしている場合にのみ処理を実行。

📌 for val, name, color in zip(...):
交点の価格値(val)、名称(name)、線の色(color)をセットにしてループ処理。

📌 if val:
交点の値が存在するときのみ実行(Noneの場合はスキップ)。

📌 fig.add_vline(...)
グラフに縦の破線の補助線を引く。位置は交点の価格。

📌 fig.add_annotation(...)
補助線の近くにラベル(交点名)を縦書き(90度回転)で表示。
色・フォントサイズ・背景色も指定。

📌 fig.update_layout(...)
グラフのタイトル、軸ラベル、サイズ、ホバー挙動、x軸表示形式を設定。

📌 col_info, col_graph = st.columns([1, 2])
情報表示用カラム(幅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)

📌 with col_info:
左側カラムにブランド別の指標テキストや表を表示。

📌 st.markdown / st.write
ブランド名と該当人数、4つの心理的価格閾値(OPP, IDP, PMC, PME)をラベル付きで表示。
価格は丸めて整数表示し、計算不可の場合は「計算不可」と表示。

📌 results.append({...})
ブランド別の価格指標を辞書としてリストに保存。
後でまとめて表形式で扱えるように蓄積。

📌 pd.DataFrame(results)
蓄積した結果リストをデータフレーム化。
最新ブランドの行を抽出して表示。

📌 brand_row.style.format(...)
ブランド名以外の数値列を小数点以下切り捨て(整数表示)でスタイリング。

📌 with col_graph:
左側カラムにブランド別の指標テキストや表を表示。

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

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

image.png


🧮フィルター前データによるブランド別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
        })

📌 results_before_filter = []
フィルター前の全データを対象に、ブランドごとのPSM価格指標を格納するリストを初期化。

📌 for brand in brands:
全ブランドをループ処理し、ブランドごとの集計を実施。

📌 brand_cols
該当ブランドのPSMラベル列(too_cheap, cheap, expensive, too_expensive)を抽出。

📌 df_brand = df[...]
フィルター前の元データから該当ブランドの指標列に1つでもデータがある行を抽出。

📌 price_data
ブランドの各ラベル列から欠損を除き、整数型配列に変換して辞書化。

📌 valid_arrays
有効な価格データ配列だけ抽出。なければ次ブランドへスキップ。

📌 all_prices
最小値〜最大値まで100円刻みの価格リスト作成。累積比率計算用。

📌 cumulative
Van Westendorp分析に必要な4曲線の累積比率を計算。

📌 find_intersection(...)
4つの価格閾値(OPP, IDP, PMC, PME)を交点から計算。

📌 results_before_filter.append({...})
ブランド名と4つの価格指標を辞書化してリストに追加。


🧮 フィルター適用前のブランド別PSM指標

    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 != "ブランド"}))

📌 st.markdown("---")
区切り線を表示。

📌 st.markdown("…PSM 指標一覧")
フィルター前のブランド別PSM指標のタイトル表示。

📌 summary_df_before = pd.DataFrame(results_before_filter)
結果リストをデータフレーム化。

📌 st.markdown(f"調査人数:{len(df)}人")
全データ件数(調査人数)を表示。

📌 st.dataframe(...)
PSM指標一覧を表形式で表示。
ブランド名以外の数値は小数点以下切り捨て(整数表示)。

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

image.png


🧮 CSVファイルが未アップロードの場合の処理を定義

else:
    st.info("CSVファイルをアップロードしてください。")

📨 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_app.py

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

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

📨 output
image.png


📊 Celesta ブランドの価格感度分析(n=90)

image.png

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

🧭 価格の意味とポイント

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

💡まとめ

✅ 7,000円 は「高くも安くも感じない心理的な真ん中」
✅ 6,571円 を「実際の販売価格」にすれば、最も納得されやすく、売れやすい可能性が高い
⚠️ 5,912円以下だと「安すぎて不安」
⚠️ 7,600円以上だと「高くて買わない」

この商品(Celesta)は、
最適価格(OPP)である「約6,570円」が販売価格としておすすめです

理由は、

  • OPPは消費者が最も納得しやすく購入意欲が高まる価格帯であること、
  • IDP(7,000円)より少し低めで、買いやすさと納得感のバランスが良いこと、
  • 価格受容範囲の下限(5,912円)より高く、上限(7,600円)より低いため売れやすい価格帯であること、
    です。

つまり、
6,500~6,600円あたりに設定するのが、売れやすく顧客にも納得されやすい理想的な価格帯 と言えます。


🎯 最も大事な無関心価格(IDP)とは

「高くてもダメ、安すぎてもダメ」――そのちょうど真ん中が IDP。

✅ IDPは“ちょうどいい”価格帯
・これより高いと「ちょっと高いかも…」と感じて買われにくくなる
・これより安いと「安すぎて不安…」と感じて信用されなくなる

✅ 安心して買ってもらえる価格
IDPは「高すぎず・安すぎず」だから、買う側が安心しやすい価格。
納得感も高く、売る側にとっても成功しやすい価格帯です。

🎯 無関心価格(IDP)と適正価格の違い

ケースによって、どちらを重視すべきかは異なります。

用語 意味 使う視点
無関心価格(IDP) 消費者が「高くも安くも感じない」心理的にちょうど良い価格 消費者の印象・感覚
適正価格 商品の価値やコスト、市場や競合を考慮した妥当な価格 経済的・論理的妥当性

つまり…

IDP(無関心価格) 
 ➡「お客さんがパッと見て、違和感なく受け入れられる価格」= 感覚

適正価格 
 ➡「企業・消費者の両方が“論理的に納得”できる価格」= 理屈

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

📊 フィルター

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

🔍まずはフィルター条件を指定します。
📨 output

image.png

🔍指定したフィルターはタブを切り替えても保持されるので、他のブランドのグラフや指標も同じ条件で確認できます。
📨 output

image.png

📊 フィルターで見ることの意味

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

まとめ

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

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

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

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

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?