Mímisbrunnr知恵の泉

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

🎓 レベル:発展 | 重要度:A(必須)

📎 関連:確率的購買モデル(BG/NBD) | 前提:残存効果(adstock)と飽和

要点(BLUF)

1. 予算配分問題:飽和があるから「限界」で配る

手元に総予算 BB があり、TV・Digital・Promo といった複数チャネルへ振り分けるとします。素朴には「均等に配る」か「チャネルの規模(最大効果 β\beta)に比例して配る」あたりが思いつきます。しかしどちらも最適ではありません。理由は 残存効果(adstock)と飽和 の**飽和(収穫逓減)**です。

各チャネルの応答は出稿に比例しません。少額のうちは効きが立ち上がり、出すほど頭打ちになる(Hill 曲線)。すると「いま大きく貢献しているチャネル」が、追加の1円でも大きく効くとは限りません。すでに飽和域に入っていれば、その1円の見返り(=限界応答)は小さい。逆に出稿の薄いチャネルは、まだ限界が高い余地を残しています。だから配分は、チャネルの平均的な大きさではなく、**「次の1円の効き=限界応答」**で決めるべきなのです。

直感はこうです。いまの配分から、限界応答がいちばん高いチャネルへ1円動かすと総応答が増える。動かすとそのチャネルは飽和が進んで限界が下がり、減らした側は限界が上がる。これを繰り返すと、やがてどのチャネルでも次の1円の見返りが同じになり、もう動かしても得しない——そこが最適です。つまり最適配分では限界応答が全チャネルで揃う(等限界原理)

flowchart LR
  B["総予算 B(固定)"] --> ALLOC["配分 s_1, s_2, ..., s_C"]
  ALLOC --> F["各チャネルの飽和応答 f_c(s_c)"]
  F --> SUM["総応答 Σ f_c(s_c) を最大化"]
  SUM --> RULE["最適:限界応答 f_c'(s_c) が全チャネルで一致"]

これは 価格最適化(利益最大化) で「利益の微分=0」を解いたのと同じ、微分で最適を特徴づける発想の、制約付き版です。次節で数式にします。

2. 数式:制約付き最適化とラグランジュ(等限界原理)

チャネル c=1,,Cc=1,\dots,C への配分を scs_c、その飽和応答を fc(sc)f_c(s_c) とします。総予算 BB を使い切り、配分は非負、という制約のもとで総応答を最大化します。

maxs1,,sC c=1Cfc(sc)s.t.c=1Csc=B,sc0\max_{s_1,\dots,s_C}\ \sum_{c=1}^{C} f_c(s_c) \quad \text{s.t.}\quad \sum_{c=1}^{C} s_c = B,\quad s_c \ge 0

等式制約付きの最大化なので、ラグランジュ関数を作ります。乗数 λ\lambda を予算制約に当てます。

L(s,λ)=cfc(sc)λ(cscB)\mathcal{L}(s,\lambda) = \sum_{c} f_c(s_c) - \lambda\left(\sum_{c} s_c - B\right)

内点(すべて sc>0s_c>0)での最適は、各 scs_c で偏微分がゼロ。

Lsc=fc(sc)λ=0      fc(sc)=λ(c)\frac{\partial \mathcal{L}}{\partial s_c} = f_c'(s_c) - \lambda = 0 \ \implies\ f_c'(s_c) = \lambda \quad (\forall c)

これが等限界原理です。最適では、すべてのチャネルの限界応答 fc(sc)f_c'(s_c) が共通の値 λ\lambda に等しいλ\lambda は「予算をもう1単位増やしたときに総応答がどれだけ伸びるか」という**予算の影の価格(限界価値)**でもあります。csc=B\sum_c s_c = Bfc(sc)=λf_c'(s_c)=\lambda を連立すれば、最適配分 sc\*s_c^\*λ\lambda が決まります。

飽和は 残存効果(adstock)と飽和 と同じ Hill 関数α=2\alpha=2)を使います。

fc(s)=βcs2s2+κc2f_c(s) = \beta_c\,\frac{s^{2}}{s^{2}+\kappa_c^{2}}

限界応答は、商の微分でこうなります。

fc(s)=2βcκc2s(s2+κc2)2f_c'(s) = \frac{2\,\beta_c\,\kappa_c^{2}\,s}{\left(s^{2}+\kappa_c^{2}\right)^{2}}

⚠️ 凸性の注意α=2\alpha=2 の Hill は S 字で、原点近くは(限界が増える区間)を含むため、問題は厳密には凸最適化ではありません。等限界条件は1階の必要条件で、複数の停留点があり得ます。実務では妥当な初期点(例:均等配分)から解き、必要なら多点スタートで大域性を確かめます。本ノートの数値例では、最適配分が全チャネルとも変曲点より上の凹領域に入り、等限界の解が大域最適になります(コードで検証)。凸最適化・ラグランジュ双対の理論は最適化・統計のテキストへ。

