Lecture 15:Dynamic pricing — 企業の動学的価格設定

計量経済学II

作者

Kei Ikegami

重要この講義で押さえたいこと
  • 「dynamic pricing」という言葉には複数の意味が混ざっている。この講義では「企業の価格ルールが状態(時間・在庫・需給)の関数になっていること」として統一的に定義する。
  • 静学の独占価格(逆弾力性ルール)を復習し、そこに「時間」と「在庫」という2つの状態変数を足すと revenue management(RM)になる。
  • RMの核心は第13回で学んだbackward inductionの再演である。Bellman方程式 \(V(t,c) = \max_p\{\lambda(p)[p + V(t-1,c-1)] + (1-\lambda(p))V(t-1,c)\}\) を解くだけで、航空券の価格が「残り期間・残り座席」に応じてどう動くべきかが1枚のヒートマップになって出てくる。
  • surge pricing(ride-hailing)は「儲けるための値上げ」だけでなく「需給を再均衡へ動かす価格」として理解する。
  • コース全体の総括:需要推定(第2〜4回)× 因果推論(第7〜8回)× 異質性(第9〜11回)× 動学(第13〜15回)が、すべて「価格を賢く決める」という1つの実務課題に収束することを確認する。

前回までの道のりと、今日の位置

第13回では、消費者が「今の効用」と「将来への影響」を天秤にかけて選ぶ、forward-lookingな動学的離散選択モデルを学んだ。状態 \(x\)、行動 \(a\)、価値関数 \(V(x)\)、choice-specific value function \(v(x,a)\)、Bellman方程式、backward inductionという道具一式をそこで手に入れた。第14回では、その消費者側の動学を、スイッチングコストやロイヤルティ、セールに反応した買いだめといった具体的な現象に応用した。

第15回、最終回である今日は、視点を企業側に移す。第13回で身につけたBellman方程式とbackward inductionを、今度は企業の価格設定問題へ使い回すのが今日の出発点である。ただし、今日の数値例は「消費者がセールを戦略的に待つ」モデルを組み込むのではなく、各期の購買確率を静学的なbinary logitで表し、企業だけが残り期間・在庫を見て動学的に価格を決める。消費者の戦略的待機や買いだめも同時に扱うモデルは、このベンチマークを拡張した先にある。

そして今日はコースの最終回でもある。第2回の logit 需要、第3回の価格内生性、第4回の供給側 Bertrand-Nash、第6回のコンジョイント、第7〜8回の因果推論、第9〜11回の異質性とターゲティング、第13〜14回の動学。これらを全部使うと何ができるか。答えは「価格を最適化できる」だ。今日はその結線を最後に確認して、コースを締める。

70%くらい分かったら先に進もう。今日は最終回なので、細部よりも「全体がどうつながっているか」の実感を優先してほしい。

1. 「dynamic」の交通整理

「dynamic pricing」という言葉は、世間では驚くほど雑に使われている。ニュースやビジネス書で語られる「ダイナミックプライシング」を分解すると、少なくとも次の3つの話が混ざっている。

  1. 時間帯・需給で価格が変わる:電気料金のピーク時間帯課金、ライドシェアのサージ、深夜のタクシー割増。
  2. 販売期限と在庫で価格が変わる:航空券やホテルの価格が、出発日・チェックイン日が近づくにつれて、また空席数に応じて変わる。revenue management (RM) と呼ばれる領域。
  3. 顧客ごとに価格が変わる:会員ランクによる割引、購買履歴に基づくクーポン配布、過去の閲覧履歴を使ったパーソナライズ価格。

この3つは共通の需要推定を使い得るが、主に解く状態・識別・制約が異なる。(1)(2)は「同じ商品を、同じ客に対して、状況に応じて違う価格で売る」問題であり、(3)は「同じ状況で、違う客に対して違う価格で売る」問題だ。ニュースで「AIが個人の購買履歴から価格を決めている」という話と「航空券が満席に近づくと値上がりする」という話は、企業がやっていることとしては別物なのに、同じ「ダイナミックプライシング」という言葉で語られてしまう。

この講義では用語を曖昧にしないため、(1)(2)を dynamic pricing、(3)を personalized pricing と呼び分ける。両者を同時に実装することはできるが、同じ概念ではない。

企業の価格「ルール」が、時間・在庫・市場需要などの運用状態の関数になっている。 これをこの講義では dynamic pricing と呼ぶ。顧客の属性・履歴に応じる personalized pricing は隣接領域だが、区別する。

今日は(1)と(2)を扱う。(3)については、第11回のターゲティングとの接続だけを確認する。

ノートビジネスの現場で:「うちもダイナミックプライシングを」と言われたら

経営会議で「競合がAIで価格を変えているらしい。うちも導入しよう」という話が出ることがある。このとき最初にすべきことは「時間・在庫・需給に応じるdynamic pricingなのか、顧客に応じるpersonalized pricingなのか」を確認することだ。「閑散期・繁忙期で価格を変えたい」なら、今日学ぶ在庫・時間の状態管理が効いてくる。「優良顧客とライトユーザーで変えたい」ならpersonalized pricingであり、第11回の道具がそのまま十分とは限らない。価格や割引が二値・少数の離散armなら、各armの因果効果を識別できる実験と十分なoverlapのもとで、CATE推定やpolicy learningを使える場合がある。一方、連続価格や多数の価格水準を選ぶには、価格全体に対する需要曲線・dose-response・choice modelの推定、候補価格に十分なsupport / overlap、そして連続・多値処置に対応したpolicy learningが必要である。同じ言葉で会議が進むと、必要な技術もリスクも違うのに議論がかみ合わなくなる。

2. 静学ベンチマーク:逆弾力性ルールの復習

dynamic pricingを理解する前に、まず「動かない価格」つまり静学的な独占価格づけを復習しておく。すべてはここが出発点になる。

第2回で学んだように、企業が価格 \(p\) の財を1つだけ売っている独占企業だとする。需要量を \(q(p)\)、限界費用を \(mc\) とすると、利潤は

\[ \pi(p) = (p - mc)\, q(p) \]

これを \(p\) について最大化する一階条件を整理すると、おなじみの逆弾力性ルール(Lerner指数)が出てくる。

\[ \frac{p - mc}{p} = \frac{1}{|\eta(p)|}, \qquad \eta(p) = \frac{dq}{dp}\frac{p}{q} \]

マージン率は弾力性の逆数で決まる。弾力性を知らなければ、そもそも「適切な価格」を計算する土俵にすら立てない。 第2〜4回でロジットの弾力性 \(\eta_{jj} = -\alpha p_j(1-s_j)\) を推定する方法をあれだけ丁寧にやったのは、まさにこの土俵に立つためだった。

逆弾力性ルールは需要側の静学ベンチマークである。Revenue managementでは、これに状態ごとの在庫の機会費用(bid price)が加わる。surge pricingを二面市場として考えるなら、さらにドライバーの供給反応、空間的なマッチング摩擦、企業利潤か社会厚生かという目的関数も必要になる。したがってどちらも需要弾力性を無視できないが、静学的な逆弾力性ルールを適用し直すだけではない。

Rシミュ1:logit需要のもとでの最適価格

第2回のロジット需要を思い出そう。財1つ(買う/買わないの2択、outside optionが \(j=0\))に対する購買確率を

\[ \lambda(p) = \frac{\exp(\theta_0 - \alpha p)}{1 + \exp(\theta_0 - \alpha p)} \]

とする。市場規模を \(M\)、限界費用を \(mc = 0\) とすると(航空券1席のように限界費用がほぼゼロに近いケースを念頭に置く)、利潤は \(\pi(p) = M \cdot \lambda(p) \cdot p\) になる。これを価格についてプロットし、最適価格を数値的に探す。

theta0 <- 6.0
alpha  <- 0.045

lambda_p <- function(p, theta0, alpha) {
  u <- theta0 - alpha * p
  1 / (1 + exp(-u))
}

p_grid <- seq(1, 350, length.out = 400)
profit_grid <- lambda_p(p_grid, theta0, alpha) * p_grid

static_df <- data.frame(price = p_grid, profit = profit_grid)
p_star_static <- p_grid[which.max(profit_grid)]

