Mímisbrunnr知恵の泉

← マーケティングサイエンス 一覧

🎓 レベル:標準 | 重要度:A(必須)

📎 関連:第7章 実験と因果推論 目次 | 前提:ターゲティングとペルソナ設計A/Bテストの設計と分析

要点(BLUF)

1. 反応率の高さではなく「施策で動く人」を狙う(4類型)

ターゲティングとペルソナ設計 の§⚠️で、最も根深い誤解として「反応率が高い=施策効果が高い、ではない」を挙げました。本ノートはそこを出発点にします。

反応率(接触したときに買う確率)が高いセグメントを狙えば成果が出る——直感的ですが、半分間違いです。反応率の高い層には2種類の人が混ざっています。ひとつは「施策がきっかけで買った人」、もうひとつは「施策がなくても、もともと買っていた人」。後者にいくら接触しても売上は1円も増えません。コストだけかかる無駄打ちです。施策の価値を生むのは前者だけ——「施策を打ったから態度が変わった人」で、これを**説得可能層(persuadables)**と呼びます。

顧客を「放っておいても買うか」と「施策を打つと買うか」の2軸で切ると、4類型が現れます。

類型放っておくと施策を打つとアップリフト τ施策すべきか
説得可能(persuadables)買わない買う大きく正◎ 狙う
鉄板(sure things)買う買うほぼ 0× 無駄打ち
無関心(lost causes)買わない買わないほぼ 0× 無駄打ち
天邪鬼(sleeping dogs)買う買わなくなる×× 触ると損

狙うべきは説得可能だけです。鉄板と無関心は、施策の有無で結果が変わらない(τ0\tau\approx0)ので接触してもコストの持ち出し。とりわけ危険なのが天邪鬼——放っておけば買ってくれたのに、施策がかえって逆効果になる層です。しつこいリマインドメールが「うっとうしい」と解約を誘発する、不要なクーポンが「定価で買うのは損」と通常購入を止める、押しの強い営業電話が顧客を遠ざける——現実によくある現象で、触れば触るほど売上を減らす(負のアップリフト)。英語の sleeping dogs(寝た子)=「寝た子を起こすな」がぴったりの層で、ここには手を出さないのが最善手です。

ここで反応率(処置時CVR)が役に立たない理由がはっきりします。反応率は「処置したとき買う確率」しか見ないので、説得可能(処置で買う)と鉄板(処置でも対照でも買う)を区別できません——両者とも処置時CVRは高いのです。両者を分けるのは「処置したときしなかったときの差」だけ。この差こそアップリフトで、§2でこれを定式化します。

flowchart TB
  ST["施策(処置)を打つ相手は?"] --> Q1{"放っておいても買う人か"}
  Q1 -->|"買わない"| B0{"施策を打つと"}
  Q1 -->|"買う"| B1{"施策を打つと"}
  B0 -->|"買うようになる"| PERS["説得可能(persuadables)<br/>対照で買わず処置で買う/τ が正<br/>=狙うべき層"]
  B0 -->|"やはり買わない"| LOST["無関心(lost causes)<br/>どうせ買わない/τ ほぼ0<br/>=無駄打ち"]
  B1 -->|"変わらず買う"| SURE["鉄板(sure things)<br/>放っても買う/τ ほぼ0<br/>=無駄打ち"]
  B1 -->|"逆に買わなくなる"| DOG["天邪鬼(sleeping dogs)<br/>触ると離れる/τ が負<br/>=触ると損"]

2. アップリフト=個別の処置効果 CATE と T-learner(数式)

A/Bテストの設計と分析 が測った処置効果 δ\delta は、全員をひとまとめにした**平均処置効果(ATE)**でした。アップリフトはそれを顧客の属性 xx(セグメント・年齢・購買履歴など)ごとに分解した量で、**条件つき平均処置効果(CATE)**と呼びます。

τ(x)  =  E[YT=1,x]処置したときの反応率    E[YT=0,x]処置しなかったときの反応率\tau(x) \;=\; \underbrace{E[Y\mid T{=}1,\, x]}_{\text{処置したときの反応率}} \;-\; \underbrace{E[Y\mid T{=}0,\, x]}_{\text{処置しなかったときの反応率}}