3. SLSQPで最適配分を解く(コード)

3チャネルの Hill 飽和(TV・Digital・Promo)に総予算 B=100B=100 を配分します。scipy.optimize.minimizeSLSQP(逐次二次計画法)で、等式制約 csc=B\sum_c s_c=B と境界 sc0s_c\ge0 のもと、負の総応答を最小化=総応答を最大化します。均等配分・β比例配分と総応答を比べ、最適が最大であること、そして最適点で各チャネルの限界応答 fc(sc)f_c'(s_c) が一致することを確かめます。数式との対応:目的が cfc(sc)\sum_c f_c(s_c)、制約が csc=B\sum_c s_c=Bmarginalfc(s)=2βcκc2s/(s2+κc2)2f_c'(s)=2\beta_c\kappa_c^2 s/(s^2+\kappa_c^2)^2

import numpy as np
from scipy.optimize import minimize

# 3チャネルの Hill 飽和応答 f_c(s) = β_c · s^2/(s^2 + κ_c^2)(04-04 の α=2)
beta = np.array([1000.0, 800.0, 600.0])   # 各チャネルの最大効果(応答の上限)
kappa = np.array([40.0, 25.0, 15.0])      # 半飽和点(応答が上限の半分に達する出稿)
names = ["TV", "Digital", "Promo"]
B = 100.0                                  # 総予算

def response(s):
    s = np.asarray(s, dtype=float)
    return beta * s**2 / (s**2 + kappa**2)

def total_response(s):
    return response(s).sum()

def marginal(s):
    # f_c'(s) = 2 β_c κ_c^2 s / (s^2 + κ_c^2)^2
    s = np.asarray(s, dtype=float)
    return 2*beta*kappa**2*s / (s**2 + kappa**2)**2

# === 最適化:SLSQP で総応答を最大化(= 負の総応答を最小化)s.t. Σs=B, s>=0 ===
cons = {"type": "eq", "fun": lambda s: s.sum() - B}
bnds = [(0.0, B)] * 3
x0 = np.full(3, B/3)   # 均等配分から出発
res = minimize(lambda s: -total_response(s), x0, method="SLSQP",
               bounds=bnds, constraints=cons,
               options={"ftol": 1e-12, "maxiter": 500})
s_opt = res.x

# === 比較する配分:均等/β比例(チャネル規模に比例)===
s_even = np.full(3, B/3)
s_prop = B * beta / beta.sum()

print("=== 配分方式と総応答の比較(総予算 B=100)===")
print(f"{'配分':<12}{'TV':>9}{'Digital':>10}{'Promo':>9}{'総応答':>12}")
for label, s in [("均等", s_even), ("β比例", s_prop), ("最適(SLSQP)", s_opt)]:
    print(f"{label:<12}{s[0]:9.2f}{s[1]:10.2f}{s[2]:9.2f}{total_response(s):12.2f}")

# === 最適点で各チャネルの限界応答が一致(等限界原理=ラグランジュ乗数 λ)===
mg = marginal(s_opt)
print("\n=== 最適点の限界応答 f_c'(s)(全チャネルで一致=ラグランジュ乗数 λ)===")
for nm, s, g in zip(names, s_opt, mg):
    print(f"{nm:<8} 配分 s = {s:6.2f}   限界 f'(s) = {g:.4f}")
print(f"限界応答の 最大 - 最小 = {mg.max()-mg.min():.2e}  (ほぼ 0 = 等限界が成立)")

出力:

=== 配分方式と総応答の比較(総予算 B=100)===
配分                 TV   Digital    Promo         総応答
均等              33.33     33.33    33.33     1420.80
β比例             41.67     33.33    25.00     1473.58
最適(SLSQP)       44.23     33.08    22.69     1476.80

=== 最適点の限界応答 f_c'(s)(全チャネルで一致=ラグランジュ乗数 λ)===
TV       配分 s =  44.23   限界 f'(s) = 11.1913
Digital  配分 s =  33.08   限界 f'(s) = 11.1913
Promo    配分 s =  22.69   限界 f'(s) = 11.1913
限界応答の 最大 - 最小 = 2.73e-06  (ほぼ 0 = 等限界が成立)

出力の意味

最適が最大。総応答は、均等 1420.801420.80 < β比例 1473.581473.58 < 最適 1476.801476.80。均等配分は最も劣り、最適に対し 5656 も取りこぼします(3.8%-3.8\%)。チャネル規模 β\beta に比例させる β比例はかなり健闘しますが、それでも最適には 3.223.22 及びません——比例配分は「それらしい」が最適ではない。同じ予算 100100 で、配り方を変えるだけで総応答が動くこと、そして最適が確かに最大であることが数字で確認できます。

