Mímisbrunnr知恵の泉

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

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

📎 関連:アトリビューション分析 | 前提:マーケティングミックスモデリング(MMM)

要点(BLUF)

1. 残存効果(adstock)と飽和:広告効果の時間と量の非線形性

マーケティングミックスモデリング(MMM) の線形 MMM は、売上を各チャネルの出稿額に比例させていました(係数 βk\beta_k 倍)。これは2つの意味で単純すぎます。

第一に、広告は打った瞬間に効いて、その週で消えるわけではありません。今週見た CM は、来週・再来週の購買にも影を落とします。この時間的な繰越を**残存効果(adstock/carryover)**と呼びます。テレビやブランド広告は尾が長く(ゆっくり減衰)、検索広告は尾が短い(すぐ減衰)、というように繰越の強さはチャネルごとに違います。

第二に、出稿は増やすほど効きが鈍ります。視聴者の上限・広告疲れ・刈り取り尽くしで、22 倍出しても売上は 22 倍にならない。この量的な頭打ち飽和(saturation)=収穫逓減です。線形のまま「もっと出せば比例で伸びる」と外挿すると、過大な期待で予算を盛りすぎます。

flowchart LR
  X["出稿 x_t(毎週の額)"] --> AD["adstock:時間で繰り越す(尾を引く)"]
  AD --> SAT["飽和:量で頭打ち(収穫逓減)"]
  SAT --> EFF["広告の効果(売上への寄与)"]
  EFF --> MMM["線形MMMの回帰に入れる(04-02の拡張)"]

ポイントは、この2つは別方向の非線形性だということです。adstock は時間方向(いつ効くか)、飽和は量方向(どれだけ出すと頭打ちか)。順番に通すパイプライン——まず出稿を時間で繰り越し(adstock)、その繰越済みの量を飽和に通す——として MMM に入れます。

2. 数式:幾何 adstock と Hill 飽和、その合成

幾何 adstock は、今週の出稿に「先週までの繰越」を一定割合 λ\lambda で足します。

at=xt+λat1,λ[0,1)a_t = x_t + \lambda\,a_{t-1}, \qquad \lambda \in [0,1)

λ\lambda繰越率で、λ=0\lambda=0 なら繰越なし(at=xta_t=x_t、線形 MMM そのもの)、λ\lambda11 に近いほど尾が長く残ります。この漸化式を展開すると、過去の出稿に幾何級数の重みがかかった形になります。

at=k=0λkxtka_t = \sum_{k=0}^{\infty} \lambda^{k}\,x_{t-k}

つまり kk 週前の出稿は λk\lambda^{k} 倍だけ今週に効く——λ=0.5\lambda=0.5 なら 1,0.5,0.25,1,\,0.5,\,0.25,\dots と半減していく尾です。単発で 100100 を1回だけ出すと、その後の週に 50,25,12.5,50,25,12.5,\dots と繰り越されるのはこのためです。

飽和Hill 関数で表します。

f(x)=xαxα+καf(x) = \frac{x^{\alpha}}{x^{\alpha} + \kappa^{\alpha}}

実際に MMM で使う広告効果は、この2つを順に通した合成です。出稿を時間で繰り越してから、その量を飽和に通します。

効果t=f(at)=f(adstock(x)t)\text{効果}_t = f\big(a_t\big) = f\big(\text{adstock}(x)_t\big)

マーケティングミックスモデリング(MMM) の線形 βkXkt\beta_k X_{kt} を、この βkfk(adstockk(Xkt))\beta_k\, f_k(\text{adstock}_k(X_{kt})) に置き換えるのが、現実的な MMM への拡張です。係数 βk\beta_k に加え、繰越率 λk\lambda_k と飽和の αk,κk\alpha_k,\kappa_kデータから推定するパラメータが増えます(推定の方法論は統計・ベイズの領域)。

3. adstock・飽和・合成を実装する(コード)

3つを順に確かめます。(a) 幾何 adstock を単発パルスに適用して尾を見る、(b) Hill 飽和で出稿を増やしたときの限界応答が寝ることを表で見る、(c) 合成 f(adstock(x))f(\text{adstock}(x)) を計算する、の順です。

import numpy as np

# (a) 幾何 adstock:a_t = x_t + λ a_{t-1}。λ=0.5、週2に100の単発出稿(パルス)。
def adstock(x, lam):
    a = np.zeros_like(x, dtype=float)
    prev = 0.0
    for t in range(len(x)):
        a[t] = x[t] + lam * prev
        prev = a[t]
    return a