アウトカム YY が買う/買わない(0/10/1)なら、E[YT=1,x]E[Y\mid T{=}1,x] は「属性 xx の人を処置したときの反応率」、E[YT=0,x]E[Y\mid T{=}0,x] は「同じ人を処置しなかったときの反応率」で、その差 τ(x)\tau(x)その層に施策が生む増分です。ATE はこの CATE を母集団全体で平均したものにすぎません。

δATE  =  Ex[τ(x)]\delta_{\text{ATE}} \;=\; E_x\big[\tau(x)\big]

A/B が「施策は平均してプラスか」に答えるのに対し、アップリフトは「誰に効くのか」という効果の異質性に答えます。§1の4類型は、τ(x)\tau(x) の符号と大きさによる分類に他なりません(説得可能 τ>0\tau>0、鉄板・無関心 τ0\tau\approx0、天邪鬼 τ<0\tau<0)。

τ(x)\tau(x) をデータから推定する最も素直な方法が T-learner(Two-model learner)です。処置群だけで「処置したときの反応率」を当てるモデル μ^1\hat\mu_1 と、対照群だけで「処置しなかったときの反応率」を当てるモデル μ^0\hat\mu_0別々に作り、その差をとります。

μ^1(x)=E^[YT=1,x],μ^0(x)=E^[YT=0,x],τ^(x)=μ^1(x)μ^0(x)\hat\mu_1(x)=\hat E[Y\mid T{=}1,x],\qquad \hat\mu_0(x)=\hat E[Y\mid T{=}0,x],\qquad \hat\tau(x)=\hat\mu_1(x)-\hat\mu_0(x)

本ノートでは xx =セグメント(カテゴリ)なので、各モデルは「セグメントごとの平均反応率」という単純な表になります。μ^1(セグメント)\hat\mu_1(\text{セグメント}) は処置群でのそのセグメントの CVR、μ^0(セグメント)\hat\mu_0(\text{セグメント}) は対照群での CVR、差が τ^\hat\tau。T-learner が正しく CATE を当てられるのは、各 xx の中で処置群と対照群が比較可能なとき——つまり A/Bテストの設計と分析ランダム化が効いているときだけです。観察データでは処置を受けた人と受けない人が xx や見えない要因で元から違い(交絡)、μ^1μ^0\hat\mu_1-\hat\mu_0 は効果と素性の差を混ぜてしまいます。だから本ノートはA/B(ランダム割付)データを前提にします。

推定したアップリフトは、ターゲティングに直結します。処置する顧客集合を SS とすると、施策の総増分は各顧客のアップリフトの和です。

施策の総増分  =  iSτ(xi)\text{施策の総増分} \;=\; \sum_{i\in S}\tau(x_i)

これを最大化する狙い方は明快です。τ(x)\tau(x)負の顧客(天邪鬼)は絶対に処置しない——足すと総増分が減るからです。そのうえで予算(処置できる人数)の範囲で、τ(x)\tau(x)大きい順に処置する。これが最適なターゲティングで、反応率 P(買う処置)P(\text{買う}\mid\text{処置}) の高い順ではありません。反応率順は説得可能(高反応率・高 τ\tau)と鉄板(高反応率・低 τ\tau)を混同し、CVR が中程度の天邪鬼(負 τ\tau)すら掴んでしまいます。

顧客を τ^(x)\hat\tau(x) の降順に並べ、上位 x%x\% を処置したときの累積増分 τ\sum\tau を描いた曲線をアップリフト曲線と呼びます。曲線の頂点が「何%まで狙うべきか」を教えます(頂点を超えて処置を広げると、τ\tau が負の層が混じり増分が減る)。無作為に処置したときの直線(対角線)からどれだけ上に膨らむかを面積で測ったのが Qini 係数で、アップリフトモデルの良さの要約指標です(分類でいう AUC のアップリフト版)。

3. T-learner でアップリフトを推定する(コード)

