2つの時系列データの「ずれ」を見つける
― 広告は何日後に売上に効く? クロス相関(CCF)入門(Python)―

2つの時系列データの「ずれ」を見つける  ― 広告は何日後に売上に効く? クロス相関(CCF)入門(Python)―

広告を出した日に、売上はすぐ増えるでしょうか。チラシを見た人が来店するまでには、何日かの「ずれ」がありそうです。では、そのずれは何日なのでしょうか。

この記事では、「広告費」と「売上」という2つの日次の時系列データから、片方がもう片方に何日遅れて効いているかを見つける方法を、Pythonで一歩ずつ確かめます。使う道具はクロス相関(相互相関、CCF)です。

店主がチラシを配っている日と、その3日後にレジに行列ができている日をカレンダーで結んだ図解。店主が「何日後?」と考えている

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

  1. 2つの時系列を「同じ日どうし」で比べても、ずれがあると関係は見えません。今回のデータでは、同じ日の相関はほぼ0でした。
  2. 片方をずらしてから相関をとると、関係が現れます。ずらす日数を変えながら相関を並べたものがクロス相関で、今回は「3日前の広告費」と売上の相関が0.84と突出しました。
  3. クロス相関には2つの落とし穴があります。トレンドのある系列どうしは無関係でも高い相関が出ること(見せかけの相関)と、自己相関のある系列では山がぼやけることです。差分と事前白色化で対処します。

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

  • pandasの shift で時系列をずらし、「k日前の値」との相関を計算できる
  • statsmodelsの ccf の引数の順番と、ラグの向きを正しく読める
  • 見せかけの相関を疑い、差分で確かめられる
  • 見つけたラグを、回帰や予測の説明変数として使える
記事の流れを示すロードマップの図解。同じ日の相関、ずらして相関、ccfで一気に、2つの落とし穴、ラグを使う、の5つの看板が道に並ぶ

先に、この記事で出てくる用語を整理します

いま全部覚える必要はありません。本文で出てきたときに、ここへ戻って確かめてください。

用語 この記事での意味
時系列データ 時間の順に並んだデータ。今回は「日ごとの広告費」と「日ごとの売上」
相関係数 2つの数字がどれだけ一緒に動くかを −1〜1 で表した値。1に近いほど「片方が大きいとき、もう片方も大きい」
ラグ 時間のずれ。「ラグ3」は「3日ずらす」こと
先行・遅行 先に動く系列が「先行」、あとから動く系列が「遅行」。今回は広告費が先行、売上が遅行
クロス相関(CCF) 2つの時系列の片方をずらしながら相関を計算し、ラグごとに並べたもの。相互相関ともいう
自己相関(ACF) 1つの時系列について、「今日の値」と「k日前の値」の相関をラグごとに並べたもの
ホワイトノイズ 自己相関のない、完全にでたらめな系列。今回の広告費はこれに近い
トレンド 長い期間でゆっくり上がる(下がる)動き
定常 平均やばらつきが時間とともに変わらない状態。トレンドのある系列は定常ではない
差分 「今日の値 − 前日の値」。トレンドを消して定常に近づける基本の操作
見せかけの相関 無関係な2系列が、どちらもトレンドを持つために高い相関を示すこと
事前白色化 先行する系列の自己相関を取り除くフィルタを、両方の系列にかけてからCCFを計算する手順
説明変数 回帰で、知りたい値(目的変数)を説明するために使う値。今回は「3日前の広告費」

 

「同じ日」で比べると見えない:時系列データの「ずれ」とは

ふつうの相関分析は、「同じ行どうし」を比べます。身長と体重なら、同じ人の身長と体重を比べるので、それで問題ありません。

ところが時系列データでは、原因と結果の間に時間のずれがあることが珍しくありません。広告を見てから来店するまで、気温が上がってから電力需要が増えるまで、求人を出してから応募が来るまで。どれも「同じ日」ではなく「何日か後」に結果が出ます。

2つの波形のうち片方を右にスライドさせると重なる様子を示した図解。同じ日どうしでは重ならず、3日ずらすと重なる

このとき、同じ日どうしで相関を計算しても、関係は見えません。片方をずらして重ねる必要があります。どれだけずらせばいちばん重なるかを探すのが、クロス相関の考え方です。

 

答えの分かる練習用データを作る

実際のデータでは、本当のずれは誰にも分かりません。そこで、「売上は3日前の広告費で決まる」という正解を仕込んだデータを作り、分析でその3日を言い当てられるかを確かめます。

場面はこうです。ある店が、広告の効果を調べるために、300日間、毎日の広告費をランダムに変える実験をしました。売上は「水準200千円 + 3日前の広告費の0.8倍 + ゆらぎ」で決まります。

 

 広告費を作る

以下、コードです。

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)  # 乱数のシードを42に固定する
n = 300                          # 日数
dates = pd.date_range("2025-01-01", periods=n, freq="D")

