Mímisbrunnr知恵の泉

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

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

📎 関連:マーケティングミックスモデリング(MMM) | 前提:ファネルとコンバージョンの基本指標

要点(BLUF)

1. 観測売上の分解:ベースライン+増分+ノイズ

ある週 tt に観測される売上 YtY_t は、3つの足し算に分けて考えられます。

Yt=Btベースライン+δDt施策の増分+εtノイズY_t = \underbrace{B_t}_{\text{ベースライン}} + \underbrace{\delta\,D_t}_{\text{施策の増分}} + \underbrace{\varepsilon_t}_{\text{ノイズ}}

効果測定の本質は、見えない BtB_t を何らかの形で推定し、観測 YtY_t から差し引いて増分 δ\delta を取り出すことに尽きます。ベースラインの当て方を間違えると、増分も間違えます。

flowchart LR
  Y["観測売上 Y_t"] --> dec{"3つに分解"}
  dec --> B["ベースライン B_t(反実仮想・観測できない)"]
  dec --> U["施策の増分 δ·D_t(知りたい量)"]
  dec --> E["ノイズ ε_t"]
  B --> bad["前後比較:B_t の変化を増分に混ぜる → バイアス"]
  B --> good["回帰:B_t をモデル化して増分 δ を分離"]

2. なぜ前後比較は外れ、回帰は増分を取り出せるのか(数式)

素朴な前後比較は、施策期の平均売上から直前期の平均売上を引きます。

δ^naive=Yˉ施策期Yˉ直前期\hat\delta_{\text{naive}} = \bar Y_{\text{施策期}} - \bar Y_{\text{直前期}}

一見もっともらしいのですが、ε\varepsilon の平均が 00 なので期待値をとると、

E[δ^naive]=δ+(Bˉ施策期Bˉ直前期)E[\hat\delta_{\text{naive}}] = \delta + \big(\bar B_{\text{施策期}} - \bar B_{\text{直前期}}\big)

第2項 Bˉ施策期Bˉ直前期\bar B_{\text{施策期}} - \bar B_{\text{直前期}}バイアスです。ベースライン BtB_t がトレンドや季節性で動いていれば、施策期と直前期でベースラインの水準が違い、その差がまるごと増分に化けてしまいます。BtB_t が完全に平らなときだけ前後比較は正しい——現実にはまずありえません。本ノートの例では、直前期(季節性が高い時期)のほうがベースラインが高いため、前後比較は増分を過小評価します。

回帰は、このベースラインを関数で明示的にモデル化して切り離します。トレンドを tt の1次、季節性を年周期(52週)の三角関数で表し、

Yt=β0+β1t+β2sin2πt52+β3cos2πt52+δDt+εtY_t = \beta_0 + \beta_1 t + \beta_2 \sin\frac{2\pi t}{52} + \beta_3 \cos\frac{2\pi t}{52} + \delta\,D_t + \varepsilon_t

を最小二乗で当てます。β0+β1t+β2sin+β3cos\beta_0+\beta_1 t+\beta_2\sin+\beta_3\cos の部分が推定ベースライン、施策ダミー DtD_t の係数 δ\delta増分です。ベースラインのモデルが妥当なら、δ^\hat\deltaトレンド・季節性を差し引いた純粋な増分を捉え、平均的に真値に一致します(不偏)。ここが前後比較との決定的な違いで、回帰は「同じ時期にベースラインがどれくらいだったか」を他の週の情報から補完してくれます。

なお、推定の不確実性(標準誤差)や、ベースラインの関数形を誤ったときに残るバイアスは回帰の推定論——統計テキストの領域です。ここでの主役は「ベースラインをモデル化して増分を分離する」という発想そのものです。

3. 前後比較と回帰を比べる(コード)

合成データで真の増分を 200200/週と決め打ちし、2つの推定法がそれをどう測るか比べます。5252 週の売上を「ベースライン(トレンド +5t+5t、季節性 100sin100\sin)+キャンペーン(週26〜33に +200+200)+ノイズ」で作り、(a) 素朴な前後比較=施策期平均 − 直前8週平均、(b) 回帰=説明変数 [1,t,sin,cos,D][1,t,\sin,\cos,D]numpy.linalg.lstsq で当てた施策ダミー係数、を対比します。あわせて ROI を総売上ベース(誤)と増分ベース(正)で計算します。

import numpy as np

# 52週の週次売上を合成。真のベースライン(トレンド+季節性)に、
# 第26〜33週のキャンペーンが「真の増分 +200/週」を上乗せし、ノイズを足す。
rng = np.random.default_rng(0)
t = np.arange(52)