ggplot(static_df, aes(x = price, y = profit)) +
  geom_line(color = "steelblue", linewidth = 1) +
  geom_vline(xintercept = p_star_static, linetype = "dashed", color = "firebrick") +
  annotate("text", x = p_star_static + 30, y = max(profit_grid) * 0.9,
           label = paste0("p* = ", round(p_star_static, 1)), color = "firebrick") +
  labs(x = "価格 p", y = "期待収益(1人あたり)",
       title = "logit需要のもとでの利潤カーブと最適価格")

この単一商品・二値logit・一定限界費用の設定では、\(p\ge mc\) 上の利潤は1つの山を持つ。ただし、これはlogit対数尤度の凹性とは別の問題である。複数商品、非線形費用、制約を加えれば利潤が大域的に凹とは限らないので、グリッド上の利潤カーブと一階条件の両方を確認する。

\(\pi(p) = M\lambda(p)(p-mc)\)\(p\) について微分してゼロと置くと

\[ \lambda'(p)(p-mc) + \lambda(p) = 0 \]

ロジットでは \(\lambda'(p) = -\alpha\lambda(p)(1-\lambda(p))\) なので、これを代入すると

\[ p^* = mc + \frac{1}{\alpha(1 - \lambda(p^*))} \]

という固定点方程式になる。初等関数だけでは閉形式にならないが、Lambert \(W\) 関数を使えば

\[ p^*=mc+\frac{1+W\!\left(\exp(\theta_0-\alpha mc-1)\right)}{\alpha} \]

とも書ける。本講義では特殊関数に依存せず、利潤を直接最大化する。なお本文の数値例は \(mc=0\) なので、最初の式へ代入すればよい。

この静学の1点、「弾力性を知らないと価格は決められない」が、今日の話全体の土台になる。この後出てくる時間・在庫・需給の状態がどれだけ複雑になっても、やっていることは「その状態のもとでの実効的な弾力性・機会費用を計算し、逆弾力性ルールを適用する」ことの繰り返しでしかない。

3. Peak-load pricing:時間で変わる価格

Steinerの最小モデル

電力会社を考える。需要は昼と夜で違う。昼の需要 \(q_{\text{day}}(p)\) は夜の需要 \(q_{\text{night}}(p)\) より大きいとしよう。発電容量 \(K\) を1単位用意するコストを \(c_K\)(固定費、季節や時間帯によらない)、実際に発電する限界費用を \(c_v\)(可変費)とする。

Steiner (1957) の洞察はシンプルだ。容量は「一番厳しい期」のために用意しなければならない。 昼の需要が容量制約に当たっているなら、容量費用 \(c_K\) を負担すべきなのは昼の客だけであり、夜の客は可変費用 \(c_v\) だけを払えばよい。逆に言えば、需要が少ない時間帯の客に容量費用を負担させるのは非効率だ。

最小の数値例で確認する。

# 昼と夜、2期間の需要(線形、簡易的に)
q_day   <- function(p) pmax(100 - 2 * p, 0)
q_night <- function(p) pmax(40 - 2 * p, 0)

c_v <- 5   # 可変費用(発電1単位あたり)
c_K <- 20  # 容量費用(容量1単位を用意するコスト、固定費)
K_capacity <- 50

# ケースA: 容量費用を両期間で分担すると仮定した場合の価格
# (素朴な「平均費用」的発想、非効率の例として)
p_naive <- c_v + c_K / 2

# ケースB: Steinerのルール。容量を「使う」期(昼)だけが容量費用を負担する。
# 夜は可変費用のみ。昼は可変費用+容量費用。
p_night_efficient <- c_v
p_day_efficient    <- c_v + c_K

peak_df <- data.frame(
  ルール = c("素朴な平均費用配分", "Steinerの効率的配分(昼)", "Steinerの効率的配分(夜)"),
  価格   = c(p_naive, p_day_efficient, p_night_efficient),
  需要量 = c(q_day(p_naive), q_day(p_day_efficient), q_night(p_night_efficient)),
  注記 = c("比較用:昼需要", paste0("K=", K_capacity, "に一致(容量制約がbinding)"),
           paste0("30<K=", K_capacity, "(slack)"))
)
kable(peak_df, digits = 1, caption = "peak-load pricing:ピークだけが容量の影価格を負担")
peak-load pricing:ピークだけが容量の影価格を負担
ルール 価格 需要量 注記
素朴な平均費用配分 15 70 比較用:昼需要
Steinerの効率的配分(昼) 25 50 K=50に一致(容量制約がbinding)
Steinerの効率的配分(夜) 5 30 30<K=50(slack)

効率的ルールでは昼の需要は \(q_D(25)=50\) なので設備容量 \(K=50\) をちょうど使い切り、夜は \(q_N(5)=30<K\) なので容量制約が緩い。昼の価格に入る \(c_K\) は、容量投資の限界費用と一致する影価格である。素朴に容量費用を両期間へ機械的に折半すると、どの期が実際に限界容量を必要としているかを反映できない。

Real-time pricing と日本の実例

Borenstein (2005) は、電力のreal-time pricing(RTP:卸売市場の変動をそのまま小売価格に反映させる仕組み)について、長期的な効率性の観点から論じている。多くの家庭は現在も「時間帯によらない一律料金」で電気を買っており、これはピーク時間帯の需要を過大にし、オフピーク時間帯の需要を過小にする非効率を生む。RTPを導入して価格が需給の逼迫を正しく反映するようにすれば、需要側が自然にピークを平準化するインセンティブを持つ、というのが基本的な主張である。

日本でも時間帯別電力料金(深夜電力割引など)は昔からあるし、近年は再エネの出力変動に対応するためのダイナミックプライシング実証実験も各地で行われている。考え方はSteinerのモデルと同じで、「容量が逼迫する時間帯には高い価格を、余裕がある時間帯には低い価格を」という原理の実装だ。

ノートビジネスの現場で:コンビニの発注と「時間帯別の機会費用」

電力の話は大掛かりに聞こえるが、同じ発想は身近な小売にもある。コンビニの弁当売り場を考えると、昼のピーク時間帯に売り切れることは大きな機会損失(来た客が買えずに他店に流れる)だが、深夜に売れ残ることも廃棄コストという別の損失を生む。値引きのタイミングを「時間帯別の需給逼迫度」で決める、というのは発想としてはpeak-load pricingと同じ機会費用の考え方に立っている。この後のRMのbid price(席1つの機会費用)の考え方も、根っこは同じだ。

4. Revenue management:残り期間と在庫で変わる価格

いよいよ今日の核心に入る。航空券やホテルの価格が、なぜ「予約が進むにつれて」「出発日が近づくにつれて」動くのかを、動的計画法で正面から解く。

設定

ある航空券1路線を考える。販売開始からの残り日数を \(t\)\(t=T\) が今日、\(t=0\) が出発日=販売終了)、残り座席数を \(c\)\(c=0\) なら完売)とする。毎期、確率 \(\bar\lambda\)(到着確率)で1人の客が訪れる。到着した客は、提示された価格 \(p\) に対して、第2〜4回と同じ形のロジットで購買を決める。

\[ \Pr(\text{購入} \mid p, \text{到着}) = \frac{\exp(\theta_0 - \alpha p)}{1 + \exp(\theta_0 - \alpha p)} \]

これに到着確率をかけたものを、この講義では1期あたりの購買確率 \(\lambda(p)\) と呼ぶ。

\[ \lambda(p) = \bar\lambda \cdot \frac{\exp(\theta_0 - \alpha p)}{1 + \exp(\theta_0 - \alpha p)} \]

「客が来るかどうか」と「来た客が買うかどうか」を分けて書いているのは、実務データとの対応が良いからだ(POSやWebログでは「訪問数」と「訪問あたり購買率」を別々に観測できることが多い)。

第13回で断った通り、この3回では効用パラメータに \(\theta\) を使い、割引因子には \(\beta\) を予約している(需要推定の回では \(\beta\) は製品属性への選好パラメータだったが、動学の慣習に合わせて割引因子に転用している)。今日の設定では企業は近視眼的ではなく将来の座席の価値まで織り込んで今日の価格を決めるので、実質的に割引因子 \(\beta=1\)(将来の座席が売れる価値を割り引かない)の動学最適化を解いていると考えてよい。長期在庫や与信のように将来価値を割り引く必要がある文脈では \(\beta<1\) を入れるが、今日の航空券・ホテルの例(販売期限が数十日程度)ではこの単純化で十分である。