# 広告費(千円):毎日ランダムに変えた。先頭に「観測を始める前の3日分」も作っておく
ad_all = rng.normal(50, 10, n + 3).round(1)
ad = ad_all[3:]                  # 観測する300日分(4つ目以降)
print("観測前の3日分:", ad_all[:3])
print("観測する最初の3日分:", ad[:3])

 

以下、実行結果です。

観測前の3日分: [53.  39.6 57.5]
観測する最初の3日分: [59.4 30.5 37. ]

 

rng.normal(50, 10, n + 3) は、平均50・標準偏差10の乱数を303日分作る命令です。観測する300日分より3日多く作っているのは、観測初日の売上に「その3日前の広告費」が必要だからです。先頭の3日分(53.0、39.6、57.5)は観測前の広告費として使い、4つ目(59.4)からが観測初日の広告費です。

 

 売上を作る

以下、コードです。

# 売上(千円)= 水準200 + 0.8 × 3日前の広告費 + ゆらぎ
sales = 200 + 0.8 * ad_all[:-3] + rng.normal(0, 5, n)

df = pd.DataFrame({"ad": ad, "sales": sales.round(1)}, index=dates)
df.index.name = "date"
print(df.head(6))

 

以下、実行結果です。

              ad  sales
date                   
2025-01-01  59.4  240.8
2025-01-02  30.5  231.4
2025-01-03  37.0  240.7
2025-01-04  51.3  245.8
2025-01-05  46.8  230.9
2025-01-06  49.8  232.5

 

ad_all[:-3] は「末尾の3つを除いた広告費」で、各日から見た3日前の広告費に当たります。たとえば1月4日の売上は、1月1日の広告費59.4の0.8倍(47.5)を水準200に足した247.5に、ゆらぎが乗った245.8になっています。

 

 グラフで見る:2つの時系列

まず、2つの系列をそのまま時系列プロットにします。最初の4行は、グラフに日本語を表示するための設定です(以降のグラフにも効きます)。

以下、コードです。

import matplotlib.pyplot as plt

