窓関数とパワースペクトル密度(PSD)の理論とPython実装

窓関数とPSD(パワースペクトル密度)の理論・比較・Python実装を scipy.signal.welch / periodogram / get_window で解説。FFTのスペクトル漏れを抑えるHann・Hamming・Blackman・Kaiser窓の選び方、Welch法によるPSD推定コード、振動・音響データ解析への応用までまとめます。

はじめに

FFTの記事 では、離散フーリエ変換(DFT)のアルゴリズムと基本的な周波数解析を解説しました。その中でHann窓によるスペクトル漏れの軽減に触れましたが、窓関数にはさまざまな種類があり、用途に応じた使い分けが重要です。

また、FFTで得られる振幅スペクトルは「各周波数にどれだけの振幅があるか」を示しますが、実用上は単位周波数あたりの信号パワーを知りたい場面が多くあります。これが**パワースペクトル密度(PSD: Power Spectral Density)**です。

本記事では、スペクトル漏れの数学的背景を掘り下げた上で、主要な窓関数の特性を比較し、PSDの推定手法であるWelch法をPythonで実装します。

スペクトル漏れの数学的理解

有限長信号と矩形窓

実際の観測では信号を無限に記録することはできず、有限長 \(N\) の信号 \(x[n]\) (\(n = 0, 1, \ldots, N-1\) )を取得します。これは数学的には、無限長の信号 \(x_\infty[n]\) に矩形窓 \(w_R[n]\) を掛けることと等価です。

\[x[n] = x_\infty[n] \cdot w_R[n] \tag{1}\]

ここで矩形窓は以下のように定義されます。

\[w_R[n] = \begin{cases} 1, & 0 \leq n \leq N-1 \\ 0, & \text{otherwise} \end{cases} \tag{2}\]

周波数領域での影響

時間領域での乗算は、周波数領域では畳み込みに対応します。

\[X(f) = X_\infty(f) * W_R(f) \tag{3}\]

矩形窓のフーリエ変換はディリクレ核(Dirichlet kernel)であり、連続近似ではsinc関数に比例します。

\[W_R(f) \approx N \cdot \text{sinc}(Nf) \cdot e^{-j\pi f(N-1)} \tag{4}\]

このsinc関数には大きなメインローブと徐々に減衰するサイドローブがあります。式 \((3)\) の畳み込みにより、本来は単一周波数に集中するはずのスペクトルがサイドローブを通じて隣接周波数に広がります。これがスペクトル漏れの数学的な原因です。

メインローブとサイドローブ

窓関数の周波数特性は以下の2つの要素で特徴づけられます。

  • メインローブ: 中心周波数付近の主要なピーク。幅が狭いほど周波数分解能が高い
  • サイドローブ: メインローブの両側に現れる副次的なピーク。レベルが低いほどスペクトル漏れが少ない

矩形窓のサイドローブは最大で \(-13\) dBと大きく、隣接する弱い信号成分がサイドローブに埋もれてしまう問題があります。窓関数を工夫することで、このトレードオフを制御できます。

主要な窓関数

以下に代表的な窓関数の数学的定義を示します。いずれも長さ \(N\) の窓を定義しています。

矩形窓(Rectangular Window)

\[w[n] = 1, \quad 0 \leq n \leq N-1 \tag{5}\]

窓関数を適用しない場合と等価です。メインローブ幅が最も狭く周波数分解能は最高ですが、サイドローブが大きくスペクトル漏れが顕著です。

Hann窓

\[w[n] = 0.5\left(1 - \cos\left(\frac{2\pi n}{N-1}\right)\right) \tag{6}\]

両端がゼロになる余弦ベースの窓です。サイドローブの減衰率が \(-18\) dB/octと速く、汎用的に最もよく使われます。

Hamming窓

\[w[n] = 0.54 - 0.46\cos\left(\frac{2\pi n}{N-1}\right) \tag{7}\]

Hann窓に類似していますが、両端がゼロにならず約0.08の値を持ちます。最初のサイドローブが \(-43\) dBに抑えられる一方、遠方のサイドローブ減衰は \(-6\) dB/octとHann窓より遅くなります。

