CausalImpactで実践する 施策の効果検証(第3回)
CausalImpactの練習用データをPythonで作る(正解の効果を仕込む)

CausalImpactで実践する 施策の効果検証(第3回)CausalImpactの練習用データをPythonで作る(正解の効果を仕込む)

この連載で使っている campaign_sales.csv は、答えが分かっているデータです。

5月の自店舗の売上には、キャンペーンの効果として、ちょうど10%を上乗せしてあります。だから第0回では、CausalImpactの推定9.6%が正解に近いと確かめられました。

今回は、このデータを作るコードを、部品ごとに見ていきます。

結論を先に3行で書きます。

  1. 練習用データは「ふだんの動き(トレンド・曜日・GW)」「ゆらぎ」「キャンペーンの効果」を組み合わせて作ります。部品ごとに作るので、どこに何が入っているかが全部分かります。
  2. 効果を仕込む前の売上を残しておけば、それが本当の反実仮想です。5月の平均は478.5千円で、第0回のCausalImpactの予測(480.25千円)はこれに近い値でした。
  3. 乱数のシードを42に固定すれば、誰が作っても、配布しているCSVとまったく同じデータになります。

この記事を読み終えると、次のことができるようになります。

  • 答えの分かるデータで手法を確かめる、という考え方を説明できる
  • トレンド・曜日・祝日・ゆらぎを組み合わせて、時系列データを作れる
  • 効果の大きさや期間を変えた練習用データを、自分で作れる
  • 配布しているCSVと同じデータを作れたかを、確かめられる

この回も、CausalImpactのライブラリは使いません。前回(第2回)で作った仮想環境でも、普段のPython環境でも動きます。

campaign_sales.csv

なぜ、答えの分かるデータを自分で作るのか

実際の売上データでは、キャンペーンの本当の効果は誰にも分かりません。CausalImpactが「効果は9.6%」と出しても、それが合っているのか確かめようがないのです。

そこで、効果の大きさを自分で決めて仕込んだデータを作ります。手法がその大きさを言い当てられるかを見れば、手法の使い方や設定が正しいかを確かめられます。

この連載でも、後半の回で「対照店舗が施策の影響を受けたら」「施策がなかった期間に当てたら」といった条件を、このデータを少し変えて試します。今回のコードは、その土台になります。

データは、次の4つの部品から作ります。

部品 中身
① 曜日・祝日の効果 平日1.0倍、土日1.15倍、ゴールデンウィーク(GW)1.5倍
② 対照店舗A・B 基準の売上 × 年率の伸び × ①、にゆらぎを足す
③ 自店舗(キャンペーンなし) 店舗A・Bと連動する売上に、ゆらぎを足す
④ キャンペーンの効果 2026年5月だけ、③を1.10倍にする

 

部品①:日付と、曜日・祝日の効果を作る

まず、2025年1月1日から2026年5月31日までの日付を作り、日ごとに「曜日・祝日でどれだけ売上が増えるか」の倍率を決めます。

以下、コードです。

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)  # 乱数のシードを42に固定する

dates = pd.date_range("2025-01-01", "2026-05-31", freq="D")
n = len(dates)    # 日数(516日)
t = np.arange(n)  # 0, 1, 2, … と数える通し番号(トレンドに使う)

# 曜日・祝日の効果:平日1.0、土日1.15、ゴールデンウィーク1.5
gw_days = pd.to_datetime([
    "2025-04-29", "2025-05-03", "2025-05-04", "2025-05-05", "2025-05-06",
    "2026-04-29", "2026-05-03", "2026-05-04", "2026-05-05", "2026-05-06",
])
day_effect = np.where(dates.dayofweek >= 5, 1.15, 1.0)
day_effect = np.where(dates.isin(gw_days), 1.5, day_effect)

# 2026年のGWの前後を表示して確かめる
check = pd.Series(day_effect, index=dates)
print(check["2026-04-27":"2026-05-07"])

 

以下、実行結果です。

2026-04-27    1.00
2026-04-28    1.00
2026-04-29    1.50
2026-04-30    1.00
2026-05-01    1.00
2026-05-02    1.15
2026-05-03    1.50
2026-05-04    1.50
2026-05-05    1.50
2026-05-06    1.50
2026-05-07    1.00
Freq: D, dtype: float64

 

