07 フィルタリング・自由度・P値

Niño3.4 と SOI を題材に、FFT ベースのローパス・ハイパス・バンドパスフィルターを実装し、フィルター後の相関係数、自由度、p 値の意味を考える。

講義名
データサイエンス
使用言語
Python
到達目標
時間スケールを分けて相関を見直し、フィルター後の自由度と p 値を正しく解釈できるようになる

1. 背景:相関はどの時間スケールを見ているのか

前回までに、Niño3.4 海面水温偏差と SOI の間には強い負の相関があることを確認した。たとえば、Niño3.4 が高いときには SOI が低くなる傾向があり、これは El Niño / La Niña に伴う大気海洋相互作用を反映している。

しかし、時系列には ENSO だけでなく、月々の短周期変動、数年スケールの変動、より長い変動が混ざっている。したがって、相関係数を 1 つだけ計算しても、それがどの時間スケールの関係を見ているのかはわからない。

この回では、FFT を使って時系列を周波数領域に移し、特定の周期だけを残すことで、SOI と Niño3.4 の関係を時間スケールごとに考える。
オリジナルとバンドパス後の散布図比較
オリジナルの相関と、ENSO帯域(24–84か月)だけを残した後の相関の比較。
バンドパス後の散布図
バンドパス後の Niño3.4 と SOI。ENSO帯域だけを見ると、非常に強い負の相関が現れる。だからといっていいわけじゃない!

2. 使用データ

使用するデータは、これまでの回で使ったものと同じである。ファイルはこの HTML と同じディレクトリに置く。

ファイル使う列内容
sstoi.indicesANOM34Niño3.4 領域の海面水温偏差
SOI_timeseries_updated.txtSOI南方振動指数
Niño3.4 と SOI はデータ期間が異なる。相関を計算するときは、必ず time で共通期間をそろえる必要がある。

3. 計算の流れ

  1. SOI と Niño3.4 を読み込む
  2. FFT を使って時系列を周波数領域へ変換する
  3. 24か月を境にローパス・ハイパスへ分ける
  4. 24–84か月だけを残すバンドパスフィルターを作る
  5. 逆 FFT により、フィルター後の時系列へ戻す
  6. オリジナルとフィルター後の相関係数を比較する
  7. 自己相関を使って有効自由度を計算する
  8. p 値を計算し、フィルター後の有意性を考える

4. フィルタリングの考え方

ローパス・ハイパス

ローパスフィルターは、ある周期より長い変動だけを残す。ここでは 24か月以上の変動を残す。ハイパスフィルターはその逆で、24か月未満の短い変動を残す。

Low-pass: period ≥ 24 months
High-pass: period < 24 months

バンドパス

ENSO の代表的な時間スケールは、おおよそ 2–7 年である。そこで、24–84か月の周期だけを残すことで、ENSOらしい変動成分を取り出す。

Band-pass: 24 months ≤ period ≤ 84 months

周波数での指定

FFT では、周期ではなく周波数で操作する。周期と周波数は逆数の関係にある。

frequency = 1 / period

したがって、周期 24か月のカットオフは、周波数で見ると 1/24 cycles/month である。

ここでの 24か月や 84か月は、パワースペクトルから自動的に決まった値ではない。ENSO の物理的時間スケールを考えて、人間が決めた値である。スペクトルは「その選択が妥当そうか」を確認するために使う。

5. Pythonでよく出る機能の意味

書き方意味
np.fft.fft(x)時系列を周波数成分へ分解する。
np.fft.fftfreq(n, d=dt)FFT の各成分に対応する周波数を作る。
np.fft.ifft(X)周波数成分から時系列へ戻す。
np.abs(freq)正負両側の周波数を同じように扱う。
mask残す周波数を True、消す周波数を False とする配列。
pd.merge(..., on="time")2つの時系列を共通の時刻でそろえる。
np.corrcoef(x, y)相関係数を計算する。
stats.t.cdf(...)t 分布を使って p 値を計算する。
STEP 1

6. データの読み込み

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats

soi_file = "SOI_timeseries_updated.txt"
nino_file = "sstoi.indices"

cutoff_period_months = ______
band_min_period = ______
band_max_period = ______

# ---- SOI ----
soi_df = pd.read_csv(soi_file, sep=r"\s+")
soi_time = pd.to_datetime(dict(year=soi_df["YEAR"],
                               month=soi_df["MONTH"],
                               day=15))
soi = soi_df["SOI"].values.astype(float)

# ---- Niño3.4 ----
colnames = [
    "YR", "MON",
    "NINO12", "ANOM12",
    "NINO3",  "ANOM3",
    "NINO4",  "ANOM4",
    "NINO34", "ANOM34"
]

nino_df = pd.read_csv(
    nino_file,
    sep=r"\s+",
    skiprows=1,
    names=colnames
)

nino_time = pd.to_datetime(dict(year=nino_df["YR"],
                                month=nino_df["MON"],
                                day=15))
