Mímisbrunnr知恵の泉

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

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

📎 関連:第4章 市場反応モデル 目次 | 前提:広告・販促の効果測定

要点(BLUF)

1. MMM とは:複数チャネルの貢献を1本の回帰で分解する

広告・販促の効果測定 は単一施策の増分を回帰で取り出しました。MMM は同じ回帰の発想を、複数チャネルが同時に動く集計時系列に広げたものです。週次・月次の総売上を、TV・デジタル・店頭販促といった各チャネルの出稿額と、ベース需要・季節性に回帰し、「売上のうちどれだけが各チャネルのおかげか」を分解します。

特徴はトップダウンであること——個人レベルのクリックや購買ログを使わず、集計済みのマクロな時系列だけで全体像を描きます。Cookie 規制やプライバシー保護でユーザー単位の追跡が難しくなった近年、MMM が再評価されているのはこのためです。出力は各チャネルの限界効果(出稿1単位あたりの売上)と貢献額で、これを予算配分の根拠にします。「次の100万円をどのチャネルに積むか」をデータで語るための道具です。

flowchart LR
  TV["TV 出稿"] --> M["売上の回帰モデル"]
  DG["Digital 出稿"] --> M
  PR["Promo 出稿"] --> M
  S["ベース・季節性"] --> M
  M --> C["チャネル別の貢献分解"]
  C --> B["予算配分の見直し"]

2. 線形 MMM の数式と貢献分解

線形 MMM は、週 tt の売上 YtY_t を次のように表します。

Yt=β0+k=1KβkXkt+s(t)+εtY_t = \beta_0 + \sum_{k=1}^{K} \beta_k X_{kt} + s(t) + \varepsilon_t

これは説明変数が増えただけの重回帰で、最小二乗で β\beta を一気に推定できます。複数チャネルを同時に入れるのが肝心で、各 βk\beta_k は「他チャネルを一定に保ったときのそのチャネルの効果」を表します——単一チャネルだけを見る前後比較と違い、チャネル同士の交絡をある程度ほどけます。

推定したら、各チャネルの貢献額

貢献k=β^kXˉk\text{貢献}_k = \hat\beta_k \cdot \bar X_k

(係数 ×\times 平均出稿)で求めます。これは平均的な売上のうちチャネル kk が押し上げている金額で、貢献シェア 貢献kj貢献j\dfrac{\text{貢献}_k}{\sum_j \text{貢献}_j} が予算配分の出発点です。限界効果 β^k\hat\beta_k が大きいチャネルほど、追加予算の1単位が効く——ただしこれは出稿と売上が**線形(収穫一定)**という仮定の上での話です。

本ノートでは見通しを良くするため、残存効果(adstock)も飽和(収穫逓減)も入れない素の線形で、最小二乗が真の係数を回復することを確かめます。これらの非線形性を入れる拡張は本章後半 04-04 で扱います。

3. 係数の回復と貢献分解(コード)

104104 週ぶんの売上を「ベース 2000200033\cdotTV + 55\cdotDigital + 88\cdotPromo + 季節性 + ノイズ」で合成し、説明変数 [1,TV,Digital,Promo,sin,cos][1,\text{TV},\text{Digital},\text{Promo},\sin,\cos]numpy.linalg.lstsq で当てます。推定係数が真値 [3,5,8][3,5,8] を回復すること、貢献額 == 係数 ×\times 平均出稿で貢献シェアに分解できることを確かめます。

import numpy as np
import pandas as pd

# 104週ぶんの週次データを合成。3チャネルの出稿額(TV/Digital/Promo)と、
# 真の係数 β=[3,5,8]、ベース2000、年周期の季節性、ノイズから売上を作る。
rng = np.random.default_rng(1)
t = np.arange(104)
TV = rng.uniform(0, 100, 104)        # 出稿額(乱数の生成順がデータを決めるので順序厳守)
Digital = rng.uniform(0, 80, 104)
Promo = rng.uniform(0, 50, 104)
beta_true = np.array([3.0, 5.0, 8.0])  # TV/Digital/Promo の真の限界効果(出稿1単位あたり売上)
season = 150 * np.sin(2 * np.pi * t / 52)
noise = rng.normal(0, 80, 104)
sales = 2000 + beta_true[0] * TV + beta_true[1] * Digital + beta_true[2] * Promo + season + noise

# 説明変数 [1, TV, Digital, Promo, sin, cos] を最小二乗で当てる
X = np.column_stack([
    np.ones_like(t, dtype=float),
    TV, Digital, Promo,
    np.sin(2 * np.pi * t / 52),
    np.cos(2 * np.pi * t / 52),
])
beta_hat, *_ = np.linalg.lstsq(X, sales, rcond=None)

names = ["TV", "Digital", "Promo"]
spend_mean = np.array([TV.mean(), Digital.mean(), Promo.mean()])
contrib = beta_hat[1:4] * spend_mean        # 貢献額 = 推定係数 × 平均出稿

tbl = pd.DataFrame({
    "真の係数": beta_true,
    "推定係数": beta_hat[1:4],
    "平均出稿": spend_mean,
    "貢献額": contrib,
    "貢献シェア": contrib / contrib.sum(),
}, index=names)