4セグメント(各 25002500 人)を作り、各人を A/B でランダムに処置/対照へ割付して、アウトカム Y(0/1)Y(0/1) を生成します。真のアップリフトは設計上 [+0.30,+0.05,+0.01,0.20][+0.30,+0.05,+0.01,-0.20]。これを T-learner(処置群モデル μ^1\hat\mu_1 と対照群モデル μ^0\hat\mu_0 の差)で推定し、真値を回復できるか、そして各推定の 95%95\% 信頼区間を確かめます。

import numpy as np
import pandas as pd
from scipy import stats

rng = np.random.default_rng(0)

# 4セグメント×2500人。(対照反応率 → 処置反応率) = 真のアップリフト τ
segs    = ["説得可能", "鉄板", "無関心", "天邪鬼"]
N_each  = 2500
p_ctrl  = {"説得可能": 0.10, "鉄板": 0.80, "無関心": 0.02, "天邪鬼": 0.50}
p_treat = {"説得可能": 0.40, "鉄板": 0.85, "無関心": 0.03, "天邪鬼": 0.30}
#  真のτ  =        +0.30        +0.05        +0.01        −0.20

segment = np.repeat(segs, N_each)              # 各人のセグメント(長さ10000)
N = segment.size
treat = rng.integers(0, 2, N)                  # A/B:1=処置, 0=対照(ランダム化)
pc = np.array([p_ctrl[s] for s in segment])
pt = np.array([p_treat[s] for s in segment])
Y = rng.binomial(1, np.where(treat == 1, pt, pc))   # アウトカム Y(0/1)
df = pd.DataFrame({"segment": segment, "treat": treat, "Y": Y})

# T-learner:処置群モデル mu1=E[Y|T=1,seg] と対照群モデル mu0=E[Y|T=0,seg] を別々に作り、差をとる
g1 = df[df["treat"] == 1].groupby("segment")["Y"]
g0 = df[df["treat"] == 0].groupby("segment")["Y"]
mu1, mu0 = g1.mean().reindex(segs), g0.mean().reindex(segs)   # 処置時CVR・対照時CVR
n1, n0   = g1.size().reindex(segs), g0.size().reindex(segs)
tau_hat  = mu1 - mu0                                          # アップリフト推定 = 差
tau_true = pd.Series({s: p_treat[s] - p_ctrl[s] for s in segs}).reindex(segs)

# 差の95%信頼区間(各群の二項分散から)
se = np.sqrt(mu1 * (1 - mu1) / n1 + mu0 * (1 - mu0) / n0)
zc = stats.norm.ppf(0.975)
lo, hi = tau_hat - zc * se, tau_hat + zc * se

tbl = pd.DataFrame({"対照CVR": mu0, "処置CVR": mu1, "推定τ": tau_hat,
                    "真のτ": tau_true, "95CI下": lo, "95CI上": hi}).reindex(segs)
print("=== T-learner:セグメント別アップリフト τ の推定(A/Bデータから)===")
print(tbl.to_string(formatters={
    "対照CVR": "{:.3f}".format, "処置CVR": "{:.3f}".format,
    "推定τ": "{:+.3f}".format, "真のτ": "{:+.3f}".format,
    "95CI下": "{:+.3f}".format, "95CI上": "{:+.3f}".format}))

出力:

=== T-learner:セグメント別アップリフト τ の推定(A/Bデータから)===
     対照CVR 処置CVR    推定τ    真のτ  95CI下  95CI上
説得可能 0.090 0.394 +0.304 +0.300 +0.273 +0.335
鉄板   0.798 0.847 +0.049 +0.050 +0.019 +0.079
無関心  0.020 0.036 +0.016 +0.010 +0.003 +0.029
天邪鬼  0.497 0.303 -0.194 -0.200 -0.232 -0.157

出力の意味:まず対照CVR と処置CVR の2列を見れば、4類型がそのまま読めます。鉄板は両方とも高い(0.7980.8470.798\to0.847、放っても買う)、無関心は両方とも低い(0.0200.0360.020\to0.036、どうせ買わない)、説得可能は対照で低く処置で跳ね上がる(0.0900.3940.090\to0.394)、そして天邪鬼は処置すると下がる0.4970.3030.497\to0.303)——施策が反応率を減らしています。この「下がる」が負のアップリフトの正体です。