状態は「残り期間 \(t\)」と「残り在庫 \(c\)」の組 \((t,c)\)、行動は「価格 \(p\)」。第13回の記法に合わせれば、状態 \(x = (t,c)\)、行動 \(a = p\) にあたる。

Bellman方程式

企業の問題は、残り期間 \(t\)・残り在庫 \(c\) のもとで、今期の価格 \(p\) をいくらにすれば、今期の期待収益と将来の期待収益の合計が最大になるか、である。第13回のBellman方程式の形を思い出そう。今日の設定に翻訳すると

\[ V(t, c) = \max_{p} \Big\{ \lambda(p)\,\big[p + V(t-1, c-1)\big] + \big(1-\lambda(p)\big)\,V(t-1, c) \Big\} \]

解釈は単純だ。今期、確率 \(\lambda(p)\) で客が来て \(p\) 円で買ってくれる。そのときは席が1つ減るので、続きは \(V(t-1, c-1)\)。確率 \(1-\lambda(p)\) で客が来ないか、来ても買わない。そのときは席がそのまま残るので、続きは \(V(t-1,c)\)。境界条件は「在庫が尽きたら以後の収益はゼロ」(\(V(t,0)=0\))、「期限が来たら以後の収益はゼロ」(\(V(0,c)=0\))。

これは第13回でやったBellman方程式・backward inductionの完全な再演である。違うのは「状態が2次元(\(t\)\(c\))になった」ことと、「行動が離散選択ではなく連続変数の価格になった」ことだけだ。

初めてこの式を見ると4つの添字が一度に出てくるので、1期の順序を先に固定しておこう。

順序 その期に起きること Bellman式で対応する部分
1 期首に状態 \((t,c)\) を観察する \(V(t,c)\)
2 価格 \(p\) を提示する \(\max_p\)
3a 確率 \(\lambda(p)\) で販売し、収益 \(p\) を得る \(p+V(t-1,c-1)\)
3b 確率 \(1-\lambda(p)\) で販売しない \(V(t-1,c)\)

この「状態を見る → 価格を決める → 売れたかで次状態が分岐する」という順序が、後でbid priceの時間添字を判断する基準になる。

警告計算上の落とし穴:Rの1始まり索引でのズレに要注意

本ノートのRコードでは、Bellman方程式の右辺をそのまま\(\lambda(p)[p+V(t-1,c-1)] + (1-\lambda(p))V(t-1,c)\) の形のまま)評価してから最大化する、という最も愚直な実装を採用している。\(V(t-1,c)-V(t-1,c-1)\)(後で説明する「bid price」)を使って式を整理し直すことも可能だが、その整理自体が誤りの原因になるわけではない。実際にこの教材を作る過程で踏んだ落とし穴は、Rの1始まり索引と、DPの状態が0始まりであることのズレだった。「非購入時(在庫が変わらない)の1期前の値」を参照するつもりで書いた添字が、実際には「これから計算しようとしている状態そのもの」を指してしまい、動的価格の期待収益が固定価格の収益を下回るという(理論上あり得ない)結果が出た。原因は次のcalloutで説明する索引のズレそのもので、Pythonで0始まり配列の参照実装を先に作り、Rコードの計算結果と数値で突き合わせて初めて発見できた。DPをbase Rで実装するときは、添字を書くたびに「これは数式のどの状態を指しているか」を確認し、可能なら別の言語で0始まりの参照実装を作って数値を突き合わせる、という手順を強く勧める。

Rシミュ2(i):backward inductionで解く

第13回と同じく、\(t=0\) から出発して \(t\) を1つずつ増やしながら \(V(t,\cdot)\) を埋めていく。各 \((t,c)\) で、価格のグリッドを全部試して一番良い価格を選ぶ、という素朴な実装にする。

# パラメータ設定:航空券1路線のイメージ
theta0      <- 6.0     # 価格ゼロでの基準効用
alpha       <- 0.045   # 価格係数(第2回以来の記法、alpha > 0)
lambda_bar  <- 0.95    # 1期あたりの客の到着確率
T_period    <- 60      # 販売期間(残り日数の最大値)
C_capacity  <- 30      # 座席数

p_grid <- seq(1, 350, length.out = 300)

purchase_prob <- function(p, theta0, alpha, lambda_bar) {
  u <- theta0 - alpha * p
  lambda_bar * (1 / (1 + exp(-u)))
}
solve_dp <- function(T_period, C_capacity, theta0, alpha, lambda_bar, p_grid) {
  # 行:t=0..T, 列:c=0..C。R索引はt+1, c+1にずれることに注意。
  V      <- matrix(0, nrow = T_period + 1, ncol = C_capacity + 1)
  pstar  <- matrix(NA_real_, nrow = T_period + 1, ncol = C_capacity + 1)
  lamstar <- matrix(0, nrow = T_period + 1, ncol = C_capacity + 1)

  lam_grid <- purchase_prob(p_grid, theta0, alpha, lambda_bar)  # 価格に依存、(t,c)に依らないので外で計算

  for (t in 1:T_period) {
    for (c in 1:C_capacity) {
      # Bellman方程式の右辺をそのまま評価(bid priceへの簡略化はしない)
      # V[t, c]     = V(t-1, c-1) (購入時:1期進み、在庫が1つ減る)
      # V[t, c + 1] = V(t-1, c)   (非購入時:1期進み、在庫は変わらない)
      obj <- lam_grid * (p_grid + V[t, c]) + (1 - lam_grid) * V[t, c + 1]
      best_idx <- which.max(obj)
      V[t + 1, c + 1]      <- obj[best_idx]
      pstar[t + 1, c + 1]  <- p_grid[best_idx]
      lamstar[t + 1, c + 1] <- lam_grid[best_idx]
    }
    V[t + 1, 1] <- 0  # 在庫ゼロ(c=0)なら収益なし
  }
  list(V = V, pstar = pstar, lamstar = lamstar)
}
警告Rの1始まり索引と、DPの0始まり状態の対応関係

DPの状態は \(t=0,\dots,T\)\(c=0,\dots,C\) と0始まりで書くのが自然だが、Rの行列は1始まりの索引しか使えない。このノートでは「行 t+1 が状態 \(t\)」「列 c+1 が状態 \(c\)」という対応で統一する。この対応のもとでは、ループ変数t, c(1始まりで回る)に対して、状態 \((t-1,c-1)\)(購入時の1期前)は V[t, c]、状態 \((t-1,c)\)(非購入時の1期前)は V[t, c + 1] と書かなければならない。計算結果の代入先は状態 \((t,c)\) を指す V[t + 1, c + 1] になる。上のコードで V[t, c]V[t, c + 1]V[t + 1, c + 1] という3つの似た添字が並んでいるのはこのズレのせいであり、バグではない。base Rでこの手のDPを書くときは、常に「今書いている添字が、数式のどの \(t,c\) に対応するか」を紙に書いてから実装することを強く勧める。これを怠ると、今日のこのノートの検証段階で実際に踏んだような「非購入時の項のつもりで書いた添字が、実は計算中の状態自身を指してしまっていた」というバグに悩まされる。

dp_result <- solve_dp(T_period, C_capacity, theta0, alpha, lambda_bar, p_grid)
V_mat      <- dp_result$V
pstar_mat  <- dp_result$pstar
lamstar_mat <- dp_result$lamstar

cat("期待総収益 V(T=60, C=30):", round(V_mat[T_period + 1, C_capacity + 1], 1), "\n")
期待総収益 V(T=60, C=30): 3833.2 
cat("残り60日・満席時の最適価格:", round(pstar_mat[T_period + 1, C_capacity + 1], 1), "\n")
残り60日・満席時の最適価格: 130.6 
cat("残り60日・残り1席時の最適価格:", round(pstar_mat[T_period + 1, 2], 1), "\n")
残り60日・残り1席時の最適価格: 221.6 
cat("出発前日・満席時の最適価格:", round(pstar_mat[2, C_capacity + 1], 1), "\n")
出発前日・満席時の最適価格: 103.7 

この実行で期待総収益は 3833.2、残り60日・満席時の価格は 130.6、残り60日・残り1席では 221.6、出発前日・満席では 103.7 である。絶対額は \(\theta_0,\alpha\) と価格グリッドに依存するので、状態に沿った相対的な動きを読む。