print("=== MMM:推定係数の回復とチャネル別貢献の分解 ===")
print(tbl.to_string(formatters={
    "真の係数": "{:.1f}".format,
    "推定係数": "{:.2f}".format,
    "平均出稿": "{:.1f}".format,
    "貢献額": "{:.1f}".format,
    "貢献シェア": "{:.1%}".format}))

# ベース(切片)と当てはまり(決定係数 R^2)
resid = sales - X @ beta_hat
r2 = 1 - resid.var() / sales.var()
print(f"\nベース(切片): 真 2000 → 推定 {beta_hat[0]:.1f}")
print(f"3チャネル貢献の合計 = {contrib.sum():.1f}(平均売上 {sales.mean():.0f} のうち)")
print(f"決定係数 R^2 = {r2:.3f}")

出力:

=== MMM:推定係数の回復とチャネル別貢献の分解 ===
        真の係数 推定係数 平均出稿   貢献額 貢献シェア
TV       3.0 3.12 51.8 161.8 29.9%
Digital  5.0 5.08 40.4 205.4 37.9%
Promo    8.0 7.75 22.5 174.7 32.2%

ベース(切片): 真 2000 → 推定 1996.1
3チャネル貢献の合計 = 541.9(平均売上 2538 のうち)
決定係数 R^2 = 0.905

出力の意味:まず推定係数が真値をよく回復しました——TV 3.123.12(真 33)、Digital 5.085.08(真 55)、Promo 7.757.75(真 88)、ベース 1996.11996.1(真 20002000)。重回帰は3チャネルを同時に入れることで、各チャネルの限界効果を分離して取り出せています。次に貢献分解。Promo は限界効果(7.757.75)こそ最大ですが、平均出稿が 22.522.5 と小さいため貢献額は 174.7174.7。いっぽう Digital は限界効果 5.085.08 でも出稿 40.440.4 が大きく、貢献額 205.4205.4 で最大です。貢献額は「効率(係数)×規模(出稿量)」の積であり、係数だけ・出稿だけでは決まらないのがポイントです。貢献シェア(TV 30%・Digital 38%・Promo 32%)は、現状の出稿構成のもとでの売上の取り分で、予算配分を議論する共通の土台になります。3チャネル合計の貢献 541.9541.9 は平均売上 25382538 の約2割で、残りはベースと季節性——広告で動かせる部分は売上の一部にすぎないという現実感も得られます。

各チャネルの貢献を積み上げた「売上の分解」と、モデルの当てはまり(実測 vs 予測)を1枚にすると、MMM の出力像がつかめます。

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

rng = np.random.default_rng(1)
t = np.arange(104)
TV = rng.uniform(0, 100, 104)
Digital = rng.uniform(0, 80, 104)
Promo = rng.uniform(0, 50, 104)
season = 150 * np.sin(2 * np.pi * t / 52)
noise = rng.normal(0, 80, 104)
sales = 2000 + 3 * TV + 5 * Digital + 8 * Promo + season + noise

X = np.column_stack([np.ones_like(t, dtype=float), TV, Digital, Promo,
                     np.sin(2 * np.pi * t / 52), np.cos(2 * np.pi * t / 52)])
beta_hat, *_ = np.linalg.lstsq(X, sales, rcond=None)
pred = X @ beta_hat

spend_mean = np.array([TV.mean(), Digital.mean(), Promo.mean()])
contrib = beta_hat[1:4] * spend_mean
base_hat = beta_hat[0]

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

# 左:平均売上の分解(ベース+各チャネルの貢献を積み上げ)
labels = ["ベース", "TV", "Digital", "Promo"]
vals = [base_hat, contrib[0], contrib[1], contrib[2]]
colors = ["#bbbbbb", "C0", "C1", "C2"]
bottom = 0.0
for lab, v, col in zip(labels, vals, colors):
    ax[0].bar(0, v, bottom=bottom, color=col, label=f"{lab}: {v:.0f}")
    bottom += v
ax[0].set_xlim(-0.8, 0.8)
ax[0].set_xticks([])
ax[0].set_ylabel("平均週次売上の内訳")
ax[0].set_title("売上の分解:ベース+チャネル貢献")
ax[0].legend(loc="upper right", fontsize=9)

# 右:実測 vs 予測(当てはまり)
ax[1].plot(t, sales, color="C0", lw=1.0, marker="o", ms=2.5, label="実測売上")
ax[1].plot(t, pred, color="C3", lw=1.6, label="モデル予測")
ax[1].set_xlabel("週")
ax[1].set_ylabel("売上")
ax[1].set_title("実測 vs 予測")
ax[1].legend(loc="upper right", fontsize=9)

fig.tight_layout()
plt.show()

左の積み上げ棒は、平均売上 25382538 の大部分がベース 19961996(広告ゼロでも売れる分)で、その上に TV・Digital・Promo の貢献が薄く乗ることを示します。右は実測(青)と予測(赤)の重なりで、R2=0.905R^2=0.905 の当てはまりが目で確認できます。予算配分の議論は、この「動かせる薄い層」をどう組み替えるかの話だ、という縮尺感が大切です。

⚠️ よくある誤解

関連ノート