x = np.array([0, 0, 100, 0, 0, 0, 0, 0], dtype=float)
a = adstock(x, lam=0.5)

print("=== (a) adstock:単発出稿の効果が指数減衰の尾を引く(λ=0.5)===")
print("出稿   x =", x.tolist())
print("adstock a =", a.tolist())
print("→ 週2に出した100が、翌週50・25・12.5…と繰り越して効き続ける")

# (b) Hill 飽和:f(x) = x^α / (x^α + κ^α)。α=2, κ=50。
def hill(x, alpha, kappa):
    x = np.asarray(x, dtype=float)
    return x**alpha / (x**alpha + kappa**alpha)

alpha, kappa = 2.0, 50.0
spend = np.array([0, 25, 50, 100, 200], dtype=float)
resp = hill(spend, alpha, kappa)

dresp = np.diff(resp, prepend=0.0)            # 限界(前ステップとの差)
dspend = np.diff(spend, prepend=0.0)
per_unit = np.divide(dresp, dspend, out=np.zeros_like(dresp), where=dspend > 0)

print("\n=== (b) Hill 飽和:出稿を増やすほど限界が寝る(α=2, κ=50)===")
print(f"{'出稿':>6}{'飽和応答f':>12}{'限界Δf':>12}{'1単位あたり':>14}")
for s, r, d, pu in zip(spend, resp, dresp, per_unit):
    print(f"{s:6.0f}{r:12.4f}{d:12.4f}{pu:14.5f}")
print(f"変曲点 x = κ√((α-1)/(α+1)) = {kappa*np.sqrt((alpha-1)/(alpha+1)):.2f}")
print(f"f(50)={hill(50,alpha,kappa):.2f}, f(100)={hill(100,alpha,kappa):.2f}"
      f" → 出稿を2倍(50→100)にしても応答は {hill(100,alpha,kappa)/hill(50,alpha,kappa):.2f} 倍(2倍未満)")

# (c) 合成効果:飽和(adstock(x))。adstock の尾の各点をさらに Hill で飽和させる。
combined = hill(a, alpha, kappa)
print("\n=== (c) 合成効果 f(adstock(x)):繰越(時間)と飽和(量)を重ねる ===")
print("adstock a   =", np.round(a, 3).tolist())
print("合成 f(a)   =", np.round(combined, 4).tolist())
print("→ 出稿が当たる週2で応答0.8、以降は繰越の尾が飽和でさらに縮みながら減衰")

出力:

=== (a) adstock:単発出稿の効果が指数減衰の尾を引く(λ=0.5)===
出稿   x = [0.0, 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0]
adstock a = [0.0, 0.0, 100.0, 50.0, 25.0, 12.5, 6.25, 3.125]
→ 週2に出した100が、翌週50・25・12.5…と繰り越して効き続ける

=== (b) Hill 飽和:出稿を増やすほど限界が寝る(α=2, κ=50)===
    出稿       飽和応答f        限界Δf        1単位あたり
     0      0.0000      0.0000       0.00000
    25      0.2000      0.2000       0.00800
    50      0.5000      0.3000       0.01200
   100      0.8000      0.3000       0.00600
   200      0.9412      0.1412       0.00141
変曲点 x = κ√((α-1)/(α+1)) = 28.87
f(50)=0.50, f(100)=0.80 → 出稿を2倍(50→100)にしても応答は 1.60 倍(2倍未満)

=== (c) 合成効果 f(adstock(x)):繰越(時間)と飽和(量)を重ねる ===
adstock a   = [0.0, 0.0, 100.0, 50.0, 25.0, 12.5, 6.25, 3.125]
合成 f(a)   = [0.0, 0.0, 0.8, 0.5, 0.2, 0.0588, 0.0154, 0.0039]
→ 出稿が当たる週2で応答0.8、以降は繰越の尾が飽和でさらに縮みながら減衰

出力の意味(a) adstock は数式どおりの尾を作ります。週2に出した 100100 が、λ=0.5\lambda=0.5 で翌週 5050、その次 252512.512.56.256.253.1253.125 と半減しながら繰り越されます——出稿はその週で終わらない。テレビ広告の「あとからじわじわ効く」を、たった1行 at=xt+λat1a_t=x_t+\lambda a_{t-1} で表現できているわけです。