Rシミュ2(ii):最適価格のヒートマップ

この \((t,c)\) に対する最適価格 \(p^*(t,c)\) を、残り期間×残り座席の全ての組み合わせについて図示する。

heat_df <- expand.grid(t = 1:T_period, c = 1:C_capacity) %>%
  rowwise() %>%
  mutate(price = pstar_mat[t + 1, c + 1]) %>%
  ungroup()

ggplot(heat_df, aes(x = t, y = c, fill = price)) +
  geom_tile() +
  scale_fill_viridis_c(name = "最適価格", option = "magma") +
  scale_x_continuous(breaks = seq(0, 60, 10)) +
  labs(x = "残り期間(日)", y = "残り座席数",
       title = "航空券の最適価格ヒートマップ",
       subtitle = "横軸が0(出発直前)に近く、縦軸が下(在庫僅少)に近いほど、価格の挙動が対照的になる")

最適価格 p*(t,c) のヒートマップ:残り期間×残り座席

図の見方はこうだ。横軸をへ行くほど残り期間が長く、縦軸をへ行くほど残り座席が多い。したがって「期間が長く在庫が少ない」は右下、「期限直前で在庫が多い」は左上である。前者では将来の高WTP客へ座席を残す価値が高く、後者では売れ残りを避ける圧力が強い。

Rシミュ2(iii):bid price(席1つの機会費用)

RM実務でよく使われる概念にbid priceがある。状態 \((t,c)\) で今1席売ると、次期は \((t-1,c-1)\)、売らなければ \((t-1,c)\) へ進む。したがって今期の販売判断に入る機会費用は

\[ b(t,c)=V(t-1,c)-V(t-1,c-1) \]

である。これは「売らずに \(c\) 席を次期へ持ち越す価値」と「今売って \(c-1\) 席を次期へ持ち越す価値」の差だ。一方、\(V(t,c)-V(t,c-1)\)同じ期首時点で追加1席を持つ価値であり、有用な限界在庫価値だが、今期販売のBellman式へ直接入るbid priceとは時間添字が違う。

Bellman方程式をこのbid priceで書き直すと

\[ V(t,c)=V(t-1,c)+\max_p\lambda(p)\{p-b(t,c)\}. \]

条件付き購買確率を \(s(p)=\operatorname{logit}^{-1}(\theta_0-\alpha p)\)\(\lambda(p)=\bar\lambda s(p)\) とすれば、内部解の一階条件は

\[ p^*(t,c)=b(t,c)+\frac{1}{\alpha\{1-s(p^*(t,c))\}}. \]

つまり価格はbid priceに等しいのでも、静学価格へ単純にbid priceを足したものでもない。bid priceを「限界費用」の位置へ入れたlogit markupの固定点である。

bid_price_df <- expand.grid(t = 1:T_period, c = 1:C_capacity) %>%
  rowwise() %>%
  mutate(bid_price = V_mat[t, c + 1] - V_mat[t, c]) %>%
  ungroup()

ggplot(bid_price_df, aes(x = t, y = c, fill = bid_price)) +
  geom_tile() +
  scale_fill_viridis_c(name = "bid price", option = "viridis") +
  scale_x_continuous(breaks = seq(0, 60, 10)) +
  labs(x = "残り期間(日)", y = "残り座席数",
       title = "座席1つの機会費用(bid price)のヒートマップ")

bid priceが高い状態では、「今安く売ると、将来高く売る機会を失う」コストが大きい。最適価格は、この機会費用を限界費用として扱い、その上に需要弾力性で決まるmarkupを載せる。上式は連続価格の内部解であり、実装では価格グリッド・上下限制約により境界解になる場合がある。

Rシミュ2(iv):予約パスのシミュレーション

実際にこの価格政策のもとで、日々どのように価格が動いていくかを、いくつかのシミュレーションパスで見てみる。

simulate_booking_path <- function(seed, T_period, C_capacity, pstar_mat, lamstar_mat) {
  set.seed(seed)
  c_remain <- C_capacity
  t_remain <- T_period
  path <- data.frame(day = integer(0), t_remain = integer(0), c_remain = integer(0), price = numeric(0))
  day <- 0
  while (t_remain >= 1 && c_remain >= 1) {
    day <- day + 1
    p   <- pstar_mat[t_remain + 1, c_remain + 1]
    lam <- lamstar_mat[t_remain + 1, c_remain + 1]
    path <- rbind(path, data.frame(day = day, t_remain = t_remain, c_remain = c_remain, price = p))
    bought <- runif(1) < lam
    if (bought) c_remain <- c_remain - 1
    t_remain <- t_remain - 1
  }
  path
}

paths_list <- lapply(1:4, function(i) {
  p <- simulate_booking_path(30000 + i, T_period, C_capacity, pstar_mat, lamstar_mat)
  p$path_id <- factor(i)
  p
})
paths_df <- bind_rows(paths_list)

ggplot(paths_df, aes(x = day, y = price, color = path_id)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 1.2) +
  labs(x = "販売開始からの日数", y = "提示価格",
       title = "予約パスに沿った価格の推移(シミュレーション4本)",
       color = "パスID")

各パスで、売れ行きが良ければ在庫が早く減って価格が早めに上がり、売れ行きが悪ければ価格が抑えられたまま推移する。同じ価格ルール \(p^*(t,c)\) から出発しても、実現する需要のばらつき次第で価格の軌道は変わる。これがブッキングカーブ(累積販売数の推移)を毎日眺めながら価格を微調整する、RM実務の日常の姿そのものだ。

Rシミュ2(v):最適固定価格 vs 動的価格

では、この動的な価格づけは、期間中ずっと同じ価格を使い続ける「最適固定価格」と比べてどれだけ得なのか。固定価格 \(p\) を1つ選んで使い続けたときの期待収益も、同じ形の再帰式で評価できる。

\[ W_p(t,c) = \lambda(p)\big[p + W_p(t-1,c-1)\big] + (1-\lambda(p))\,W_p(t-1,c) \]

これは価格を最適化せず、外から与えられた固定の \(p\) について評価するだけなので、\(\max_p\) が無い分だけ単純だ。

evaluate_fixed_price <- function(p, T_period, C_capacity, theta0, alpha, lambda_bar) {
  lam <- purchase_prob(p, theta0, alpha, lambda_bar)  # スカラー
  W <- matrix(0, nrow = T_period + 1, ncol = C_capacity + 1)
  for (t in 1:T_period) {
    for (c in 1:C_capacity) {
      W[t + 1, c + 1] <- lam * (p + W[t, c]) + (1 - lam) * W[t, c + 1]
    }
    W[t + 1, 1] <- 0
  }
  W[T_period + 1, C_capacity + 1]
}

fixed_p_grid <- seq(1, 350, length.out = 150)
fixed_revenues <- sapply(fixed_p_grid, evaluate_fixed_price,
                          T_period = T_period, C_capacity = C_capacity,
                          theta0 = theta0, alpha = alpha, lambda_bar = lambda_bar)

best_fixed_idx <- which.max(fixed_revenues)
best_fixed_p   <- fixed_p_grid[best_fixed_idx]
best_fixed_rev <- fixed_revenues[best_fixed_idx]

dynamic_rev <- V_mat[T_period + 1, C_capacity + 1]
lift_pct <- 100 * (dynamic_rev - best_fixed_rev) / best_fixed_rev

comparison_df <- data.frame(
  方式 = c("最適固定価格", "動的価格(DP最適)"),
  価格の目安 = c(round(best_fixed_p, 1), "状態に応じて変動"),
  期待収益 = c(round(best_fixed_rev, 1), round(dynamic_rev, 1))
)
kable(comparison_df, caption = "固定価格 vs 動的価格の期待収益比較")
固定価格 vs 動的価格の期待収益比較
方式 価格の目安 期待収益
最適固定価格 129.8 3739.7
動的価格(DP最適) 状態に応じて変動 3833.2
cat("\n動的価格による収益リフト:", round(lift_pct, 2), "%\n")

動的価格による収益リフト: 2.5 %

動的価格は最適固定価格に対して、この設定で 2.5% の収益リフトをもたらす。動的政策は固定価格を選べる政策集合を含むので、同じ真のモデル・同じ価格集合で正しく計算すればリフトは負にならない。

