自己相関・相互相関の理論とPython実装

numpy.correlate・scipy.signal.correlate・scipy.signal.correlation_lags・statsmodels.tsa.acf で自己相関関数(ACF)と相互相関関数(CCF)を実装。FFTによる高速計算・周期性検出・遅延推定・音声ピッチ推定・plot_acf/plot_pacf による可視化・白色性検定までPython実装で体系的に解説。

はじめに

「この信号にはどんな周期性が隠れているか?」「2つのセンサーの信号はどれだけ遅延しているか?」—こうした問いに答える基本的なツールが相関関数です。

**自己相関関数(ACF: Autocorrelation Function)**は信号とそれ自身の遅延コピーの類似度を測り、信号の周期性・繰り返し構造を明らかにします。一方、**相互相関関数(CCF: Cross-Correlation Function)**は2つの異なる信号間の類似度と遅延を定量化します。これらは信号処理・統計・機械学習の多くの分野で基礎的な役割を果たしています。

本記事では自己相関・相互相関の数学的定義から、FFTを用いた高速計算法、音声ピッチ推定・遅延推定・時系列分析への実践的な応用まで、Pythonで体系的に解説します。

自己相関関数(ACF)の定義と性質

連続時間の自己相関

連続時間信号 \(x(t)\) のエネルギー有限信号(決定論的信号)の自己相関関数は以下のように定義されます。

\[R_{xx}(\tau) = \int_{-\infty}^{\infty} x(t) \cdot x(t + \tau) \, dt \tag{1}\]

確率過程(確率的信号)の場合は期待値で定義されます。

\[R_{xx}(\tau) = E[x(t) \cdot x(t + \tau)] \tag{2}\]

ここで \(\tau\) は**ラグ(遅延)**と呼ばれるパラメータです。

離散時間の自己相関

長さ \(N\) の離散時間信号 \(x[n]\) の自己相関は次のように定義されます。

\[R_{xx}[l] = \sum_{n=-\infty}^{\infty} x[n] \cdot x[n + l] \tag{3}\]

実用的には正規化自己相関(最大値を1に正規化)を使うことが多く、これにより信号のスケールに依存しない類似度指標が得られます。

\[\rho_{xx}[l] = \frac{R_{xx}[l]}{R_{xx}[0]} \tag{4}\]

\(R_{xx}[0] = \sum_n x[n]^2\) は信号のエネルギー(または分散に比例)であり、常に正です。

ACFの重要な性質

偶関数性(対称性)

\[R_{xx}[-l] = R_{xx}[l] \tag{5}\]

これはラグの符号を反転しても値が変わらないことを意味します。

最大値性

\[|R_{xx}[l]| \leq R_{xx}[0] \quad \text{for all } l \tag{6}\]

正規化すると \(|\rho_{xx}[l]| \leq 1\) となります。

周期信号との関係:信号 \(x[n]\) が周期 \(T\) を持つ場合、ACFも同じ周期 \(T\) を持ちます。

\[x[n + T] = x[n] \Rightarrow R_{xx}[l + T] = R_{xx}[l] \tag{7}\]

ウィーナー・ヒンチン定理:自己相関関数のフーリエ変換は**パワースペクトル密度(PSD)**に等しいです。

\[S_{xx}(f) = \mathcal{F}\{R_{xx}[\tau]\}(f) \tag{8}\]

これは自己相関とパワースペクトルが同等の情報を異なる表現で持つことを示す重要な定理です。

相互相関関数(CCF)の定義

2つの信号 \(x[n]\) と \(y[n]\) の相互相関関数は以下のように定義されます。

\[R_{xy}[l] = \sum_{n=-\infty}^{\infty} x[n] \cdot y[n + l] \tag{9}\]

\(R_{xy}[l]\) が最大となるラグ \(l^*\) が、\(y\) が \(x\) より進んでいる(または遅れている)サンプル数を示します。

\[l^* = \arg\max_l R_{xy}[l] \tag{10}\]

相互相関は一般に対称でないことに注意が必要です。

\[R_{xy}[l] \neq R_{xy}[-l] \quad \text{(一般に)} \tag{11}\]

ただし以下の共役対称性があります。

\[R_{xy}[-l] = R_{yx}[l] \tag{12}\]

正規化相互相関係数は以下のように定義されます。

\[\rho_{xy}[l] = \frac{R_{xy}[l]}{\sqrt{R_{xx}[0] \cdot R_{yy}[0]}} \tag{13}\]

\(|\rho_{xy}[l]| \leq 1\) が保証され、1に近いほど2つの信号の類似度が高いことを示します。

FFTを用いた高速相関計算

直接計算による自己相関の計算量は \(O(N^2)\) ですが、FFTの畳み込み定理を利用することで \(O(N \log N)\) に削減できます。

時間領域の相関は周波数領域では複素共役の積に対応します。