T-learner は真のアップリフトを回復しています。推定 τ^\hat\tau は説得可能 +0.304+0.304(真 +0.300+0.300)、鉄板 +0.049+0.049+0.050+0.050)、無関心 +0.016+0.016+0.010+0.010)、天邪鬼 0.194-0.1940.200-0.200)。処置群と対照群の平均をセグメント別にとって引くだけで、施策の個別効果が当たります。とりわけ重要なのは天邪鬼の 95%95\% 信頼区間 [0.232,0.157][-0.232,-0.157]00 を含まず明確に負なこと——「触ると損」はノイズではなく統計的に有意な負の効果です。一方、無関心の区間 [+0.003,+0.029][+0.003,+0.029] はぎりぎり 00 を超えるものの、点推定 0.0160.016 は真値 0.0100.010 から 66 割もずれています。アップリフトは2つの反応率の差なので、効果が小さい層では誤差が相対的に大きくなり、推定が難しい(§⚠️)。信頼できるのは説得可能 +0.30+0.30・天邪鬼 0.20-0.20 という大きなシグナルで、ターゲティングはそこで決まります。

4. アップリフトでターゲティングを設計する(コード)

推定したアップリフトで、実際に狙い方を設計します。3つの施策を比べます——(1) 全員処置(2) アップリフトが正の層だけ処置(3) 同じ人数を処置CVR(反応率)の高い順に処置。各施策が生む総増分コンバージョンを、真のアップリフトで採点します(合成データなので「実際にいくつ増えたか」を真値で評価できます)。(2) と (3) は同じ人数を処置するので、差は純粋に「誰を選ぶか」から来ます。

import numpy as np
import pandas as pd

rng = np.random.default_rng(0)
segs    = ["説得可能", "鉄板", "無関心", "天邪鬼"]
N_each  = 2500
p_ctrl  = {"説得可能": 0.10, "鉄板": 0.80, "無関心": 0.02, "天邪鬼": 0.50}
p_treat = {"説得可能": 0.40, "鉄板": 0.85, "無関心": 0.03, "天邪鬼": 0.30}

segment = np.repeat(segs, N_each)
N = segment.size
treat = rng.integers(0, 2, N)
pc = np.array([p_ctrl[s] for s in segment])
pt = np.array([p_treat[s] for s in segment])
Y = rng.binomial(1, np.where(treat == 1, pt, pc))
df = pd.DataFrame({"segment": segment, "treat": treat, "Y": Y})

# T-learner(§3と同じ):処置時CVR mu1 と アップリフト推定 tau_hat
mu1 = df[df["treat"] == 1].groupby("segment")["Y"].mean().reindex(segs)            # 処置時CVR
mu0 = df[df["treat"] == 0].groupby("segment")["Y"].mean().reindex(segs)            # 対照時CVR
tau_hat  = mu1 - mu0                                                               # アップリフト
N_seg    = pd.Series({s: N_each for s in segs}).reindex(segs)
tau_true = pd.Series({s: p_treat[s] - p_ctrl[s] for s in segs}).reindex(segs)

# 各施策が実際に生む増分 = 処置した人数 × 真のアップリフト(合成データなので真値で採点できる)
def increment(sel):
    return float((N_seg[sel] * tau_true[sel]).sum())

# (1) 全員処置:全セグメントに打つ
inc_all = increment(list(segs))
# (2) アップリフト施策:推定アップリフトが正のセグメントだけ処置(効果が正の人だけ狙う)
uplift_sel = [s for s in segs if tau_hat[s] > 0]
n_budget   = int(N_seg[uplift_sel].sum())
inc_uplift = increment(uplift_sel)
# (3) 反応率施策:同じ予算(同じ処置人数)を、処置CVRが高い順に充てる
cvr_sel, cum = [], 0
for s in mu1.sort_values(ascending=False).index:
    if cum >= n_budget:
        break
    cvr_sel.append(s); cum += int(N_seg[s])
inc_cvr = increment(cvr_sel)