ノートなぜリフトは「意外と小さい」のか

動的価格の価値は、到着・WTPの不確実性、有限在庫と期限の厳しさ、需要タイプの時間変化、利用できる価格集合に依存する。ただし「在庫を減らせば必ずリフトが単調に増える」とは限らない。導入判断では、複数の容量・需要シナリオについて同じコードでリフトを再計算し、推定誤差と運用費用も差し引く。

ヒント実証研究コーナー:Gallego and van Ryzin (1994)

研究の問い:残り期間・残り在庫という2つの状態のもとで、企業はどのような価格政策をとれば期待収益を最大化できるか。また、それは実務でよく使われる「良い固定価格」とどれだけ違うのか。

モデル:価格に敏感な確率的需要のもとで、期限までに所与の在庫を売る問題をintensity controlとして定式化し、有限期間・有限在庫の最適政策を分析する。本講義の離散時間Bellman方程式は、この問題を1期1人までの到着へ単純化した類似物である。

主要な発見:最適な需要強度(したがって価格)が在庫と残り期間にどう反応するかについて単調性を導く。さらに、決定論版から得る上界を用い、期待販売量を大きくスケールさせる漸近領域では、単純な固定価格政策が漸近的に最適になることを示す。本講義の有限標本シミュレーションでリフトが2〜3%だからこの定理が実証された、という関係ではない。ここでは、固定価格も有力なベンチマークになりうることを数値で確認した、と読む。

なぜこの講義のトピックと繋がるか:この論文はrevenue managementという実務分野の理論的支柱であり、今日のBellman方程式・backward inductionによる価格政策の構成は、この論文の枠組みを最小構成に単純化したものである。「動的最適化がいつ本当に重要になるか」という実務的な問い(在庫逼迫度・需要不確実性の大きさで判断する)も、この論文の理論的結果から直接導かれる。

ヒント実証研究コーナー:Williams (2022)

研究の問い:航空会社が実際に行っている動的価格設定は、消費者の厚生(welfare)にどのような影響を与えているか。動的価格は「価格差別の道具」なのか、それとも「効率的な配分の道具」なのか。

データ・識別戦略:米国の航空市場における、出発日までの日数・残り座席数と実際の運賃の関係を、詳細な予約データを用いて分析している。需要側の異質性(早めに予約する価格に敏感なレジャー客と、直前に予約する価格に鈍感なビジネス客)を明示的にモデル化し、動的価格が無かった場合(一物一価で運営した場合)の反実仮想と比較する、という枠組みを取っている。

主要な発見:動的価格は、価格に敏感な客ほど早期に安く購入し、価格に鈍感な客ほど直前に高く購入する、という形で異なるタイプの客に異なる価格を配分している。これは一種の価格差別だが、同時に、限られた座席を「支払意思額が高い客」に優先的に配分するという効率性の側面も持つ。動的価格を一律価格に置き換えた反実仮想では、総余剰・消費者余剰・企業収益のバランスが変化し、動的価格には価格差別的な側面と効率改善的な側面の両方が併存することが示されている。

なぜこの講義のトピックと繋がるか:本講義で構築したRMのDPモデルは「企業が収益を最大化する」という一方向の視点だったが、Williams (2022) はそこに「消費者にとってどうなのか」という厚生の視点を持ち込んでいる。第11回で学んだ異質性・ターゲティングの視点(誰に効くか)と、今日の動学の視点(いつ・どの状態で)を組み合わせると、価格政策の「儲かるかどうか」だけでなく「誰が得をして誰が損をするか」まで問えるようになる、というこの講義の終盤にふさわしい統合的な研究である。

5. Surge pricing:需給の不均衡を緩和するための価格

ride-hailing(配車サービス)のsurge pricing(需要急増時の価格上乗せ)は、航空券のRMとは少し違う顔をしている。RMは「限られた在庫を、期限までにどう配分するか」という問題だったが、surge pricingは「今この瞬間の需要と供給を、どうマッチさせるか」という問題だ。

コンサートやスポーツイベントの終演直後を想像してほしい。何千人もの人が同時に配車を呼ぼうとするのに、その場にいるドライバーの数は限られている。価格を動かさなければ何が起きるか。まず、需要が供給をはるかに上回るので、多くの客が配車されずに待たされる。次に、通常価格のままだと、遠方のドライバーがわざわざそこまで向かうインセンティブがない。結果として、需給のミスマッチが解消されないまま待ち時間だけが伸びていく。

サージ価格の役割は3つに整理できる。

  1. 需要抑制:価格が上がることで、「今すぐでなくてもいい」客は配車を諦めたり、時間をずらしたりする。
  2. 供給喚起:価格が上がることで、周辺エリアのドライバーがそのエリアに向かうインセンティブが生まれる。
  3. マッチング摩擦の緩和:需給が均衡に近づくことで、客とドライバーが出会うまでの待ち時間(マッチング摩擦)が短くなる。

Uber自身は、需要が供給を上回ると価格が上がり、一部の利用者が待つ一方で混雑地域へ向かうドライバーの誘因が強まり、市場が再均衡へ向かう、と公式ページで説明している。ただし、これは企業による制度説明であって因果効果の推定ではない。「サージを止めた技術障害で待ち時間が何倍になった」といった出典不明の逸話を実証結果として扱わず、次の査読論文が示す理論機序と実証的証拠を区別して読む。

サージ価格をめぐっては、単純に「価格が上がれば供給が増える」というモデルだけでは説明しきれない現象も報告されている。

ヒント研究コーナー:Castillo, Knoepfle, and Weyl (2024)

研究の問い:サージ価格が発動していない、あるいは価格が低すぎる状況では、ride-hailing市場の需給調整はどのように失敗しうるか。

モデル・メカニズム:需要が高いのに価格が低いままだと、空車の減少、迎車時間の増加、ドライバー収益の低下が互いを悪化させるフィードバックが起きうる。このマッチング失敗を”wild goose chase”(無駄足)と呼ぶ。迎車時間の増加は個々のドライバーの手間を増やすだけでなく、供給側の実効的な処理能力(単位時間あたりに対応できる客数)も引き下げる。

主要な発見:価格が低すぎることは、単に「需要超過を放置する」だけでなく、この”wild goose chase”を通じて「供給側の効率そのものを壊す」方向に作用しうる。逆に言えば、適切なサージ価格は、需要を抑えるだけでなく、マッチングの空間的な効率性を保つことで、供給側の処理能力を守る役割も果たしている。

なぜこの講義のトピックと繋がるか:RMの文脈では「価格は将来の座席という資源の機会費用を反映すべきだ」という話をしたが、surge pricingの文脈では「価格はマッチングの効率性(誰と誰がどれだけの手間で出会えるか)にも影響する」という、RMには無かった側面が登場する。今日学んだ「状態に応じて価格を変える」という枠組みが、在庫だけでなく空間的なマッチング効率にまで応用されている例として押さえておいてほしい。

Galichon and Hsieh (2017) は、価格が規制されて市場を清算できず、待ち時間が非価格配分の役割を持つ分権的マッチング市場を分析する。その上で、ランダムな需要・供給についての信念が与えられたとき、市場設計者が期待非効率を小さくする価格をどう設定すべきか、というsurge pricing問題を考える。したがって、この論文は「あらゆる配車市場でサージが均衡を実現する」と保証するものではなく、待ち時間による配分と価格設計のトレードオフを明示する理論である。

重要サージの再フレーミング

「サージ価格は企業が儲けるための値上げだ」という理解だけでは不十分である。サージは、需要を抑え、供給を呼び込み、マッチング摩擦を緩和することで、市場を「回す」役割を持ち得る価格でもある。価格を動かさなければ超過需要が残り、多くの客が長く待つか、配車を受けられない可能性が高まる。もちろん、サージ価格が実際にどこまで「市場を回す」役に立っているか、どこからが単なる超過利潤の徴収になっているかは、実証的に検証すべき問いであり、規制当局や研究者の関心が今も続いている論点である。

6. 推定→最適化パイプライン:誤差はどこへ消えるか

コース最後の見せ場として、今日学んだRMの枠組みと、コース前半の需要推定を1つのパイプラインでつなげる。ここでのメッセージは1つ。需要推定の誤差は、消えてなくなるわけではなく、価格政策の収益ロスという「金額」に変換される。