\[R_{xy}[l] = \mathcal{F}^{-1}\{X^*(f) \cdot Y(f)\} \tag{14}\]

ここで \(X^*(f)\) は \(X(f)\) の複素共役です。自己相関の場合は \(Y = X\) なので:

\[R_{xx}[l] = \mathcal{F}^{-1}\{|X(f)|^2\} \tag{15}\]

\(|X(f)|^2\) はまさにパワースペクトルであり、ウィーナー・ヒンチン定理と一致します。

Python実装

基本的な自己相関・相互相関の計算

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import correlate, correlation_lags

# --- テスト信号の生成 ---
np.random.seed(42)
fs = 1000      # サンプリング周波数 [Hz]
T = 2.0        # 信号長 [s]
t = np.arange(0, T, 1/fs)

# 信号1: 50Hzの正弦波 + ノイズ
x = np.sin(2 * np.pi * 50 * t) + 0.3 * np.random.randn(len(t))

# 信号2: xを100サンプル遅延させたもの + 異なるノイズ
delay_samples = 100
y = np.zeros_like(x)
y[delay_samples:] = x[:-delay_samples] + 0.3 * np.random.randn(len(t) - delay_samples)

# --- 自己相関 ---
def autocorr_fft(x):
    """FFTを用いた高速自己相関計算(正規化あり)"""
    N = len(x)
    # ゼロパディングして循環誤差を防ぐ
    X = np.fft.fft(x, n=2*N)
    # パワースペクトル → 逆FFT
    acf = np.fft.ifft(X * np.conj(X)).real
    # 正の遅延部分のみ取得し、正規化
    acf = acf[:N]
    acf /= acf[0]  # 正規化
    return acf

# --- 相互相関 ---
# scipy.signal.correlate は完全な相関を計算
corr_xy = correlate(x, y, mode='full')
lags = correlation_lags(len(x), len(y), mode='full')

# 正規化
norm = np.sqrt(np.sum(x**2) * np.sum(y**2))
corr_xy_normalized = corr_xy / norm

# --- プロット ---
fig, axes = plt.subplots(3, 1, figsize=(12, 10))

# 元の信号
axes[0].plot(t[:500], x[:500], label='x(t)', alpha=0.8)
axes[0].plot(t[:500], y[:500], label=f'y(t) = x(t - {delay_samples/fs*1000:.0f}ms)',
             alpha=0.8)
axes[0].set_xlabel('Time [s]')
axes[0].set_ylabel('Amplitude')
axes[0].set_title('Input Signals')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# 自己相関
acf = autocorr_fft(x)
lag_times = np.arange(len(acf)) / fs * 1000  # ms単位
axes[1].plot(lag_times, acf)
axes[1].axhline(y=0, color='k', linestyle='--', linewidth=0.8)
axes[1].set_xlabel('Lag [ms]')
axes[1].set_ylabel('Normalized ACF')
axes[1].set_xlim(0, 100)
axes[1].grid(True, alpha=0.3)
# 最初のピーク検出(ラグ0を除く)
first_peak_idx = np.argmax(acf[10:]) + 10
axes[1].set_title(f'Autocorrelation of x(t): argmax peak at '
                   f'{first_peak_idx/fs*1000:.0f} ms → {fs/first_peak_idx:.0f} Hz')
print(f"ACF最初のピーク: lag={first_peak_idx/fs*1000:.1f} ms → "
      f"周波数 = {fs/first_peak_idx:.1f} Hz")

# 相互相関
axes[2].plot(lags / fs * 1000, corr_xy_normalized)
axes[2].axhline(y=0, color='k', linestyle='--', linewidth=0.8)
axes[2].set_xlabel('Lag [ms]')
axes[2].set_ylabel('Normalized CCF')
axes[2].set_xlim(-300, 300)
axes[2].grid(True, alpha=0.3)

# 最大相関のラグを表示
peak_lag = lags[np.argmax(corr_xy_normalized)]
axes[2].set_title(f'Cross-Correlation (scipy raw): peak at lag = {peak_lag/fs*1000:.0f} ms')
print(f"相互相関ピーク: lag={peak_lag/fs*1000:.1f} ms → "
      f"遅延 = {peak_lag} サンプル")

plt.tight_layout()
plt.show()

実行結果

ACF最初のピーク: lag=40.0 ms → 周波数 = 25.0 Hz
相互相関ピーク: lag=-100.0 ms → 遅延 = -100 サンプル

一見すると奇妙な結果です。信号 x は 50 Hz の正弦波なので周期は 20 ms のはずですが、検出されたピークは 40 ms(25 Hz)でした。また yx を 100 サンプル遅延させて作ったのに、相互相関のピークラグは -100 サンプルと符号が逆になっています。これは実装のバグではなく、ノイズの影響scipy の相関の符号規約という2つの重要な落とし穴を示しています。