nino34 = nino_df["ANOM34"].values.astype(float)

ポイント

STEP 2

7. ローパス・ハイパスフィルター関数

def fft_filter(x, dt=1.0, cutoff_period=24.0):
    x = np.asarray(x, dtype=float)

    if np.any(~np.isfinite(x)):
        x = pd.Series(x).interpolate(limit_direction="both").values

    n = len(x)

    x_mean = np.mean(x)
    x0 = x - x_mean

    X = np.fft.fft(x0)
    freq = np.fft.fftfreq(n, d=dt)

    fcut = 1.0 / cutoff_period

    low_mask = np.abs(freq) <= ______
    high_mask = np.abs(freq) > ______

    X_low = X * low_mask
    x_low = np.fft.ifft(X_low).real + x_mean

    X_high = X * high_mask
    x_high = np.fft.ifft(X_high).real

    return x_low, x_high, freq, X

コードの中身

ハイパス成分には平均値を戻していない。ハイパスは短周期の変動成分なので、平均 0 付近を振動する形でよい。
STEP 3

8. バンドパスフィルター関数

def fft_bandpass_filter(x, dt=1.0, min_period=24.0, max_period=84.0):
    x = np.asarray(x, dtype=float)

    if np.any(~np.isfinite(x)):
        x = pd.Series(x).interpolate(limit_direction="both").values

    n = len(x)

    x_mean = np.mean(x)
    x0 = x - x_mean

    X = np.fft.fft(x0)
    freq = np.fft.fftfreq(n, d=dt)

    f_low = 1.0 / max_period
    f_high = 1.0 / min_period

    band_mask = (np.abs(freq) >= ______) & (np.abs(freq) <= ______)

    X_band = X * band_mask
    x_band = np.fft.ifft(X_band).real

    return x_band, freq, X, band_mask

コードの中身

確認
周期が長いほど周波数は小さくなる。したがって、24–84か月を残すとき、周波数では 1/84 から 1/24 の範囲を残す。
STEP 4

9. フィルターの実行

soi_low, soi_high, freq_soi, X_soi = fft_filter(
    soi,
    dt=1.0,
    cutoff_period=cutoff_period_months
)

nino_low, nino_high, freq_nino, X_nino = fft_filter(
    nino34,
    dt=1.0,
    cutoff_period=cutoff_period_months
)

soi_band, freq_soi_band, X_soi_band, mask_soi_band = fft_bandpass_filter(
    soi,
    dt=1.0,
    min_period=band_min_period,
    max_period=band_max_period
)

nino_band, freq_nino_band, X_nino_band, mask_nino_band = fft_bandpass_filter(
    nino34,
    dt=1.0,
    min_period=band_min_period,
    max_period=band_max_period
)

ポイント

STEP 5

10. 時系列とスペクトルの描画

ローパス・ハイパスでは、24か月の赤線を境に、低周波成分と高周波成分を分けている。

Niño3.4 low-pass high-pass filtering
Niño3.4 のローパス・ハイパスフィルター。赤線は24か月以上、青線は24か月未満の成分。
SOI low-pass high-pass filtering
SOI のローパス・ハイパスフィルター。スペクトル下段の赤線がカットオフ周期 24か月。
SOI and Nino3.4 band-pass filtering
SOI と Niño3.4 の 24–84か月バンドパス成分。ENSO帯域の変動だけを残している。

描画コードの例

fig, axes = plt.subplots(2, 1, figsize=(14, 8), constrained_layout=True)

axes[0].plot(nino_time, nino34, color="0.65", lw=1.0,
             label="Original Niño3.4 anomaly")
axes[0].plot(nino_time, nino_low, color="red", lw=2.0,
             label=f"Low-pass (period >= {cutoff_period_months:.0f} months)")
axes[0].plot(nino_time, nino_high, color="blue", lw=1.2,
             label=f"High-pass (period < {cutoff_period_months:.0f} months)")
axes[0].axhline(0, color="k", lw=0.8)
axes[0].set_title("Niño3.4: FFT-based low-pass / high-pass filtering")
axes[0].set_ylabel("Niño3.4 anomaly (°C)")
axes[0].legend(loc="upper right")

図の見方

  • 灰色:元データ
  • 赤:24か月以上の低周波成分
  • 青:24か月未満の高周波成分
  • 紫:ENSO帯域である 24–84か月の成分
STEP 6

11. バンドパス後の相関

フィルターをかけた後に相関を計算するには、Niño3.4 と SOI の共通期間をそろえる必要がある。

df_band = pd.DataFrame({
    "time": nino_time,
    "nino": nino_band,
}).merge(
    pd.DataFrame({
        "time": soi_time,
        "soi": soi_band,
    }),
    on="time",
    how="inner"
).dropna()

x = df_band["nino"].values
y = df_band["soi"].values

r = np.corrcoef(x, y)[0, 1]
R2 = r**2

coef = np.polyfit(x, y, 1)
yfit = np.polyval(coef, x)

