「値上げしたら売れなくなる」を数字で言えるか
原材料費の高騰を受けて商品を10%値上げする。売上はどうなるか。
「売れにくくなるとは思うが、どれくらいかは…」という感覚論では意思決定が難しい。ここで使うのが価格弾力性(Price Elasticity of Demand:PED)だ。
価格弾力性 = 需要量の変化率 ÷ 価格の変化率 = (ΔQ / Q) ÷ (ΔP / P)
価格弾力性が -2 なら「価格が10%上がると需要量が20%減る」ことを意味する。弾力性が -0.3 なら「10%値上げで3%しか減らない」ので値上げしても利益が出やすい。
| 弾力性の絶対値 | 解釈 | 値上げへの対応 |
|---|---|---|
| > 1 | 弾力的(価格に敏感) | 値上げで販売数が大きく減る |
| = 1 | 単位弾力的 | 売上は変わらない |
| < 1 | 非弾力的(価格に鈍感) | 値上げしても販売数はほとんど減らない |
| 0 | 完全非弾力的 | 価格に無関係に需要が一定 |
今回は価格改定履歴と注文データを使って、商品ごとの価格弾力性をSQLで計算する。さらに対数回帰の近似式をウィンドウ関数で実装し、精度の高い弾力性推定を行う。
価格弾力性の計算方法
方法1:単純な弧弾力性(2時点の比較)
PED = ((Q2 - Q1) / ((Q2 + Q1) / 2)) ÷ ((P2 - P1) / ((P2 + P1) / 2))
中点法(Midpoint Method)とも呼ばれる。価格改定前後の2時点を比較する最もシンプルな方法だ。
方法2:対数回帰(複数時点を使った推定)
ln(Q) = α + β × ln(P) + εここで β が価格弾力性の推定値
需要関数を対数変換することで、β が直接価格弾力性になる。複数の価格変動を使うため、単純な2時点比較より安定した推定ができる。SQLでは LN() 関数と、最小二乗法の公式をウィンドウ関数で近似実装する。
使用するテーブル
-- product_price_history(価格改定履歴)
-- product_id, price, valid_from, valid_to
-- order_items(注文明細)
-- order_id, product_id, quantity, unit_price
-- orders(注文テーブル)
-- order_id, order_date, statusSTEP 1 ― 価格×期間ごとの販売数量を集計する
まず「この価格だったとき、月にどれだけ売れたか」というデータを作る。
-- STEP1: 価格帯別・月次の販売数量を集計する
WITH price_history AS (
-- SCDテーブル。valid_toがNULLなら現在有効
SELECT
product_id,
price,
valid_from,
COALESCE(valid_to, TIMESTAMP '2099-12-31 23:59:59') AS valid_to_safe
FROM product_price_history
),
monthly_sales AS (
SELECT
oi.product_id,
DATE_TRUNC('month', o.order_date) AS sales_month,
SUM(oi.quantity) AS total_quantity,
COUNT(DISTINCT o.order_id) AS order_count,
-- その月の適用価格(月初に有効だった価格)
MAX(ph.price) AS price_in_month
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN price_history ph
ON oi.product_id = ph.product_id
-- 月初日が価格の有効期間内にある
AND DATE_TRUNC('month', o.order_date) >= CAST(ph.valid_from AS DATE)
AND DATE_TRUNC('month', o.order_date) < CAST(ph.valid_to_safe AS DATE)
WHERE o.status = 'completed'
AND o.order_date >= DATE '2023-01-01'
AND o.order_date < DATE '2025-01-01'
GROUP BY oi.product_id, DATE_TRUNC('month', o.order_date)
)
SELECT *
FROM monthly_sales
ORDER BY product_id, sales_month;出力イメージ(商品 P001)
| product_id | sales_month | total_quantity | order_count | price_in_month |
|---|---|---|---|---|
| P001 | 2023-01-01 | 412 | 298 | 1,800 |
| P001 | 2023-02-01 | 389 | 271 | 1,800 |
| P001 | 2024-04-01 | 284 | 204 | 1,980 |
| P001 | 2024-05-01 | 271 | 198 | 1,980 |
| P001 | 2024-08-01 | 521 | 378 | 1,680 |
| P001 | 2024-09-01 | 498 | 361 | 1,680 |
価格が1,800円→1,980円(値上げ10%)で販売数が約30%減少し、1,680円(値下げ)で大きく増えている様子が見える。
STEP 2 ― 弧弾力性で価格改定前後を2時点比較する
-- STEP2: 弧弾力性(中点法)で価格改定前後を比較する
WITH price_history AS (
SELECT product_id, price, valid_from,
COALESCE(valid_to, TIMESTAMP '2099-12-31 23:59:59') AS valid_to_safe
FROM product_price_history
),
monthly_sales AS (
SELECT
oi.product_id,
DATE_TRUNC('month', o.order_date) AS sales_month,
SUM(oi.quantity) AS total_quantity,
MAX(ph.price) AS price_in_month
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN price_history ph
ON oi.product_id = ph.product_id
AND DATE_TRUNC('month', o.order_date) >= CAST(ph.valid_from AS DATE)
AND DATE_TRUNC('month', o.order_date) < CAST(ph.valid_to_safe AS DATE)
WHERE o.status = 'completed'
AND o.order_date >= DATE '2023-01-01'
AND o.order_date < DATE '2025-01-01'
GROUP BY oi.product_id, DATE_TRUNC('month', o.order_date)
),
price_change_periods AS (
-- 価格改定が起きた前後の月を特定する
SELECT
product_id,
price_in_month,
sales_month,
total_quantity,
LAG(price_in_month) OVER (PARTITION BY product_id ORDER BY sales_month)
AS prev_price,
LAG(total_quantity) OVER (PARTITION BY product_id ORDER BY sales_month)
AS prev_quantity,
-- 価格が変化したか
CASE WHEN price_in_month
<> LAG(price_in_month) OVER (PARTITION BY product_id ORDER BY sales_month)
THEN 1 ELSE 0 END AS is_price_change
FROM monthly_sales
),
arc_elasticity AS (
SELECT
product_id,
sales_month AS change_month,
prev_price AS price_before,
price_in_month AS price_after,
prev_quantity AS qty_before,
total_quantity AS qty_after,
-- 価格変化率(中点法)
ROUND(
(price_in_month - prev_price) * 1.0
/ ((price_in_month + prev_price) / 2.0)
, 4) AS price_change_pct,
-- 数量変化率(中点法)
ROUND(
(total_quantity - prev_quantity) * 1.0
/ ((total_quantity + prev_quantity) / 2.0)
, 4) AS qty_change_pct,
-- 弧弾力性(価格弾力性)
ROUND(
((total_quantity - prev_quantity) * 1.0
/ ((total_quantity + prev_quantity) / 2.0))
/
NULLIF(
(price_in_month - prev_price) * 1.0
/ ((price_in_month + prev_price) / 2.0)
, 0)
, 4) AS arc_elasticity
FROM price_change_periods
WHERE is_price_change = 1 -- 価格改定が発生した月のみ
)
SELECT
product_id,
change_month,
price_before,
price_after,
qty_before,
qty_after,
ROUND(price_change_pct * 100, 2) AS price_change_pct,
ROUND(qty_change_pct * 100, 2) AS qty_change_pct,
arc_elasticity,
-- 弾力性の解釈
CASE
WHEN arc_elasticity < -1 THEN '弾力的(価格敏感)'
WHEN arc_elasticity BETWEEN -1 AND 0 THEN '非弾力的(価格鈍感)'
WHEN arc_elasticity > 0 THEN '正の弾力性(ギッフェン財の可能性)'
ELSE '計算不可'
END AS elasticity_type
FROM arc_elasticity
ORDER BY product_id, change_month;出力イメージ
| product_id | change_month | price_before | price_after | price_change_pct | qty_change_pct | arc_elasticity | elasticity_type |
|---|---|---|---|---|---|---|---|
| P001 | 2024-04 | 1,800 | 1,980 | +9.52% | -29.8% | -3.13 | 弾力的 |
| P001 | 2024-08 | 1,980 | 1,680 | -16.40% | +60.2% | -3.67 | 弾力的 |
| P003 | 2024-06 | 1,600 | 1,750 | +8.97% | -8.1% | -0.90 | 非弾力的 |
| P008 | 2024-03 | 2,400 | 2,700 | +11.76% | -4.2% | -0.36 | 非弾力的 |
P001(シャンプー)は弾力性 -3.13 と非常に弾力的だ。10%値上げで31%も需要が減る。競合製品に簡単に代替できる商品だと推測される。P008(日焼け止め)は -0.36 と非弾力的で、10%値上げでも4%しか減らない。季節需要が強く代替が難しい商品の特性だ。
STEP 3 ― 対数回帰による弾力性推定(複数時点版)
複数の価格変動を使って、より安定した弾力性を推定する。対数線形モデル ln(Q) = α + β × ln(P) の最小二乗法の公式は
β = (n × Σ(lnP × lnQ) - ΣlnP × ΣlnQ) / (n × Σ(lnP)² - (ΣlnP)²)
これは相関係数の計算と全く同じ構造だ。CORR() 関数でも計算できる。
-- STEP3: 対数回帰による弾力性(複数時点を使った安定推定)
WITH price_history AS (
SELECT product_id, price, valid_from,
COALESCE(valid_to, TIMESTAMP '2099-12-31 23:59:59') AS valid_to_safe
FROM product_price_history
),
monthly_sales AS (
SELECT
oi.product_id,
DATE_TRUNC('month', o.order_date) AS sales_month,
SUM(oi.quantity) AS total_quantity,
MAX(ph.price) AS price_in_month
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN price_history ph
ON oi.product_id = ph.product_id
AND DATE_TRUNC('month', o.order_date) >= CAST(ph.valid_from AS DATE)
AND DATE_TRUNC('month', o.order_date) < CAST(ph.valid_to_safe AS DATE)
WHERE o.status = 'completed'
AND o.order_date >= DATE '2023-01-01'
AND o.order_date < DATE '2025-01-01'
GROUP BY oi.product_id, DATE_TRUNC('month', o.order_date)
),
log_transformed AS (
SELECT
product_id,
sales_month,
total_quantity,
price_in_month,
LN(price_in_month) AS ln_price,
LN(total_quantity) AS ln_quantity
FROM monthly_sales
WHERE total_quantity > 0 -- ゼロは対数変換不可
AND price_in_month > 0
)
SELECT
product_id,
COUNT(*) AS data_points,
-- CORR() で対数回帰の傾き(= 価格弾力性)を計算
ROUND(CORR(ln_price, ln_quantity), 4) AS log_elasticity,
-- 方法1(手動計算):最小二乗法の公式
ROUND(
(COUNT(*) * SUM(ln_price * ln_quantity) - SUM(ln_price) * SUM(ln_quantity))
/ NULLIF(
COUNT(*) * SUM(ln_price * ln_price) - SUM(ln_price) * SUM(ln_price)
, 0)
, 4) AS log_elasticity_ols,
-- 切片 α の計算
ROUND(
(SUM(ln_quantity) - (
(COUNT(*) * SUM(ln_price * ln_quantity) - SUM(ln_price) * SUM(ln_quantity))
/ NULLIF(COUNT(*) * SUM(ln_price * ln_price) - SUM(ln_price) * SUM(ln_price), 0)
) * SUM(ln_price)
) / COUNT(*)
, 4) AS log_intercept,
-- 推定の決定係数(R²)
ROUND(POWER(CORR(ln_price, ln_quantity), 2), 4) AS r_squared,
-- 価格の範囲
ROUND(MIN(price_in_month), 0) AS min_price,
ROUND(MAX(price_in_month), 0) AS max_price,
-- 弾力性の解釈
CASE
WHEN CORR(ln_price, ln_quantity) < -1 THEN '弾力的(価格敏感)'
WHEN CORR(ln_price, ln_quantity) BETWEEN -1 AND 0 THEN '非弾力的'
ELSE '正の弾力性(要確認)'
END AS elasticity_type
FROM log_transformed
GROUP BY product_id
HAVING COUNT(*) >= 6 -- 最低6時点のデータがある商品のみ
ORDER BY log_elasticity ASC;出力イメージ
| product_id | data_points | log_elasticity | r_squared | min_price | max_price | elasticity_type |
|---|---|---|---|---|---|---|
| P001 | 18 | -3.24 | 0.8812 | 1,680 | 1,980 | 弾力的(価格敏感) |
| P003 | 18 | -0.89 | 0.7243 | 1,600 | 1,750 | 非弾力的 |
| P008 | 18 | -0.34 | 0.6124 | 2,400 | 2,700 | 非弾力的 |
| P012 | 18 | -1.52 | 0.7891 | 900 | 1,200 | 弾力的(価格敏感) |
CORR(ln_price, ln_quantity) が直接、対数線形モデルの価格弾力性推定値になる。R²(決定係数)が0.7以上であれば、このモデルが価格と販売数の関係をある程度説明できていると言える。
STEP 4 ― 価格弾力性を使って最適価格を推定する(完成版)
弾力性が分かると「どの価格なら売上(または利益)が最大になるか」を計算できる。
売上最大化の観点では、価格弾力性 = -1 のときが売上のピーク(これより弾力的なら値下げ、非弾力的なら値上げが有利)
-- STEP4: 価格弾力性から最適価格を推定する
WITH elasticity_data AS (
-- STEP3の結果を使う(商品ごとの弾力性と切片)
SELECT
product_id,
log_elasticity AS beta, -- 弾力性
log_intercept AS alpha, -- 切片
-- 現在価格(最新の価格)
MAX(price_in_month) AS current_price,
-- 現在の月次販売数(最新2ヶ月平均)
AVG(CASE WHEN sales_month >= DATE '2024-08-01' THEN total_quantity END)
AS current_qty
FROM (
-- STEP3のlog_transformedとlog_elasticityの結果を結合(実際はCTEとして連結する)
SELECT ms.product_id, ms.sales_month, ms.total_quantity, ms.price_in_month,
ols.log_elasticity, ols.log_intercept
FROM monthly_sales ms
JOIN (SELECT product_id,
ROUND((COUNT(*) * SUM(ln_price * ln_quantity) - SUM(ln_price) * SUM(ln_quantity))
/ NULLIF(COUNT(*) * SUM(ln_price * ln_price) - SUM(ln_price) * SUM(ln_price), 0), 4) AS log_elasticity,
ROUND((SUM(ln_quantity) - ((COUNT(*) * SUM(ln_price * ln_quantity) - SUM(ln_price) * SUM(ln_quantity))
/ NULLIF(COUNT(*) * SUM(ln_price * ln_price) - SUM(ln_price) * SUM(ln_price), 0)) * SUM(ln_price)) / COUNT(*), 4) AS log_intercept
FROM log_transformed GROUP BY product_id) ols ON ms.product_id = ols.product_id
)
GROUP BY product_id, log_elasticity, log_intercept
)
SELECT
product_id,
ROUND(beta, 3) AS price_elasticity,
ROUND(current_price, 0) AS current_price,
ROUND(current_qty, 0) AS current_monthly_qty,
-- 現在の月次売上推定
ROUND(current_price * current_qty, 0) AS current_monthly_revenue,
-- 価格別シナリオ分析(-20% / -10% / +10% / +20%)
-- 数量予測:Q_new = Q_current × (P_new / P_current)^beta
ROUND(current_price * 0.80, 0) AS price_minus_20pct,
ROUND(current_qty * POWER(0.80, beta), 0) AS qty_at_minus_20pct,
ROUND(current_price * 0.80 * current_qty * POWER(0.80, beta), 0) AS rev_at_minus_20pct,
ROUND(current_price * 0.90, 0) AS price_minus_10pct,
ROUND(current_qty * POWER(0.90, beta), 0) AS qty_at_minus_10pct,
ROUND(current_price * 0.90 * current_qty * POWER(0.90, beta), 0) AS rev_at_minus_10pct,
ROUND(current_price * 1.10, 0) AS price_plus_10pct,
ROUND(current_qty * POWER(1.10, beta), 0) AS qty_at_plus_10pct,
ROUND(current_price * 1.10 * current_qty * POWER(1.10, beta), 0) AS rev_at_plus_10pct,
ROUND(current_price * 1.20, 0) AS price_plus_20pct,
ROUND(current_qty * POWER(1.20, beta), 0) AS qty_at_plus_20pct,
ROUND(current_price * 1.20 * current_qty * POWER(1.20, beta), 0) AS rev_at_plus_20pct
FROM elasticity_data
ORDER BY ABS(beta) DESC;出力イメージ(P001: シャンプー、弾力性 -3.24)
| 価格シナリオ | 価格 | 予測数量 | 予測月次売上 |
|---|---|---|---|
| 現在 | 1,680円 | 498個 | 837,240円 |
| -20% | 1,344円 | 1,108個 | 1,489,152円 |
| -10% | 1,512円 | 734個 | 1,109,808円 |
| +10% | 1,848円 | 337個 | 622,776円 |
| +20% | 2,016円 | 229個 | 461,664円 |
P001 は弾力性 -3.24 と非常に弾力的なため、20%値下げすると売上が78%増加するシミュレーション結果が出た。逆に10%値上げで売上が26%減少する。現在の価格は既に割引価格(セール価格1,680円)なので、定価に戻すことで売上が大きく落ちることが分かる。
実務での運用ヒント
① 競合価格・季節性の交絡要因に注意
価格変化の時期が季節需要のピーク(年末・夏)と重なっていると、価格弾力性が過大・過小評価される。「4月に値上げしたら5月のGW需要で数量が戻った」のような場合、季節性の影響を除去する必要がある。季節ダミー変数を加えた重回帰(SQLでは相関係数の拡張が必要)に移行するか、価格変化の前後を「同じ季節の年を比較する」設計にする。
② データ点数の確認
STEP3で HAVING COUNT(*) >= 6 を設定したのは、データ点数が少ないと回帰が不安定になるためだ。価格改定が1回しかない商品は弧弾力性(STEP2)のみを使い、対数回帰は使わない。
③ 価格弾力性は区間ごとに異なる
対数線形モデルは「すべての価格帯で弾力性が一定」という仮定を置いている。実際には高価格帯と低価格帯で弾力性が異なることが多い(高価格帯ではより弾力的になる傾向)。より精度を上げるには、価格を高・中・低の帯に分けて別々に弾力性を推定する。
まとめ
価格弾力性をSQLで計算するアプローチをまとめる。
- 弧弾力性(中点法) — 価格改定前後の2時点を比較する。実装が簡単で直感的
- 対数回帰(OLS) —
LN(price)とLN(quantity)の相関係数または最小二乗法で計算。複数時点を使うため安定した推定ができる - シナリオ分析 — 弾力性 β を使って
Q_new = Q_current × (P_new / P_current)^βで価格別の予測数量・売上を計算する
弾力性が -3 なら値下げが有効、-0.3 なら値上げが有効という判断は、感覚論ではなくデータから導き出されたものだ。「値上げしたら売れなくなるか」という問いに、会議室でSQLの結果を見せながら答えられることが、このクエリの最大の価値だ。