print(f"処置人数の予算 = {n_budget}人(アップリフトが正の層の人数にそろえる)")
print(f"(1) 全員処置({N}人)              : 増分CV {inc_all:+.0f} 件  対象={'・'.join(segs)}")
print(f"(2) アップリフト施策({n_budget}人)   : 増分CV {inc_uplift:+.0f} 件  対象={'・'.join(uplift_sel)}")
print(f"(3) 反応率(処置CVR)施策({n_budget}人): 増分CV {inc_cvr:+.0f} 件  対象={'・'.join(cvr_sel)}")
print(f"アップリフト施策 − 反応率施策 = {inc_uplift - inc_cvr:+.0f} 件")
print(f"アップリフト施策 − 全員処置   = {inc_uplift - inc_all:+.0f} 件(天邪鬼を外して回収)")

出力:

処置人数の予算 = 7500人(アップリフトが正の層の人数にそろえる)
(1) 全員処置(10000人)              : 増分CV +400 件  対象=説得可能・鉄板・無関心・天邪鬼
(2) アップリフト施策(7500人)   : 増分CV +900 件  対象=説得可能・鉄板・無関心
(3) 反応率(処置CVR)施策(7500人): 増分CV +375 件  対象=鉄板・説得可能・天邪鬼
アップリフト施策 − 反応率施策 = +525 件
アップリフト施策 − 全員処置   = +500 件(天邪鬼を外して回収)

出力の意味

全員処置(400400 件)。3つの良い層(説得可能 +750+750・鉄板 +125+125・無関心 +25+25)は合計 +900+900 件を生むのに、天邪鬼が 2500×(0.20)=5002500\times(-0.20)=-500 件を差し引くため、全員に打つと正味 400400 件しか残りません。「とりあえず全員に配る」は、天邪鬼の逆効果をまるごと抱え込みます。

アップリフト施策(900900 件)推定アップリフトが正のセグメントだけ(説得可能・鉄板・無関心の 7,5007{,}500 人)を処置すると、天邪鬼に触れないので 900900 件を満額回収。全員処置より +500+500 件多く、この差は天邪鬼の赤字 500-500 件を回避した分にちょうど一致します。ターゲティングとペルソナ設計 で「期待利益が正のセグメントだけ狙えばブランケットに勝つ」と見たのと同じ構造で、ここでは判断軸が**期待利益から個別の処置効果(アップリフト)**に変わっています。「効果が正の人だけ狙う」が、施策の世界での最適なターゲティングです。

反応率(処置CVR)施策(375375 件)。ここが本ノートの核心です。同じ 7,5007{,}500を処置するのに、処置CVR の高い順に選ぶと、対象は鉄板・説得可能・天邪鬼になります。CVR ランキングは鉄板(0.850.85)を真っ先に拾うので、説得可能と同じ枠を「放っても買う鉄板」に無駄打ちし、さらに CVR 0.300.30 の天邪鬼を「反応率が高い良い客」と勘違いして掴みます。結果、増分は 375375 件——全員処置の 400400 件すら下回る大失敗。アップリフトで選んだ場合(900900 件)との差は +525+525 件で、同じ人数に接触しながら 2.42.4の開きです。狙う人数も予算も同じ。違うのは「反応率(誰が買うか)で選ぶか、アップリフト(誰が施策で変わるか)で選ぶか」だけ——これが成果を倍以上動かします。

最後に、セグメント別アップリフト(正=狙う/負=触らない)と、アップリフト曲線(アップリフト降順に顧客を並べ、上位 x%x\% を処置したときの累積増分)を1枚に描きます。比較のため、処置CVR降順・無作為(対角線)も重ねます。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib

rng = np.random.default_rng(0)
segs    = ["説得可能", "鉄板", "無関心", "天邪鬼"]
N_each  = 2500
p_ctrl  = {"説得可能": 0.10, "鉄板": 0.80, "無関心": 0.02, "天邪鬼": 0.50}
p_treat = {"説得可能": 0.40, "鉄板": 0.85, "無関心": 0.03, "天邪鬼": 0.30}

segment = np.repeat(segs, N_each)
N = segment.size
treat = rng.integers(0, 2, N)
pc = np.array([p_ctrl[s] for s in segment])
pt = np.array([p_treat[s] for s in segment])
Y = rng.binomial(1, np.where(treat == 1, pt, pc))
df = pd.DataFrame({"segment": segment, "treat": treat, "Y": Y})