# 日本語を表示するためのフォント(Windows・Mac・Linuxの順に探す)
plt.rcParams["font.sans-serif"] = [
    "Meiryo", "Hiragino Sans", "Noto Sans CJK JP", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False  # マイナス記号の文字化けを防ぐ

axes = df.plot(subplots=True, figsize=(10, 6), legend=False, linewidth=0.8,
               color=["#ff7f0e", "#1f77b4"])
axes[0].set_title("広告費(千円):毎日ランダムに変えた", loc="left")
axes[1].set_title("売上(千円)", loc="left")
axes[1].set_xlabel("")
plt.show()

 

以下、実行結果です。

広告費と売上の日次の時系列プロット(300日分、架空の例)。上段は毎日ランダムに変えた広告費、下段は売上で、どちらもでたらめに上下して見える

 

どちらもでたらめに上下しているように見えます。この2つに関係があるとは、ぱっと見では分かりません。最初の4週間を拡大して、広告費が特に高かった日に印を付けてみます。

以下、コードです。

sub = df["2025-01-01":"2025-01-28"]          # 最初の4週間
top2 = sub["ad"][:-3].nlargest(2).index          # 広告費が特に高かった2日(3日後が期間内のもの)

axes = sub.plot(subplots=True, figsize=(10, 6), legend=False, marker="o",
                color=["#ff7f0e", "#1f77b4"])
for d in top2:
    for ax in axes:
        ax.axvline(d, color="#ff7f0e", linestyle="--", linewidth=1)                       # 広告の日
        ax.axvline(d + pd.Timedelta(days=3), color="#1f77b4", linestyle="--", linewidth=1)  # その3日後
axes[0].set_title("広告費(千円):オレンジの点線は、広告費が特に高かった日", loc="left")
axes[1].set_title("売上(千円):青い点線は、その3日後", loc="left")
axes[1].set_xlabel("")
plt.show()

 

以下、実行結果です。

最初の4週間の広告費と売上を上下に並べた折れ線グラフ。広告費が特に高かった日にオレンジの点線、その3日後に青い点線が引かれ、青い点線の位置で売上が山になっている

 

広告費が高かった日(オレンジの点線)の3日後(青い点線)に、売上の山が来ています。目で見れば分かるこの「3日のずれ」を、数字で取り出すのがこれからの作業です。

 

ずらしてから相関をとる:手作業のクロス相関

まず、ふつうに同じ日どうしの相関を計算してみます。それから、広告費を3日ずらして計算し直します。

以下、コードです。

# 同じ日どうしの相関
print(f"同じ日の相関:{df['sales'].corr(df['ad']):.3f}")

# 広告費を3日ずらしてから相関をとる(shift(3) は「3行下にずらす」)
ad_lag3 = df["ad"].shift(3)
print(ad_lag3.head(5))
print(f"3日前の広告費との相関:{df['sales'].corr(ad_lag3):.3f}")

 

以下、実行結果です。

同じ日の相関:-0.072
date
2025-01-01     NaN
2025-01-02     NaN
2025-01-03     NaN
2025-01-04    59.4
2025-01-05    30.5
Freq: D, Name: ad, dtype: float64
3日前の広告費との相関:0.843

 

同じ日の相関は −0.072 で、ほぼ0です。ところが広告費を3日ずらすと、相関は 0.843 に跳ね上がります。

日付と広告費の表の、広告費の列だけが3行下にずれて、同じ行に3日前の広告費が並ぶ様子を示した図解。先頭の3行は空欄

ずらす操作をしているのが shift(3) です。出力を見ると、1月4日の行に1月1日の広告費(59.4)が入っています。つまり shift(3) は「各行に3日前の値を持ってくる」操作で、先頭の3日は前の値がないので NaN(欠損)になります。corr は NaN の行を自動で除いて計算します。

 

散布図で見比べます。

以下、コードです。

fig, axes = plt.subplots(1, 2, figsize=(10, 4.5))
axes[0].scatter(df["ad"], df["sales"], s=12, color="#1f77b4", alpha=0.6)
axes[0].set_title("同じ日の広告費 と 売上", loc="left")
axes[1].scatter(ad_lag3, df["sales"], s=12, color="#1f77b4", alpha=0.6)
axes[1].set_title("3日前の広告費 と 売上", loc="left")
for ax in axes:
    ax.set_xlabel("広告費(千円)")
    ax.set_ylabel("売上(千円)")
plt.tight_layout()
plt.show()

 

以下、実行結果です。

左は同じ日の広告費と売上の散布図で丸く散らばり、右は3日前の広告費と売上の散布図で右上がりにまとまっている

 

左は丸い雲のように散らばっていて関係が見えません。右は右上がりにまとまっていて、「3日前の広告費が多いほど、今日の売上が多い」ことがはっきり見えます。

 

 ずらす日数を変えて、相関を並べる

3日が正解だと知っているから3日ずらしましたが、実際のデータでは何日ずらせばよいか分かりません。そこで、ずらす日数(ラグ)を0〜6日まで変えて、相関を全部計算します。

以下、コードです。

# ずらす日数(ラグ)を0〜6日まで変えて、相関を計算する
lag_corr = pd.Series(
    {k: df["sales"].corr(df["ad"].shift(k)) for k in range(7)}, name="相関")
lag_corr.index.name = "ラグ(日)"
print(lag_corr.round(3))

 

以下、実行結果です。

ラグ(日)
0   -0.072
1   -0.065
2    0.092
3    0.843
4    0.103
5   -0.048
6   -0.108
Name: 相関, dtype: float64

 

これがクロス相関の正体です。「ラグ k の相関」を k ごとに並べただけで、難しいことは何もしていません。棒グラフにします。

以下、コードです。

ax = lag_corr.plot.bar(color="#1f77b4", figsize=(8, 4), rot=0)
ax.axhline(0, color="#7f7f7f", linewidth=0.8)
ax.set_title("「k日前の広告費」と「売上」の相関(ラグ k を変える)", loc="left")
ax.set_ylabel("相関係数")
plt.show()

 

以下、実行結果です。

ラグ0から6日までの「k日前の広告費」と「売上」の相関を並べた棒グラフ。ラグ3だけが0.84と突出し、他はほぼ0

 

ラグ3だけが 0.84 と突出し、ほかはほぼ0です。「広告費は3日遅れて売上に効く」と、データから言い当てられました。

 

statsmodelsの ccf で一気に計算する

手作業で考え方が分かったので、専用の関数を使います。statsmodelsの ccf は、ラグごとの相関をまとめて返してくれます。

以下、コードです。

from statsmodels.tsa.stattools import ccf

# ccf(x, y)[k] は「x を k 日先にずらしたもの」と「y」の相関
ccf_sales_ad = ccf(df["sales"], df["ad"], adjusted=False, nlags=7)
print("ccf(sales, ad):", ccf_sales_ad.round(3))

 

以下、実行結果です。

ccf(sales, ad): [-0.072 -0.065  0.091  0.832  0.102 -0.047 -0.106]

 

手作業の結果(ラグ3で 0.843)とほぼ同じ値(0.832)が、ラグ0〜6の順に並んでいます。

書き方 意味
ccf(x, y) ラグ k の値は「x を k 日先にずらしたもの」と「y」の相関。つまり ccf(sales, ad)[k] は「k日後の売上」と「今日の広告費」の相関
adjusted=False 相関を計算するときの割り算をデータ数 n で行う(手作業の corr に近い値になる)
nlags=7 ラグ0〜6の7個を返す

手作業の 0.843 と ccf の 0.832 が少し違うのは、計算に使う平均と標準偏差の取り方が違うためです。corr はずらした後に残った297日分で平均と標準偏差を計算し直しますが、ccf は300日全体の平均と標準偏差を使います。データが長ければ、この差は小さくなります。

 

 引数の順番で、ずらす向きが変わる

ccf でいちばん間違えやすいのが、引数の順番です。入れ替えて計算してみます。

以下、コードです。

# 引数を入れ替えると、ずらす向きが逆になる
ccf_ad_sales = ccf(df["ad"], df["sales"], adjusted=False, nlags=7)
print("ccf(ad, sales):", ccf_ad_sales.round(3))

 

以下、実行結果です。

ccf(ad, sales): [-0.072 -0.013  0.031 -0.076  0.08   0.005 -0.014]

 

ccf(sales, ad) の引数の向きを示した図解。時間軸の上に今日の広告費と3日後の売上が矢印で結ばれ、第1引数が「あとから動く側」と示されている

今度は、どのラグも目立った値がありません。ccf(ad, sales)[k] は「k日後の広告費」と「今日の売上」の相関、つまり売上が先で広告費が後という向きを調べています。今回のデータでは売上が広告費に影響することはないので、何も出ないのが正解です。

 

2つを並べて描きます。

以下、コードです。

fig, axes = plt.subplots(1, 2, figsize=(10, 4), sharey=True)
axes[0].bar(range(7), ccf_sales_ad, color="#1f77b4")
axes[0].set_title("ccf(sales, ad):k日後の売上 と 今日の広告費", loc="left", fontsize=10)
axes[1].bar(range(7), ccf_ad_sales, color="#ff7f0e")
axes[1].set_title("ccf(ad, sales):k日後の広告費 と 今日の売上", loc="left", fontsize=10)
for ax in axes:
    ax.axhline(0, color="#7f7f7f", linewidth=0.8)
    ax.set_xlabel("ラグ k(日)")
axes[0].set_ylabel("相関係数")
plt.tight_layout()
plt.show()

 

以下、実行結果です。

左は ccf(sales, ad) でラグ3だけが高い棒グラフ、右は ccf(ad, sales) で目立った値がない棒グラフ

 

覚え方は「第1引数が、あとから動く側」です。「広告費(先)→ 売上(後)」の関係を調べたいなら、ccf(sales, ad) と書きます。

向きに自信が持てないときは、df["sales"].corr(df["ad"].shift(3)) のように shift で直接計算して、ccf の値と一致するかを確かめるのが確実です。ライブラリや言語によってラグの定義が逆のものもあるので、この確認の習慣はどこでも役に立ちます。

 

 両方向を1枚のグラフにまとめる

ccf は正のラグ(第1引数を先にずらす向き)しか返しません。両方向を見るには、引数を入れ替えた結果を負のラグ側として、つなげて1本にします。

以下、コードです。

# 負のラグ(売上が先)と正のラグ(広告が先)をつなげて1本にする
neg = ccf(df["ad"], df["sales"], adjusted=False, nlags=11)[1:][::-1]  # ラグ -10〜-1
pos = ccf(df["sales"], df["ad"], adjusted=False, nlags=11)             # ラグ 0〜10
lags = np.arange(-10, 11)
ccf_full = pd.Series(np.r_[neg, pos], index=lags, name="相関")
ccf_full.index.name = "ラグ(日)"

bound = 2 / np.sqrt(n)  # 「相関なし」とみなす目安の幅
print(f"目安の幅:±{bound:.3f}")
print(ccf_full[ccf_full.abs() > bound].round(3))

 

以下、実行結果です。

目安の幅:±0.115
ラグ(日)
3    0.832
Name: 相関, dtype: float64

 

2 / np.sqrt(n) は、「本当は相関がないとき、偶然の相関はだいたいこの幅に収まる」という目安です。n=300なら ±0.115 です。この幅を超えているのはラグ3だけでした。

以下、コードです。

fig, ax = plt.subplots(figsize=(10, 4))
ax.bar(lags, ccf_full, color=np.where(lags >= 0, "#1f77b4", "#ff7f0e"))
ax.axhline(bound, color="#d62728", linestyle="--", linewidth=1)
ax.axhline(-bound, color="#d62728", linestyle="--", linewidth=1)
ax.axhline(0, color="#7f7f7f", linewidth=0.8)
ax.set_xticks(lags)
ax.set_title("クロス相関(左:売上が先に動く/右:広告費が先に動く)", loc="left")
ax.set_xlabel("ラグ(日)")
ax.set_ylabel("相関係数")
plt.show()

 

以下、実行結果です。

ラグ−10から10までのクロス相関の棒グラフ。負のラグはオレンジ、正のラグは青で、赤い点線の目安の幅を超えているのはラグ3の1本だけ

 

これがクロスコレログラムと呼ばれるグラフです。右側(正のラグ)は「広告費が先に動く」向き、左側(負のラグ)は「売上が先に動く」向きです。赤い点線が目安の幅で、はみ出しているのはラグ3の1本だけ。「広告費が3日先行して売上に効く」と読めます。

 

落とし穴1:トレンドのある2系列は「見せかけの相関」を生む

どちらも右肩上がりの2本の折れ線を見て人物が「関係ある!」と喜んでいるが、背後に「時間の経過」という共通の原因が隠れている図解

クロス相関には、知らないと必ずはまる落とし穴があります。どちらも右肩上がり(または右肩下がり)の系列は、まったく無関係でも高い相関が出るのです。無関係な2つの系列を作って確かめます。

以下、コードです。

# 無関係な2つの系列。どちらも右肩上がりのトレンドだけを持つ
t = np.arange(n)
trend = pd.DataFrame({
    "自店の来客数": 100 + 0.30 * t + rng.normal(0, 8, n),
    "別の店の売上": 300 + 0.25 * t + rng.normal(0, 8, n),
}, index=dates)

print(f"同じ日の相関:{trend.corr().iloc[0, 1]:.3f}")
ccf_xz = ccf(trend["自店の来客数"], trend["別の店の売上"], adjusted=False, nlags=11)
print("ccf:", ccf_xz.round(2))

 

以下、実行結果です。

同じ日の相関:0.898
ccf: [0.9  0.88 0.88 0.87 0.87 0.85 0.85 0.84 0.82 0.8  0.8 ]

 

自店の来客数と、遠く離れた別の店の売上。お互いに何の影響もない2つの系列ですが、同じ日の相関は 0.898 で、クロス相関もすべてのラグで 0.8 を超えています。

以下、コードです。

fig, axes = plt.subplots(1, 2, figsize=(10, 4))
trend.plot(ax=axes[0], color=["#1f77b4", "#7f7f7f"], linewidth=0.8)
axes[0].set_title("どちらも右肩上がりの、無関係な2系列", loc="left", fontsize=10)
axes[0].set_xlabel("")
axes[1].bar(range(11), ccf_xz, color="#1f77b4")
axes[1].axhline(bound, color="#d62728", linestyle="--", linewidth=1)
axes[1].set_title("そのままのCCF:全部のラグで高い(見せかけ)", loc="left", fontsize=10)
axes[1].set_xlabel("ラグ(日)")
plt.tight_layout()
plt.show()

 

以下、実行結果です。

左はどちらも右肩上がりの無関係な2系列の時系列プロット、右はそのクロス相関で全ラグが0.8以上

 

なぜこうなるかというと、「時間が経つほど両方とも増える」という共通のトレンドがあるからです。相関係数は「片方が大きいとき、もう片方も大きいか」しか見ていないので、どちらも後半で大きければ高い値になります。これを見せかけの相関と呼びます。

 

 差分をとって、トレンドを消す

対処は「前日との差」をとることです。これを差分といいます。右肩上がりの系列も、前日との差をとれば「毎日だいたい同じくらい増える」という平らな系列になります。

以下、コードです。

# 前日との差(差分)をとって、トレンドを消す
trend_diff = trend.diff().dropna()  # 1行目は前日がないので除く

print(f"差分後の同じ日の相関:{trend_diff.corr().iloc[0, 1]:.3f}")
ccf_diff = ccf(trend_diff["自店の来客数"], trend_diff["別の店の売上"], adjusted=False, nlags=11)
print("差分後の ccf:", ccf_diff.round(2))

 

以下、実行結果です。

差分後の同じ日の相関:0.089
差分後の ccf: [ 0.09 -0.11  0.1  -0.06  0.05 -0.04  0.01  0.07 -0.02 -0.09  0.07]

 

差分をとると、同じ日の相関は 0.089、クロス相関もすべて目安の幅(±0.115)の中に収まりました。無関係だという本当の姿が見えています。

以下、コードです。

fig, axes = plt.subplots(1, 2, figsize=(10, 4))
trend_diff.plot(ax=axes[0], color=["#1f77b4", "#7f7f7f"], linewidth=0.8)
axes[0].set_title("差分をとるとトレンドが消える", loc="left", fontsize=10)
axes[0].set_xlabel("")
axes[1].bar(range(11), ccf_diff, color="#1f77b4")
axes[1].axhline(bound, color="#d62728", linestyle="--", linewidth=1)
axes[1].axhline(-bound, color="#d62728", linestyle="--", linewidth=1)
axes[1].set_title("差分後のCCF:目安の幅に収まる(関係なし)", loc="left", fontsize=10)
axes[1].set_xlabel("ラグ(日)")
plt.tight_layout()
plt.show()

 

以下、実行結果です。

左は差分をとって平らになった2系列、右はそのクロス相関で全ラグが赤い点線の目安の幅の中に収まっている

 

クロス相関を計算する前に、まず2つの系列の時系列プロットを見て、トレンドがあれば差分をとる。これが基本の手順です。

ここまでの「広告費と売上」のデータでは差分をとりませんでした。広告費を毎日ランダムに変えていて、どちらの系列にもトレンドがなかったからです。実際のデータでは、まずプロットを見てトレンドの有無を確かめてください。

 

落とし穴2:自己相関のある系列は山がぼやける

もうひとつの落とし穴は、先に動く側の系列に自己相関があるときです。実際の広告費は、毎日ランダムに変えるのではなく、「キャンペーン期間は連日多く、それ以外は少ない」のように、前日と似た値が続くのがふつうです。そのような広告費を作ってみます。

以下、コードです。

# 広告費を「前日の8割を引き継ぐ」形にする(自己相関のある系列)
ad2_all = np.zeros(n + 3)
e = rng.normal(0, 6, n + 3)
ad2_all[0] = 50
for i in range(1, n + 3):
    ad2_all[i] = 50 + 0.8 * (ad2_all[i - 1] - 50) + e[i]

ad2 = ad2_all[3:]
sales2 = 200 + 0.8 * ad2_all[:-3] + rng.normal(0, 5, n)  # 効くのは3日前、前と同じ
df2 = pd.DataFrame({"ad": ad2.round(1), "sales": sales2.round(1)}, index=dates)
print(df2.head(6))

 

以下、実行結果です。

              ad  sales
date                   
2025-01-01  40.0  229.5
2025-01-02  36.7  236.7
2025-01-03  53.1  244.3
2025-01-04  54.2  232.7
2025-01-05  58.7  229.7
2025-01-06  54.0  245.2

 

ad2_all[i] = 50 + 0.8 * (ad2_all[i - 1] - 50) + e[i] は、「今日の広告費は、前日が平均50からずれていた分の8割を引き継ぎ、さらにゆらぎが乗る」という式です。売上は前と同じく、3日前の広告費の0.8倍で決まります。

以下、コードです。

from statsmodels.graphics.tsaplots import plot_acf

fig, axes = plt.subplots(1, 2, figsize=(10, 4))
df2["ad"][:60].plot(ax=axes[0], color="#ff7f0e", marker="o", markersize=3)
axes[0].set_title("広告費(最初の60日):高い日が続き、低い日が続く", loc="left", fontsize=10)
axes[0].set_ylabel("広告費(千円)")
axes[0].set_xlabel("")
plot_acf(df2["ad"], lags=10, ax=axes[1], title="", color="#ff7f0e",
         vlines_kwargs={"colors": "#ff7f0e"})
axes[1].set_title("広告費の自己相関(ACF)", loc="left", fontsize=10)
axes[1].set_xlabel("ラグ(日)")
plt.tight_layout()
plt.show()

 

以下、実行結果です。

左は自己相関のある広告費の最初の60日の折れ線で高い日と低い日がそれぞれ続く、右はその自己相関(ACF)で数日にわたり相関が残っている

 

左のグラフでは、高い日が何日か続き、低い日が何日か続いています。右は広告費の自己相関(ACF)で、1日前との相関が0.82、2日前とも0.68と、数日にわたって相関が残っています。この広告費と売上のクロス相関を計算します。

以下、コードです。

ccf2 = ccf(df2["sales"], df2["ad"], adjusted=False, nlags=11)
print("ccf(sales, ad):", ccf2.round(2))

 

以下、実行結果です。

ccf(sales, ad): [0.46 0.57 0.7  0.86 0.7  0.59 0.47 0.37 0.27 0.21 0.11]

 

以下、コードです。

fig, ax = plt.subplots(figsize=(8, 4))
ax.bar(range(11), ccf2, color="#1f77b4")
ax.axhline(bound, color="#d62728", linestyle="--", linewidth=1)
ax.axhline(0, color="#7f7f7f", linewidth=0.8)
ax.set_xticks(range(11))
ax.set_title("自己相関のある広告費:ラグ3以外も大きく、山がぼやける", loc="left")
ax.set_xlabel("ラグ(日)")
ax.set_ylabel("相関係数")
plt.show()

 

以下、実行結果です。

自己相関のある広告費と売上のクロス相関の棒グラフ。ラグ3が最も高いが、ラグ0から10のすべてが目安の幅を超えている

 

ラグ3がいちばん高い(0.86)のは変わりませんが、ラグ0〜10のすべてが目安の幅を超えています。「3日遅れ」ではなく「0〜10日遅れのどれでも効いている」ように見えてしまいます。

ぼやけた広い山と、くっきりした細い山を並べた図解。左は自己相関のある系列のクロス相関、右は事前白色化のあと

理由はこうです。3日前の広告費は、2日前や4日前の広告費とも似ています(自己相関があるから)。売上は3日前の広告費で決まるので、それと似ている2日前や4日前の広告費とも、間接的に相関してしまうのです。

 

 事前白色化:先に動く側の自己相関を取り除く

対処は、「前日から引き継いでいる分」を両方の系列から差し引いてからクロス相関を計算することです。この手順を事前白色化(プレホワイトニング)と呼びます。

以下、コードです。

from statsmodels.tsa.ar_model import AutoReg

# 1. 先に動く側(広告費)に「前日からどれだけ引き継ぐか」のモデルを当てはめる
phi = AutoReg(df2["ad"], lags=1).fit().params.iloc[1]
print(f"前日を引き継ぐ割合:{phi:.3f}")

# 2. 両方の系列から「前日の分」を同じ割合で引く(事前白色化)
ad_w = (df2["ad"] - phi * df2["ad"].shift(1)).dropna()
sales_w = (df2["sales"] - phi * df2["sales"].shift(1)).dropna()

ccf2_w = ccf(sales_w, ad_w, adjusted=False, nlags=11)
print("白色化後の ccf(sales, ad):", ccf2_w.round(2))

 

以下、実行結果です。

前日を引き継ぐ割合:0.819
白色化後の ccf(sales, ad): [-0.04  0.02 -0.02  0.61 -0.05  0.06  0.    0.03 -0.06  0.1  -0.04]

 

手順は2つです。

  1. 先に動く側(広告費)に AutoReg(…, lags=1) を当てはめて、「前日をどれだけ引き継いでいるか」の割合を推定します。0.819 と、仕込んだ0.8に近い値が出ました。
  2. 広告費と売上の両方から、「前日の値 × 0.819」を引きます。広告費からは自己相関が消え(白色化)、売上にも同じ操作をするので、2つの系列の間のラグの関係はそのまま残ります。

以下、コードです。

fig, ax = plt.subplots(figsize=(8, 4))
ax.bar(range(11), ccf2_w, color="#1f77b4")
ax.axhline(bound, color="#d62728", linestyle="--", linewidth=1)
ax.axhline(0, color="#7f7f7f", linewidth=0.8)
ax.set_xticks(range(11))
ax.set_title("事前白色化のあと:ラグ3だけがはっきり残る", loc="left")
ax.set_xlabel("ラグ(日)")
ax.set_ylabel("相関係数")
plt.show()

 

以下、実行結果です。

事前白色化のあとのクロス相関の棒グラフ。ラグ3だけが0.61で残り、他は目安の幅の中に収まっている

 

ラグ3だけが 0.61 で残り、ほかは目安の幅の中に収まりました。山がくっきりして、「3日遅れ」と読み取れます。

事前白色化の後は、相関の値そのもの(0.61)は元の 0.86 より小さくなります。これは自己相関によって水増しされていた分が除かれたためで、「どのラグに関係があるか」を読むための手順だと考えてください。

 

見つけたラグを使う:3日前の広告費で売上を説明する

カレンダーの上で、今日の広告費から3日後の売上へ矢印が伸び、人物がその先の売上を予測している図解

クロス相関で「3日」が分かったら、それを回帰の説明変数にできます。最初の「広告費と売上」のデータに戻って、「3日前の広告費」の列を作り、売上を回帰します。

以下、コードです。

import statsmodels.formula.api as smf

df["ad_lag3"] = df["ad"].shift(3)  # 3日前の広告費を列として持つ
res = smf.ols("sales ~ ad_lag3", data=df.dropna()).fit()
print(res.params.round(3))
print(f"決定係数 R2:{res.rsquared:.3f}")

 

以下、実行結果です。

Intercept    197.623
ad_lag3        0.847
dtype: float64
決定係数 R2:0.710

 

係数は 0.847、切片は 197.6 です。仕込んだ正解(0.8倍、水準200)をよく再現しています。決定係数 0.71 は、売上の変動の約7割を「3日前の広告費」だけで説明できた、という意味です。

以下、コードです。

fig, ax = plt.subplots(figsize=(8, 4.5))
ax.scatter(df["ad_lag3"], df["sales"], s=12, color="#1f77b4", alpha=0.6, label="実際の値")
xs = np.linspace(20, 80, 2)
a, b = res.params["Intercept"], res.params["ad_lag3"]
ax.plot(xs, a + b * xs, color="#d62728", label=f"回帰直線:売上 = {a:.1f} + {b:.2f} × 3日前の広告費")
ax.set_xlabel("3日前の広告費(千円)")
ax.set_ylabel("売上(千円)")
ax.set_title("見つけたラグを回帰に使う", loc="left")
ax.legend(loc="upper left")
plt.show()

 

以下、実行結果です。

横軸に3日前の広告費、縦軸に売上をとった散布図と回帰直線。直線の式は売上=197.6+0.85×3日前の広告費

 

この式があれば、「今日の広告費を1千円増やすと、3日後の売上が約0.85千円増える」と見積もれますし、今日までの広告費から3日先までの売上を予測できます。クロス相関は、この「何日前の値を説明変数にするか」を決めるための道具です。

 

実務で使うときの注意点

実務の注意点を4コマで示した図解。相関は因果ではない、先にプロットして定常化、向きはshiftで確認、ラグの単位

① 相関は因果ではない
クロス相関で「広告費が3日先行する」と分かっても、それは「3日前の広告費と売上が一緒に動く」ことを示しているだけです。今回のデータでは広告費をランダムに変えた(実験した)ので因果と言えますが、実データでは「月末にセールと広告を同時にやる」のような第3の要因が、両方を動かしていることがあります。

② 先にプロットを見て、定常化する
落とし穴1で見たとおり、トレンドのある系列は見せかけの相関を生みます。必ず時系列プロットを見て、トレンドや季節性があれば差分や季節調整で取り除いてから計算します。

③ 向きは shift で確かめる
ccf(x, y) の「第1引数があとから動く側」という約束は、ライブラリによって逆のことがあります。見つけたラグを使う前に、shift で直接計算して一致を確かめてください。

④ ラグの単位と、データの間隔
日次データのラグ3は「3日」ですが、週次データなら「3週」です。また、休業日を飛ばして並べたデータでは、ラグ1が「翌営業日」の意味になります。カレンダー上の日数と、データ上のラグが一致しているかを確認してください。

 

よくある質問

Q. ラグの範囲(nlags)はどう決めればよいですか?

「業務上ありえる最大のずれ」より少し長めにします。

広告の効果なら2〜3週間、気温と電力需要なら数日、といった具合です。

長くしすぎると、偶然に目安の幅を超えるラグが増えるので、見つけたラグが業務上説明できるかをあわせて考えてください。

Q. 目安の幅 \pm2/\sqrt{n} を超えたら「関係あり」と言い切ってよいですか?

言い切れません。

この幅は「無関係なら95%くらいはこの中に収まる」という目安なので、20個のラグを見れば1個くらいは偶然はみ出します。

はっきり突出しているか、業務上の理由を説明できるか、を合わせて判断します。

きちんと検定したいときは、発展で触れるグレンジャー因果検定を使います。

Q. 効果が1日だけでなく、2〜4日にまたがって出る場合は?

クロス相関では、ラグ2〜4がまとまって高くなります。

その場合は、回帰の説明変数に「2日前」「3日前」「4日前」の広告費を3つとも入れる(分布ラグモデル)のが基本です。

ただし落とし穴2の自己相関による見かけの広がりと区別するために、事前白色化をしてから判断してください。

 

まとめ

今回のポイントです。

  • 時系列データでは、原因と結果の間に時間のずれ(ラグ)があることが多い。同じ日どうしの相関では見えない(今回は −0.07)
  • 片方を shift でずらして相関をとり、ずらす日数ごとに並べたものがクロス相関。今回はラグ3だけが 0.84 と突出した
  • statsmodelsの ccf(x, y) は「第1引数があとから動く側」。向きは shift で確かめる
  • 落とし穴1:トレンドのある2系列は無関係でも高い相関になる(見せかけの相関、今回は 0.90)。差分をとってから計算する
  • 落とし穴2:先に動く側に自己相関があると山がぼやける。事前白色化でラグ3だけが残った
  • 見つけたラグは回帰の説明変数になる。「3日前の広告費」で売上の約7割を説明できた
記事の流れを示すロードマップの図解(再掲)

 

発展:クロス相関の先にあるもの

クロス相関は「どのラグに関係がありそうか」を探すための道具です。見つけた関係を確かめたり、モデルに組み込んだりするには、次のような手法に進みます。

クロス相関を入口に、グレンジャー因果検定、分布ラグモデル・SARIMAX、VARモデルへ分かれる道を示した図解
手法 役割 クロス相関との関係
クロス相関(今回) 2系列のラグごとの相関を見る 探索の入口。どのラグが怪しいかを見つける
グレンジャー因果検定 「xの過去の値が、yの予測に役立つか」を検定する クロス相関で見つけたラグの関係を、統計的に確かめる
分布ラグモデル・SARIMAX ラグ付きの説明変数(外生変数)を入れて予測する 見つけたラグを説明変数として使う。今回の回帰を発展させた形
VARモデル 複数の系列が互いに影響し合う関係をまとめてモデル化する 向きが一方向でなく、双方向に影響するときに使う

関連記事です。

Python 時系列分析 1,000本ノック – ノック104: クロス相関 –(ccf の引数の向きを問うクイズ)

時系列データの自己相関と相互相関をPythonで求めてみよう

Pythonで時系列解析・超入門(その1) 時系列データに対する3つの特徴把握方法(変動成分・定常性・コレログラム)

Python 時系列分析 1,000本ノック – ノック22: 時系列データの差分処理 –

Python 時系列分析 1,000本ノック – ノック40: VARモデルの推定 –

Screenshot

【月1 特定テーマ講座(12月)】
需要予測エージェントを作る(需要予測 × PYTHON × 生成AI)

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