落とし穴1: 単純な argmax はノイズ下で高調波ラグに飛びつく

acf[10:] の中で最大値を探す単純な argmax は、真の基本周期(lag=20 ms)と全く無関係にラグ 40 ms を選んでしまいました。実際に該当区間のACF値を調べると、

acf[20] = 0.8309  (真の基本周期のピーク)
acf[40] = 0.8315  (2周期目のピーク。ノイズによりわずかに上回る)

とほぼ同値(差はわずか 0.0006)であり、np.random.seed(42) のノイズがたまたま2周期目をわずかに持ち上げたため、大域的な argmax が誤って2倍周期(半分の周波数)を選んでしまったのです。周期性検出やピッチ推定を大域探索の argmax だけで実装すると、ノイズや倍音構造によって基本周波数の整数分の1(オクターブ下)を誤検出する典型的な失敗パターンに陥ります。次節の detect_periodicity 関数がゼロクロスで探索区間を制限しているのは、まさにこの問題を避けるためです。

落とし穴2: scipy.signal.correlate(x, y) は \(R_{xy}\) ではなく \(R_{yx}\) を返す

相互相関のピークラグが -100 と負になったのも、実装ミスではなく式(12)の対称性、すなわち

\[ R_{xy}[-l] = R_{yx}[l] \]

そのものです。scipy.signal.correlate(in1, in2, mode='full') は定義上 \(\sum_n \text{in2}[n] \cdot \text{in1}[n+l]\) 、すなわち本記事の記法では correlate(x, y) が \(R_{xy}[l]\) ではなく \(R_{yx}[l]\) を計算します。yx より 100 サンプル遅れているとき、\(R_{xy}[l]\) は \(l=+100\) でピークを持ちますが(式(9)、(10)より)、上式の対称性からその符号反転である \(l=-100\) で \(R_{yx}[l]\) がピークを持ちます。これが実行結果の -100 の正体です。「どちらの信号が先行しているか」を relative delay の符号から結論づける際は、使っているライブラリ関数がどちらの変数を基準にした相関を返すのか実データで検証することが不可欠です。

ACF/CCFの実行結果に現れる2つの落とし穴:ノイズによる倍周期の誤検出とscipy相関の符号規約

ACFによる周期性検出

import numpy as np
import matplotlib.pyplot as plt

def detect_periodicity(signal, fs, max_lag_sec=None):
    """
    ACFを用いた周期性検出。

    Parameters
    ----------
    signal : np.ndarray
        入力信号
    fs : float
        サンプリング周波数 [Hz]
    max_lag_sec : float, optional
        解析する最大ラグ [s]。省略時は信号長の半分。

    Returns
    -------
    period_sec : float or None
        検出された周期 [s](検出されない場合は None)
    acf : np.ndarray
        正規化ACF
    lags_sec : np.ndarray
        ラグ [s]
    """
    N = len(signal)
    max_lag = int(max_lag_sec * fs) if max_lag_sec else N // 2

    # FFTベースのACF
    X = np.fft.fft(signal, n=2*N)
    acf = np.fft.ifft(X * np.conj(X)).real[:N]
    acf = acf[:max_lag]
    acf_norm = acf / acf[0]
    lags_sec = np.arange(max_lag) / fs

    # 最初のピーク(1周期分先の極大)を検出
    # ACFはラグ0から減衰して zero_cross[0] で最初にゼロを切り、
    # その後 zero_cross[1] で再び正に戻る(この区間 [0]-[1] は「谷」であり
    # ピークではない点に注意)。真の周期に対応する最初のピークは
    # 谷を抜けた後の区間 zero_cross[1]-zero_cross[2] に現れる。
    zero_cross = np.where(np.diff(np.sign(acf_norm)))[0]
    if len(zero_cross) < 3:
        return None, acf_norm, lags_sec

    search_start = zero_cross[1] + 1
    search_end = zero_cross[2]
    peak_idx = search_start + np.argmax(acf_norm[search_start:search_end])
    period_sec = lags_sec[peak_idx]

    return period_sec, acf_norm, lags_sec


# --- 複数の信号で周期性を比較 ---
fs = 1000
t = np.arange(0, 1.0, 1/fs)
np.random.seed(0)

signals = {
    '50 Hz 正弦波': np.sin(2 * np.pi * 50 * t) + 0.2 * np.random.randn(len(t)),
    '複合正弦波\n(50 + 130 Hz)': (np.sin(2 * np.pi * 50 * t)
                                   + 0.5 * np.sin(2 * np.pi * 130 * t)
                                   + 0.1 * np.random.randn(len(t))),
    'ホワイトノイズ': np.random.randn(len(t)),
}

fig, axes = plt.subplots(len(signals), 2, figsize=(14, 10))