Blackman窓

\[w[n] = 0.42 - 0.5\cos\left(\frac{2\pi n}{N-1}\right) + 0.08\cos\left(\frac{4\pi n}{N-1}\right) \tag{8}\]

3項の余弦の組み合わせで構成されます。最初のサイドローブが \(-58\) dBと非常に低く、高ダイナミックレンジの解析に適しますが、メインローブ幅が広くなるため周波数分解能は低下します。

Kaiser窓

\[w[n] = \frac{I_0\left(\beta\sqrt{1 - \left(\frac{2n}{N-1} - 1\right)^2}\right)}{I_0(\beta)} \tag{9}\]

ここで \(I_0\) は第1種変形ベッセル関数、\(\beta\) は形状パラメータです。\(\beta\) を調整することで、メインローブ幅とサイドローブ抑制のトレードオフを連続的に制御できる柔軟な窓関数です。\(\beta = 0\) で矩形窓、\(\beta \approx 5.4\) でHamming窓に近い特性になります。

窓関数の特性比較

窓関数メインローブ幅サイドローブ極大 [dB]サイドローブ減衰ENBW (bins)
矩形2 bins-13-6 dB/oct1.00
Hann4 bins-32-18 dB/oct1.50
Hamming4 bins-43-6 dB/oct1.36
Blackman6 bins-58-18 dB/oct1.73
Kaiser (β=6)可変可変可変可変

ENBW(Equivalent Noise Bandwidth)は、窓関数が白色ノイズに対して等価的に何ビン分の帯域幅を持つかを示す指標です。値が大きいほどノイズの影響を受けやすくなります。

ENBWの導出

ENBWは「窓関数を適用したピリオドグラムのピーク付近のゲインで、白色雑音を矩形フィルタに通したとすると何ビン分の帯域幅に相当するか」を表す量です。以下で導出します。

窓関数 \(w[n]\) (\(n=0,\ldots,N-1\) )のDFTを \(W[k]\) とすると、Parsevalの定理より周波数領域でのパワーの総和は時間領域でのパワーの総和の \(N\) 倍に等しくなります。

\( \sum_{k=0}^{N-1} |W[k]|^2 = N \sum_{n=0}^{N-1} w[n]^2 \tag{15} \)

一方、周波数応答のピーク(\(k=0\) 、DC)でのパワーゲインは \(|W[0]|^2 = \left(\sum_n w[n]\right)^2\) です。ENBW(ビン単位)は、「ピークパワーゲイン × ENBW」が式 \((15)\) の全パワーに等しくなるように定義されます。

\[\text{ENBW} \cdot \left(\sum_{n=0}^{N-1} w[n]\right)^2 = N \sum_{n=0}^{N-1} w[n]^2 \tag{16}\]

これを解くと次の式が得られます。

\( \text{ENBW} = N \cdot \dfrac{\sum_{n=0}^{N-1} w[n]^2}{\left(\sum_{n=0}^{N-1} w[n]\right)^2} \tag{17} \)

矩形窓(\(w[n]=1\) )では分子が \(N \cdot N = N^2\) 、分母が \(N^2\) となるため \(\text{ENBW}=1\) です。式 \((17)\) をPythonで数値的に検証します。

import numpy as np
from scipy.signal.windows import hann, hamming, blackman, kaiser

def enbw(w):
    """式(17): 窓関数の等価雑音帯域幅(ビン単位)"""
    N = len(w)
    return N * np.sum(w**2) / np.sum(w)**2

N = 1024
windows = {
    'Rectangular': np.ones(N),
    'Hann': hann(N),
    'Hamming': hamming(N),
    'Blackman': blackman(N),
    'Kaiser (β=6)': kaiser(N, beta=6),
}
for name, w in windows.items():
    print(f"{name:15s} ENBW = {enbw(w):.4f} bins")

実行結果(実際に走らせた出力):

