🎓 レベル:標準 | 重要度:A(必須)
📎 関連:マーケティングミックスモデリング(MMM) | 前提:ファネルとコンバージョンの基本指標
要点(BLUF)
- 観測売上は「ベースライン(反実仮想)+施策の増分(インクリメンタル)+ノイズ」に分解できます。効果測定とは、施策がなくても売れていたはずの見えないベースラインを推定し、そこからの上乗せ分=増分だけを取り出すことです。
- 素朴な前後比較(施策期の売上 − 直前期の売上)は、トレンドや季節性でベースライン自体が動くと、その差をまるごと増分に混ぜて誤ります。本ノートの例では、真の増分 /週を前後比較は /週と取り違えます(過小評価)。
- トレンドと季節性を説明変数に入れた回帰で、施策ダミーの係数として増分を推定すると、ベースラインを差し引いた純粋な増分が得られます。1回の推定では /週とノイズで揺れますが、くり返すと平均は に収束(不偏)。前後比較は平均しても とズレ続けます(系統的バイアス)。さらに ROI は総売上でなく増分売上で測るべきこともコードで確かめます。
1. 観測売上の分解:ベースライン+増分+ノイズ
ある週 に観測される売上 は、3つの足し算に分けて考えられます。
- :ベースライン。その施策を打たなかったら売れていたはずの売上=反実仮想(counterfactual)。事業成長のトレンドや、年末商戦などの季節性で、それ自体が時間とともに動きます。決定的に厄介なのは、これが観測できないこと——施策を打った週に「打たなかったら」の売上は、どこにも記録されていません。
- :施策ダミー。キャンペーン実施週で 、そうでなければ 。
- :増分(インクリメンタル)。施策が1週あたり押し上げた売上。これこそ知りたい量です。
- :ノイズ。需要のランダムな揺れ。平均 とします。
効果測定の本質は、見えない を何らかの形で推定し、観測 から差し引いて増分 を取り出すことに尽きます。ベースラインの当て方を間違えると、増分も間違えます。
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. なぜ前後比較は外れ、回帰は増分を取り出せるのか(数式)
素朴な前後比較は、施策期の平均売上から直前期の平均売上を引きます。
一見もっともらしいのですが、 の平均が なので期待値をとると、
第2項 がバイアスです。ベースライン がトレンドや季節性で動いていれば、施策期と直前期でベースラインの水準が違い、その差がまるごと増分に化けてしまいます。 が完全に平らなときだけ前後比較は正しい——現実にはまずありえません。本ノートの例では、直前期(季節性が高い時期)のほうがベースラインが高いため、前後比較は増分を過小評価します。
回帰は、このベースラインを関数で明示的にモデル化して切り離します。トレンドを の1次、季節性を年周期(52週)の三角関数で表し、
を最小二乗で当てます。 の部分が推定ベースライン、施策ダミー の係数 が増分です。ベースラインのモデルが妥当なら、 はトレンド・季節性を差し引いた純粋な増分を捉え、平均的に真値に一致します(不偏)。ここが前後比較との決定的な違いで、回帰は「同じ時期にベースラインがどれくらいだったか」を他の週の情報から補完してくれます。
なお、推定の不確実性(標準誤差)や、ベースラインの関数形を誤ったときに残るバイアスは回帰の推定論——統計テキストの領域です。ここでの主役は「ベースラインをモデル化して増分を分離する」という発想そのものです。
3. 前後比較と回帰を比べる(コード)
合成データで真の増分を /週と決め打ちし、2つの推定法がそれをどう測るか比べます。 週の売上を「ベースライン(トレンド 、季節性 )+キャンペーン(週26〜33に )+ノイズ」で作り、(a) 素朴な前後比較=施策期平均 − 直前8週平均、(b) 回帰=説明変数 を 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
→ 総売上だと黒字に見えるが、大半は施策がなくても売れた分。増分で測ると赤字。
出力の意味:真の増分 /週に対し、素朴な前後比較は (誤差 )と大きく外し、回帰は (誤差 )とずっと近い。前後比較がここまで外れるのは、§2 のバイアス項どおり——直前期(週18〜25)は季節性が高くベースラインが押し上がっていたため、その後の施策期との差が増分を食ってしまったからです。回帰はトレンドと季節性を別の係数で吸収するので、施策ダミーの係数が増分だけを拾います。ROI の対比はさらに実務的に効きます。**総売上 で測ると ROI は (黒字に見える)**が、その大半はキャンペーンがなくても売れたベースライン。**増分 で測れば ROI は (実は赤字)**です。「キャンペーン中によく売れた」と「キャンペーンが売上を増やした」はまったく別物だ、という効果測定の核心が数字に出ています。
回帰の にもまだ の誤差が残ります。これはバイアスではなくノイズ——同じ仕組みでノイズだけを変えてくり返すと、回帰の平均は真値 に収束し、前後比較はズレ続けることを確かめましょう。
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回ごとのズレはノイズで、平均すると消える。
出力の意味:ここにバイアスとノイズの違いがはっきり出ます。前後比較は 回平均しても で、真値 から 系統的にズレ続けます——いくらデータを増やしても消えない、これがバイアスです。いっぽう回帰の平均は (バイアス )でほぼ不偏。さきほどの1回ぶんの は、標準偏差 の範囲で揺れたノイズの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()
図では、キャンペーン区間(オレンジの帯)で観測売上(青)が推定ベースライン(赤破線)の上に乗り、その縦の隙間が増分になります。帯の外では青と赤がほぼ重なる——施策のない週ではベースラインが売上をうまく説明している、という当てはまりの確認にもなっています。
⚠️ よくある誤解
- 前後比較は他要因を増分に混ぜる:施策期と直前期の売上差を増分と呼ぶのは、その間にトレンド・季節性・他施策・外的ショックが何も動いていない場合だけ。現実にはベースラインが動くので、その変化分がまるごと増分に化けます(本ノートで の誤差)。最低でもトレンド・季節性を回帰で除く、より厳密には対照群との差分(DiD)や実験で反実仮想を作ります。
- 「キャンペーン中によく売れた」≠「キャンペーンが売上を増やした」:観測された売上の高さ(相関)と、施策による増分(因果)は別物。総売上で ROI を測ると、ベースライン分まで施策の手柄にしてしまい、赤字の施策を黒字と誤判定します。ROI は増分売上 × 粗利率 − 費用で見るのが鉄則です。
- 回帰は万能ではない:回帰が増分を不偏に取り出せたのは、ベースラインの関数形(トレンド+年周期)が真のデータ生成と合っていたから。関数形を誤れば回帰にもバイアスが残ります。また回帰係数は相関にもとづく推定で、施策の割り当てが売上と無関係(外生)でない限り、厳密な因果効果の保証にはなりません。割り当てをランダム化して反実仮想を担保するのが A/B テスト・対照群(第7章 実験と因果推論)です。
- 「効果が出る出口」を取り違えない:広告の増分は、売上だけでなくファネルの各段階(認知・来訪・CV)に現れます。どの指標で増分を測るかは ファネルとコンバージョンの基本指標 の設計に依存します。クリックは増えたが売上は増えていない、という乖離はよくあります。
関連ノート
- マーケティングミックスモデリング(MMM)(次のトピック・単一施策の増分を複数チャネル同時の回帰へ拡張)
- 第4章 市場反応モデル 目次
- ファネルとコンバージョンの基本指標(増分が現れる出口の指標。効果はどの段階で測るか)
- 厳密な因果効果は対照群・ランダム化(A/B テスト)で測る——第7章 実験と因果推論/回帰の推定論・標準誤差・関数形の誤りは統計テキスト/施策や価格の内生性を踏まえた因果推定は因果推論テキストへ
- マーケティング・サイエンス 全体目次