4月29日(昭和の日)と5月3〜6日が1.5倍、土曜日の5月2日が1.15倍になっています。

1行目の np.random.default_rng(42) は、乱数(でたらめな数)を作る道具を用意する命令です。かっこの中の42をシード(乱数の種)と呼び、これを固定すると、何度実行しても同じ乱数が出てきます。

 

部品②:対照店舗A・Bの売上を作る

次に、キャンペーンをしていない店舗A・Bの売上を作ります。考え方は「ふだんの動き + ゆらぎ」です。

以下、コードです。

trend_a = 1 + 0.08 * t / 365  # 店舗A:年8%の伸び
trend_b = 1 + 0.05 * t / 365  # 店舗B:年5%の伸び

# ふだんの動き(基準の売上 × 伸び × 曜日・祝日の効果)+ ゆらぎ
store_a = 300 * trend_a * day_effect + rng.normal(0, 15, n)
store_b = 250 * trend_b * day_effect + rng.normal(0, 12, n)

# 最初の月(2025年1月)と最後の月(2026年5月)の平均を比べる
print(
    f"店舗A:{store_a[:31].mean():.1f}千円 "
    f"→ {store_a[-31:].mean():.1f}千円"
)
print(
    f"店舗B:{store_b[:31].mean():.1f}千円 "
    f"→ {store_b[-31:].mean():.1f}千円"
)

 

以下、実行結果です。

店舗A:313.9千円 → 366.1千円
店舗B:259.7千円 → 294.7千円

 

店舗Aを例に、式を読み解きます。

式の部分 意味
300 基準の売上(1日300千円)
trend_a 1年で8%ずつ伸びる倍率(1年後に1.08倍)
day_effect 部品①の、曜日・祝日の倍率
rng.normal(0, 15, n) 平均0、ばらつき(標準偏差)15のゆらぎを、n日分作る

最初の月から最後の月にかけて、店舗Aは313.9千円から366.1千円に増えています。伸びのトレンドに加えて、最後の月(2026年5月)にはGWが入っているためです。

 

部品③:自店舗の売上を、対照店舗と連動させて作る

自店舗の売上は、店舗A・Bの売上に連動するように作ります。ここではまだキャンペーンの効果を入れないので、名前を no_campaign(キャンペーンがなかった場合の売上)にしておきます。

以下、コードです。

# キャンペーンがなかった場合の自店舗の売上:対照店舗と連動して動く + ゆらぎ
no_campaign = (
    40 
    + 0.8 * store_a 
    + 0.5 * store_b 
    + rng.normal(0, 12, n)
)

print(
    f"店舗Aとの相関:{np.corrcoef(no_campaign, store_a)[0, 1]:.3f}"
)
print(
    f"店舗Bとの相関:{np.corrcoef(no_campaign, store_b)[0, 1]:.3f}"
)

 

以下、実行結果です。

店舗Aとの相関:0.932
店舗Bとの相関:0.884

 

相関は、2つの数字の動きがどれだけそろっているかを表す値で、1に近いほど同じように動きます。自店舗は店舗Aと0.932、店舗Bと0.884で、強く連動しています。

この連動が、CausalImpactにとっての手がかりです。店舗A・Bの5月の売上から「キャンペーンがなかった自店舗の5月」を予測できるのは、ふだんから一緒に動いているからです。

 

部品④:キャンペーンの効果を仕込む

最後に、キャンペーン期間(2026年5月)だけ、売上を1.10倍にします。これが正解の効果(+10%)です。

以下、コードです。

# キャンペーン期間(2026年5月)だけ、売上を1.10倍にする(これが正解の効果)
campaign = (dates >= "2026-05-01") & (dates <= "2026-05-31")
sales_own = np.where(
    campaign, 
    no_campaign * 1.10, 
    no_campaign
)

print(
    "5月の平均(キャンペーンなし):"
    f"{no_campaign[campaign].mean():.1f}千円"
)
print(
    "5月の平均(キャンペーンあり):"
    f"{sales_own[campaign].mean():.1f}千円"
)

 

以下、実行結果です。

5月の平均(キャンペーンなし):478.5千円
5月の平均(キャンペーンあり):526.3千円

 