Rectangular     ENBW = 1.0000 bins
Hann            ENBW = 1.5015 bins
Hamming         ENBW = 1.3638 bins
Blackman        ENBW = 1.7284 bins
Kaiser (β=6)    ENBW = 1.4682 bins

先の表に示した近似値(矩形1.00、Hann 1.50、Hamming 1.36、Blackman 1.73)と、式 \((17)\) から数値的に計算した値が一致することが確認できます。

窓関数の周波数特性比較(Python実装)

すべての窓関数を生成し、その周波数応答をdBスケールで比較します。

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal.windows import hann, hamming, blackman, kaiser

# 窓の長さ
N = 64
# FFTの点数(ゼロパディングで補間)
N_fft = 4096

# 各窓関数を生成
windows = {
    'Rectangular': np.ones(N),
    'Hann': hann(N),
    'Hamming': hamming(N),
    'Blackman': blackman(N),
    'Kaiser (β=6)': kaiser(N, beta=6),
}

# 周波数応答を計算してプロット
fig, ax = plt.subplots(figsize=(10, 6))

for name, w in windows.items():
    # FFTで周波数応答を計算
    W = np.fft.fft(w, n=N_fft)
    W_shift = np.fft.fftshift(W)
    # 正規化(メインローブのピークを0 dBに)
    W_dB = 20 * np.log10(np.maximum(np.abs(W_shift) / np.abs(W_shift).max(), 1e-12))
    # 周波数軸(ビン単位)
    freq_bins = np.linspace(-N / 2, N / 2, N_fft)
    ax.plot(freq_bins, W_dB, label=name)

ax.set_xlim(-15, 15)
ax.set_ylim(-120, 5)
ax.set_xlabel('Frequency [bins]')
ax.set_ylabel('Magnitude [dB]')
ax.set_title('Window Functions: Frequency Response Comparison')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

このプロットから、矩形窓はメインローブが最も狭い代わりにサイドローブが大きく、Blackman窓はサイドローブが非常に低い代わりにメインローブが広いことが視覚的に確認できます。

窓関数の周波数応答比較(N=64、4096点ゼロパディング)。矩形窓(青)はメインローブが最も狭いが、サイドローブが-13dB程度までしか減衰しない。Kaiser窓(紫)・Blackman窓(緑)はサイドローブが-60dB近くまで抑えられる代わりにメインローブが広がっている

パワースペクトル密度(PSD)

PSDの定義と物理的意味

振幅スペクトル \(|X[k]|\) は各周波数ビンにおける信号の振幅を示しますが、FFTの点数 \(N\) やサンプリング周波数 \(f_s\) に依存するため、異なる条件で取得したスペクトル同士の直接比較が困難です。

**パワースペクトル密度(PSD)**は、単位周波数あたりの信号パワーとして定義され、信号のパワーが周波数軸上にどのように分布しているかを示します。単位は \(\text{V}^2/\text{Hz}\) (あるいは対象となる物理量に応じた単位/Hz)です。

PSDの重要な性質として、全周波数にわたるPSDの積分が信号の全パワー(分散)に等しくなります。

\[\int_0^{f_s/2} S_{xx}(f) \, df = \sigma_x^2 \tag{10}\]

振幅スペクトルとの違い

特性振幅スペクトルパワースペクトル密度
\(\|X[k]\|\)\(S_{xx}(f)\)
単位V(入力信号の単位)\(\text{V}^2/\text{Hz}\)
N依存性Nに比例Nに非依存
用途特定周波数の振幅確認パワー分布の定量評価

PSD推定手法

ピリオドグラム法

最も単純なPSD推定法はピリオドグラムです。FFTの結果から直接計算します。

\[\hat{S}_{xx}[k] = \frac{|X[k]|^2}{N \cdot f_s \cdot U} \tag{11}\]

ここで \(X[k]\) は窓関数を適用した信号のDFTであり、\(U = \frac{1}{N}\sum_{n=0}^{N-1} w[n]^2\) は窓関数のパワー補正係数です(矩形窓の場合 \(U=1\) )。ピリオドグラムは漸近的に不偏な推定量ですが(\(N \to \infty\) で不偏)、分散が大きいという重大な欠点があります。データ長 \(N\) を増やしても分散が減少しないため、推定値がノイズで大きく揺らぎます。