(b) 飽和限界(追加1単位の効き)が寝ていくことを示します。注目は右端の「1単位あたり」列です。α=2\alpha=2 の Hill は S 字なので、出稿が小さい立ち上がり区間(変曲点 28.8728.87 より下)では限界がむしろ増え(0250\to250.0080.008 より 255025\to500.0120.012 が大きい)、変曲点を越えると収穫逓減に入ります。実務で効くのはこの逓減で、255010020025\to50\to100\to200 と進むほど1単位あたりの効きは 0.0120.0060.00140.012\to0.006\to0.0014 と急速に寝ます。集約すると f(50)=0.5, f(100)=0.8f(50)=0.5,\ f(100)=0.8——出稿を2倍にしても応答は 1.61.6にしかなりません。線形ならここで 2.02.0 倍を期待してしまう。この差が、高出稿域での過大外挿の正体です。

(c) 合成 f(adstock(x))f(\text{adstock}(x)) は2つを重ねます。adstock の尾 [0,0,100,50,25,12.5,6.25,3.125] を Hill に通すと、効果は [0,0,0.8,0.5,0.2,0.0588,0.0154,0.0039]。出稿が当たる週2で応答 0.80.8 とピークを打ち、以降は繰越の尾が、飽和でさらに縮みながら減衰します。時間方向(adstock)と量方向(飽和)の非線形性が1本の効果系列に同居しているのが見て取れます。

左に adstock の減衰、右に飽和カーブを並べて、2つの非線形性を図にします。

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

def adstock(x, lam):
    a = np.zeros_like(x, dtype=float); prev = 0.0
    for t in range(len(x)):
        a[t] = x[t] + lam * prev; prev = a[t]
    return a
def hill(x, alpha, kappa):
    x = np.asarray(x, dtype=float)
    return x**alpha / (x**alpha + kappa**alpha)

x = np.array([0, 0, 100, 0, 0, 0, 0, 0], dtype=float)
a = adstock(x, 0.5)
alpha, kappa = 2.0, 50.0
weeks = np.arange(len(x))

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

# 左:adstock の減衰(出稿パルスと繰越の尾)
ax[0].bar(weeks, x, width=0.5, color="#cccccc", label="出稿 x(単発パルス)")
ax[0].plot(weeks, a, color="C3", marker="o", lw=1.8, label="adstock a(繰越)")
for t in range(2, len(x)):
    ax[0].annotate(f"{a[t]:g}", (weeks[t], a[t]), textcoords="offset points",
                   xytext=(0, 6), ha="center", fontsize=8, color="C3")
ax[0].set_xlabel("週"); ax[0].set_ylabel("出稿・adstock")
ax[0].set_title("adstock:単発出稿が繰り越して減衰(λ=0.5)")
ax[0].legend(loc="upper right", fontsize=9)

# 右:Hill 飽和カーブ(限界が寝る)
g = np.linspace(0, 250, 400)
ax[1].plot(g, hill(g, alpha, kappa), color="C0", lw=2, label="飽和応答 f(Hill)")
pts = np.array([0, 25, 50, 100, 200], dtype=float)
ax[1].plot(pts, hill(pts, alpha, kappa), "o", color="C1", ms=7, label="出稿点")
ax[1].axvline(kappa, color="gray", ls=":", lw=1)
ax[1].text(kappa + 3, 0.06, "κ=50で f=0.5", color="gray", fontsize=9)
ax[1].annotate("ここで限界が寝る\n(追加1単位の効きが逓減)",
               xy=(200, hill(200, alpha, kappa)), xytext=(120, 0.55),
               arrowprops=dict(arrowstyle="->", color="C3"), fontsize=9, color="C3")
ax[1].set_xlabel("出稿(量)"); ax[1].set_ylabel("飽和応答 f(0〜1)")
ax[1].set_title("飽和(Hill, α=2):収穫逓減で頭打ち")
ax[1].legend(loc="lower right", fontsize=9)

fig.tight_layout()
plt.show()

左の図は、灰色の単発パルス(週2の 100100)に対し、赤い線が 100502512.5100\to50\to25\to12.5\dots と尾を引く——adstock が時間方向に効果を広げるさまです。右の図は Hill 飽和カーブで、出稿点(オレンジ)が右に行くほど曲線が寝て、追加出稿の見返り(傾き=限界)が小さくなるのが分かります。κ=50\kappa=50 で応答がちょうど半分(0.50.5)に達する点も目印になります。

⚠️ よくある誤解

関連ノート