パイプラインは次の3段階だ。

  1. 真の需要パラメータ \((\theta_0,\alpha)\) のもとで生成された予約データから、\((\hat\theta_0,\hat\alpha)\) を最尤推定する(第2回の再演)。
  2. \((\hat\theta_0,\hat\alpha)\) を真の値だと思い込んで、backward inductionで価格政策 \(\hat p^*(t,c)\) を作る。
  3. その政策 \(\hat p^*(t,c)\) を、真のモデルの上で走らせたときの期待収益を評価する。

\((\hat\theta_0,\hat\alpha)\) が真値からずれていれば、政策 \(\hat p^*(t,c)\) も真に最適な政策 \(p^*(t,c)\) からずれうる。その同時推定誤差が、どれだけの収益の目減りになるかを数値で確認する。

# ステップ0:価格実験データの生成(複数の価格水準をランダムに割り当てて需要を観測する)
price_menu <- c(90, 110, 130, 150, 170)

generate_booking_data <- function(seed, T_period, C_capacity, theta0, alpha, lambda_bar, price_menu) {
  set.seed(seed)
  c_remain <- C_capacity
  prices_obs   <- numeric(0)
  purchase_obs <- numeric(0)
  for (t in T_period:1) {
    if (c_remain == 0) break
    p <- sample(price_menu, 1)
    arrived <- runif(1) < lambda_bar
    if (arrived) {
      u <- theta0 - alpha * p
      prob_buy <- 1 / (1 + exp(-u))
      purchased <- as.numeric(runif(1) < prob_buy)
      prices_obs   <- c(prices_obs, p)
      purchase_obs <- c(purchase_obs, purchased)
      if (purchased == 1) c_remain <- c_remain - 1
    }
  }
  list(prices = prices_obs, purchase = purchase_obs)
}

booking_data <- generate_booking_data(20265, T_period, C_capacity, theta0, alpha, lambda_bar, price_menu)
cat("観測された到着客数:", length(booking_data$prices), "\n")
観測された到着客数: 51 
# ステップ1:ニュートン法による自作MLE(logitの対数尤度は大域的に凹なので、
# 解析的な勾配・ヘシアンを使うと安定して速く収束する)
sigmoid <- function(x) 1 / (1 + exp(-x))

fit_logit_newton <- function(prices, purchase, theta_init = c(0, 0.01), max_iter = 100, tol = 1e-10) {
  n <- length(prices)
  X <- cbind(1, -prices)  # u = X %*% c(const, alpha)
  theta <- theta_init
  for (it in 1:max_iter) {
    u     <- as.vector(X %*% theta)
    p_hat <- sigmoid(u)
    resid <- purchase - p_hat
    grad  <- as.vector(t(X) %*% resid)
    W     <- p_hat * (1 - p_hat)
    H     <- -t(X * W) %*% X
    step  <- solve(H, grad)
    theta_new <- theta - step
    if (max(abs(theta_new - theta)) < tol) {
      theta <- theta_new
      break
    }
    theta <- theta_new
  }
  list(theta = theta, n_iter = it)
}

mle_result <- fit_logit_newton(booking_data$prices, booking_data$purchase)
const_hat <- mle_result$theta[1]
alpha_hat <- mle_result$theta[2]

cat("真の (theta0, alpha):", theta0, alpha, "\n")
真の (theta0, alpha): 6 0.045 
cat("推定 (theta0_hat, alpha_hat):", round(const_hat, 3), round(alpha_hat, 4), "\n")
推定 (theta0_hat, alpha_hat): 9.213 0.0677 
cat("収束までの反復回数:", mle_result$n_iter, "\n")
収束までの反復回数: 6 
# ステップ2:推定した(theta0_hat, alpha_hat)でDPを解いて価格政策を作る
dp_hat <- solve_dp(T_period, C_capacity, const_hat, alpha_hat, lambda_bar, p_grid)
pstar_hat <- dp_hat$pstar

# ステップ3:その政策を「真のモデル」の上で評価する
evaluate_policy_on_true_model <- function(pstar_policy, T_period, C_capacity, theta0, alpha, lambda_bar) {
  W <- matrix(0, nrow = T_period + 1, ncol = C_capacity + 1)
  for (t in 1:T_period) {
    for (c in 1:C_capacity) {
      p <- pstar_policy[t + 1, c + 1]
      lam_true <- purchase_prob(p, theta0, alpha, lambda_bar)
      W[t + 1, c + 1] <- lam_true * (p + W[t, c]) + (1 - lam_true) * W[t, c + 1]
    }
    W[t + 1, 1] <- 0
  }
  W[T_period + 1, C_capacity + 1]
}

revenue_true_policy <- V_mat[T_period + 1, C_capacity + 1]
revenue_hat_policy   <- evaluate_policy_on_true_model(pstar_hat, T_period, C_capacity, theta0, alpha, lambda_bar)
revenue_loss <- revenue_true_policy - revenue_hat_policy

cat("真の政策の期待収益:", round(revenue_true_policy, 1), "\n")
真の政策の期待収益: 3833.2 
cat("推定需要パラメータで作った政策を真のモデルで評価した期待収益:", round(revenue_hat_policy, 1), "\n")
推定需要パラメータで作った政策を真のモデルで評価した期待収益: 3822.3 
cat("収益ロス:", round(revenue_loss, 1), "(真の収益の", round(100*revenue_loss/revenue_true_policy, 2), "%)\n")
収益ロス: 10.9 (真の収益の 0.29 %)

1回のデータ生成・推定だと、この収益ロスがたまたま大きいか小さいかは分からない。そこで、この一連のパイプライン(データ生成→MLE→DP→評価)を200回繰り返すモンテカルロで、推定誤差がどんな収益ロスの分布を生むかを可視化する。

n_mc <- 200
theta0_hat_vec   <- rep(NA_real_, n_mc)
alpha_hat_vec    <- rep(NA_real_, n_mc)
revenue_loss_vec <- rep(NA_real_, n_mc)

for (m in 1:n_mc) {
  bd <- generate_booking_data(10000 + m, T_period, C_capacity, theta0, alpha, lambda_bar, price_menu)
  if (length(bd$prices) < 5 || sd(bd$prices) < 1e-8) {
    next
  }
  fit_m <- fit_logit_newton(bd$prices, bd$purchase)
  a_hat_m <- fit_m$theta[2]
  c_hat_m <- fit_m$theta[1]
  theta0_hat_vec[m] <- c_hat_m
  alpha_hat_vec[m] <- a_hat_m
  if (!is.finite(c_hat_m) || !is.finite(a_hat_m) || a_hat_m <= 0 || a_hat_m > 1) {
    next
  }
  dp_m <- solve_dp(T_period, C_capacity, c_hat_m, a_hat_m, lambda_bar, p_grid)
  rev_m <- evaluate_policy_on_true_model(dp_m$pstar, T_period, C_capacity, theta0, alpha, lambda_bar)
  revenue_loss_vec[m] <- revenue_true_policy - rev_m
}

mc_df <- data.frame(theta0_hat = theta0_hat_vec,
                    alpha_hat = alpha_hat_vec,
                    revenue_loss = revenue_loss_vec) %>%
  filter(!is.na(revenue_loss))

cat("有効な反復数:", nrow(mc_df), "/", n_mc, "\n")
有効な反復数: 200 / 200 
cat("theta0_hatの平均:", round(mean(mc_df$theta0_hat), 3), "(真値:", theta0, ")\n")
theta0_hatの平均: 6.325 (真値: 6 )
cat("theta0_hatの標準偏差:", round(sd(mc_df$theta0_hat), 3), "\n")
theta0_hatの標準偏差: 1.933 
cat("alpha_hatの平均:", round(mean(mc_df$alpha_hat), 4), "(真値:", alpha, ")\n")
alpha_hatの平均: 0.0475 (真値: 0.045 )
cat("alpha_hatの標準偏差:", round(sd(mc_df$alpha_hat), 4), "\n")
alpha_hatの標準偏差: 0.0146 
cat("収益ロスの平均:", round(mean(mc_df$revenue_loss), 1),
    "(真の収益の", round(100*mean(mc_df$revenue_loss)/revenue_true_policy, 2), "%)\n")