ピリオドグラムの分散が減らないことの数値検証

上記の「\(N\) を増やしても分散が減らない」という性質は直感に反するため、実際に単位分散のホワイトノイズを何度も生成してピリオドグラムを計算し、特定の周波数ビンでの推定値のばらつき(変動係数 = 標準偏差/平均)を調べます。比較のため、同じデータに対するWelch法の変動係数も合わせて計算します。

import numpy as np
from scipy.signal import welch

fs = 1000.0
n_trials = 200

def periodogram_cv(N, trials=n_trials, seed=0):
    """N点のホワイトノイズからピリオドグラムを trials 回計算し、
    固定ビンでの変動係数(標準偏差/平均)を返す"""
    rng = np.random.default_rng(seed)
    vals = []
    for _ in range(trials):
        x = rng.standard_normal(N)
        X = np.fft.rfft(x)
        psd = (np.abs(X)**2) / (N * fs)
        psd[1:-1] *= 2
        idx = max(1, N // 8)  # DC・ナイキスト以外の固定ビン
        vals.append(psd[idx])
    vals = np.array(vals)
    return vals.mean(), vals.std() / vals.mean()

for N in [256, 1024, 4096, 16384]:
    mean, cv = periodogram_cv(N)
    print(f"N={N:6d}  periodogram mean={mean:.6f}  CV(std/mean)={cv:.3f}")

def welch_cv(N, nperseg, trials=n_trials, seed=1):
    rng = np.random.default_rng(seed)
    vals = []
    for _ in range(trials):
        x = rng.standard_normal(N)
        f, p = welch(x, fs=fs, window='hann', nperseg=nperseg, noverlap=nperseg // 2)
        idx = max(1, len(f) // 8)
        vals.append(p[idx])
    vals = np.array(vals)
    K = (N - nperseg) // (nperseg // 2) + 1  # 平均に使うセグメント数
    return vals.mean(), vals.std() / vals.mean(), K

print()
for nperseg in [256, 128, 64]:
    mean, cv, K = welch_cv(16384, nperseg)
    print(f"Welch nperseg={nperseg:4d} (K={K:3d}segs)  mean={mean:.6f}  CV(std/mean)={cv:.3f}  1/sqrt(K)={1/np.sqrt(K):.3f}")

実行結果(実際に走らせた出力):

N=   256  periodogram mean=0.002234  CV(std/mean)=1.041
N=  1024  periodogram mean=0.001887  CV(std/mean)=1.023
N=  4096  periodogram mean=0.002017  CV(std/mean)=1.160
N= 16384  periodogram mean=0.002157  CV(std/mean)=0.983

Welch nperseg= 256 (K=127segs)  mean=0.002005  CV(std/mean)=0.084  1/sqrt(K)=0.089
Welch nperseg= 128 (K=255segs)  mean=0.002011  CV(std/mean)=0.068  1/sqrt(K)=0.063
Welch nperseg=  64 (K=511segs)  mean=0.002005  CV(std/mean)=0.047  1/sqrt(K)=0.044

ピリオドグラムの変動係数は \(N\) を256から16384まで64倍にしても常に約1.0前後であり、理論通りデータ長を増やしても分散が減らないことが確認できます(これはピリオドグラムの推定値がおおむね自由度2の\(\chi^2\) 分布に従い、相対標準偏差が\(N\) に依存せず1に近づくという統計的性質によるものです)。一方Welch法の変動係数は、セグメント数 \(K\) が127・255・511と増えるにつれて0.084・0.068・0.047と減少し、理論値 \(1/\sqrt{K}\) (0.089・0.063・0.044)とほぼ一致しています。これは独立な(あるいはほぼ独立な)\(K\) 個の推定値を平均するとき分散が \(1/K\) になるという中心極限定理の帰結であり、Welch法が分散低減を実現する仕組みを定量的に裏付けています。

Welch法

Welch法(1967年)は、ピリオドグラムの分散を低減する実用的な手法です。以下の手順で計算します。

  1. 信号を長さ \(L\) のセグメントに分割(オーバーラップあり)
  2. 各セグメントに窓関数 \(w[n]\) を適用
  3. 各セグメントのピリオドグラム(修正ピリオドグラム)を計算
  4. すべてのセグメントの結果を平均

\(i\) 番目のセグメントの修正ピリオドグラムは以下のように定義されます。

\[\hat{S}_{xx}^{(i)}[k] = \frac{1}{L \cdot f_s \cdot U} \left| \sum_{n=0}^{L-1} x_i[n] \cdot w[n] \cdot e^{-j2\pi kn/L} \right|^2 \tag{12}\]

ここで \(U\) は窓関数のパワーの正規化係数です。

\[U = \frac{1}{L}\sum_{n=0}^{L-1} w[n]^2 \tag{13}\]

\(K\) 個のセグメントの平均をとることで、Welch法によるPSD推定値が得られます。

\[\hat{S}_{xx}^{\text{Welch}}[k] = \frac{1}{K}\sum_{i=1}^{K} \hat{S}_{xx}^{(i)}[k] \tag{14}\]

**セグメントの重なり(オーバーラップ)**は、データを有効に活用して平均するセグメント数を増やすために用います。一般的に50%のオーバーラップが使われます。オーバーラップが大きすぎるとセグメント間の相関が高まり、平均の効果が薄れます。

セグメント長のトレードオフとして、セグメント長 \(L\) を長くすると周波数分解能が向上しますが、平均に使えるセグメント数が減るため分散が増加します。逆に短くすると分散は減少しますが、周波数分解能が低下します(真のPSDがそのビン内で変化している場合、セグメントが短いほど平滑化によるバイアスも増えます)。

このバイアス・分散トレードオフは長年放置されていた実用上の課題で、Astfalck・Sykulski・Cripps (2023) “Debiasing Welch’s Method for Spectral Density Estimation”( arXiv:2312.13643Biometrika 誌に採録)は、Welch法と同じ計算量・漸近的な一致性を保ったまま有限サンプルでのバイアスを低減するデバイアス手法を提案しています。論文ではセグメントを不等間隔の周波数グリッドで評価することで追加の分散低減も同時に達成できるとしており、実装はPython/Rの両方で公開されています。従来のWelch法の「セグメント長を短くするほど分散は減るがバイアスが増える」というトレードオフに対し、バイアス側を積極的に補正するアプローチとして2023年以降の研究動向の一例です。

実践:PSD推定のPython実装

50Hzと120Hzの正弦波にホワイトノイズを加えた信号を生成し、ピリオドグラムとWelch法でPSDを推定して比較します。

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import welch
from scipy.signal.windows import hann

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

# 50Hz(振幅1.0)と120Hz(振幅0.5)+ ホワイトノイズ
signal = (np.sin(2 * np.pi * 50 * t)
          + 0.5 * np.sin(2 * np.pi * 120 * t)
          + 0.8 * np.random.randn(N_total))

# --- 1. ピリオドグラム(手動実装) ---
window = hann(N_total)
X = np.fft.fft(signal * window)
freqs_periodo = np.fft.fftfreq(N_total, 1/fs)
# 窓関数のパワー補正
U = np.mean(window**2)
# 片側ピリオドグラム
psd_periodo = (np.abs(X[:N_total//2])**2) / (N_total * fs * U)
psd_periodo[1:-1] *= 2  # DC成分とナイキスト成分以外を2倍
freqs_periodo = freqs_periodo[:N_total//2]

# --- 2. Welch法(scipy.signal.welch) ---
freqs_welch, psd_welch = welch(signal, fs=fs, window='hann',
                                nperseg=512, noverlap=256)

# --- 3. 窓関数による違いの比較 ---
windows_list = ['hann', 'hamming', 'blackman']
psd_results = {}
for win_name in windows_list:
    f, p = welch(signal, fs=fs, window=win_name,
                 nperseg=512, noverlap=256)
    psd_results[win_name] = (f, p)

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

# (a) ピリオドグラム vs Welch法
axes[0].semilogy(freqs_periodo, psd_periodo, alpha=0.5, label='Periodogram')
axes[0].semilogy(freqs_welch, psd_welch, label='Welch (nperseg=512)')
axes[0].set_xlabel('Frequency [Hz]')
axes[0].set_ylabel('PSD [V²/Hz]')
axes[0].set_title('Periodogram vs Welch Method')
axes[0].legend()
axes[0].set_xlim(0, 200)
axes[0].grid(True, alpha=0.3)

# (b) 窓関数ごとのWelch法の結果
for win_name, (f, p) in psd_results.items():
    axes[1].semilogy(f, p, label=f'Welch ({win_name})')
axes[1].set_xlabel('Frequency [Hz]')
axes[1].set_ylabel('PSD [V²/Hz]')
axes[1].set_title('Welch Method: Window Function Comparison')
axes[1].legend()
axes[1].set_xlim(0, 200)
axes[1].grid(True, alpha=0.3)

# (c) 入力信号の時間波形
axes[2].plot(t[:500], signal[:500])
axes[2].set_xlabel('Time [s]')
axes[2].set_ylabel('Amplitude')
axes[2].set_title('Input Signal (first 0.5 s)')
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

ピリオドグラムとWelch法によるPSD推定の比較。上段: ピリオドグラム(灰、大きく揺らぐ)とWelch法(青、滑らか)。中段: Welch法における窓関数(Hann/Hamming/Blackman)ごとの推定結果はほぼ重なる。下段: 入力信号の時間波形(最初の0.5秒)

上のコードに続けて、ピーク周波数でのPSD値とノイズフロアの揺らぎを具体的な数値で確認します。

# --- ピーク値の確認 ---
idx_w50 = np.argmin(np.abs(freqs_welch - 50))
idx_w120 = np.argmin(np.abs(freqs_welch - 120))
print("=== Welch法 (nperseg=512, noverlap=256) ===")
print(f"50Hz付近: freq={freqs_welch[idx_w50]:.2f} Hz, PSD={psd_welch[idx_w50]:.5f} V^2/Hz")
print(f"120Hz付近: freq={freqs_welch[idx_w120]:.2f} Hz, PSD={psd_welch[idx_w120]:.5f} V^2/Hz")
print(f"ピーク比(120Hz/50Hz) = {psd_welch[idx_w120] / psd_welch[idx_w50]:.4f} (理論値 0.5^2=0.25)")

idx_p50 = np.argmin(np.abs(freqs_periodo - 50))
idx_p120 = np.argmin(np.abs(freqs_periodo - 120))
print()
print("=== ピリオドグラム ===")
print(f"50Hz付近: freq={freqs_periodo[idx_p50]:.2f} Hz, PSD={psd_periodo[idx_p50]:.5f} V^2/Hz")
print(f"120Hz付近: freq={freqs_periodo[idx_p120]:.2f} Hz, PSD={psd_periodo[idx_p120]:.5f} V^2/Hz")
print(f"ピーク比(120Hz/50Hz) = {psd_periodo[idx_p120] / psd_periodo[idx_p50]:.4f}")

# --- ノイズフロア(130-200Hz帯)の変動係数 ---
mask_p = (freqs_periodo > 130) & (freqs_periodo < 200)
mask_w = (freqs_welch > 130) & (freqs_welch < 200)
print()
print(f"ノイズフロアCV(130-200Hz) ピリオドグラム: {psd_periodo[mask_p].std() / psd_periodo[mask_p].mean():.3f}")
print(f"ノイズフロアCV(130-200Hz) Welch:        {psd_welch[mask_w].std() / psd_welch[mask_w].mean():.3f}")

実行結果(実際に走らせた出力):

=== Welch法 (nperseg=512, noverlap=256) ===
50Hz付近: freq=50.78 Hz, PSD=0.13720 V^2/Hz
120Hz付近: freq=119.14 Hz, PSD=0.04132 V^2/Hz
ピーク比(120Hz/50Hz) = 0.3012 (理論値 0.5^2=0.25)

=== ピリオドグラム ===
50Hz付近: freq=50.00 Hz, PSD=1.33429 V^2/Hz
120Hz付近: freq=120.00 Hz, PSD=0.40055 V^2/Hz
ピーク比(120Hz/50Hz) = 0.3002

ノイズフロアCV(130-200Hz) ピリオドグラム: 1.099
ノイズフロアCV(130-200Hz) Welch:        0.289

ピリオドグラムは推定値が大きく揺らいでいるのに対し、Welch法は平均化によって滑らかな推定が得られています。50Hzと120Hzのピークが明確に現れ、ピーク比は両手法とも0.30程度で、振幅比0.5の理論値(\(0.5^2=0.25\) )に近い値です(周波数分解能の制約とノイズの影響で厳密な0.25からはややずれます)。ノイズフロアの変動係数(標準偏差/平均)を比較すると、ピリオドグラムは1.099とほぼ1.0(分散が理論通り大きい)であるのに対し、Welch法は0.289まで低減されており、セグメント数 \(K=14\) (\(N_{\text{total}}=4000\) 、nperseg=512、noverlap=256から算出)に対する理論値 \(1/\sqrt{14} \approx 0.267\) に近い値であることが確認できます。

実務上の落とし穴:スキャロッピングロスとピケットフェンス効果

窓関数を適用してもなお、信号の周波数がFFTのビン間隔に一致しない場合は振幅・パワーの推定値が過小評価されます。これは「ピケットフェンス効果」と呼ばれ、その損失量をスキャロッピングロスと言います。

窓関数の周波数応答 \(W(f)\) は、\(f=0\) (ビン中心)で最大値を取り、ビンとビンのちょうど中間(0.5ビンのずれ)で最小になります。スキャロッピングロス(dB)は次のように定義されます。

\[L_{\text{scallop}} = 20 \log_{10} \frac{|W(0.5)|}{|W(0)|} \tag{18}\]

矩形窓・Hann窓・フラットトップ窓について、40Hzちょうどのビンに乗る信号と、0.5ビン分ずれた信号(40.5相当)の振幅推定値を実際に計算して比較します。

import numpy as np
from scipy.signal import get_window
from scipy.signal.windows import hann

N = 256
fs = 256.0
n = np.arange(N)

def peak_amplitude(freq, window):
    """freq[Hz]の正弦波をwindowで切り出し、FFTピーク振幅を
    コヒーレントゲイン補正込みで推定する"""
    x = np.sin(2 * np.pi * freq / fs * n)
    X = np.fft.rfft(x * window)
    mag = np.abs(X) / (np.sum(window) / 2)
    return mag.max()

w_rect = np.ones(N)
w_hann = hann(N, sym=False)
w_flattop = get_window('flattop', N, fftbins=True)

for name, w in [('Rectangular', w_rect), ('Hann', w_hann), ('Flattop', w_flattop)]:
    on_bin = peak_amplitude(40.0, w)     # ビン中心に一致
    off_bin = peak_amplitude(40.5, w)    # 0.5ビン分ずれ
    loss_db = 20 * np.log10(off_bin / on_bin)
    print(f"{name:12s} on-bin={on_bin:.4f}  off-bin={off_bin:.4f}  scalloping loss={loss_db:.3f} dB")

# フラットトップ窓のENBW(N=1024で計算し直す)
N2 = 1024
w_flattop_1024 = get_window('flattop', N2, fftbins=True)
enbw_flattop = N2 * np.sum(w_flattop_1024**2) / np.sum(w_flattop_1024)**2
print(f"\nFlattop ENBW (N={N2}) = {enbw_flattop:.4f} bins")

実行結果(実際に走らせた出力):

Rectangular  on-bin=1.0000  off-bin=0.6392  scalloping loss=-3.887 dB
Hann         on-bin=1.0000  off-bin=0.8488  scalloping loss=-1.424 dB
Flattop      on-bin=1.0000  off-bin=0.9989  scalloping loss=-0.010 dB

Flattop ENBW (N=1024) = 3.7702 bins

矩形窓では最大で約3.9 dB(振幅にして約36%)も過小評価される一方、Hann窓では約1.4 dBに抑えられ、フラットトップ窓ではわずか0.010 dBとほぼ無視できるレベルになります。これはフラットトップ窓のメインローブが平坦(flat top)になるよう設計されているためで、scipy.signal.get_window('flattop', N) で生成できます。ただし代償としてメインローブ幅(ENBW)が広がり、実際に計算すると flattop のENBWは約3.77ビンで、Hann窓(1.50ビン)の2.5倍に達します。つまりフラットトップ窓は振幅の絶対値を正確に測りたい単一トーン測定(校正など)に向き、近接周波数の分離能力は大きく犠牲になるというトレードオフがあります。

なお scipy.signal.get_window(name, N)'hann'('kaiser', 6.0) のようにパラメータ付きの窓も文字列/タプルで指定できる統一インタフェースで、scipy.signal.welchwindow 引数にもそのまま渡せます。実務では「まず get_window で必要な窓を試作し、welch にそのまま渡す」という流れが簡潔です。

この効果を軽減する実務的な対策は次の2つです。

  • ゼロパディングによる補間: FFT点数を増やすと真のピークに近いビンが得られる可能性が上がりますが、周波数分解能自体(ENBW)は変わらないため、あくまで「見た目の」補間に過ぎません。
  • 窓関数の選択: 振幅の絶対値精度が必要ならフラットトップ窓、周波数分解能が必要ならHann窓など矩形窓に近い窓を使う、という使い分けが基本です。

窓関数の選び方ガイドライン

用途推奨窓関数理由
汎用的な周波数解析Hannサイドローブ抑制と分解能のバランスが良い
近接した周波数の分離矩形 / Kaiser(低β)メインローブが最も狭い
高ダイナミックレンジ解析Blackman / Kaiser(高β)サイドローブが最も低く、微弱信号の検出に有利
ハードウェア/リアルタイムHamming計算が単純で、端がゼロにならないため実装しやすい

実際の解析では、まずHann窓を試し、必要に応じて他の窓関数に切り替えるアプローチが推奨されます。

まとめ

  • スペクトル漏れは有限長信号の切り出し(矩形窓)による周波数領域での畳み込みが原因で発生する
  • 窓関数はメインローブ幅とサイドローブ抑制のトレードオフを制御し、Hann・Hamming・Blackman・Kaiserなど目的に応じた選択肢がある
  • **パワースペクトル密度(PSD)**は単位周波数あたりのパワーを示し、異なる条件間でのスペクトル比較を可能にする
  • ピリオドグラムは分散が大きいため、実用的にはWelch法によるセグメント平均が標準的な推定手法である
  • 窓関数の選択はPSDの推定精度にも影響するため、解析目的に応じた適切な選択が重要である

おすすめ書籍

はじめて学ぶディジタル・フィルタと高速フーリエ変換(三上直樹、CQ出版)

ディジタルフィルタと FFT の原理を基礎から丁寧に解説した定番の入門書です。本記事で扱った処理の理論的背景を体系的に学べます。

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

関連記事

参考文献

  • Harris, F. J. (1978). “On the Use of Windows for Harmonic Analysis with the Discrete Fourier Transform.” Proceedings of the IEEE, 66(1), 51-83.
  • Welch, P. D. (1967). “The Use of Fast Fourier Transform for the Estimation of Power Spectra.” IEEE Transactions on Audio and Electroacoustics, 15(2), 70-73.
  • Oppenheim, A. V., & Schafer, R. W. (2009). Discrete-Time Signal Processing (3rd ed.). Prentice Hall.
  • Astfalck, L. C., Sykulski, A. M., & Cripps, E. J. (2023). “Debiasing Welch’s Method for Spectral Density Estimation.” Biometrika. arXiv:2312.13643
  • NumPy FFT documentation
  • SciPy Signal Processing documentation