ここが今回のいちばん大事なところです。no_campaign は、キャンペーンがなかった場合の本当の売上、つまり本当の反実仮想です。実際のデータでは決して見られないものが、練習用データでは手元にあります。

5月の平均は478.5千円でした。第0回でCausalImpactが予測した「キャンペーンがなかった場合の5月」は480.25千円(95%区間 472.92〜487.13千円)で、正解を区間の中に捉えていました。

第1回の簡易版(対照店舗の伸びを借りる方法)も、前月比では478.5千円とほぼ同じ値でした。ただし簡易版は、比べる期間によって効果が8.9〜10.2%まで動いたことも思い出してください。

 

CSVに保存して、配布版と同じか確かめる

部品をひとつの表にまとめ、CSVファイルに保存します。続けて、配布している campaign_sales.csv と中身が同じかを確かめます。

以下、コードです。

df = pd.DataFrame(
    {
        "sales_own": sales_own, 
        "store_a": store_a, 
        "store_b": store_b
    },
    index=dates,
).round(1)
df.index.name = "date"
df.to_csv(
    "campaign_sales_my.csv",
    encoding="utf-8-sig"
)
print(df.head())

# 配布しているCSV(同じフォルダに置いておく)と同じかを確かめる
mine = pd.read_csv(
    "campaign_sales_my.csv", 
    index_col="date", 
    parse_dates=True
)
original = pd.read_csv(
    "campaign_sales.csv", 
    index_col="date", 
    parse_dates=True
)
print("配布版と同じか:", mine.equals(original))

 

以下、実行結果です。

            sales_own  store_a  store_b
date                                   
2025-01-01      406.7    304.6    258.0
2025-01-02      394.3    284.5    241.8
2025-01-03      413.2    311.4    260.8
2025-01-04      481.0    359.3    307.2
2025-01-05      442.7    316.0    276.0
配布版と同じか: True

 

最後の行が True なら、配布版とまったく同じデータを作れています。

ファイルどうしを直接比べず、読み込んでから中身を比べているのには理由があります。WindowsとMac・Linuxでは、保存したときの改行の記号が違います。中身が同じでも、ファイルとしては一致しないことがあるのです。

 

グラフで見る:GWの山と、仕込んだ効果

作ったデータをグラフにします。上は全期間の3店舗、下は2026年3〜5月の自店舗と正解の反実仮想です。

以下、コードです。

import matplotlib.pyplot as plt
import matplotlib_fontja

start = pd.Timestamp("2026-05-01")  # キャンペーン開始日
fig, axes = plt.subplots(2, 1, figsize=(9, 8))

# 上:全期間の3店舗の売上
ax = axes[0]
ax.plot(
    df.index, 
    df["store_b"], 
    color="#b5b3ab", 
    linewidth=1, 
    label="店舗B(対照)"
)
ax.plot(
    df.index, 
    df["store_a"], 
    color="#52514e", 
    linewidth=1, 
    label="店舗A(対照)"
)
ax.plot(
    df.index, 
    df["sales_own"], 
    color="#2a78d6", 
    linewidth=1, 
    label="自店舗"
)
ax.axvspan(
    start, 
    pd.Timestamp("2026-05-31"), 
    color="#eeeeee"
)
ax.set_title(
    "3店舗の日次売上(灰色の帯がキャンペーン期間)", 
    loc="left"
)
ax.set_ylabel("売上(千円)")
ax.legend(loc="upper left")

# 下:2026年3〜5月の自店舗と、正解の反実仮想
ax = axes[1]
p = df.index >= "2026-03-01"
ax.plot(
    df.index[p], 
    df["sales_own"][p], 
    color="#2a78d6",
    label="実測値(キャンペーンあり)"
)
ax.plot(
    df.index[p], 
    no_campaign[p], 
    color="#eb6834", 
    linestyle="--",
    label="正解の反実仮想(キャンペーンなし)"
)
ax.fill_between(
    df.index[campaign], 
    no_campaign[campaign], 
    sales_own[campaign],
    color="#86b6ef", 
    alpha=0.5, 
    label="仕込んだ効果(+10%)"
)
ax.axvline(
    start, 
    color="#898781", 
    linestyle=":"
)
ax.set_title(
    "自店舗の売上と、正解の反実仮想(2026年3〜5月)", 
    loc="left"
)
ax.set_ylabel("売上(千円)")
ax.legend(loc="upper left")