収益ロスの平均: 49 (真の収益の 1.28 %)
cat("収益ロスの中央値:", round(median(mc_df$revenue_loss), 1), "\n")
収益ロスの中央値: 20.5 
p1 <- ggplot(mc_df, aes(x = alpha_hat)) +
  geom_histogram(bins = 25, fill = "steelblue", alpha = 0.8) +
  geom_vline(xintercept = alpha, linetype = "dashed", color = "firebrick") +
  labs(x = expression(hat(alpha)), y = "頻度", title = "alpha_hatの標本分布")

p2 <- ggplot(mc_df, aes(x = revenue_loss)) +
  geom_histogram(bins = 25, fill = "darkorange", alpha = 0.8) +
  labs(x = "収益ロス(真の政策の収益 − 推定政策の収益)", y = "頻度",
       title = "収益ロスの分布")

p1 + p2

推定誤差から生まれる収益ロスの分布(モンテカルロ200回)

この図が今日、そしてこのコース全体の最後のメッセージだ。政策は \((\hat\theta_0,\hat\alpha)\) の両方から作られるため、需要パラメータの同時推定誤差がDPの政策選択を通じて非線形に収益ロスへ変換される。 左図の \(\hat\alpha\) はその同時分布の一つの周辺分布を見せているだけで、左図と右図のばらつきが一対一に対応するわけではない。収益ロスの分布は右に長く裾を引いており、「たいていはそこそこうまくいくが、稀に推定が大きく外れると収益が大きく毀損する」という構造になっている。需要推定・因果推論・異質性のモデリングにどれだけ手を抜くかが、巡り巡って「いくら儲け損なうか」という金額に直結する。推定・推論の質が、意思決定を経てお金に変わる。

フィードバックループ問題:本当に内生な価格DGPを作る

価格が単に時間・在庫の決定論的関数で、当期の需要ショックがiidなら、価格変動が狭くても、それだけで価格内生性は生じない。識別が弱くなることと、誤差項との相関は別である。第3回の価格内生性を再現するには、企業には見えるが分析者には見えない当期需要ショックが、需要と価格の両方に入る必要がある。

複数の販売シーズン \(s\) を作り、需要ショックを

\[ \xi_{s,t}=\rho\xi_{s,t+1}+\nu_{s,t} \]

と持続的にする。企業は \(\xi_{s,t}\) を予約検索や競合情報から把握し、高需要時に価格を上げるが、分析者は \(\xi_{s,t}\) を観測できない:

ここで \(t\) は残り期間なので、カレンダー時間が1期進むと添字は \(t+1\) から \(t\) へ減る。このため通常のAR(1)と見た目の添字方向が逆になっている。

\[ p_{s,t}=p^*(t,c)+\gamma\xi_{s,t},\qquad \Pr(y_{s,t}=1)=\Lambda(\theta_0+\xi_{s,t}-\alpha p_{s,t}). \]

generate_price_panel <- function(seed, n_seasons, pricing,
                                 T_period, C_capacity, theta0, alpha,
                                 lambda_bar, pstar_policy, price_menu,
                                 rho_xi = 0.75, sigma_nu = 0.55,
                                 gamma_xi = 10) {
  set.seed(seed)
  out <- vector("list", n_seasons)
  for (s in 1:n_seasons) {
    c_remain <- C_capacity
    xi <- rnorm(1, 0, sigma_nu / sqrt(1 - rho_xi^2))
    rows <- vector("list", T_period)
    k <- 0
    for (t in T_period:1) {
      if (c_remain == 0) break
      xi <- rho_xi * xi + rnorm(1, 0, sigma_nu)
      base_price <- pstar_policy[t + 1, c_remain + 1]
      p <- if (pricing == "endogenous") {
        pmin(max(p_grid), pmax(min(p_grid), base_price + gamma_xi * xi))
      } else {
        sample(price_menu, 1)  # xiと独立にランダム割当
      }
      if (runif(1) < lambda_bar) {
        purchased <- as.numeric(runif(1) < sigmoid(theta0 + xi - alpha * p))
        k <- k + 1
        rows[[k]] <- data.frame(season = s, t = t, c = c_remain,
                                price = p, purchase = purchased, xi = xi)
        if (purchased == 1) c_remain <- c_remain - 1
      }
    }
    out[[s]] <- bind_rows(rows[seq_len(k)])
  }
  bind_rows(out)
}

obs_panel <- generate_price_panel(
  15015, 300, "endogenous", T_period, C_capacity, theta0, alpha,
  lambda_bar, pstar_mat, price_menu)
exp_panel <- generate_price_panel(
  15016, 300, "randomized", T_period, C_capacity, theta0, alpha,
  lambda_bar, pstar_mat, price_menu)

fit_obs <- fit_logit_newton(obs_panel$price, obs_panel$purchase)
fit_exp <- fit_logit_newton(exp_panel$price, exp_panel$purchase)
# xiを観測できたと仮定する診断用のoracle。価格係数の符号を反転してalphaに直す。
fit_exp_oracle <- glm(purchase ~ price + xi, family = binomial(), data = exp_panel)
alpha_exp_oracle <- -unname(coef(fit_exp_oracle)["price"])

endog_tbl <- data.frame(
  data = c("企業がxiを見て価格設定(分析者はxiを欠落)",
           "価格をxiと独立にランダム化(xiを欠落)",
           "ランダム化+xiを含むoracle", "構造パラメータの真値"),
  alpha_hat = c(fit_obs$theta[2], fit_exp$theta[2], alpha_exp_oracle, alpha),
  cor_price_xi = c(cor(obs_panel$price, obs_panel$xi),
                   cor(exp_panel$price, exp_panel$xi),
                   cor(exp_panel$price, exp_panel$xi), 0)
)
kable(endog_tbl, digits = 4,
      caption = "価格支持の狭さではなく、価格と欠落需要ショックの相関が内生性を作る")
価格支持の狭さではなく、価格と欠落需要ショックの相関が内生性を作る
data alpha_hat cor_price_xi
企業がxiを見て価格設定(分析者はxiを欠落) 0.0008 0.7046
価格をxiと独立にランダム化(xiを欠落) 0.0395 0.0015
ランダム化+xiを含むoracle 0.0450 0.0015
構造パラメータの真値 0.0450 0.0000

観察価格では高需要ショック時に高価格を付けるため、価格と欠落した \(\xi\) が正に相関し、価格の負の効果を過小評価する方向へ働く。ランダム価格ではこの相関が切れ、各価格での周辺的な購買確率は因果的に比較できる。ただし、\(\xi\) を積分した周辺需要曲線は一般に単純なlogitではない。logit係数は非可約(non-collapsible)でもあるので、\(\xi\) を欠落した2行目の係数は標本を増やしても一般に真の構造係数 \(\alpha\) へは一致しない。これはランダム化の失敗ではなく、構造logitと周辺的なworking logitのestimandが違うためである。構造係数を回収するには、oracle行のように \(\xi\) を条件づけるか、その分布を明示して積分する必要がある。

推論を行うなら同一シーズン内の系列相関を考慮し、シーズン単位でクラスタ化する。実務では完全なランダム化だけでなく、許容範囲内の探索価格、IV、需要ショックのproxy、方策の既知な割当確率を使う方法もある。

7. アルゴリズム価格への一言

最後に、これからの論点として1つだけ触れておく。Calvano, Calzolari, Denicolò, and Pastorello (2020, American Economic Review) は、強化学習(Q学習)で動くプライシングエージェント同士をシミュレーション上で競争させると、明示的なコミュニケーションが一切無いにもかかわらず、エージェントたちが自律的に協調的な(competitiveな水準より高い)価格に収束する、という結果を報告した。

これは規制当局にとって悩ましい問題を提起する。従来の独占禁止法・競争法は「事業者間の意思疎通」を違法なカルテルの要件としてきたが、アルゴリズムが人間の指示なしに勝手に協調的価格へ収束するとしたら、それをどう規制すべきか。まだ決定的な答えのある話ではないが、dynamic pricingが企業実務に浸透するほど、この論点は重要性を増していく。今日学んだRMのDPも、複数企業が同時にそれぞれのDPを回し合えば、この協調価格問題の入り口に立つことになる。

8. コース総括:15回の地図

