CausalImpactで施策の純増効果を測る実装
A/Bテストが組めない施策の「純増効果」を、ベイズ構造時系列で反実仮想を組み立てて測ります。前処理から推定・効果区間まで動くコードで示し、再現可能な合成データで真値+17%を回収した検証を添えます。
by Shin
A/Bテストが組めない単発施策の効果は、どう測ればいいのか。 施策が無かった場合の系列(反実仮想)をベイズ構造時系列で予測し、実測との差を「純増効果」として区間つきで出します。これが CausalImpact の仕事です。 以下では考え方・対照群の選び方・前処理から効果区間までの実装を、再現可能なコードと一次データで通します。
施策を打ったあと売上が伸びました。ですが「施策のおかげ」とどこまで言えるのか——この問いの全体像(相関と因果、手法の地図、予算の止めどき続けどき)は 「その広告、本当に効きましたか?」 に書きました。本稿はその実装側の一本で、CausalImpact に絞って深掘りします。DiD・RDD・PSM を含めた手法の使い分けは 因果推論の実装パターン集 を参照してください。ここでは「どれを選ぶか」ではなく「CausalImpact を選んだあと、どう正しく回すか」だけを扱います。
CausalImpactの考え方(ベイズ構造時系列)
CausalImpact が答えるのは一つの問いだけです。「介入が無かったら、この系列はどう推移していたか」。この観測できない並行世界=反実仮想を統計的に組み立て、実測との差分を効果とみなします。手法の出自は Brodersen らによる2015年の論文で、Google が広告キャンペーンの増分効果を測るために設計したものです。R の CausalImpact パッケージが原典にあたります。
反実仮想の作り方が、この手法の核心であり差別化点でもあります。単純な「前後比較」は介入時点のトレンドや季節性をすべて効果に押し付けてしまいます。CausalImpact は代わりに、介入の影響を受けない対照系列(共変量) と、目的系列の時系列構造(水準・トレンド・季節) の両方を、ベイズ構造時系列モデル(Bayesian Structural Time-Series, BSTS)でまとめて学習します。
状態空間で書けば、観測方程式と状態方程式はおおむね次の形になります。
観測: y_t = μ_t + β·x_t + ε_t ε_t ~ N(0, σ_ε²)
状態: μ_t = μ_{t-1} + η_t η_t ~ N(0, σ_η²) (局所水準)
μ_t がランダムウォークする局所水準(トレンドや季節を足すこともできる)、β·x_t が対照系列による回帰成分。介入前期間でこのモデルを当て、介入後は対照系列 x_t だけを使って y_t を予測します。これが反実仮想です。ベイズなので点推定ではなく事後分布が得られ、効果は常に信用区間つきで出ます——ここが「効果は出ました」で止まらないための要になります。
成り立っていなければならない前提は二つ。(1) 対照系列が介入の影響を受けていないこと。(2) 介入前に成立していた目的系列と対照系列の関係が、介入後も安定していること。 この二つが崩れると、反実仮想はもっともらしく見えても中身は嘘になります。次節はまさにこの (1) を満たす対照群をどう選ぶかの話です。
下の図版は、その直観を手で確かめるためのものです。介入日(pre/post の境界)をドラッグすると、ブラウザ内で反実仮想が引き直され、相対効果と累積純増がリアルタイムに変わります。境界を真の介入日より手前に置くと推定がどう歪むかを、まず触って体感してみてください。
対照群(共変量)の選び方
CausalImpact の精度の8割は、推定アルゴリズムではなく対照系列の選定で決まります。よい対照系列の条件は三つです。
第一に、介入前期間で目的系列とよく連動していること。同じ市場要因・季節性・トレンドを共有しているほど、反実仮想の予測が締まります。第二に、介入の影響を受けていないこと。これが最重要で、ここを外すと因果ではなく相関を測ることになります。第三に、汚染リスクが低いこと——たとえば「全社売上」を対照に使うと、その中に介入対象の売上が混ざり、効果を自分自身で薄めてしまいます。
実務での調達先は、おおむね次のどれかに落ちます。
- 未出稿の地域・セグメント: 同じ施策を打っていない地方やユーザー群。地理実験の代替として最も使いやすい候補です。
- 姉妹ブランド/別プロダクトの同種指標: 同じ景気・季節の波を受けるが、当該施策の対象外のもの。
- 指名検索ボリューム以外の検索トレンド: 市場全体の関心を表す外生的な指標。
- 施策と無関係なチャネルのベースライン: 例として、テレビ CM を測るとき、CM の影響が及ばないチャネルの素の流入。
避けるべきは「効くから」という理由だけで関係指標を全部突っ込むことです。共変量が増えるほど多重共線性と過学習のリスクが上がります。原典の BSTS は spike-and-slab 事前分布で変数選択を内蔵し、効いていない共変量の係数をゼロに縮めます。自前で statsmodels を使う場合はこの機能が無いので、共変量は 3〜5本程度に絞り、標準化してスケールを揃えるのが安全な既定値になります。
汚染の典型例も挙げておきます。リターゲティング広告の効果を測るとき、対照に「オーガニック流入」を選んだとします。一見独立に見えますが、リターゲ広告がブランド想起を押し上げて指名検索→オーガニック流入を増やしていれば、対照そのものが介入で持ち上がっており、効果は過小評価されます。「この系列は本当に介入の外にいるか」を、相関ではなく因果の経路で問うこと。ここを言語化できるかどうかが、ツールを叩くだけの人との差になります。
実装(前処理→推定→効果区間)
パッケージの現在地(2026年時点)
Python の CausalImpact 実装は系譜がややこしいので、先に整理しておきます。原典は R。Python へは Dafiti の Willian Fuks が statsmodels ベースで移植したのが最初で、その後 TensorFlow Probability ベースに書き直した tfcausalimpact がコミュニティの定番になりました。さらに Google 自身が tfp-causalimpact(PyPI 名 tfp-causalimpact / import は causalimpact)を公開しています。本番の推定精度(HMC によるフルベイズ)を取りに行くならこの TFP 系列が第一候補です。
ただ本稿では、まず中身を statsmodels の UnobservedComponents で素手で組みます。理由は単純で、pip install してブラックボックスを叩くだけでは、前提が崩れたときに何が起きているか読めないからです。BSTS の正体は「局所水準+共変量回帰のカルマンフィルタ」であり、これは外部依存をほぼ増やさず書けます。
pip install numpy pandas statsmodels前処理:pre/post 分割と標準化
import numpy as np
import pandas as pd
from statsmodels.tsa.statespace.structural import UnobservedComponents
# --- 再現可能な合成データ(手元で動かせるよう真の効果 +17% を埋め込む)-------
rng = np.random.default_rng(42)
n_post = 21 # 評価期間(後述の通り長さは結論を変える)
n = 60 + n_post # pre 60日 + post 21日
idx = pd.date_range("2026-01-01", periods=n, freq="D")
weekly = np.where(idx.dayofweek < 5, 1.0, 0.62) # 平日高・週末低
trend = np.linspace(0, 1, n)
common = 1.0 + 0.18 * trend + rng.normal(0, 0.03, n) # 共通の市場因子
# 対照群(共変量):x1=指名検索指数, x2=姉妹ブランドのサインアップ, x3=全体トラフィック指数
x1 = 100 * common * weekly * np.exp(rng.normal(0, 0.05, n))
x2 = 320 * common * (0.5 + 0.5 * weekly) * np.exp(rng.normal(0, 0.06, n))
x3 = 800 * common * weekly * np.exp(rng.normal(0, 0.04, n))
# 目的系列:日次サインアップ。pre 期で対照群との関係が安定している
y = (60 + 1.4 * x1 + 0.35 * x2 + 0.05 * x3) * np.exp(rng.normal(0, 0.04, n))
intervention = 60
TRUE_LIFT = 0.17
y[intervention:] *= (1 + TRUE_LIFT) # 介入:+17% の乗法的リフトを注入
# --- 前処理:分割と標準化 -----------------------------------------------------
pre, post = slice(0, intervention), slice(intervention, n)
X = np.c_[x1, x2, x3]
Xz = (X - X[pre].mean(0)) / X[pre].std(0) # pre 統計量で標準化(リーク防止)
y_pre = y[pre]
y_post_actual = y[post]標準化は pre 期間の平均・標準偏差で行う点に注意してください。post を含めて標準化すると介入後の情報が前処理に漏れます(リーク)。共変量を絞り、スケールを揃えるだけで推定はかなり安定します。
推定:局所水準+共変量回帰
model = UnobservedComponents(
y_pre,
level="local level", # 局所水準(ランダムウォーク)。CausalImpact の既定に対応
exog=Xz[pre], # 共変量による回帰成分
)
res = model.fit(disp=False, maxiter=300)ここで level="local linear trend"(水準+傾き)も選べますが、持続的な介入を測るなら "local level" が安全です。傾き成分は介入後の上昇トレンドを「もともとの傾き」として吸収してしまい、効果を過小評価しやすくなります。これは後述の通り実際に数値で効いてきます。
効果区間:事後予測シミュレーション
点推定だけでは意思決定に使えません。CausalImpact が信用区間を出せるのは、反実仮想を多数のサンプルパスとして事後予測シミュレーションするからです。statsmodels でも同じことができます。
N_SIM = 4000
cf = np.asarray(res.simulate( # 反実仮想のサンプルパス (n_post, N_SIM)
nsimulations=n_post, anchor="end",
exog=Xz[post], repetitions=N_SIM,
random_state=7, # シード固定で完全再現
))
if cf.ndim == 3:
cf = cf[:, 0, :]
pointwise = y_post_actual[:, None] - cf # 毎日の効果(純増)
cumulative = np.cumsum(pointwise, axis=0) # 累積効果
# 期間平均効果・累積効果・相対効果を、事後分布の分位点で区間化
avg = pointwise.mean(axis=0)
cum_final = cumulative[-1, :]
rel = y_post_actual.sum() / cf.sum(axis=0) - 1.0
def band(a):
return a.mean(), np.percentile(a, 2.5), np.percentile(a, 97.5)
print(f"平均効果(1日,純増) : {band(avg)[0]:6.1f} 95%CI [{band(avg)[1]:.1f}, {band(avg)[2]:.1f}]")
print(f"累積純増効果 : {band(cum_final)[0]:6.0f} 95%CI [{band(cum_final)[1]:.0f}, {band(cum_final)[2]:.0f}]")
print(f"相対効果 : {band(rel)[0]:+6.1%} 95%CI [{band(rel)[1]:+.1%}, {band(rel)[2]:+.1%}]")
print(f"効果がプラスの確率 : {(cum_final > 0).mean():.1%}")このコードを上から実行すると、次の出力が得られます(シード固定済み・再現可能)。
平均効果(1日,純増) : 59.1 95%CI [52.0, 66.1]
累積純増効果 : 1240 95%CI [1092, 1389]
相対効果 : +16.2% 95%CI [+13.9%, +18.4%]
効果がプラスの確率 : 100.0%
埋め込んだ真値は +17%。回収した相対効果は +16.2%、95%信用区間 [+13.9%, +18.4%] で、真値を区間内に含み、点推定もほぼ一致しました。標準化した回帰係数は x1(指名検索指数)が +34.3 と支配的で、x2(+13.3)、x3(+1.8)と続きます——反実仮想の大半を指名検索が説明しており、対照群の選定が効いていることが数値からも読めます。
本番でフルベイズを取りに行く場合
精度を最大化したいときは、TFP 系列で HMC を回します。インターフェースはほぼ同じです。
# pip install tfp-causalimpact
import causalimpact
data = pd.DataFrame({"y": y, "x1": x1, "x2": x2, "x3": x3}, index=idx)
impact = causalimpact.fit_causalimpact(
data=data,
pre_period=[idx[0], idx[intervention - 1]],
post_period=[idx[intervention], idx[-1]],
)
print(causalimpact.summary(impact))HMC は数千点・複数の季節成分を含む系列では推定に時間がかかります(GPU でも分〜時間単位になりえます)。素早く回したいときは変分推論、結論を固めるときは HMC、という使い分けでよいでしょう。
結果の読み方と意思決定への接続
出力は三つの量で読みます。平均効果(1日あたりの純増)、累積純増効果(評価期間で何件・何円増えたか)、相対効果(介入が無かった場合に対して何%増か)。発注者に渡すのは多くの場合この相対効果と累積効果ですが、必ず信用区間とセットで渡します。点推定だけを切り出すのは、反煽りの観点からも実務の観点からも筋が悪いやり方です。
評価期間(horizon)の罠
CausalImpact で最初に踏む落とし穴がこれです。同じデータで評価期間だけを伸ばすと、推定はこう変わります。
| 評価期間 | 相対効果(点推定) | 95%信用区間 |
|---|---|---|
| 21日 | +16.2% | [+13.9%, +18.4%] |
| 30日 | +12.7% | [+4.2%, +22.5%] |
長くするほど点推定が真値から減衰し、区間が広がっています。理由は二つあります。局所水準のランダムウォークが、持続する効果を時間とともに「もともとの水準」として徐々に吸収してしまうこと。そして反実仮想の予測誤差が評価期間に沿って累積し、不確実性が増すこと。CausalImpact は持続的な施策の長期効果を測るのには本質的に弱いのです。短〜中期の単発介入に向く道具だと割り切るのが正しいでしょう。
「効果がプラスの確率」の扱い
事後分布から出る「効果がプラスである確率」は直感的で発注者にも伝わりやすいですが、p 値の代わりに使って「100% だから確実」と語るのは誇張です。これはあくまでモデルと前提が正しいと仮定したうえでの事後確率にすぎません。対照群の前提が崩れていれば、確率がどれだけ高くても結論は無効になります。確率の高さは前提の正しさを保証しない——この一文を結果報告に添えるだけで、信頼の質が変わります。
意思決定への橋渡し
実務で効くのは結局、区間で意思決定するルールを先に決めておくことです。たとえば「相対効果の信用区間の下限がプラスなら継続、下限がゼロをまたいだら撤退」のように、点推定ではなく区間で判断軸を引きます。区間が広すぎて決められないなら、それは「効果が無い」ではなく「まだ判断材料が足りない」というシグナルであり、評価期間を区切り直すか対照群を見直す合図です。
予算をいつ止め、いつ続けるか——その意思決定そのものの設計は、ビジネス側の文脈とともに 因果推論が変えるマーケ意思決定 に整理しました。実装で出した区間を、実際の予算判断にどう接続するかはそちらを参照してください。