mu1 = df[df["treat"] == 1].groupby("segment")["Y"].mean().reindex(segs)
tau_hat = mu1 - df[df["treat"] == 0].groupby("segment")["Y"].mean().reindex(segs)
tau_true = {s: p_treat[s] - p_ctrl[s] for s in segs}

# 各人を「予測アップリフト(自分のセグメントの推定τ)」の降順に並べ、上位x%を処置したときの累積増分
pred = np.array([tau_hat[s] for s in segment])      # 並べ替えのキー(アップリフト)
gain = np.array([tau_true[s] for s in segment])     # 採点:処置で実際に生む増分(真のτ)
cvrk = np.array([mu1[s] for s in segment])          # 比較用:反応率(処置CVR)

def curve(order):
    cum = np.concatenate([[0.0], np.cumsum(gain[order])])
    return np.arange(0, N + 1) / N * 100, cum

f, c_up = curve(np.argsort(-pred, kind="stable"))   # アップリフト降順
_, c_cv = curve(np.argsort(-cvrk, kind="stable"))   # 処置CVR降順
peak = int(np.argmax(c_up))                          # 累積増分が最大になる位置

fig, (axb, axc) = plt.subplots(1, 2, figsize=(10.4, 4.4))
colors = ["C0" if tau_hat[s] > 0 else "C3" for s in segs]   # 正=青(狙う), 負=赤(触らない)
axb.bar(segs, [tau_hat[s] for s in segs], color=colors)
axb.axhline(0, color="black", lw=1)
for i, s in enumerate(segs):
    axb.text(i, tau_hat[s] + (0.012 if tau_hat[s] > 0 else -0.012), f"{tau_hat[s]:+.2f}",
             ha="center", va="bottom" if tau_hat[s] > 0 else "top", fontsize=10)
axb.set_ylabel("推定アップリフト τ")
axb.set_title("セグメント別アップリフト(正=狙う/負=触らない)")
axb.grid(alpha=0.3, axis="y")

axc.plot(f, c_up, color="C2", lw=2.2, label="アップリフト降順で処置")
axc.plot(f, c_cv, color="C1", lw=1.8, label="処置CVR降順で処置")
axc.plot([0, 100], [0, c_up[-1]], color="gray", ls="--", lw=1.3, label="無作為に処置")
axc.scatter([f[peak]], [c_up[peak]], color="C2", zorder=5)
axc.annotate(f"頂点 {c_up[peak]:.0f}件(上位{f[peak]:.0f}%)", (f[peak], c_up[peak]),
             textcoords="offset points", xytext=(-12, -40), fontsize=9, ha="center")
axc.set_xlabel("処置した顧客の割合(%)"); axc.set_ylabel("累積の増分CV(件)")
axc.set_title("アップリフト曲線:上位何%を狙うと増分が最大か"); axc.legend(loc="lower right")
axc.grid(alpha=0.3)

fig.suptitle("アップリフトモデリング:誰が施策で動くかで狙う")
fig.tight_layout()
plt.show()

左パネルはセグメント別の推定アップリフトです。説得可能の青い棒が +0.30+0.30 と突出し、鉄板 +0.05+0.05・無関心 +0.02+0.02 は小さく、天邪鬼だけが赤くゼロ線の下(0.19-0.19)に沈みます。棒の符号が「狙う/触らない」の境界——ゼロより上だけを処置対象にするのが、§4の「アップリフトが正の層だけ狙う」操作です。

右パネルがアップリフト曲線です。緑(アップリフト降順)は、説得可能から処置するので最初に急上昇し(上位 25%25\%+750+750 件)、鉄板・無関心で緩やかに伸びて上位 75%75\% で頂点 900900に達し、そこから先(天邪鬼)を処置に加えると転落して 400400になります。この頂点が「75%75\% まで狙え、それ以上は天邪鬼が混じるからやめろ」と教えてくれます。橙(処置CVR降順)は鉄板から始めるため立ち上がりが鈍く、説得可能で跳ね、75%75\% で天邪鬼を踏んで急降下——反応率順がいかに不安定かが見えます。灰の対角線は無作為(狙わない)で、緑がそこから上に膨らんだ面積が Qini=アップリフトモデルの稼ぎです。

⚠️ よくある誤解

関連ノート