# === CBC_DGP ===
choice_prob <- function(quality, price, alpha) {
v <- quality - price * alpha
vmax <- pmax(0, v[, 1], v[, 2])
ev <- exp(v - vmax)
cbind(outside = exp(-vmax), ev) / (exp(-vmax) + rowSums(ev))
}
simulate_cbc <- function(seed = 202607, N = 300, Ttrain = 8, Thold = 8) {
set.seed(seed)
beta_known <- c(battery12 = 0.5, nc = 0.8, major_brand = 0.3)
mu_true <- log(0.12) - 0.6^2 / 2
tau_true <- 0.6
theta_true <- rnorm(N, mu_true, tau_true)
make_tasks <- function(T) {
id <- rep(seq_len(N), each = T)
nr <- length(id)
price <- matrix(sample(c(8, 12, 16), nr * 2, replace = TRUE), nr, 2)
battery12 <- matrix(rbinom(nr * 2, 1, 0.5), nr, 2)
nc <- matrix(rbinom(nr * 2, 1, 0.5), nr, 2)
major_brand <- matrix(rbinom(nr * 2, 1, 0.5), nr, 2)
quality <- beta_known[1] * battery12 + beta_known[2] * nc + beta_known[3] * major_brand
prob <- choice_prob(quality, price, exp(theta_true[id]))
choice <- vapply(seq_len(nr), function(r) sample(0:2, 1, prob = prob[r, ]), integer(1))
list(id = id, price = price, battery12 = battery12, nc = nc,
major_brand = major_brand, quality = quality, choice = choice, T = T)
}
list(train = make_tasks(Ttrain), test = make_tasks(Thold), N = N,
beta_known = beta_known, theta_true = theta_true, mu_true = mu_true, tau_true = tau_true)
}
# === HB_ESTIMATION ===
individual_loglik <- function(theta, tasks, N) {
v <- tasks$quality - tasks$price * exp(theta[tasks$id])
vmax <- pmax(0, v[, 1], v[, 2])
logden <- vmax + log(exp(-vmax) + rowSums(exp(v - vmax)))
chosen <- numeric(length(tasks$choice))
inside <- which(tasks$choice > 0)
chosen[inside] <- v[cbind(inside, tasks$choice[inside])]
as.vector(rowsum(chosen - logden, tasks$id, reorder = FALSE))
}
fit_hb <- function(dat, seed = 202609, n_iter = 16000, burn = 4000, thin = 4,
prior_mu = log(0.2), prior_mu_sd = 1.5,
prior_shape = 2, prior_rate = 0.5) {
keep <- seq(burn + thin, n_iter, by = thin)
one_chain <- function(chain) {
set.seed(seed + chain)
theta <- rep(prior_mu + c(-0.4, 0.4)[chain], dat$N)
mu <- mean(theta); tau2 <- 0.5
ll <- individual_loglik(theta, dat$train, dat$N)
theta_draw <- matrix(NA_real_, length(keep), dat$N)
mu_draw <- tau_draw <- numeric(length(keep))
accepted <- numeric(dat$N); saved <- 0
for (iteration in seq_len(n_iter)) {
proposal <- theta + rnorm(dat$N, 0, 0.7)
ll_new <- individual_loglik(proposal, dat$train, dat$N)
logratio <- ll_new - ll + dnorm(proposal, mu, sqrt(tau2), log = TRUE) -
dnorm(theta, mu, sqrt(tau2), log = TRUE)
accept <- log(runif(dat$N)) < logratio
theta[accept] <- proposal[accept]; ll[accept] <- ll_new[accept]
if (iteration > burn) accepted <- accepted + accept
variance_mu <- 1 / (dat$N / tau2 + 1 / prior_mu_sd^2)
mean_mu <- variance_mu * (sum(theta) / tau2 + prior_mu / prior_mu_sd^2)
mu <- rnorm(1, mean_mu, sqrt(variance_mu))
tau2 <- 1 / rgamma(1, shape = prior_shape + dat$N / 2,
rate = prior_rate + sum((theta - mu)^2) / 2)
if (iteration %in% keep) {
saved <- saved + 1
theta_draw[saved, ] <- theta
mu_draw[saved] <- mu; tau_draw[saved] <- sqrt(tau2)
}
}
list(theta = theta_draw, mu = mu_draw, tau = tau_draw,
acceptance = accepted / (n_iter - burn))
}
chains <- lapply(1:2, one_chain)
list(theta = rbind(chains[[1]]$theta, chains[[2]]$theta),
mu = c(chains[[1]]$mu, chains[[2]]$mu), tau = c(chains[[1]]$tau, chains[[2]]$tau),
chain = rep(1:2, each = length(keep)), chains = chains,
settings = c(n_iter = n_iter, burn = burn, thin = thin))
}
# === PREDICTION ===
predict_existing <- function(fit, tasks) {
answer <- matrix(0, length(tasks$id), 3)
for (b in seq_len(nrow(fit$theta))) {
answer <- answer + choice_prob(tasks$quality, tasks$price, exp(fit$theta[b, tasks$id]))
}
answer / nrow(fit$theta)
}
predict_new <- function(fit, quality, price, n_integration = 80) {
# 母集団パラメータの不確実性と新しい個人の異質性の両方を積分。
nodes <- qnorm((seq_len(n_integration) - 0.5) / n_integration)
answer <- matrix(0, nrow(price), 3)
for (b in seq_along(fit$mu)) {
for (z in nodes) {
answer <- answer + choice_prob(quality, price, exp(fit$mu[b] + fit$tau[b] * z))
}
}
answer / (length(fit$mu) * n_integration)
}
log_score <- function(prob, choice) -mean(log(pmax(prob[cbind(seq_along(choice), choice + 1)], 1e-12)))
split_rhat <- function(chain1, chain2) {
n <- floor(length(chain1) / 2)
z <- cbind(chain1[1:n], tail(chain1, n), chain2[1:n], tail(chain2, n))
W <- mean(apply(z, 2, var)); B <- n * var(colMeans(z))
sqrt(((n - 1) * W / n + B / n) / W)
}Lecture 10:個人別需要からターゲティングへ
計量経済学II
- 個人の需要が推定できたら、それを使って「誰に、どれくらい値引きするか」を比較できる。
- 比較するのは購入確率だけではない。値引きで失うマージンを含めた、施策あり・なしの期待利益の差である。
- 個人パラメータの事後分布から、施策ごとの購買確率を平均して計算する。事後平均の係数を1回代入するだけでは、この計算にならない。
- 推定した需要を価格変更に使うには、価格がどのように割り当てられたかが重要になる。最後は、固定した施策ルールを別のランダム化実験で評価する。
前回とのつながり・今回のゴール
第9回では、同じ人の選択履歴を使って、価格感応度の事後分布を求めた。履歴が短い人には集団の情報も使い、本人の履歴が蓄積するとその情報を強く反映する。得られたのは「この人の価格係数は0.12だ」という確定した答えではなく、どの値がどれくらいありそうかを表す分布だった。
今回は、その分布を使ってクーポンを比較する。商品Aを買いそうな人に配るべきか。それとも、まだ買いそうにない人に配るべきか。価格を下げれば購買確率は上がるが、買ってくれたときの利益は減る。この2つを合わせて考えないと、配る相手は決まらない。
ゴールは、第9回と同じモデルをもう一度推定し、クーポンなし、一律のクーポン、個人別のクーポンの期待利益を比較することである。後半では、この計算を実際の施策につなげるために必要な仮定と、ランダム化実験での確かめ方を整理する。
よく買う人に配ればよいか
まずは仮の数字で考えよう。通常価格で売れたときのマージンは300円で、100円引きのクーポンを用意する。クーポンは買ったときだけ使われ、送る費用はないとする。
| 顧客 | 通常の購買確率 | クーポンあり | 通常の期待利益 | クーポンありの期待利益 | 増分 |
|---|---|---|---|---|---|
| A | 0.90 | 0.95 | 270円 | 190円 | −80円 |
| B | 0.30 | 0.60 | 90円 | 120円 | +30円 |
Aはよく買う顧客だが、クーポンがなくても買う可能性が高い。その人に値引きすると、購買確率の上昇よりも失うマージンが大きい。Bについては逆になる。買う確率が高い人と、クーポンによって利益が増える人は同じとは限らない。
キャンペーン後の購入者が増えていても、利益が増えたとは限らない。元々通常価格で買うはずだった人も、クーポンを使うからである。クーポン利用者だけを見て「購入があったから成功」とするのも同じ問題を含む。比較対象は、同じ顧客にクーポンを出さなかった場合の利益である。配信費やポイント原資がある場合は、それも含めて比較する。
個人の需要から期待利益へ
何を変えるかを決める
イヤホンAを販売する企業を考える。Aの通常価格は12千円、原価は1千円とする。Bは他社の商品で、その価格は12千円のまま固定する。この企業はAだけの利益を最大化する。AとBを両方売る小売店なら、Bからの利益も含める必要がある。
値引き額の候補は \(c\in\mathcal C=\{0,1,2,3\}\) 千円とする。\(c=0\) は何もしない選択肢である。価格や値引きはすべて千円単位なので、後で期待利益も千円で出てくる。ここでは1回の購買機会だけを考え、在庫制約、他社の価格反応、顧客間のクーポン譲渡は扱わない。
第9回の効用を引き継ぐと、価格感応度は \(\alpha_i=\exp(\theta_i)>0\) であり、Aの購買確率は
\[ q_i(c;\theta_i)= \frac{\exp(x_A'\beta-\exp(\theta_i)(p_A-c))} {1+\exp(x_A'\beta-\exp(\theta_i)(p_A-c)) +\exp(x_B'\beta-\exp(\theta_i)p_B)}. \]
分母の1は「どちらも買わない」の選択肢である。この例ではAのバッテリー・ノイズキャンセリング・ブランドの属性をすべて1、Bはバッテリーとブランドを1、ノイズキャンセリングを0とする。したがって、価格以外の効用はAが1.6、Bが0.8になる。
事後分布を使って平均する
これまでの全顧客の履歴を \(D\) と書く。保存した事後drawを \(\theta_i^{(1)},\ldots,\theta_i^{(B)}\) とすると、予測購買確率は
\[ \bar q_i(c)=E[q_i(c;\theta_i)\mid D] \approx\frac1B\sum_{b=1}^B q_i(c;\theta_i^{(b)}) \]
である。第9回の事後予測を、今度は施策ごとに計算している。個人の履歴が少なくても、集団の情報を借りながら計算できる。ただし、本人の好みが正確に分かったわけではない。
クーポンの配信費用を \(k(c)\)、原価を \(m\) とすると、期待利益は
\[ \bar\pi_i(c)=(p_A-c-m)\bar q_i(c)-k(c) \]
になる。今回の数値例では \(k(c)=0\) とする。通常価格との差を
\[ \bar\Delta_i(c)=\bar\pi_i(c)-\bar\pi_i(0) \]
と書こう。個人別に選べるなら
\[c_i^*=\arg\max_{c\in\mathcal C}\bar\pi_i(c)\]
を使い、全員同じクーポンにするなら
\[c^{\mathrm{uniform}}=\arg\max_{c\in\mathcal C}\sum_i\bar\pi_i(c)\]
を使う。いずれも期待利益を最大にするという基準は同じで、全員に同じものを配るという制約があるかどうかが違う。
どこまで価格変更の予測に使えるか
第7・9回のデータでは、提示する属性と価格を実験者が無作為に割り当てた。そのため、価格の高い場面にたまたま購買意欲の高い人が集まる、といった問題を避けている。実際の購買履歴を使うなら、価格が需要ショックと独立なのか、実験なのか、あるいは第3・4回のような内生性への対応が必要なのかを確認する。階層ベイズに変えても、この問題は自動的には解消しない。
もう1つ、今回の履歴はコンジョイントの仮想的な回答である。そこから実際の売上を予測するには、仮想的な選択と実際の購買で同じ需要モデルが成り立つという追加の仮定が必要になる。以下は、その仮定のもとで計算の流れを学ぶ教育用シミュレーションである。調査で推定した係数を、そのまま実際のクーポン施策の効果だと判断してはいけない。
また、推定時の価格は8・12・16千円である。ここで提示する9〜12千円はその範囲内だが、9・10・11千円の反応にはlogitの関数形による補間が入る。クーポンという表示自体に心理的な効果がある場合も、単なる価格変更のモデルから外れる。その確認が、後で行う実験の役割になる。
Rで第9回の推定を再現する
以下は第9回と同じDGPと推定コードである。300人について、推定用8問とholdout用8問の回答を作る。平均的な価格感応度は0.12、\(\theta_i\) の標準偏差は0.6とする。属性係数 \(\beta=(0.5,0.8,0.3)\) は既知と置き、今回は価格感応度の異質性に絞って推定する。実際の分析で属性係数も未知なら、それも推定して不確実性を含める必要がある。
共通の事前分布は \(\mu\sim N(\log(0.2),1.5^2)\)、\(\tau^2\sim\mathrm{InverseGamma}(2,0.5)\) とし、\(\theta_i\mid\mu,\tau\sim N(\mu,\tau^2)\) を使う。ここでの \(\mu\) は、第8回の集団平均 \(\bar\theta\) に当たる。InverseGammaの第2引数は、\(1/\tau^2\sim\mathrm{Gamma}(2,\mathrm{rate}=0.5)\) となるパラメータ化である。2本のチェーンを走らせ、初期部分を除いたdrawを保存する。
dat <- simulate_cbc()
fit <- fit_hb(dat)
parameter_table <- data.frame(
parameter = c("mu", "tau"), truth = c(dat$mu_true, dat$tau_true),
posterior_mean = c(mean(fit$mu), mean(fit$tau)),
lower = c(quantile(fit$mu, .025), quantile(fit$tau, .025)),
upper = c(quantile(fit$mu, .975), quantile(fit$tau, .975)),
split_Rhat = c(split_rhat(fit$chains[[1]]$mu,fit$chains[[2]]$mu),
split_rhat(fit$chains[[1]]$tau,fit$chains[[2]]$tau)))
kable(parameter_table, digits=3)| parameter | truth | posterior_mean | lower | upper | split_Rhat |
|---|---|---|---|---|---|
| mu | -2.3 | -2.333 | -2.448 | -2.228 | 1.003 |
| tau | 0.6 | 0.611 | 0.506 | 0.728 | 1.007 |
cat("holdoutの平均負の対数予測確率:",
log_score(predict_existing(fit,dat$test),dat$test$choice),"\n")holdoutの平均負の対数予測確率: 0.9859635
推定結果が真値の周辺にあるか、2本のチェーンで同じ領域を探索しているかを確かめる。ここで示す \(\widehat R\) は簡単なsplit診断であり、これだけで収束が保証されるわけではない。第9回で確認したトレースや自己相関も合わせて見る。holdoutの回答は、個人パラメータの推定には使っていない。
alpha_mean <- colMeans(exp(fit$theta))
selected <- order(alpha_mean)[c(30,150,270)]
post_df <- data.frame(alpha=as.vector(exp(fit$theta[,selected])),
customer=factor(rep(paste0("顧客",selected),each=nrow(fit$theta))))
ggplot(post_df,aes(alpha,color=customer))+geom_density(linewidth=.8)+
coord_cartesian(xlim=c(0,.5))+labs(x="価格感応度 alpha",y="密度",color="顧客")個人差は見えるが、分布は重なっている。この不確実性を残したまま、施策ごとの確率を計算しよう。
施策ごとの購買確率と期待利益
make_offer_tasks <- function(N, coupon) {
list(id=seq_len(N), quality=matrix(rep(c(1.6,.8),each=N),N,2),
price=matrix(rep(c(12-coupon,12),each=N),N,2))
}
coupons <- 0:3
cost <- 1
# draw × 顧客 × 施策。同じdrawで各施策を比較する。
q_draw <- array(NA_real_,c(nrow(fit$theta),dat$N,length(coupons)))
for (k in seq_along(coupons)) {
tasks <- make_offer_tasks(dat$N,coupons[k])
for (b in seq_len(nrow(fit$theta)))
q_draw[b,,k] <- choice_prob(tasks$quality,tasks$price,exp(fit$theta[b,]))[,2]
}
q_mean <- apply(q_draw,c(2,3),mean)
profit_mean <- sweep(q_mean,2,12-coupons-cost,"*")
gain_mean <- profit_mean-profit_mean[,1]
best_index <- max.col(profit_mean,ties.method="first")
uniform_index <- which.max(colMeans(profit_mean))
chosen_coupon <- coupons[best_index]
kable(data.frame(coupon=coupons, purchase_probability=colMeans(q_mean),
profit=colMeans(profit_mean)),digits=4,
caption="全員に同じクーポンを配った場合。金額は千円/人。")| coupon | purchase_probability | profit |
|---|---|---|
| 0 | 0.4372 | 4.8090 |
| 1 | 0.4616 | 4.6157 |
| 2 | 0.4869 | 4.3822 |
| 3 | 0.5131 | 4.1048 |
値引きすると購入確率は上がる。しかしマージンも減るので、期待利益が上がるとは限らない。選択肢に0円の値引きを入れることは、「配らない」という意思決定を残すために必要である。
curve_df <- expand.grid(coupon=coupons,customer=selected)
curve_df$prob <- mapply(function(c,i) q_mean[i,match(c,coupons)],
curve_df$coupon,curve_df$customer)
ggplot(curve_df,aes(coupon,prob,color=factor(customer)))+
geom_line()+geom_point()+labs(x="値引き額(千円)",y="Aの事後予測購買確率",color="顧客")事後平均を代入すると何が違うか
tasks0 <- make_offer_tasks(dat$N,0)
plugin_q <- choice_prob(tasks0$quality,tasks0$price,exp(colMeans(fit$theta)))[,2]
ggplot(data.frame(alpha=alpha_mean,difference=100*(q_mean[,1]-plugin_q)),
aes(alpha,difference))+geom_point(alpha=.6)+geom_hline(yintercept=0,linetype=2)+
labs(x="価格感応度の事後平均",y="積分 − 係数代入(パーセントポイント)")指数関数とlogitはいずれも非線形なので、平均してから変換する場合と、変換してから平均する場合は一致しない。差が小さく見える顧客でも、施策間の利益差がさらに小さければ、選ぶクーポンに影響しうる。
gain_df <- data.frame(id=rep(seq_len(dat$N),times=length(coupons)),
alpha=rep(alpha_mean,times=length(coupons)),
coupon=factor(rep(coupons,each=dat$N)),gain=as.vector(gain_mean))
ggplot(gain_df,aes(alpha,gain,color=coupon))+geom_point(alpha=.45,size=1)+
geom_hline(yintercept=0,linetype=2)+
labs(x="価格感応度の事後平均",y="期待増分利益(千円)",color="値引き(千円)")原価や通常価格が違えば、同じ需要でも最適な値引きは変わる。価格感応度が高いという情報だけで配布を決めるのではなく、上の利益を計算する。
一律と個別のルールを比較する
policy_index <- list("配布なし"=rep(1,dat$N),
"全員に3千円"=rep(4,dat$N),
"最適な一律"=rep(uniform_index,dat$N),"個人別"=best_index)
# 真値を使うのは検算だけ。施策の選択には使わない。
truth_q <- sapply(coupons,function(c) {
z <- make_offer_tasks(dat$N,c)
choice_prob(z$quality,z$price,exp(dat$theta_true))[,2]
})
truth_profit <- sweep(truth_q,2,12-coupons-cost,"*")
policy_table <- data.frame(policy=names(policy_index),
posterior=sapply(policy_index,function(k) mean(profit_mean[cbind(seq_len(dat$N),k)])),
truth=sapply(policy_index,function(k) mean(truth_profit[cbind(seq_len(dat$N),k)])))
kable(policy_table,digits=4,row.names=FALSE,caption="1人当たり期待利益(千円)")| policy | posterior | truth |
|---|---|---|
| 配布なし | 4.8090 | 4.7474 |
| 全員に3千円 | 4.1048 | 4.0781 |
| 最適な一律 | 4.8090 | 4.7474 |
| 個人別 | 4.8400 | 4.7792 |
policy_table %>% pivot_longer(c(posterior,truth),names_to="evaluation",values_to="profit") %>%
ggplot(aes(policy,profit,fill=evaluation))+geom_col(position="dodge")+
scale_fill_discrete(labels=c(posterior="事後期待値",truth="真値での評価"))+
labs(x=NULL,y="期待利益(千円/人)",fill="評価")kable(data.frame(coupon=coupons,customers=tabulate(best_index,nbins=4)),
caption="個人別ルールが選んだクーポン")| coupon | customers |
|---|---|
| 0 | 255 |
| 1 | 14 |
| 2 | 6 |
| 3 | 25 |
事後期待利益で評価する限り、個人別に選べるルールは、一律ルール以上になる。一律に全員へ同じクーポンを出すことも、個人別ルールが選べる候補に含まれるからである。しかし、これは使ったモデルとデータに基づく比較である。現実の利益でも必ず改善するという保証ではない。シミュレーションでは真のパラメータが分かるので、その違いを表の truth 列で確認できる。
ここで個別化したのは、本人の履歴がある既存顧客である。属性も履歴もない新規顧客なら、第9回の predict_new() のように集団分布で平均する。同じ情報しかない顧客には同じ予測が出るので、根拠のある個別化はできない。
個別化による改善が1人当たり数円なら、配信システムの費用をかけてまで行うべきかは別の問題になる。個人別の値引きに運用費用があるなら、その費用を目的関数に入れる。全員同じ施策と比べるだけでなく、2〜3グループに分ける方法も候補になる。ここでは個別化の費用を0と置いたため、その分だけ実務での利益を高く見積もっている可能性がある。
利益差にも事後分布がある
gain_draw <- numeric(nrow(fit$theta))
for (b in seq_len(nrow(fit$theta))) {
qb <- q_draw[b,,]
selected_q <- qb[cbind(seq_len(dat$N),best_index)]
gain_draw[b] <- mean((12-chosen_coupon-cost)*selected_q-(12-cost)*qb[,1])
}
ggplot(data.frame(gain=gain_draw),aes(gain))+geom_histogram(bins=35,fill="#4C72B0")+
geom_vline(xintercept=0,linetype=2)+labs(x="配布なしとの期待利益差(千円/人)",y="draw数")kable(data.frame(mean=mean(gain_draw),lower=quantile(gain_draw,.025),
upper=quantile(gain_draw,.975),prob_positive=mean(gain_draw>0)),digits=4,row.names=FALSE)| mean | lower | upper | prob_positive |
|---|---|---|---|
| 0.0309 | 0.0144 | 0.0448 | 0.9993 |
ここでは、事後期待利益で選んだルールを固定し、そのルールの利益差を各drawで計算した。drawごとに最適なクーポンを選び直して平均すると、毎回の真の好みを知ったうえで施策を変えられるような計算になり、実際に実行できるルールの価値を上回りうる。また、この区間はパラメータの不確実性についてのものであり、次の購買結果そのものの予測区間ではない。
最後は施策をランダム化して確かめる
ルールを決めるデータと、評価するデータ
推定した需要に合わせて施策を選んだ後は、そのルールを固定する。次に、対象顧客を「ルールに従ってクーポンを提示する群」と「提示しない群」に無作為に分ける。施策後の実現利益を比較すれば、そのルールを採用する効果を評価できる。
ここでは各顧客に、新しい1回の購買機会を作る。処置 \(D_i=1\) は個人別ルールを適用すること、\(D_i=0\) は配布なしである。ルールが値引きゼロを選んだ顧客にも \(D_i=1\) は割り当てられる。評価対象は「クーポンを実際に使った人」ではなく、「ルールを適用された人」である。
set.seed(202610)
D <- rbinom(dat$N,1,.5)
assigned_index <- ifelse(D==1,best_index,1)
assigned_coupon <- coupons[assigned_index]
prob_buy <- truth_q[cbind(seq_len(dat$N),assigned_index)]
bought_A <- rbinom(dat$N,1,prob_buy)
observed_profit <- (12-assigned_coupon-cost)*bought_A
ate_profit <- mean(observed_profit[D==1])-mean(observed_profit[D==0])
se_profit <- sqrt(var(observed_profit[D==1])/sum(D==1)+
var(observed_profit[D==0])/sum(D==0))
true_policy_effect <- mean(truth_profit[cbind(seq_len(dat$N),best_index)]-truth_profit[,1])
rct_table <- data.frame(estimate=ate_profit,lower=ate_profit-1.96*se_profit,
upper=ate_profit+1.96*se_profit,truth=true_policy_effect)
kable(rct_table,digits=4)| estimate | lower | upper | truth |
|---|---|---|---|
| -0.1874 | -1.4381 | 1.0633 | 0.0318 |
ggplot(rct_table,aes(x="個人別ルール − 配布なし",y=estimate))+
geom_point(size=3)+geom_errorbar(aes(ymin=lower,ymax=upper),width=.12)+
geom_hline(yintercept=true_policy_effect,color="#C44E52",linetype=2)+
labs(x=NULL,y="利益への効果(千円/人)")300人・1回の購買だけでは、区間が広くなりうる。モデル上で小さな利益改善が予測されても、少人数の実験で明確に確かめられるとは限らない。実際の導入では、検出したい利益差、利益の分散、必要な人数を考えて実験を設計する。今回は顧客ごとに独立な1回の結果なので、上の2群の分散を使った標準誤差で比較している。同じ顧客を何度も観測するなら、その依存も考慮する。
購入予測、価格反応、CATEは何が違うか
計量経済学Iで扱った潜在アウトカムを使うと、クーポン施策のCATEは
\[\tau(x)=E[Y_i(1)-Y_i(0)\mid X_i=x]\]
である。\(X_i\) は施策前に分かる属性や購買履歴、\(Y_i\) は施策後の購買や利益である。単なる \(E[Y_i(0)\mid X_i=x]\)、つまり「何もしなくても買いそうか」とは違う。
今回の価格感応度 \(\alpha_i\) は選好の異質性を表す。CATEは、指定した施策によるアウトカムの差を表す。価格が変わる施策をlogitモデルで表すなら、\(q_i(c)-q_i(0)\) が購買への効果につながるが、\(\alpha_i\) 自体がCATEなのではない。さらに、クーポン表示の効果や注意を向ける効果があれば、その施策は単なる価格変更では表せない。
需要モデルは、複数の価格や商品の反実仮想を同じ仕組みで比較するために役立つ。一方、ランダム化実験は、実際に行った施策の効果を比較するために役立つ。両方を組み合わせることで、モデルで候補を絞り、実際の施策で確かめることができる。
施策前の特徴が多く、施策効果が誰によって違うかを調べたい場合には、causal forestなどのCATE推定法が候補になる。DMLは、柔軟に推定した補助関数を使いながら、低次元の因果パラメータについて推論するための枠組みである。いずれも、処置がランダムなのか、観測変数で条件付ければ交絡を除けるのか、各群に比較可能な観測があるのか、といった識別条件を置き換えるものではない。また、CATEは同じ特徴を持つ集団の平均効果であり、個人の2つの潜在アウトカムが両方観測できるわけではない。今回の主題は手法を増やすことではなく、意思決定に必要な比較を定めることである。
実証研究コーナー
この論文は、世帯の購買履歴を使ってクーポンを個別化する価値を調べた。階層的なブランド選択モデルで、情報の少ない世帯にも集団の情報を使う。論文のモデルに基づく比較では、短い履歴を使った個別化による純収益の増加が、一律クーポンの2.5倍になった。また、1回の購買情報だけでも、一律クーポンより50%大きい増加が得られると報告している。これはランダム化したクーポン施策の実績差ではなく、推定モデルを使った収益比較である。原論文はprobitモデルなので、今回のlogitによる教材は仕組みを共有する簡略例である。著者公開論文
この研究は、求人プラットフォームZipRecruiterで価格を無作為に割り当て、そのデータから需要を推定した。最適な一律価格への変更で、事後期待利益は元の価格に比べて55%増えると計算される。さらに個別価格を使うと、最適な一律価格に比べて19%増えるという結果だった。元の価格に比べた改善と、既に最適化した一律価格に比べた改善を区別している点が大切である。著者らは別の実験でも予測を検証している。ただし、この利益改善率を他の市場や今回の教材にもそのまま当てはめることはできない。論文公開稿・要旨
この論文は、通信と会員制サービスの2つのフィールド実験を使い、離脱リスクに基づく配分と、施策への反応に基づく配分を比較した。反応の異質性を使う配分では、離脱リスクの高い人から配る方法に比べて、離脱率をさらに4.1ポイント、8.7ポイント下げられると試算される。これは、同じキャンペーンの対象者をどう選ぶかという比較である。離脱しそうな人の予測と、施策によって離脱を防げる人の予測は異なる。この結果は「離脱リスクが高い人には何をしても無駄」という意味ではない。次回のCLVでも、予測される価値や離脱リスクを、そのまま施策効果と読み替えないようにする。著者公開論文
- 個人の需要は、クーポンあり・なしの購買確率を計算するために使える。
- 個人パラメータの事後分布で予測を積分し、値引き後のマージンを掛けて期待利益を求める。
- 一律と個別の施策は、同じ利益基準で比較する。個別化の運用費用があれば、それも含める。
- 価格の割当、仮想回答と実購買の関係、施策の表示効果などは、推定法だけでは解決しない。
- 選んだ施策ルールを固定し、別のランダム化実験で実現利益を比較する。
宿題
- 原価を1千円から5千円に変えると、選ばれるクーポンはどう変わるか。
- 1人にクーポンを提示するごとに100円の費用がかかるなら、どこを修正すればよいか。
- Playground 10で、価格感応度の不確実性と原価を変えてみよう。
- coding課題は assignment10.qmd を参照。
次回予告
今回の期待利益は1回の購買機会についてのものだった。次回は、過去の購買頻度と最終購買時点から、将来の購買回数を予測する。顧客ごとの長期価値をCLVとして計算するが、CLVが高いことと施策による増分が大きいことは、やはり区別する必要がある。
参考文献
- Rossi, P. E., R. E. McCulloch, and G. M. Allenby (1996), “The Value of Purchase History Data in Target Marketing,” Marketing Science, 15(4), 321–340.
- Dubé, J.-P. and S. Misra (2023), “Personalized Pricing and Consumer Welfare,” Journal of Political Economy, 131(1), 131–189.
- Ascarza, E. (2018), “Retention Futility: Targeting High-Risk Customers Might Be Ineffective,” Journal of Marketing Research, 55(1), 80–98.
- Train, K. E. (2009), Discrete Choice Methods with Simulation, 2nd ed., Chapters 11–13.
- Wager, S. and S. Athey (2018), “Estimation and Inference of Heterogeneous Treatment Effects using Random Forests,” Journal of the American Statistical Association, 113(523), 1228–1242.
- Chernozhukov, V. et al. (2018), “Double/Debiased Machine Learning for Treatment and Structural Parameters,” The Econometrics Journal, 21(1), C1–C68.