最終回として、これまでの15回がどうつながっていたかを1枚の表で振り返る。

course_map <- data.frame(
= c("需要推定", "需要推定", "需要推定", "供給側行動", "異質性の把握", "因果推論",
         "因果推論", "階層・異質性", "階層・異質性", "異質性・政策", "動学",
         "動学", "動学(本日)"),
= c("第2回", "第3回", "第4回", "第4回", "第6回", "第7回",
         "第8回", "第9回", "第10回", "第11回", "第13回",
         "第14回", "第15回"),
  内容 = c("logit需要・MLE", "IV・nested logit", "random coefficient logit",
          "Bertrand-Nash・合併分析", "コンジョイント・WTP", "A/Bテスト",
          "観察データの因果推論", "階層モデル・shrinkage", "BG/NBD・CLV",
          "ターゲティング・policy learning", "動学的離散選択(消費者)",
          "state dependence・買いだめ", "dynamic pricing(企業)")
)
kable(course_map, caption = "計量経済学IIの15回地図:4つの軸から価格最適化へ")
計量経済学IIの15回地図:4つの軸から価格最適化へ
内容
需要推定 第2回 logit需要・MLE
需要推定 第3回 IV・nested logit
需要推定 第4回 random coefficient logit
供給側行動 第4回 Bertrand-Nash・合併分析
異質性の把握 第6回 コンジョイント・WTP
因果推論 第7回 A/Bテスト
因果推論 第8回 観察データの因果推論
階層・異質性 第9回 階層モデル・shrinkage
階層・異質性 第10回 BG/NBD・CLV
異質性・政策 第11回 ターゲティング・policy learning
動学 第13回 動学的離散選択(消費者)
動学 第14回 state dependence・買いだめ
動学(本日) 第15回 dynamic pricing(企業)

この講義でやってきたことは、大きく4つの軸に分けられる。

  • 需要推定(何が売れるか):第2〜4回。価格を動かしたら数量がどう動くかを、内生性に注意しながら推定する技術。
  • 因果推論(施策は効くか):第7〜8回。A/Bテストと観察データから、政策・介入の効果を測る技術。
  • 異質性(誰に効くか):第9〜11回。顧客ごとの違いを階層モデルで捉え、ターゲティングに使う技術。
  • 動学(いつ・どの状態で):第13〜15回。消費者・企業の意思決定が時間や状態にどう依存するかを、動的計画法で解く技術。

そして今日、その4つが「価格を最適化する」という1つの実務課題に全部刺さることを確認した。需要の弾力性が分からなければ最適価格は計算できない(軸1)。価格を動かした効果が本当にその価格のせいなのかを見極めるには因果推論が要る(軸2)。同じ価格でも客によって反応が違うなら、一律価格は機会損失を生む(軸3)。そして今日見たように、状態に応じて価格を変えるなら、その価格ルール自体を動的最適化として解く必要がある(軸4)。

この講義で学んだ全てのパーツが、価格最適化という1つの実務課題に刺さっている。 これが計量経済学IIを通じて伝えたかった、最後のメッセージだ。

まとめ

重要この講義のまとめ、そしてコース全体のまとめ
  • 世間の「dynamic pricing」には、時間・需給や販売期限・在庫で変わる価格と、顧客ごとに変わる価格が混同されることがある。この講義では前者をdynamic pricing、後者をpersonalized pricingとして区別した。
  • peak-load pricingは、容量費用を「容量を実際に使う期」に負担させる、という発想の実装である。real-time pricingは、その発想を電力市場の日々の変動にまで拡張したものだ。
  • revenue managementの核心は、第13回のBellman方程式・backward inductionの再演である。\(V(t,c) = \max_p\{\lambda(p)[p+V(t-1,c-1)] + (1-\lambda(p))V(t-1,c)\}\) を解くだけで、「期限が近く在庫が余ると安く、在庫が減ると高くなる」という価格ヒートマップが1枚出てくる。
  • 動的価格の固定価格に対する優位性は、需要と在庫の状況次第で数%程度になることもあれば、需要の不確実性や在庫逼迫が強いほど拡大する。「うちにRMは必要か」を判断する軸は、需要の変動性と在庫の逼迫度である。
  • surge pricingは、需要抑制・供給喚起・マッチング摩擦の緩和を通じて市場を「回す」役割を持ち得る。どの経路がどれほど働くかと厚生効果は、需要・供給反応とプラットフォーム設計に依存する。
  • 需要推定の誤差は、動的計画法を通じて価格政策の収益ロスという「金額」に変換される。そして自社の価格ルールが生んだデータで再推定すると、内生性が再発し、誤差はさらに拡大しうる。
  • 計量経済学IIの15回は、需要推定×因果推論×異質性×動学という4本の軸が、すべて「価格・施策の最適化」という1つの実務課題に収束するように組み立てられていた。

このコースを通じて、「モデルを書けばシミュレーションできる。シミュレーションできれば推定量の性質を実験で確かめられる」という計量経済学Iからの哲学を、logit需要、IV、階層モデル、動的計画法と、次々に難しくなる道具に対して繰り返し適用してきた。この「まず手を動かしてデータを作り、それを自分の推定量にかけてみる」という姿勢は、道具が変わっても、そしてこれから先、新しい手法に出会うたびにも、変わらず使える武器になるはずだ。

宿題

  • ブラウザ実験室(playground15.html)で、今回のシミュレーションの数値を自分でいじって遊んでみよう。インストール不要、ブラウザだけでRが動く(初回ロードのみ30秒〜1分かかる)。
  1. 今日のRシミュ2のヒートマップで、lambda_bar(到着確率)を0.95から0.5まで下げるとヒートマップの色の分布がどう変わるか、手元で動かして確認してみよう。需要が弱くなると、動的価格の「見せ場」(在庫と期限による価格差)はどうなるだろうか。
  2. Uber、JAL、ANA、あるいは身近なECサイトの価格が「いつ」「どんな条件で」動いているように見えるか、思い出してChatGPTに「この価格変動は、状態依存の価格ルールとして、どんな状態変数に反応していそうか」と聞いてみよう。今日の交通整理((a)(b)(c)のどれに近いか)を当てはめて考えてみるとよい。
  3. coding課題は assignment15.qmd を参照。航空券1路線の予約データを自分で生成し、backward inductionで価格政策を作り、推定誤差が収益ロスにどう変換されるかをモンテカルロで確認してもらう。

このコースの最後の課題でもある。期末レポート(research proposal)を書く上でも、「需要推定→因果推論→異質性→動学」のどこか1つの軸を深掘りするだけでなく、「その先にどんな意思決定(価格、ターゲティング、在庫)が待っているか」まで考えてみると、問いの立て方に厚みが出るはずだ。

参考文献

  • Steiner, P. O. (1957). “Peak Loads and Efficient Pricing.” Quarterly Journal of Economics, 71(4), 585–610. https://doi.org/10.2307/1885712
  • Borenstein, S. (2005). “The Long-Run Efficiency of Real-Time Electricity Pricing.” Energy Journal, 26(3), 93–116.
  • Gallego, G., and van Ryzin, G. (1994). “Optimal Dynamic Pricing of Inventories with Stochastic Demand over Finite Horizons.” Management Science, 40(8), 999–1020. https://doi.org/10.1287/mnsc.40.8.999
  • Galichon, A., and Hsieh, Y.-W. (2017). “A Theory of Decentralized Matching Markets without Transfers, with an Application to Surge Pricing.” USC-INET Research Paper 17-03. https://doi.org/10.2139/ssrn.2908532
  • Williams, K. R. (2022). “The Welfare Effects of Dynamic Pricing: Evidence from Airline Markets.” Econometrica, 90(2), 831–858. https://doi.org/10.3982/ECTA16180
  • Castillo, J. C., Knoepfle, D., and Weyl, E. G. (2024). “Matching and Pricing in Ride Hailing: Wild Goose Chases and How to Solve Them.” Management Science, 71(5), 4377–4395. https://doi.org/10.1287/mnsc.2022.00096 (previous drafts were titled “Surge Pricing Solves the Wild Goose Chase.”)
  • Calvano, E., Calzolari, G., Denicolò, V., and Pastorello, S. (2020). “Artificial Intelligence, Algorithmic Pricing, and Collusion.” American Economic Review, 110(10), 3267–3297.