for i, (name, sig) in enumerate(signals.items()):
    period, acf, lags = detect_periodicity(sig, fs, max_lag_sec=0.15)
    if period:
        print(f"{name.strip()}: period={period*1000:.1f} ms → {1/period:.1f} Hz")
    else:
        print(f"{name.strip()}: 周期を検出できず")

    axes[i, 0].plot(t[:300], sig[:300])
    axes[i, 0].set_xlabel('Time [s]')
    axes[i, 0].set_ylabel('Amplitude')
    axes[i, 0].set_title(f'Signal: {name}')
    axes[i, 0].grid(True, alpha=0.3)

    axes[i, 1].plot(lags * 1000, acf)
    axes[i, 1].axhline(y=0, color='k', linestyle='--', linewidth=0.8)
    if period:
        axes[i, 1].axvline(x=period * 1000, color='r', linestyle='--',
                           label=f'Period={period*1000:.1f} ms\n{1/period:.1f} Hz')
        axes[i, 1].legend(fontsize=9)
    axes[i, 1].set_xlabel('Lag [ms]')
    axes[i, 1].set_ylabel('Normalized ACF')
    axes[i, 1].set_title('Autocorrelation')
    axes[i, 1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

実行結果

50 Hz 正弦波: period=20.0 ms → 50.0 Hz
複合正弦波(50 + 130 Hz): period=22.0 ms → 45.5 Hz
ホワイトノイズ: period=5.0 ms → 200.0 Hz

50 Hz 正弦波は正しく周期 20 ms(50 Hz)を検出できました。ここで探索区間を zero_cross[1] から zero_cross[2] の間に取っているのが重要です。もし前節と同じ発想で zero_cross[0] から zero_cross[1] の間(ACFが最初にゼロを切ってから再びゼロに戻るまでの「谷」の区間)を探索すると、ピークではなく谷の縁を検出してしまい、50 Hz 信号ですら誤って period=6.0 ms(166.7 Hz)のような無意味な値を返します。谷と山を取り違えないよう、ゼロクロスの区間を1つずらすのがこの実装の要点です。

複合正弦波(50 + 130 Hz)では period=22.0 ms(45.5 Hz)と、50 Hz 成分の周期20msから2ms(10%)ずれた値になりました。これは130 Hz成分との重ね合わせによってACFの形が単純な余弦から歪み、探索区間内での極大位置がわずかにシフトするためです。周期性検出は単一の卓越周波数がある信号では有効ですが、複数の周波数成分が近い強度で混在する信号では、ACFのピーク位置が個々の周波数の正確な周期からずれることに注意が必要です(正確な周波数分解能が必要ならFFTベースのスペクトル解析の方が適切です)。

ホワイトノイズでは period=5.0 ms(200 Hz)という値が返りましたが、これは意味を持たない偽ピークです。ホワイトノイズの理論上のACFは \(\delta\) 関数(ラグ0でのみ1、それ以外で0)ですが、有限サンプル(このケースでは1000サンプル)から推定したACFには必ず統計的な揺らぎが残り、ゼロ近傍の小さな山谷の中からたまたま最大のものが選ばれてしまいます。周期性検出器を実運用する際は、検出された周期の値だけでなく、そのピークの高さ(ACF値)が有意水準(後述の \(\pm 1.96/\sqrt{N}\) など)を上回っているかを必ず確認する必要があります。

相互相関による遅延推定(GCC-PHAT)

マイクロフォンアレイ等での音源定位では、2つのマイクに到達する音の**到達時間差(TDOA: Time Difference of Arrival)を推定します。単純な相互相関よりGCC-PHAT(Generalized Cross-Correlation with Phase Transform)**が位置推定精度が高いとして広く使われます。

\[\hat{R}^{\text{PHAT}}_{xy}[l] = \mathcal{F}^{-1}\left\{\frac{X^*(f) Y(f)}{|X^*(f) Y(f)|}\right\} \tag{16}\]

分母で振幅スペクトルを正規化することで、特定周波数成分への依存が減り、ノイズ耐性が向上します。式(16)は式(14)の一般的なCCFのFFT表現 \(R_{xy}[l] = \mathcal{F}^{-1}\{X^*(f)Y(f)\}\) の位相のみを取り出した特殊形であり、複素共役を取る対象は式(14)と揃えて \(X(f)\) 側にしています。

注意(文献による流儀の違い):GCC-PHATの原論文である Knapp & Carter (1976) は \(G_{xy}(f) = X(f)Y^*(f)\) (\(Y\) 側を共役)という逆の規約を採用しています。これは本記事の式(9)の記法では \(R_{yx}\) に対応し、式(12)の対称性(\(R_{xy}\) と \(R_{yx}\) はラグの符号反転で移り合う)により、遅延推定値の符号がちょうど反転します。どちらの規約も数学的には正しい「相互相関」ですが、実装/文献間で共役を取る変数が食い違うと遅延の符号を取り違えるので、後述のように既知の遅延を持つ合成データで符号を検証することが重要です。

import numpy as np
import matplotlib.pyplot as plt

def gcc_phat(x, y, fs, max_delay=None):
    """
    GCC-PHAT による遅延推定。

    Parameters
    ----------
    x, y : np.ndarray
        2つの入力信号
    fs : float
        サンプリング周波数 [Hz]
    max_delay : float, optional
        探索する最大遅延 [s]

    Returns
    -------
    delay_sec : float
        推定遅延 [s](y は x より delay_sec 遅れている)
    cc : np.ndarray
        GCC-PHAT の相関値
    lags_sec : np.ndarray
        ラグ [s]
    """
    N = len(x) + len(y) - 1
    N_fft = 2 ** int(np.ceil(np.log2(N)))

    X = np.fft.fft(x, n=N_fft)
    Y = np.fft.fft(y, n=N_fft)

    # GCC-PHAT: 位相のみ利用(式(14)(16)と揃え、Xを共役にする)
    G = np.conj(X) * Y
    G_phat = G / (np.abs(G) + 1e-10)  # 数値安定化
    cc = np.fft.ifft(G_phat).real

    # lags の計算
    lags = np.fft.fftfreq(N_fft, d=1) * N_fft
    lags = lags.astype(int)

    # fftshift して中心をゼロラグに
    cc_shifted = np.fft.fftshift(cc)
    lags_shifted = np.fft.fftshift(lags)
    lags_sec = lags_shifted / fs

    if max_delay:
        mask = np.abs(lags_sec) <= max_delay
        cc_shifted = cc_shifted[mask]
        lags_sec = lags_sec[mask]

    peak_idx = np.argmax(cc_shifted)
    delay_sec = lags_sec[peak_idx]

    return delay_sec, cc_shifted, lags_sec


# --- テスト ---
fs = 16000
t = np.arange(0, 0.5, 1/fs)
np.random.seed(7)

# 音源信号
source = np.random.randn(len(t))
# マイク1: 直接波
mic1 = source.copy() + 0.1 * np.random.randn(len(t))
# マイク2: 実際の遅延(5 ms = 80 サンプル)
true_delay_samples = int(0.005 * fs)   # 80 サンプル
mic2 = np.zeros_like(mic1)
mic2[true_delay_samples:] = source[:-true_delay_samples] + 0.1 * np.random.randn(len(t) - true_delay_samples)

delay_est, cc, lags = gcc_phat(mic1, mic2, fs, max_delay=0.02)
print(f"真の遅延:     {true_delay_samples / fs * 1000:.2f} ms ({true_delay_samples} samples)")
print(f"GCC-PHAT推定: {delay_est * 1000:.2f} ms ({int(delay_est * fs)} samples)")

plt.figure(figsize=(10, 4))
plt.plot(lags * 1000, cc)
plt.axvline(x=delay_est * 1000, color='r', linestyle='--',
            label=f'Estimated delay = {delay_est*1000:.2f} ms')
plt.axvline(x=true_delay_samples / fs * 1000, color='g', linestyle=':',
            label=f'True delay = {true_delay_samples/fs*1000:.2f} ms')
plt.xlabel('Lag [ms]')
plt.ylabel('GCC-PHAT')
plt.title('GCC-PHAT Cross-Correlation for TDOA Estimation')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

実行結果

真の遅延:     5.00 ms (80 samples)
GCC-PHAT推定: 5.00 ms (80 samples)

式(14)と規約を揃えた G = np.conj(X) * Y により、真の遅延5.00 ms(80サンプル)にピッタリ一致する推定が得られました。もし前述の注意の通り Knapp & Carter (1976) の規約(G = X * np.conj(Y))のまま実行すると、

真の遅延:       5.00 ms (80 samples)
GCC-PHAT推定(逆規約): -5.00 ms (-80 samples)

と、大きさは一致するのに符号だけが反転した結果になります。これは実装のバグではなく式(12)の対称性がそのまま現れたものですが、レーダーやマイクアレイでの音源定位のように遅延の符号(どちらが先行したか)が結果の意味を左右する応用では、実装時に既知の遅延を持つ合成信号で符号を必ず検証すべきという教訓になります。

GCC-PHATによるTDOA推定は現在も活発に研究が続いている分野です。Berg et al. (2024) は「NGCC-PHAT」として、GCC-PHATの位相変換部分を学習可能なニューラルネットワークで置き換え、複数の音源が重なる状況でもTDOAを推定できる手法を提案し、DCASE 2024で発表された音源定位・検出タスク(SELD: Sound Event Localization and Detection)において、従来のGCC-PHAT特徴量やSALSA-Liteを上回る精度を示しています(STARSS23データセットでの評価)。古典的なGCC-PHATが依然として「学習可能な特徴抽出器への入力」として現役で使われている点が興味深いところです。

応用:音声ピッチ推定(YIN 法の基礎)

ACFに基づくピッチ推定は音声処理で広く使われます。YIN アルゴリズム(de Cheveigné & Kawahara, 2002)は差分関数とACFを組み合わせてピッチを高精度に推定します。

差分関数 \(d[l]\) は以下のように定義されます。

\[d[l] = \sum_{n=0}^{N-1} (x[n] - x[n+l])^2 \tag{17}\]

展開すると自己相関との関係が明確になります。

\[d[l] = 2R_{xx}[0] - 2R_{xx}[l] \tag{18}\]

差分関数を用いた累積平均正規化差分(CMND)によりゼロクロス付近でのピーク検出精度が向上します。

import numpy as np
import matplotlib.pyplot as plt

def yin_pitch(signal, fs, fmin=75, fmax=500, threshold=0.1):
    """
    YIN アルゴリズムによるピッチ推定(簡易版)。

    Returns
    -------
    f0 : float or None
        推定F0 [Hz]
    d_norm : np.ndarray
        正規化差分関数
    """
    N = len(signal)
    W = N // 2  # 探索ウィンドウ

    # 差分関数(FFTで高速計算)
    X = np.fft.fft(signal, n=2*N)
    acf = np.fft.ifft(X * np.conj(X)).real[:N]

    d = np.zeros(W)
    d[0] = 0
    for l in range(1, W):
        d[l] = 2 * acf[0] - 2 * acf[l]

    # 累積平均正規化差分(CMND)
    d_norm = np.zeros(W)
    d_norm[0] = 1.0
    cumsum = 0.0
    for l in range(1, W):
        cumsum += d[l]
        d_norm[l] = d[l] / (cumsum / l)

    # ピッチ範囲の制限
    lag_min = int(fs / fmax)
    lag_max = int(fs / fmin)

    # 閾値を下回る最初のディップを検出
    f0 = None
    for l in range(lag_min, min(lag_max, W)):
        if d_norm[l] < threshold:
            # サブサンプル精度のためのパラボリック補間
            if 0 < l < W - 1:
                alpha = d_norm[l-1]
                beta = d_norm[l]
                gamma = d_norm[l+1]
                delta = (alpha - gamma) / (2 * (alpha - 2*beta + gamma))
                l_refined = l + delta
            else:
                l_refined = l
            f0 = fs / l_refined
            break

    return f0, d_norm


# --- テスト:異なるピッチの合成音声 ---
fs = 16000
t = np.arange(0, 0.1, 1/fs)  # 100 ms のフレーム

test_cases = {
    '男性声 (120 Hz)': 120,
    '女性声 (220 Hz)': 220,
    '子供声 (350 Hz)': 350,
}

fig, axes = plt.subplots(len(test_cases), 2, figsize=(14, 10))

for i, (label, f0_true) in enumerate(test_cases.items()):
    # ハーモニクスを含む合成音声
    np.random.seed(i)
    voice = (np.sin(2 * np.pi * f0_true * t)
             + 0.5 * np.sin(2 * np.pi * 2 * f0_true * t)
             + 0.3 * np.sin(2 * np.pi * 3 * f0_true * t)
             + 0.05 * np.random.randn(len(t)))

    f0_est, d_norm = yin_pitch(voice, fs)
    print(f"{label}: 真のF0={f0_true} Hz, 推定F0={f0_est:.2f} Hz, "
          f"誤差={f0_est - f0_true:+.2f} Hz ({(f0_est - f0_true) / f0_true * 100:+.2f}%)")

    axes[i, 0].plot(t * 1000, voice)
    axes[i, 0].set_xlabel('Time [ms]')
    axes[i, 0].set_ylabel('Amplitude')
    axes[i, 0].set_title(f'{label}: True F0 = {f0_true} Hz')
    axes[i, 0].grid(True, alpha=0.3)

    lag_ms = np.arange(len(d_norm)) / fs * 1000
    axes[i, 1].plot(lag_ms, d_norm)
    axes[i, 1].axhline(y=0.1, color='r', linestyle='--', label='threshold=0.1')
    if f0_est:
        axes[i, 1].axvline(x=1000/f0_est, color='g', linestyle='--',
                           label=f'Est. F0 = {f0_est:.1f} Hz')
    axes[i, 1].set_xlabel('Lag [ms]')
    axes[i, 1].set_ylabel('Normalized d[l]')
    axes[i, 1].set_title('YIN: Cumulative Mean Normalized Difference')
    axes[i, 1].set_xlim(0, 30)
    axes[i, 1].legend(fontsize=9)
    axes[i, 1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

実行結果

男性声 (120 Hz): 真のF0=120 Hz, 推定F0=120.09 Hz, 誤差=+0.09 Hz (+0.07%)
女性声 (220 Hz): 真のF0=220 Hz, 推定F0=218.98 Hz, 誤差=-1.02 Hz (-0.47%)
子供声 (350 Hz): 真のF0=350 Hz, 推定F0=348.34 Hz, 誤差=-1.66 Hz (-0.48%)

いずれのケースも誤差1%未満と高精度に推定できています。ただし高い声(子供声、350 Hz)ほど誤差の絶対値が大きくなる傾向が見て取れます。これは、fs=16000 Hz・fmax=500 Hz の制約下では高いピッチほどラグ(fs/f0)が短くなり、放物線補間で使える整数ラグの間隔が粗くなる(サブサンプル補間の相対誤差が増える)ためです。実運用では、フレーム長・サンプリング周波数・探索するピッチレンジのバランスを目的の音声帯域に合わせて調整する必要があります。

YINは2002年の発表から20年以上経った現在も音声処理の標準的ベースラインとして使われ続けていますが、近年は自己教師あり学習によるピッチ推定器がYINを置き換えつつあります。Riou et al. (2023) の PESTO(Pitch Estimation with Self-supervised Transposition-equivariant Objective, ISMIR 2023 Best Paper)は、CQT(定Q変換)上でピッチシフトに対する等変性を自己教師あり目的関数として学習する軽量ニューラルネット(パラメータ数3万未満)を提案し、YINやCREPEなどの既存手法と比較して、ラベルなしデータのみからリアルタイムより12倍高速な推論でありながら競争力のある精度を達成しています。ACFベースの決定論的アルゴリズムであるYINが、深層学習ベースの手法における比較基準(ベースライン)として今なお参照され続けている点は、本記事で解説した理論の実務的な価値を示しています。

応用:時系列分析(ARIMAモデルの次数決定)

時系列分析において、ACFと**偏自己相関関数(PACF: Partial Autocorrelation Function)**はARIMAモデルの次数を決定するための基本的なツールです。

\[\text{PACF}(k) = \text{Corr}(x_t, x_{t-k} \mid x_{t-1}, \ldots, x_{t-k+1}) \tag{19}\]

PACFはラグ \(k\) 以外のラグの影響を除いた純粋な相関を表します。

import numpy as np
import matplotlib.pyplot as plt
from statsmodels.tsa.stattools import acf, pacf
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf

# --- AR(2) プロセスのシミュレーション ---
# x[t] = 0.7 * x[t-1] - 0.3 * x[t-2] + noise
np.random.seed(42)
N = 300
phi = [0.7, -0.3]   # AR係数
noise_std = 1.0

x = np.zeros(N)
for t in range(2, N):
    x[t] = phi[0] * x[t-1] + phi[1] * x[t-2] + np.random.randn() * noise_std

# ACF と PACF の計算
nlags = 20
acf_vals = acf(x, nlags=nlags, fft=True)
pacf_vals = pacf(x, nlags=nlags)

# 信頼区間(近似: ±1.96/√N)
ci = 1.96 / np.sqrt(N)

fig, axes = plt.subplots(3, 1, figsize=(12, 10))

# 時系列プロット
axes[0].plot(x)
axes[0].set_xlabel('Time step')
axes[0].set_ylabel('x[t]')
axes[0].set_title('AR(2) Process: x[t] = 0.7·x[t-1] − 0.3·x[t-2] + noise')
axes[0].grid(True, alpha=0.3)

# ACF プロット
lags = np.arange(nlags + 1)
axes[1].bar(lags, acf_vals, color='steelblue', alpha=0.7)
axes[1].axhline(y=ci, color='r', linestyle='--', linewidth=0.8, label='±95% CI')
axes[1].axhline(y=-ci, color='r', linestyle='--', linewidth=0.8)
axes[1].axhline(y=0, color='k', linewidth=0.5)
axes[1].set_xlabel('Lag')
axes[1].set_ylabel('ACF')
axes[1].set_title('Autocorrelation Function (ACF): Gradual decay → AR process')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

# PACF プロット
axes[2].bar(lags, pacf_vals, color='coral', alpha=0.7)
axes[2].axhline(y=ci, color='r', linestyle='--', linewidth=0.8, label='±95% CI')
axes[2].axhline(y=-ci, color='r', linestyle='--', linewidth=0.8)
axes[2].axhline(y=0, color='k', linewidth=0.5)
axes[2].set_xlabel('Lag')
axes[2].set_ylabel('PACF')
axes[2].set_title('Partial ACF (PACF): Cuts off after lag 2 → confirms AR(2)')
axes[2].legend()
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

print(f"PACF(1) = {pacf_vals[1]:.3f} (真値: φ₁ = {phi[0]})")
print(f"PACF(2) = {pacf_vals[2]:.3f} (真値: φ₂ = {phi[1]})")
print(f"PACF(3) = {pacf_vals[3]:.3f} (カットオフ: 0 に近い)")

実行結果

PACF(1) = 0.486 (真値: φ₁ = 0.7)
PACF(2) = -0.291 (真値: φ₂ = -0.3)
PACF(3) = -0.031 (カットオフ: 0 に近い)

95%信頼区間は \(\pm 1.96/\sqrt{300} = \pm 0.113\) です。PACF(1)=0.486, PACF(2)=-0.291 はともにこの区間を大きく超えて有意ですが、PACF(3)=-0.031 は区間内に収まりカットオフしています。これはまさにAR(2)過程の特徴(次数を超えるラグでPACFが理論的にゼロになる)と一致します。

一方で、PACF(2)=-0.291は真値 \(\phi_2=-0.3\) にほぼ一致していますが、PACF(1)=0.486は真値 \(\phi_1=0.7\) から3割近く下振れしています。これは実装のバグではなく、\(N=300\) という有限サンプルからの推定に伴う統計的な誤差です。実際、この過程の特性方程式 \(z^2 - 0.7z + 0.3 = 0\) は判別式が \(0.49 - 1.2 = -0.71 < 0\) と複素共役根を持ち、理論ACFは単調減衰ではなく緩やかな減衰振動を示します(acf_vals[:5] = [1.000, 0.484, 0.014, -0.153, -0.130] も単調減衰でないことがわかります)。次数決定という定性的な判断(PACFが何ラグでカットオフするか)は頑健ですが、係数の点推定値そのものは有限サンプルでは真値からずれうる点に注意してください。次数が決まった後は最尤推定などで係数を再推定するのが実務上の標準的な手順です。

ACFが指数的に減衰(カットオフなし)し、PACFがラグ2以降でカットオフすることから、このプロセスがAR(2)であると判断できます。

自己相関・相互相関の主な応用一覧

用途使用する相関特徴
周期性検出ACFピーク位置から基本周期を推定
ピッチ推定(音声)ACF (YIN等)声の基本周波数(F0)を抽出
TDOA推定(マイクアレイ)CCF (GCC-PHAT)到達時間差から音源位置を推定
テンプレートマッチングCCF画像・信号の類似位置探索
ARIMAモデル次数決定ACF + PACFp・qの次数選択
ホワイトノイズ検定ACF残差のランダム性検証
レーダー・ソナーCCF反射波の遅延から距離推定

まとめ

  • **自己相関関数(ACF)**は信号とその遅延コピーの類似度を測り、周期性・繰り返し構造の検出に有効である
  • ウィーナー・ヒンチン定理により、ACFのフーリエ変換はパワースペクトル密度(PSD)に等しく、時間領域と周波数領域の解析が等価であることが示される
  • **相互相関関数(CCF)**は2信号間の遅延推定・類似度定量化に使われ、GCC-PHATはノイズ耐性の高い遅延推定として音源定位に広く使われる
  • FFTを用いた高速計算(\(O(N \log N)\) )により、直接計算(\(O(N^2)\) )と比べて大幅に計算コストを削減できる
  • ACFとPACFの組み合わせによるARIMAモデルの次数決定は時系列分析の基本的な手順である
  • YINアルゴリズムは差分関数とACFの関係を利用した高精度なピッチ推定手法である

おすすめ書籍

経済・ファイナンスデータの計量時系列分析(沖本竜義、朝倉書店)

AR・MA・ARMA モデルから単位根・見せかけの回帰まで、時系列分析の標準理論を丁寧に解説した定番教科書です。

※ 上記は Amazon アソシエイトのリンクです。

関連記事

参考文献

  • de Cheveigné, A., & Kawahara, H. (2002). “YIN, a fundamental frequency estimator for speech and music.” Journal of the Acoustical Society of America, 111(4), 1917-1930.
  • Knapp, C. H., & Carter, G. C. (1976). “The generalized correlation method for estimation of time delay.” IEEE Transactions on Acoustics, Speech, and Signal Processing, 24(4), 320-327.
  • Box, G. E. P., Jenkins, G. M., Reinsel, G. C., & Ljung, G. M. (2015). Time Series Analysis: Forecasting and Control (5th ed.). Wiley.
  • Oppenheim, A. V., & Schafer, R. W. (2009). Discrete-Time Signal Processing (3rd ed.). Prentice Hall.
  • Riou, A., Lattner, S., Hadjeres, G., & Peeters, G. (2023). “PESTO: Pitch Estimation with Self-supervised Transposition-equivariant Objective.” Proceedings of the 24th International Society for Music Information Retrieval Conference (ISMIR 2023). (Best Paper Award) arXiv:2309.02265
  • Berg, A., Engman, J., Gulin, J., Åström, K., & Oskarsson, M. (2024). “Learning Multi-Target TDOA Features for Sound Event Localization and Detection.” DCASE 2024 Workshop. arXiv:2408.17166
  • SciPy signal.correlate documentation
  • Statsmodels ACF/PACF documentation