base = 1000 + 5 * t + 100 * np.sin(2 * np.pi * t / 52)   # 反実仮想ベースライン(観測できない)
campaign = ((t >= 26) & (t <= 33)).astype(float)          # 施策ダミー(週26〜33の8週)
TRUE_UPLIFT = 200.0
sales = base + TRUE_UPLIFT * campaign + rng.normal(0, 30, 52)  # 観測売上

camp = (t >= 26) & (t <= 33)        # 施策8週
pre = (t >= 18) & (t <= 25)         # 直前8週(同じ週数)

# (a) 素朴な前後比較:施策期の平均 − 直前同週数の平均
naive = sales[camp].mean() - sales[pre].mean()

# (b) 回帰:[1, t, sin, cos, campaign] を最小二乗で当て、campaign係数を増分とする
X = np.column_stack([
    np.ones_like(t, dtype=float),
    t,                                   # トレンド
    np.sin(2 * np.pi * t / 52),          # 年周期の季節性
    np.cos(2 * np.pi * t / 52),
    campaign,                            # 施策ダミー
])
beta, *_ = np.linalg.lstsq(X, sales, rcond=None)
reg_uplift = beta[4]

print("=== 施策の増分(真値 +200/週)をどう測るか ===")
print(f"(a) 素朴な前後比較     : {naive:+6.1f}/週   (誤差 {naive - TRUE_UPLIFT:+.1f})")
print(f"(b) 回帰の campaign係数 : {reg_uplift:+6.1f}/週   (誤差 {reg_uplift - TRUE_UPLIFT:+.1f})")
print(f"    真の増分           : {TRUE_UPLIFT:+6.1f}/週")

# ROI は「増分」売上で測る。総売上で測ると、ベースライン分まで施策の手柄にしてしまう。
margin, cost = 0.40, 2000.0
total_sales = sales[camp].sum()                    # 施策期の総売上
incr_sales = reg_uplift * camp.sum()                # 増分売上 = 増分/週 × 週数
roi_total = (total_sales * margin - cost) / cost    # 誤り:総売上ベース
roi_incr = (incr_sales * margin - cost) / cost      # 正:増分ベース

print(f"\n--- ROI(粗利率 {margin:.0%}、費用 {cost:,.0f})---")
print(f"総売上ベース(誤): 総売上 {total_sales:,.0f} → ROI {roi_total:+.2f}")
print(f"増分ベース  (正): 増分   {incr_sales:,.0f} → ROI {roi_incr:+.2f}")
print("→ 総売上だと黒字に見えるが、大半は施策がなくても売れた分。増分で測ると赤字。")

出力:

=== 施策の増分(真値 +200/週)をどう測るか ===
(a) 素朴な前後比較     : +127.8/週   (誤差 -72.2)
(b) 回帰の campaign係数 : +180.5/週   (誤差 -19.5)
    真の増分           : +200.0/週

--- ROI(粗利率 40%、費用 2,000)---
総売上ベース(誤): 総売上 10,382 → ROI +1.08
増分ベース  (正): 増分   1,444 → ROI -0.71
→ 総売上だと黒字に見えるが、大半は施策がなくても売れた分。増分で測ると赤字。

出力の意味:真の増分 200200/週に対し、素朴な前後比較は +127.8+127.8(誤差 72-72)と大きく外し、回帰は +180.5+180.5(誤差 20-20)とずっと近い。前後比較がここまで外れるのは、§2 のバイアス項どおり——直前期(週18〜25)は季節性が高くベースラインが押し上がっていたため、その後の施策期との差が増分を食ってしまったからです。回帰はトレンドと季節性を別の係数で吸収するので、施策ダミーの係数が増分だけを拾います。ROI の対比はさらに実務的に効きます。**総売上 10,38210{,}382 で測ると ROI は +1.08+1.08(黒字に見える)**が、その大半はキャンペーンがなくても売れたベースライン。**増分 1,4441{,}444 で測れば ROI は 0.71-0.71(実は赤字)**です。「キャンペーン中によく売れた」と「キャンペーンが売上を増やした」はまったく別物だ、という効果測定の核心が数字に出ています。

回帰の +180.5+180.5 にもまだ 2020 の誤差が残ります。これはバイアスではなくノイズ——同じ仕組みでノイズだけを変えてくり返すと、回帰の平均は真値 200200 に収束し、前後比較はズレ続けることを確かめましょう。

import numpy as np

# 同じ仕組みで「ノイズだけ」を変えて2000回くり返し、推定のクセ(バイアス)を見る。
t = np.arange(52)
base = 1000 + 5 * t + 100 * np.sin(2 * np.pi * t / 52)
campaign = ((t >= 26) & (t <= 33)).astype(float)
TRUE_UPLIFT = 200.0
pre = (t >= 18) & (t <= 25)
camp = (t >= 26) & (t <= 33)

X = np.column_stack([
    np.ones_like(t, dtype=float), t,
    np.sin(2 * np.pi * t / 52), np.cos(2 * np.pi * t / 52), campaign,
])