print(f"r = {r:.2f}")
print(f"R^2 = {R2:.2f}")
print(f"SOI = {coef[0]:.2f} × Niño3.4 + {coef[1]:.2f}")

ポイント

相関が強くなったからといって、データの情報量が増えたわけではない。フィルターにより短周期成分を除いたため、同じ時間スケールの関係が見えやすくなっただけである。
STEP 7

12. 有効自由度と p 値

時系列データでは、隣り合う月の値が互いに独立とは限らない。特にフィルター後の時系列はなめらかになるため、見かけのデータ数よりも独立な情報量は少なくなる。

Neff = N × (1 − r1r2) / (1 + r1r2)
def effective_dof(x, y):
    r1 = np.corrcoef(x[:-1], x[1:])[0, 1]
    r2 = np.corrcoef(y[:-1], y[1:])[0, 1]

    N = len(x)
    Neff = N * (1 - r1 * r2) / (1 + r1 * r2)
    Neff = max(3, Neff)

    return Neff, r1, r2


def corr_significance(r, N):
    t = r * np.sqrt((N - 2) / (1 - r**2))
    p = 2 * (1 - stats.t.cdf(np.abs(t), df=N - 2))
    return t, p

今回の結果例

データNNeffrp解釈
オリジナル519119.3-0.6950.484非常に小さい全周波数を含んでも有意な負相関
バンドパス 24–84か月5197.1-0.9530.9077.7e-04ENSO帯域では非常に強い負相関。ただし自由度は大きく減る
重要
フィルターをかけると、時系列はなめらかになり、lag-1 自己相関が大きくなる。その結果、有効自由度は大きく減る。今回の例では、バンドパス後の相関係数は非常に高いが、Neff は約 7 まで小さくなる。
今回のバンドパス後の相関は、Neff を考慮しても p 値が十分小さい。ただし、Neff が小さい結果を解釈するときは、「見かけの点数は多いが、独立な情報は少ない」ことを必ず意識する。

13. p 値とは何か

ここでの検定では、次の帰無仮説を考える。

H0: 相関係数 r = 0

つまり、「Niño3.4 と SOI の間には相関がない」と仮定する。この仮定のもとで、実際に得られた相関係数以上に大きな値が偶然に得られる確率が p 値である。

たとえば alpha = 0.05 としたとき、p < 0.05 なら「5%水準で有意」と呼ぶことが多い。

重要
p 値は「相関が存在する確率」ではない。また、「p 値が小さいほど相関が強い」という意味でもない。
相関の強さは r を見て判断し、統計的に 0 と区別できるかは p 値 を見て判断する。
見ている内容
相関係数 r2つの変数がどれくらい直線的に関係しているか
決定係数 一方の変動がもう一方でどの程度説明されるか
p 値相関が 0 ではないといえるか
有効自由度 Neff自己相関を考えたときの実質的な独立データ数

14. 議論:フィルターは相関をどう変えるか

オリジナルの Niño3.4 と SOI の相関はすでに高い。しかし、バンドパスにより ENSO帯域だけを取り出すと、相関係数の絶対値はさらに大きくなる。これは、SOI と Niño3.4 が ENSO という同じ現象を異なる側面から見ているためである。

なぜバンドパス後に相関が上がるのか

オリジナルの時系列には、ENSO以外の短周期変動やノイズも含まれる。バンドパスフィルターで 24–84か月の成分だけを残すと、ENSOに関係しやすい時間スケールだけを見ていることになる。そのため、SOI と Niño3.4 の関係がよりはっきり見える。

なぜ自由度が下がるのか

フィルター後の時系列はなめらかになる。なめらかな時系列では、隣り合う月の値がかなり似ている。つまり、点はたくさんあっても、それぞれが独立な情報を持っているわけではない。このため、有効自由度は大きく下がる。

この回の最重要ポイント
フィルターは「信号を抽出する」が、「自由度を減らす」。
したがって、フィルター後の相関を評価するときは、見かけのデータ数ではなく、有効自由度を考える必要がある。

相関が高い=同じ、ではない

SOI は大気の指標であり、Niño3.4 は海洋の指標である。ENSO帯域では非常に強く結びつくが、完全に同じものではない。大気と海洋の応答には位相差、振幅差、非対称性がある。

考えてみよう
  • オリジナルとバンドパス後で、相関係数はどう変わったか。
  • 相関係数が上がったのに、なぜ Neff は小さくなったのか。
  • p 値が小さいことと、物理的に重要であることは同じか。
  • この解析を SST トレンドにも応用するとしたら、どんな注意が必要か。

15. まとめ

この回は、次の ENSO コンポジット解析や SST トレンド解析に向けて、「短期変動」「ENSO帯域」「長期変化」を分けて考えるための準備である。

16. 解答の表示

まずは上の穴埋めコードを自分で考えること。確認したい場合だけ、パスワードを入力して解答と完全スクリプトを表示する。