plt.tight_layout()
plt.show()

 

以下、実行結果です。

 

上のグラフを見ると、5月の自店舗の売上が高いのは分かります。ただし、それがGWのせいなのか、キャンペーンのせいなのかは、見た目だけでは分かりません。2025年5月にも、同じくらいのGWの山があるからです。

下のグラフは、正解を知っているからこそ描ける図です。CausalImpactが予測しようとしているのは、このオレンジの点線です。

 

ありがちな失敗:乱数のシードを固定し忘れる

シードを指定しないと、実行するたびに違う乱数が出てきます。試してみます。

以下、コードです。

# シードを指定しないと、実行するたびに違う乱数になる
print(
    "シードなし:", 
    np.random.default_rng().normal(0, 15, 3).round(1)
)
print(
    "シードなし:", 
    np.random.default_rng().normal(0, 15, 3).round(1)
)

# シードを42にすると、何度実行しても同じ乱数になる
print(
    "シード42 :", 
    np.random.default_rng(42).normal(0, 15, 3).round(1)
)
print(
    "シード42 :", 
    np.random.default_rng(42).normal(0, 15, 3).round(1)
)

 

以下、実行結果です。

シードなし: [-12.8   4.6 -25. ]
シードなし: [ -6.4 -24.5  -4.8]
シード42 : [  4.6 -15.6  11.3]
シード42 : [  4.6 -15.6  11.3]

 

シードなしの2行は、実行するたびに数字が変わります(皆さんが実行すると、上とは別の数字になります)。シード42の2行は、何度実行しても同じです。

シード42の最初の数字4.6は、店舗Aの1日目のゆらぎと同じです。店舗Aの2025年1月1日の売上304.6千円は、基準の300千円にこの4.6を足したものです。

シードを固定し忘れると、記事と違うデータができて、結果を見比べられなくなります。自分で練習用データを作るときも、シードは必ず決めておきましょう。

なお、第0回で使った tf.random.set_seed(42) は、CausalImpactの推定で使う乱数を固定するものです。今回の default_rng(42) は、データを作るときの乱数を固定するもので、役割が違います。

 

よくある質問

Q. 効果の大きさや期間を変えるには、どこを直せばよいですか?

部品④の2行です。1.10 を 1.05 にすれば効果は5%、日付を変えればキャンペーン期間が変わります。効果を小さくするほど、ゆらぎに埋もれて見つけにくくなります。

Q. なぜ自店舗の売上を、対照店舗から作るのですか?

実際の店舗でも、近くの店舗とは天気や地域の行事、景気の影響を同じように受けて、一緒に動くことが多いからです。対照店舗と連動しない売上だと、対照店舗は予測の手がかりになりません。どんな対照店舗が役に立つかは第8回で扱います。

Q. NumPyやpandasのバージョンが違っても、同じデータになりますか?

この記事では、Python 3.11(NumPy 2.4、pandas 2.2と3.0)と、Python 3.13(NumPy 2.5、pandas 2.2)の組み合わせで、配布版と同じデータになることを確かめています。

 

まとめ

今回のポイントです。

  • 答えの分かるデータを自分で作れば、手法が効果を言い当てられるかを確かめられる
  • 練習用データは、曜日・祝日の効果、伸びのトレンド、ゆらぎ、キャンペーンの効果を組み合わせて作る
  • 自店舗の売上を対照店舗と連動させておくことが、CausalImpactの予測の手がかりになる
  • 効果を仕込む前の売上が、本当の反実仮想。5月の平均は478.5千円で、第0回の予測480.25千円の区間に入っていた
  • 乱数のシードを固定すれば、誰が作っても同じデータになる
Screenshot

【月1 特定テーマ講座(11月)】
POS売上分析Copilotを作る
(POSデータ×PYTHON×生成AI)

【開催日時】 全2回(土)2026/11/7,11/21(13:30〜18:00)
【受講形式】 当日Zoom( or 復習用に後日動画視聴)
【参加費用】 2万2千円(税込み)/人