等限界の成立。最適配分は TV 44.2344.23・Digital 33.0833.08・Promo 22.6922.69。注目は限界応答で、3チャネルとも fc(s)=11.1913f_c'(s)=11.1913 にぴたり揃っています(最大−最小が 2.73×1062.73\times10^{-6} =事実上ゼロ)。これが §2 のラグランジュ条件 fc(sc)=λf_c'(s_c)=\lambda の数値的な確認です。最適点では「次の1円の見返り」がどのチャネルでも同じ 11.1911.19——だからもう動かしても得しない。この共通値 11.1911.19予算の影の価格で、「予算を1単位増やせば総応答が約 11.1911.19 伸びる」も意味します。

なぜβ比例とずれるか。β比例(TV 41.6741.67・Promo 25.025.0)に対し、最適は TV をやや増やし(44.2344.23)、Promo をやや減らし(22.6922.69ます。Promo は κ=15\kappa=15早く飽和するので、2525 も出すと限界が下がりきっている。一方 TV は κ=40\kappa=40ゆっくり飽和するから、まだ限界が高い。そこで最後の数単位を Promo から TV へ移すと総応答が増える——限界が揃うまで移した結果が最適配分です。「大きいチャネルに比例して配る」が、飽和の速さの違いを無視していることが、このズレに表れています。

各チャネルの応答曲線と最適配分点、配分方式ごとの総応答を図にします。

import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib
from scipy.optimize import minimize

beta = np.array([1000.0, 800.0, 600.0])
kappa = np.array([40.0, 25.0, 15.0])
names = ["TV", "Digital", "Promo"]
B = 100.0

def response(s):
    s = np.asarray(s, dtype=float)
    return beta * s**2/(s**2+kappa**2)
def total_response(s):
    return response(s).sum()

cons = {"type": "eq", "fun": lambda s: s.sum()-B}
res = minimize(lambda s: -total_response(s), np.full(3, B/3), method="SLSQP",
               bounds=[(0, B)]*3, constraints=cons,
               options={"ftol": 1e-12, "maxiter": 500})
s_opt = res.x

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.3))

# 左:各チャネルの応答曲線と最適配分点(●)
g = np.linspace(0, B, 400)
colors = ["C0", "C1", "C2"]
for j, (nm, c) in enumerate(zip(names, colors)):
    fc = beta[j]*g**2/(g**2+kappa[j]**2)
    ax1.plot(g, fc, color=c, lw=2, label=f"{nm}(β={beta[j]:.0f}, κ={kappa[j]:.0f})")
    sj = s_opt[j]
    ax1.plot(sj, beta[j]*sj**2/(sj**2+kappa[j]**2), "o", color=c, ms=10,
             markeredgecolor="black")
ax1.set_xlabel("そのチャネルへの配分 s")
ax1.set_ylabel("応答 f(s)")
ax1.set_title("各チャネルの飽和応答曲線と最適配分点(●)")
ax1.legend(fontsize=9)
ax1.grid(alpha=0.3)

# 右:配分方式ごとの総応答
s_even = np.full(3, B/3)
s_prop = B*beta/beta.sum()
labels = ["均等", "β比例", "最適"]
totals = [total_response(s) for s in (s_even, s_prop, s_opt)]
bars = ax2.bar(labels, totals, color=["gray", "C4", "C3"])
for b, v in zip(bars, totals):
    ax2.text(b.get_x()+b.get_width()/2, v+6, f"{v:.1f}", ha="center", fontsize=10)
ax2.set_ylabel("総応答 Σ f_c(s_c)")
ax2.set_ylim(0, max(totals)*1.12)
ax2.set_title("総応答:最適配分が最大")
ax2.grid(alpha=0.3, axis="y")

fig.tight_layout()
plt.show()

左図は3本の Hill 応答曲線(S 字)と、最適配分点(黒縁の●)です。各チャネルは飽和の速さが違い(Promo は早く寝て、TV はゆっくり立ち上がる)、最適点では3つの曲線の接線の傾き(=限界)が等しくなっています——図では各●での傾きが揃うイメージです。右図は総応答の棒で、均等 < β比例 < 最適の並び。均等が最も低く、最適がわずかに β比例を上回る——配り方だけで総応答が動くこと、最適が頂点にあることが一目で分かります。

⚠️ よくある誤解

⚠️ 要最新確認:実務の予算最適化は、応答曲線の推定(MMM)と一体で、ツールも手法も動きが速い領域です(Google の Meridian など、応答曲線・限界 ROI ベースの最適化を備えた MMM ツールが各社から出ています)。最新の実装・ベストプラクティスは各自で確認してください。本ノートは「飽和応答 → 制約付き最適化 → 等限界原理」という数理の核を、合成データで示すことに徹しています。

関連ノート