ベイズ最適化で広告予算の配分を最適化する実装 — MMMの推定を「次の一手」に変える
MMMで応答曲線を推定したあと、その曲線を来月の予算配分にどう変換するかを扱います。決定論的最適化で足りる場面とベイズ最適化が必要な場面を切り分け、配分最適化を動くPythonコードで実装します。
by Shin
MMMで応答曲線を推定した。では来月の予算は、どのチャネルにいくら振ればいいのか。
結論を先に言います。応答曲線が「閉じた式」として手元にあるなら、配分は制約付き最適化問題であり、まず scipy の決定論的ソルバーで解くのが正しいやり方です。ベイズ最適化が必要なのは、応答がブラックボックス——観測ノイズ込みで、実際に出稿してみないと分からない場合に限られます。
この記事では、配分問題を目的関数と制約として定式化したうえで、(1) 曲線が既知なときの決定論的最適化(SLSQP)と、(2) 応答がブラックボックスなときのベイズ最適化(ガウス過程の代理モデル+EI)を、どちらも動くコードで実装します。応答曲線そのものの推定は別記事(MMMを個人で実装する完全手順)に譲り、ここでは「推定済みの曲線を次の配分に変える」一点に絞ります。
なぜベイズ最適化か(探索と活用)
最初に、よくある誤解を一つ潰しておきます。「MMM → ベイズ最適化」を一続きの作法のように語る記事は多いのですが、両者は必ずしもセットではありません。
各チャネル の応答が、出稿額 の関数として閉じた式で書けているとします。たとえばHill型の飽和なら
で、(効果の上限)・(半飽和点)・(立ち上がりの鋭さ)はすべてMMMが推定済みです。この場合、総効果 を予算制約のもとで最大化する問題は、勾配が計算できる普通の制約付き最適化にすぎません。ブラックボックス最適化の出番はありません。
実際に解いてみます。
import numpy as np
from scipy.optimize import minimize
# MMMから来る前提:チャネルごとの飽和(Hill)パラメータ
PARAMS = {
"search": dict(beta=900.0, kappa=120.0, slope=1.3),
"social": dict(beta=620.0, kappa=180.0, slope=1.1),
"display": dict(beta=300.0, kappa=150.0, slope=1.0),
"video": dict(beta=480.0, kappa=260.0, slope=1.4),
"affiliate": dict(beta=210.0, kappa=70.0, slope=1.2),
}
CHANNELS = list(PARAMS)
def hill(x, beta, kappa, slope):
x = np.maximum(x, 1e-9)
return beta * x**slope / (kappa**slope + x**slope)
def total_response(alloc):
return sum(hill(alloc[i], **PARAMS[c]) for i, c in enumerate(CHANNELS))
# 制約:合計=予算、チャネルごとの下限・上限
BUDGET = 600.0 # 月予算(万円)
LOWER = np.array([60, 30, 0, 0, 0], float) # 下限(例:検索とSNSは止めない)
UPPER = np.array([300, 250, 120, 200, 80], float) # 上限(在庫・運用上限)
cons = [{"type": "eq", "fun": lambda a: np.sum(a) - BUDGET}]
bounds = list(zip(LOWER, UPPER))
x0 = np.full(len(CHANNELS), BUDGET / len(CHANNELS))
res = minimize(lambda a: -total_response(a), x0, method="SLSQP",
bounds=bounds, constraints=cons,
options={"ftol": 1e-9, "maxiter": 500})
for c, v in zip(CHANNELS, res.x):
print(f"{c:10s}: {v:6.1f} 万円")
print("総効果(推定CV):", round(total_response(res.x), 1))
print("均等配分の総効果:", round(total_response(x0), 1))手元で動かすと、均等配分の推定CV 1084.6 に対し、最適配分は 1190.1 に上がります(検索に厚く、displayを薄く)。曲線が信用できるなら、これで終わりです。
ではベイズ最適化はいつ必要になるのか。応答が黒い箱になったときです。具体的には次のいずれかです。
- 曲線を信用しきれない。MMMの推定には不確実性があり、観測した出稿レンジの外側では飽和曲線の形が当てにならない。点推定の式を信じて外挿すると、自信満々で間違える。
- 応答を「実際に出稿して観測する」しかない。配分を変えて1〜数週間回し、ノイズ込みの実績(CV・CPA)を観測する。各評価が高コストで、勾配もない。
- 環境がドリフトする。季節・競合・在庫で応答が動く。一度解いて終わりではなく、観測しながら配分を更新し続けたい。
この「評価がノイズ込み・高コスト・勾配なし」という条件こそ、ベイズ最適化が**探索(explore)と活用(exploit)**を釣り合わせて少ない試行で最適点に迫る、その本領が出る場面です。逆に言えば、上の条件に当てはまらないなら使わなくてかまいません。
目的関数と制約(予算・下限・上限)の設計
定式化はシンプルです。配分ベクトル に対し、
- 目的関数 :総効果(推定CV/推定売上/粗利)。決定論なら 、ブラックボックスなら「出稿して観測されるKPI」。
- 等式制約:総額は月予算 にぴったり一致させる。
- 箱制約:各チャネルの下限 (運用上止められない最低額)と上限 (媒体在庫・運用キャパの天井)。
ベイズ最適化で厄介なのは、候補点が常に「合計=予算かつ箱の中」を満たす必要がある点です。素朴に各次元を独立にサンプリングすると合計制約を破ります。実装上の定石は、まず下限 を全チャネルに確保し、残り予算 をDirichlet分布で分配してから上限を超える候補を弾く、というやり方になります。これで「実行可能領域からの一様に近いサンプリング」が安価に得られます。
獲得関数は**期待改善(Expected Improvement, EI)**を使う。現在の最良観測値を 、代理モデルの予測平均・標準偏差を とすると、
は探索の強さを決めるマージン(大きいほど未知領域を攻める)。EIは「改善の期待値」を平均と不確実性の両方から評価するので、これ一つで探索と活用のバランスが取れます。
実装:探索 → 提案 → 評価のループ
ガウス過程(GP)を代理モデルに、EIで次の配分を提案し、観測してGPを更新します。下のコードでは「真の応答」を既知の関数+観測ノイズで模擬していますが、本番ではこの observe() を「実際に出稿して観測したKPIを返す関数」に差し替えるだけでループの骨格は変わりません。
import numpy as np
from scipy.stats import norm
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import (
Matern, ConstantKernel, WhiteKernel,
)
rng = np.random.default_rng(42)
# --- 真の応答:本番では「実出稿の観測値」。ここでは検証用に既知の関数 ---
PARAMS = [(900,120,1.3),(620,180,1.1),(300,150,1.0),(480,260,1.4),(210,70,1.2)]
NAMES = ["search","social","display","video","affiliate"]
BUDGET = 600.0
LOWER = np.array([60, 30, 0, 0, 0], float)
UPPER = np.array([300, 250, 120, 200, 80], float)
N = len(PARAMS)
def true_response(alloc):
s = 0.0
for x, (b, k, sl) in zip(alloc, PARAMS):
x = max(x, 1e-9)
s += b * x**sl / (k**sl + x**sl)
return s
def observe(alloc, noise=18.0):
# 現実は観測ノイズ込み(季節・計測誤差・外的要因)
return true_response(alloc) + rng.normal(0, noise)
# --- 制約を満たす配分のサンプリング(合計=BUDGET, 下限/上限) ---
def sample_feasible(n):
out = []
free = BUDGET - LOWER.sum() # 下限を引いた残り予算
while len(out) < n:
w = rng.dirichlet(np.ones(N)) # 残りをDirichletで分配
a = LOWER + w * free
if np.all(a <= UPPER + 1e-9): # 上限を超える候補は弾く
out.append(a)
return np.array(out)
# --- Expected Improvement(最大化) ---
def expected_improvement(mu, sigma, best, xi=1.0):
sigma = np.maximum(sigma, 1e-9)
imp = mu - best - xi
Z = imp / sigma
return imp * norm.cdf(Z) + sigma * norm.pdf(Z)
# --- 初期計画:少数の探索点を観測 ---
X = sample_feasible(6)
y = np.array([observe(a) for a in X])
kernel = (ConstantKernel(1.0)
* Matern(length_scale=np.ones(N), length_scale_bounds=(1e-1, 1e3), nu=2.5)
+ WhiteKernel(noise_level=1.0, noise_level_bounds=(1e-3, 1e3)))
gp = GaussianProcessRegressor(kernel=kernel, normalize_y=True,
n_restarts_optimizer=3, random_state=0)
# --- 探索 → 提案 → 評価のループ ---
for t in range(25):
gp.fit(X, y) # 代理モデルを更新
cand = sample_feasible(2000) # 実行可能な候補を多数サンプリング
mu, sd = gp.predict(cand, return_std=True)
ei = expected_improvement(mu, sd, best=y.max())
nxt = cand[np.argmax(ei)] # EI最大の配分を提案
X = np.vstack([X, nxt])
y = np.append(y, observe(nxt)) # 観測(本番では実出稿の結果)
best = X[int(np.argmax(y))]
for nm, v in zip(NAMES, best):
print(f"{nm:10s}: {v:6.1f} 万円")
print("観測回数:", len(y), "| 真の効果:", round(true_response(best), 1))このコードを回すと、初期6点+25回の観測で、ベイズ最適化が見つけた配分の真の効果はおよそ 1183(実行ごとに揺れます)に達します。決定論的に解いた既知の最適 1190.1 に、ノイズ込みの観測だけからほぼ追いついています。均等配分の 1084.6 とは明確に差がつきます。
ここで効いているのが探索と活用の釣り合いです。EIは、まだ観測の薄い配分(不確実性が大きい)と、すでに良い実績の周辺(平均が高い)を同じ尺度で天秤にかけ、ムダ打ちを抑えながら最適点へ寄っていきます。
目的関数は純関数に保つ
予算配分シミュレーションの実装で得た設計上の教訓を一つ足しておきます。上のコードの observe() はモジュールレベルの rng を握っています——検証用の最小構成ならこれで動きますが、本気で育てるコードでは目的関数を純関数に保つことを勧めます。乱数生成器を内部で共有せず、シードを引数で受け取って局所的に作ります。
def evaluate(alloc, seed, noise=18.0):
rng = np.random.default_rng(seed) # グローバルを参照せず、シードから局所生成
return true_response(alloc) + rng.normal(0, noise)同じ引数なら必ず同じ値を返す evaluate(params, seed) -> outcome の形にしておくと、最適化の次にほぼ確実に欲しくなる機能が、どれも「同じ関数を、引数を振って多数回呼ぶ薄いラッパー」になります。
- 感度分析:
paramsの一部を振ってevaluateを呼ぶだけ - モンテカルロ:
seedを振ってevaluateを呼ぶだけ - 逆算(目標から必要条件を解く):
evaluateの出力を目標値と突き合わせて探索するだけ
逆にここが崩れている——目的関数がグローバルな乱数状態や途中結果を抱えている——と、最適化そのものは動くのに、感度分析を足そうとした時点で計算ロジックの書き直しになります。テストの面でも効きます。同一シードなら同一結果が保証されるので、evaluate(x, seed=42) の値を固定した回帰テストが書けます。
一次データでの所感(匿名・レンジ)
実案件での体感を、匿名・レンジで残しておきます。あるSaaS案件(月予算レンジ 500〜800万円・5チャネル)で、自作MMMの応答曲線をこの手順で再配分したところ、同一CV目標に対するモデル推定CPAがレンジで10〜20%改善という結果を複数月で得ました。改善の中身は一貫して「検索・動画に寄せ、頭打ちのdisplayを削る」方向でした。
ただし強調したいのは、この 10〜20% はあくまでモデル上の推定改善だという点です。実地での純増効果は、後述する別建ての実験でしか確かめられません。煽るつもりはないので正直に書くと、推定改善のうち実験で再現できたのはその6〜8割程度で、残りはモデルの楽観(外挿バイアス)でした。最適化器は与えられた曲線を信じて尖った配分を出しますが、その曲線が当たっているかは別問題です。
MMMとの接続と運用上の注意
最後に、MMMの推定とこの最適化を安全につなぐための勘所を挙げます。ここを外すと「最適化したのに成果が落ちた」が起きます。
点推定ではなく事後分布を使う。 MMMをベイズで推定しているなら、係数の点推定だけでなく事後サンプルがあります。応答 を事後サンプル上で評価し、その期待値や下側分位点を目的関数にすると、不確実性に対して頑健な配分が得られます。「平均は高いが分散も大きい賭け」を最適化器が掴むのを防げます。
外挿を禁じる。 飽和曲線が信用できるのは、MMM学習時に観測した出稿レンジの内側だけです。最適化器に渡す上限 は、媒体在庫だけでなく**「過去に出したことのある最大額」**でも縛ります。
一度に動かしすぎない(トラストリージョン)。 提案配分にいきなり全張りしません。前月配分からの移動幅に上限を課します(例:各チャネル ±20%まで)。急変は媒体側の学習をリセットし、観測も汚します。小さく動かし、観測し、また動かす——このループ自体がベイズ最適化の探索と相性が良いのです。
配分更新の周期を観測ノイズに合わせる。 CVが日次で数十件しかないなら、週次・隔週で評価します。ノイズに対して評価間隔が短すぎると、最適化器はノイズを追いかけて暴れます。
重い反復をUIのスレッドで回さない。 数千回規模の評価をブラウザやローカルの画面付きツールで回すなら Web Worker(またはバックグラウンドプロセス)へ逃がします——メインスレッドで回すと操作不能になります。
因果で答え合わせをする。 モデル推定の改善は、最終的にホールドアウトや地域分割実験で純増を検証して初めて意思決定に使えます。最適化はあくまで「次に試す配分」を提案する装置であり、効果の証明ではありません。検証手法は因果推論の実装パターン集に整理しました。なぜ相関ベースの「上がった=効いた」が危険かは、意思決定者向けの整理を参照してください。
まとめると、ベイズ最適化はMMMの不確実な推定を、実地の観測で少しずつ補正しながら配分に落とすための装置です。曲線を盲信する決定論的最適化と、何も知らずに試行錯誤するA/Bテストの、ちょうど中間に置くと最も効きます。