rng = np.random.default_rng(0)
naive_list, reg_list = [], []
for _ in range(2000):
    sales = base + TRUE_UPLIFT * campaign + rng.normal(0, 30, 52)
    naive_list.append(sales[camp].mean() - sales[pre].mean())
    beta, *_ = np.linalg.lstsq(X, sales, rcond=None)
    reg_list.append(beta[4])

naive_arr = np.array(naive_list)
reg_arr = np.array(reg_list)

print("=== 2000回くり返したときの推定の平均±標準偏差(真値 200)===")
print(f"素朴な前後比較 : 平均 {naive_arr.mean():6.1f} ± {naive_arr.std():4.1f}  → 平均バイアス {naive_arr.mean()-TRUE_UPLIFT:+.1f}")
print(f"回帰 campaign係数: 平均 {reg_arr.mean():6.1f} ± {reg_arr.std():4.1f}  → 平均バイアス {reg_arr.mean()-TRUE_UPLIFT:+.1f}")
print()
print("→ 前後比較は平均しても真値からズレ続ける(系統的バイアス)。")
print("→ 回帰の平均はほぼ200(不偏)。1回ごとのズレはノイズで、平均すると消える。")

出力:

=== 2000回くり返したときの推定の平均±標準偏差(真値 200)===
素朴な前後比較 : 平均  151.2 ± 15.0  → 平均バイアス -48.8
回帰 campaign係数: 平均  200.3 ± 14.3  → 平均バイアス +0.3

→ 前後比較は平均しても真値からズレ続ける(系統的バイアス)。
→ 回帰の平均はほぼ200(不偏)。1回ごとのズレはノイズで、平均すると消える。

出力の意味:ここにバイアスとノイズの違いがはっきり出ます。前後比較は 20002000 回平均しても 151.2151.2 で、真値 200200 から 48.8-48.8 系統的にズレ続けます——いくらデータを増やしても消えない、これがバイアスです。いっぽう回帰の平均は 200.3200.3(バイアス +0.3+0.3)でほぼ不偏。さきほどの1回ぶんの 180.5180.5 は、標準偏差 14.314.3 の範囲で揺れたノイズの1サンプルにすぎず、くり返せば真値のまわりに散ります。「ベースラインをモデル化したか」が、消えない誤差(バイアス)を生むか・平均すれば消える誤差(ノイズ)で済むかを分けるわけです。

最後に、観測売上・推定ベースライン・キャンペーン区間を1枚の図にして、増分が「観測とベースラインの縦の差」として見えることを確かめます。

import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib  # 日本語ラベル

rng = np.random.default_rng(0)
t = np.arange(52)
base = 1000 + 5 * t + 100 * np.sin(2 * np.pi * t / 52)
campaign = ((t >= 26) & (t <= 33)).astype(float)
sales = base + 200 * campaign + rng.normal(0, 30, 52)

# 回帰で推定したベースライン(campaign係数を 0 にした反実仮想)
X = np.column_stack([np.ones_like(t, dtype=float), t,
                     np.sin(2 * np.pi * t / 52), np.cos(2 * np.pi * t / 52), campaign])
beta, *_ = np.linalg.lstsq(X, sales, rcond=None)
baseline_hat = beta[0] + beta[1] * t + beta[2] * np.sin(2 * np.pi * t / 52) + beta[3] * np.cos(2 * np.pi * t / 52)

fig, ax = plt.subplots(figsize=(8.4, 4.6))
ax.plot(t, sales, color="C0", marker="o", ms=3, lw=1.2, label="観測売上")
ax.plot(t, baseline_hat, color="C3", ls="--", lw=2,
        label="推定ベースライン(施策がなかったら)")
ax.axvspan(26, 33, color="C1", alpha=0.18, label="キャンペーン期(週26〜33)")

# 施策期では観測がベースラインの上に乗る。その縦の差が「増分」
ax.annotate("この縦の差が増分\n(回帰の campaign係数 ≒ 180/週)",
            xy=(29.5, baseline_hat[29] + 90), xytext=(34, 1450),
            arrowprops=dict(arrowstyle="->", color="C1"))

ax.set_title("観測売上=ベースライン(反実仮想)+施策の増分")
ax.set_xlabel("週")
ax.set_ylabel("売上")
ax.legend(loc="upper left", fontsize=9)
fig.tight_layout()
plt.show()

図では、キャンペーン区間(オレンジの帯)で観測売上(青)が推定ベースライン(赤破線)の上に乗り、その縦の隙間が増分になります。帯の外では青と赤がほぼ重なる——施策のない週ではベースラインが売上をうまく説明している、という当てはまりの確認にもなっています。

⚠️ よくある誤解

関連ノート