サビツキー・ゴーレイフィルタの理論とPython実装

Savitzky-Golayフィルタ(サビツキー・ゴーレイ)の理論・設計・Python実装(scipy.signal.savgol_filter, scipy.signal.savgol_coeffs, numpy)。局所多項式最小二乗フィッティングの数理、平滑化と高次微分推定、周波数応答、窓長・多項式次数の選び方、EMA・移動平均との比較までを解説します。

サビツキー・ゴーレイフィルタとは

サビツキー・ゴーレイ(Savitzky-Golay)フィルタは、移動窓内で局所的に多項式を最小二乗法でフィッティングし、その多項式の値を平滑化結果として用いるフィルタです。1964年にAbraham SavitzkyとMarcel J.E. Golayによって提案されました。

移動平均フィルタEMAフィルタ が信号のピークや急峻な変化を鈍らせてしまうのに対し、サビツキー・ゴーレイフィルタは信号の形状(ピーク位置、幅、高さ)を保存しながらノイズを除去できるという大きな特徴を持ちます。

主な用途

  • 分光分析データの平滑化
  • クロマトグラフィーのピーク検出
  • 生体信号(ECG、EEG)の前処理
  • 信号の微分の推定

数学的原理

局所多項式フィッティング

窓幅 \(2m+1\) (\(m\) は片側のデータ点数)の区間内で、\(p\) 次多項式を最小二乗法でフィッティングします。

窓の中心を \(n=0\) として、データ点 \(x_{-m}, x_{-m+1}, \dots, x_m\) に対して、以下の多項式を当てはめます:

\[ \hat{x}(n) = \sum_{k=0}^{p} a_k n^k = a_0 + a_1 n + a_2 n^2 + \cdots + a_p n^p \tag{1} \]

最小二乗問題は、残差の二乗和を最小化することです:

\[ \min_{\mathbf{a}} \sum_{n=-m}^{m} \left(x_n - \sum_{k=0}^{p} a_k n^k \right)^2 \tag{2} \]

行列表現と正規方程式

Vandermonde行列 \(\mathbf{A}\) を定義します:

\[ \mathbf{A} = \begin{bmatrix} (-m)^0 & (-m)^1 & \cdots & (-m)^p \\ (-m+1)^0 & (-m+1)^1 & \cdots & (-m+1)^p \\ \vdots & & \ddots & \vdots \\ m^0 & m^1 & \cdots & m^p \end{bmatrix} \tag{3} \]

正規方程式の解は:

\[ \mathbf{a} = (\mathbf{A}^T \mathbf{A})^{-1} \mathbf{A}^T \mathbf{x} \tag{4} \]

平滑化された中心点の値は \(\hat{x}(0) = a_0\) であり、これは \(\mathbf{x}\) に対する線形変換として表せます:

\[ \hat{x}_0 = \mathbf{c}^T \mathbf{x} \tag{5} \]

ここで \(\mathbf{c}\) は \((\mathbf{A}^T \mathbf{A})^{-1} \mathbf{A}^T\) の最初の行(\(a_0\) に対応)です。この \(\mathbf{c}\) がフィルタの畳み込み係数に相当します。

重要な性質

  1. 窓幅 \(2m+1\) と多項式次数 \(p\) の関係: \(p\) が大きいほど信号の形状を保存しますが、ノイズ除去能力は低下します。\(p = 0\) のとき、サビツキー・ゴーレイフィルタは単純移動平均と一致します。
  2. FIRフィルタとしての性質: 畳み込み係数 \(\mathbf{c}\) は窓幅と多項式次数のみで決まり、信号に依存しません。したがって、 FIRフィルタ の一種です。
  3. 微分の推定: \(a_1, a_2, \dots\) を取り出せば、信号の1次微分、2次微分も同時に推定できます。

係数の導出と畳み込みカーネルとしての解釈

式(4)の解 \(\mathbf{a} = (\mathbf{A}^T \mathbf{A})^{-1} \mathbf{A}^T \mathbf{x}\) を「最初の行だけ」でなく行列全体として見ると、\((\mathbf{A}^T \mathbf{A})^{-1} \mathbf{A}^T\) の各行が、多項式係数 \(a_0, a_1, \dots, a_p\) をそれぞれ \(\mathbf{x}\) の線形結合として与える重みになっていることが分かります。多項式(1)はテイラー展開そのものなので、\(k\) 次係数は \(a_k = \hat{x}^{(k)}(0)/k!\) という関係を持ちます。したがって \(d\) 次微分の中心点推定値は、次のように書けます(\([\cdot]_{d,:}\) は行列の \(d\) 行目、0-indexed)。

\[ \hat{x}^{(d)}(0) = d! \cdot a_d = d! \cdot \big[(\mathbf{A}^T \mathbf{A})^{-1} \mathbf{A}^T\big]_{d,:} \, \mathbf{x} \tag{6} \]

\(d=0\) の行が平滑化係数(式5の \(\mathbf{c}\) )、\(d=1\) が1次微分係数、\(d=2\) が2次微分係数に対応します。この係数ベクトルは 信号 \(\mathbf{x}\) に依存せず窓幅と次数だけで決まる ため、サビツキー・ゴーレイフィルタは窓を1点ずつスライドさせながら同じ係数ベクトルを畳み込むLTI(線形時不変)システムであり、通常のFIRフィルタと全く同じ枠組みで扱えます。

手計算での検証:window=5, p=2

具体例として \(m=2\) (窓幅5)、\(p=2\) の場合を見ます。式(3)のVandermonde行列は次の \(5 \times 3\) 行列になります。

\[ \mathbf{A} = \begin{bmatrix} 1 & -2 & 4 \\ 1 & -1 & 1 \\ 1 & 0 & 0 \\ 1 & 1 & 1 \\ 1 & 2 & 4 \end{bmatrix} \]

これを式(4)に代入し \(d=0\) の行を取り出すと、係数は \(\left(-\frac{3}{35}, \frac{12}{35}, \frac{17}{35}, \frac{12}{35}, -\frac{3}{35}\right)\) となります。これはSavitzky & Golay (1964) の原論文に掲載されている5点二次多項式の平滑化係数と一致する、よく知られた結果です。

Python実装による導出の数値検証

上記の導出をそのままコードに落とし、scipy.signal.savgol_coeffsの出力と比較して正しさを確認します。

import numpy as np
from math import factorial
from scipy.signal import savgol_coeffs

def sg_coeffs_manual(window_length, polyorder, deriv=0, delta=1.0):
    m = (window_length - 1) // 2
    n = np.arange(-m, m + 1)
    # Vandermonde行列: A[i, k] = n_i^k  (式3と同じ定義)
    A = np.vander(n, N=polyorder + 1, increasing=True)
    # 正規方程式の解行列 (A^T A)^{-1} A^T  (式4)
    AtA_inv_At = np.linalg.inv(A.T @ A) @ A.T
    # d次微分係数は d! 倍した d 行目 (式6)
    row = AtA_inv_At[deriv, :]
    coeffs = factorial(deriv) * row / (delta ** deriv)
    return coeffs

# window=5, p=2 の平滑化係数(手計算した値と比較)
c_5_2 = sg_coeffs_manual(5, 2, deriv=0)
print("window=5, p=2:", np.round(c_5_2, 6))
# scipyのデフォルト(use='conv')ではなく use='dot' で比較する
# (理由は次の実行結果の後で説明)
c_scipy_dot = savgol_coeffs(5, 2, deriv=0, use='dot')
print("scipy (use=dot):", np.round(c_scipy_dot, 6))

# window/order/derivを変えた網羅的な比較
cases = [(5,2,0), (7,2,0), (9,3,0), (11,3,0), (11,4,0),
         (7,2,1), (9,3,1), (11,4,1),
         (9,4,2), (11,5,2)]
max_diff = 0.0
for w, p, d in cases:
    manual = sg_coeffs_manual(w, p, deriv=d)
    scipy_dot = savgol_coeffs(w, p, deriv=d, use='dot')
    max_diff = max(max_diff, np.max(np.abs(manual - scipy_dot)))
print("全ケースでの最大差 (use='dot'):", max_diff)

# デフォルト設定(use='conv')との比較も試す
max_diff_conv = 0.0
for w, p, d in cases:
    manual = sg_coeffs_manual(w, p, deriv=d)
    scipy_conv = savgol_coeffs(w, p, deriv=d)  # デフォルト use='conv'
    max_diff_conv = max(max_diff_conv, np.max(np.abs(manual - scipy_conv)))
print("全ケースでの最大差 (デフォルトuse='conv'):", max_diff_conv)

実行結果は以下の通りです。

  • window=5, p=2: 手計算・コード導出とも [-0.085714, 0.342857, 0.485714, 0.342857, -0.085714](\(=(-3, 12, 17, 12, -3)/35\) )で完全一致
  • use='dot'指定時の全ケース最大差: 1.965e-14(浮動小数点誤差の範囲で完全一致、導出の正しさを確認)
  • デフォルト(use='conv')との最大差: 0.3249(一致しない)

注意点(実装上の落とし穴): savgol_coeffsのデフォルトuse='conv'は、np.convolveにそのまま渡して使うことを想定した順序(use='dot'の配列を反転したもの)で係数を返します。平滑化係数(\(d=0\) )や2次微分係数(\(d=2\) )は窓の中心について左右対称なので反転しても値は変わりませんが、1次微分係数(\(d=1\) )は反対称(符号が反転する)であるため、反転すると符号付きで別の配列になります。実際、上記の比較では\(d=1\) を含むケースで最大差0.21〜0.33程度の不一致が生じました。自前で導出した係数をnp.dotで直接内積を取る用途に使うならuse='dot'相当、np.convolveに渡すならuse='conv'相当([::-1]で反転)が必要です。この違いを見落とすと、1次微分の符号が反転したまま気づかずに使ってしまうバグにつながります。

周波数特性

サビツキー・ゴーレイフィルタの周波数応答は、畳み込み係数 \(\mathbf{c}\) のDTFT(離散時間フーリエ変換)で得られます:

\[ H(e^{j\omega}) = \sum_{n=-m}^{m} c_n e^{-j\omega n} \tag{7} \]

EMA・移動平均との比較

フィルタ通過帯域遮断特性信号形状保存群遅延
サビツキー・ゴーレイ広いなだらか優秀一定(線形位相)
EMAパラメータ依存なだらか中程度周波数依存
移動平均窓幅依存なだらか低い一定(線形位相)
バターワース急峻(次数依存)急峻設計次第非線形

サビツキー・ゴーレイフィルタは線形位相特性を持つため、信号のピーク位置がシフトしません。これは EMAフィルタ (非線形位相)との大きな違いです。

なぜ高次多項式フィッティングはピーク形状を保存できるのか

単純移動平均 は \(p=0\) の多項式(定数)フィッティングに相当します。窓内の信号をテイラー展開 \(x(n) = x(0) + x'(0) n + \frac{1}{2}x''(0) n^2 + \cdots\) で近似すると、定数フィッティング(=単純平均)の期待値は次のように書けます。

\[ E[\bar{x}] \approx x(0) + \frac{x''(0)}{2} \cdot \frac{1}{2m+1}\sum_{n=-m}^{m} n^2 = x(0) + \frac{x''(0)}{2} \cdot \frac{m(m+1)}{3} \tag{8} \]

(奇数次の項 \(x'(0) n\) は対称窓での和がゼロになるため消えます。)ピークの頂点では曲率 \(x''(0) < 0\) であり、しかもその係数 \(m(m+1)/3\) は窓幅の2乗のオーダーで増大するため、窓が広いほど、また信号の曲率が急なほど、単純移動平均はピークを系統的に過小評価(鈍化)します。EMAも指数重み付きではあるものの本質的には同じ定数近似に基づくため、同種のバイアスを抱えます。

これに対し \(p \geq 2\) の多項式フィッティングは \(x''(0)\) の項を明示的にモデルに含むため、窓内の信号が局所的に多項式でよく近似できる限り、このバイアスは(近似の残差程度まで)打ち消されます。これがサビツキー・ゴーレイフィルタが移動平均やEMAと本質的に異なる点であり、次節でこの効果を定量的に確認します。

Python実装

scipy.signal.savgol_filter

scipy.signalsavgol_filterを使えば、1行でサビツキー・ゴーレイフィルタを適用できます。

import numpy as np
from scipy.signal import savgol_filter
import matplotlib.pyplot as plt

# テストデータ生成
np.random.seed(42)
t = np.linspace(0, 1, 500)
# ピークを含む信号 + ノイズ
signal_clean = np.sin(2 * np.pi * 3 * t) + 0.5 * np.exp(-((t - 0.3) / 0.02)**2)
noise = 0.2 * np.random.randn(len(t))
signal_noisy = signal_clean + noise

# サビツキー・ゴーレイフィルタ適用
# window_length: 窓幅(奇数), polyorder: 多項式次数
y_sg = savgol_filter(signal_noisy, window_length=21, polyorder=3)

# 移動平均との比較
kernel = np.ones(21) / 21
y_ma = np.convolve(signal_noisy, kernel, mode='same')

# プロット
fig, axes = plt.subplots(2, 1, figsize=(12, 8))

axes[0].plot(t, signal_clean, 'g', label='Original', linewidth=2)
axes[0].plot(t, signal_noisy, 'gray', alpha=0.5, label='Noisy')
axes[0].plot(t, y_sg, 'b', label='Savitzky-Golay (w=21, p=3)')
axes[0].plot(t, y_ma, 'r--', label='Moving Average (w=21)')
axes[0].set_title('Smoothing Comparison')
axes[0].legend()
axes[0].grid(True)

# ピーク付近の拡大
mask = (t > 0.2) & (t < 0.4)
axes[1].plot(t[mask], signal_clean[mask], 'g', label='Original', linewidth=2)
axes[1].plot(t[mask], y_sg[mask], 'b', label='Savitzky-Golay')
axes[1].plot(t[mask], y_ma[mask], 'r--', label='Moving Average')
axes[1].set_title('Peak Region (Zoomed)')
axes[1].legend()
axes[1].grid(True)

plt.tight_layout()
plt.show()

ピーク付近を拡大すると、移動平均がピークを大幅に鈍らせるのに対し、サビツキー・ゴーレイフィルタはピークの位置と高さをほぼ保存していることが確認できます。

境界効果とパディング方式の比較

窓幅 \(2m+1\) の半分(\(m\) 点)が信号の範囲外にはみ出す境界付近では、はみ出した部分をどう扱うかによって推定精度が変わります。scipy.signal.savgol_filtermode引数は、代表的な4つの方式を選べます。

  • mirror: 端の値を鏡映(反転)してパディングする(端点自体は繰り返さない)
  • nearest / constant: 端の値(または指定した定数)をそのまま繰り返して埋める
  • wrap: 信号を周期的とみなして反対側の値で埋める
  • interp(デフォルト): 境界の\(2m+1\) 点だけを使い、中心をずらした**片側フィッティング(多項式外挿)**を境界の各点ごとに行う

mirrorは「境界での微分がゼロ(信号が偶対称)」、constant/nearestは「境界で信号が平坦」、wrapは「信号が周期的」という、それぞれ異なる暗黙の仮定を置いています。信号がその仮定を満たさない場合はバイアスが生じます。一方interpは窓を片側にずらして実際のデータに多項式を当てはめ直すため、対称性や周期性を仮定しない代わりに、中心にフィットする場合よりデータの偏りが大きく分散(ノイズ耐性)は悪化します。

これを定量的に確認するため、明確な非周期的トレンドを持つ信号(周期関数ではなく、境界での傾き・曲率がゼロでない信号)で各方式の誤差を比較します。

import numpy as np
from scipy.signal import savgol_filter

t = np.linspace(0, 1, 500)
# 非周期・非平坦な信号(境界でも傾き/曲率がゼロでない)
signal_clean = t**2 + 0.3 * np.sin(2 * np.pi * 5 * t)

window_length = 21
polyorder = 3
m = window_length // 2  # 10

modes = ['mirror', 'nearest', 'constant', 'wrap', 'interp']
n_trials = 200
noise_sigma = 0.15

boundary_rmse = {mode: [] for mode in modes}
interior_rmse = {mode: [] for mode in modes}

rng = np.random.default_rng(7)
for _ in range(n_trials):
    noise = noise_sigma * rng.standard_normal(len(t))
    signal_noisy = signal_clean + noise
    for mode in modes:
        y = savgol_filter(signal_noisy, window_length, polyorder, mode=mode)
        err_boundary = np.concatenate([y[:m] - signal_clean[:m], y[-m:] - signal_clean[-m:]])
        err_interior = y[m:-m] - signal_clean[m:-m]
        boundary_rmse[mode].append(np.sqrt(np.mean(err_boundary**2)))
        interior_rmse[mode].append(np.sqrt(np.mean(err_interior**2)))

print(f"{'mode':>10} {'boundary RMSE':>15} {'interior RMSE':>15}")
for mode in modes:
    print(f"{mode:>10} {np.mean(boundary_rmse[mode]):15.5f} {np.mean(interior_rmse[mode]):15.5f}")

# 境界の最初のサンプル(t=0, 真値=0)におけるバイアスと分散を分解
first_vals = {mode: [] for mode in modes}
rng = np.random.default_rng(7)
for _ in range(n_trials):
    noise = noise_sigma * rng.standard_normal(len(t))
    signal_noisy = signal_clean + noise
    for mode in modes:
        y = savgol_filter(signal_noisy, window_length, polyorder, mode=mode)
        first_vals[mode].append(y[0])
print(f"\ntrue value at t=0: {signal_clean[0]:.5f}")
for mode in modes:
    vals = np.array(first_vals[mode])
    print(f"{mode:>10}: bias={np.mean(vals) - signal_clean[0]:+.5f}  std={np.std(vals):.5f}")

実行結果(200試行平均、window=21, polyorder=3, ノイズ \(\sigma=0.15\) )は次の通りです。

mode境界RMSE内部RMSE(参考)
mirror0.056740.04926
nearest0.058800.04926
constant0.155300.04926
wrap0.214410.04926
interp0.064850.04926

内部RMSEはどのmodeでも同一(境界処理は端点にしか影響しない)である一方、境界RMSEには明確な差が出ます。特にwrapはこの信号が周期的でないため大きく悪化し(0.214、内部の4倍以上)、constantも端が平坦という誤った仮定のせいで内部の3倍以上に悪化しています。

さらに最初のサンプル(\(t=0\) 、真値0)だけを見ると、バイアスと分散のトレードオフがはっきり表れます。

modeバイアス標準偏差
mirror+0.041240.07197
nearest+0.017960.09486
constant+0.020330.03931
wrap+0.457660.04949
interp-0.002380.11948

interp(デフォルト)は境界の対称性・平坦性・周期性を仮定しない片側フィッティングであるため、バイアスは実質ゼロ(-0.0024)まで下がりますが、中心から外れた点にフィットする分散が悪化し、標準偏差はどの方式よりも大きくなっています(0.1195、constantの3倍)。wrapはバイアスが際立って大きく(+0.458)、この信号のように非周期的なデータには不適切であることが分かります。まとめると、境界処理は「信号の境界での振る舞いに関する仮定が正しいか」で選ぶべきで、周期信号でなければwrapは避け、境界に明確なトレンドがあるならinterp(デフォルト)かmirrorが無難な選択になります。

パラメータの影響

窓幅と多項式次数がフィルタの特性に与える影響を可視化します。

import numpy as np
from scipy.signal import savgol_filter
import matplotlib.pyplot as plt

np.random.seed(42)
t = np.linspace(0, 1, 500)
signal_clean = np.sin(2 * np.pi * 3 * t) + 0.5 * np.exp(-((t - 0.3) / 0.02) ** 2)
signal_noisy = signal_clean + 0.2 * np.random.randn(len(t))

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 窓幅の影響(多項式次数固定: p=3)
for w in [5, 11, 21, 41]:
    y = savgol_filter(signal_noisy, window_length=w, polyorder=3)
    axes[0].plot(t, y, label=f'w={w}')
axes[0].plot(t, signal_clean, 'k--', alpha=0.5, label='Original')
axes[0].set_title('Effect of Window Length (polyorder=3)')
axes[0].legend()
axes[0].grid(True)

# 多項式次数の影響(窓幅固定: w=21)
for p in [0, 1, 2, 3, 5]:
    y = savgol_filter(signal_noisy, window_length=21, polyorder=p)
    axes[1].plot(t, y, label=f'p={p}')
axes[1].plot(t, signal_clean, 'k--', alpha=0.5, label='Original')
axes[1].set_title('Effect of Polynomial Order (window=21)')
axes[1].legend()
axes[1].grid(True)

plt.tight_layout()
plt.show()

定性的な考察:

  • 窓幅が大きいほど強い平滑化が得られますが、信号の細かい構造が失われます
  • 多項式次数 \(p = 0\) は単純移動平均と等価です
  • \(p\) が窓幅に近いと、ノイズ除去効果がほとんどなくなります

この定性的な傾向を定量的に裏付けるため、窓幅と多項式次数を格子状に変え、真の信号に対するRMSE(二乗平均平方根誤差)を測定します(境界付近を除いた内部領域のみで評価)。

import numpy as np
from scipy.signal import savgol_filter

t = np.linspace(0, 1, 500)
signal_clean = np.sin(2 * np.pi * 3 * t) + 0.5 * np.exp(-((t - 0.3) / 0.02) ** 2)

windows = [5, 9, 11, 15, 21, 31, 41, 61]
orders = [0, 2, 3, 5]
sigma = 0.2
n_trials = 80
m_exclude = 30

results = {p: [] for p in orders}
for p in orders:
    for w in windows:
        if p >= w:
            results[p].append(None)
            continue
        rng = np.random.default_rng(hash((w, p)) % (2**31))
        errs = []
        for _ in range(n_trials):
            noise = sigma * rng.standard_normal(len(t))
            y = signal_clean + noise
            ys = savgol_filter(y, w, p)
            e = ys[m_exclude:-m_exclude] - signal_clean[m_exclude:-m_exclude]
            errs.append(np.sqrt(np.mean(e**2)))
        results[p].append(np.mean(errs))

for p in orders:
    row = ", ".join(f"w={w}:{v:.4f}" for w, v in zip(windows, results[p]) if v is not None)
    print(f"p={p}: {row}")

実行結果(ノイズ \(\sigma=0.2\) 、80試行平均のRMSE)は次のとおりです。

window\orderp=0p=2p=3p=5
50.08920.13970.1386
90.06700.10100.10100.1291
110.06030.09120.09100.1142
150.05350.07700.07790.0972
210.05130.06560.06510.0822
310.06280.05490.05570.0674
410.08890.04980.05010.0598
610.16070.05240.05180.0521

下図(a)はこのグリッドをプロットしたものです。

窓幅・多項式次数のトレードオフと、ノイズレベル別の微分推定の最適窓幅

このグリッドから読み取れる、単純な「窓は広いほど良い/次数は高いほど良い」ではないトレードオフ構造は次の通りです。

  • \(p=0\) (移動平均相当)は \(w=21\) でRMSEが最小(0.0513)となり、それより広げると急速に悪化します(\(w=61\) で0.1607、最小値の3倍以上)。これは前節で導出した曲率バイアス(式8)が窓幅の2乗で効いてくるためです。
  • \(p=2,3\) は \(w=41\) まで単調にRMSEが改善し続けます(0.0498, 0.0501)。曲率をモデル化できるため、より広い窓で平均化してもピーク周辺のバイアスが小さいままノイズだけを減らせるからです。
  • \(p=5\) はどの窓幅でも \(p=2,3\) より悪化しています(例えば \(w=21\) で0.0822 vs 0.0651-0.0656)。次数を上げすぎるとフィッティングがノイズにも追従してしまい(過学習)、分散が増えて逆効果になることが確認できます。

つまり「次数を上げすぎるとノイズに過適合し、窓を広げすぎると(次数が足りない場合は)ピーク形状が歪む」というトレードオフが実測でき、この信号・ノイズ条件では \(p=2\) または \(p=3\) 、\(w=21\) 〜 \(41\) 程度が良好という、既存の経験則を裏付ける結果が得られました。

移動平均・EMAとのピーク形状保存性能の比較

前節で導出した「高次多項式がピーク形状を保存できる理由」(式8)を、実際のピーク保存性能として定量的に確認します。孤立したガウス型ピーク(振幅1、半値全幅約33サンプル)にノイズを加え、移動平均・EMA・サビツキー・ゴーレイ(\(p=2,3,5\) )でそれぞれ平滑化した際の、ピーク高さの再現率を比較します。

import numpy as np
from scipy.signal import savgol_filter

t = np.linspace(0, 1, 500)
peak_center, peak_width, peak_height = 0.5, 0.02, 1.0
signal_clean = peak_height * np.exp(-((t - peak_center) / peak_width) ** 2)
true_peak_idx = np.argmax(signal_clean)
true_peak_val = signal_clean[true_peak_idx]

def moving_average(x, w):
    kernel = np.ones(w) / w
    return np.convolve(x, kernel, mode='same')

def ema(x, alpha):
    y = np.zeros_like(x)
    y[0] = x[0]
    for i in range(1, len(x)):
        y[i] = alpha * x[i] + (1 - alpha) * y[i - 1]
    return y

n_trials = 200
sigma = 0.1
w = 21
alpha = 2 / (w + 1)  # windowと同等のスパンに合わせたEMA係数

methods = {
    "Moving Average": lambda y: moving_average(y, w),
    "EMA": lambda y: ema(y, alpha),
    "SG (p=2)": lambda y: savgol_filter(y, w, 2),
    "SG (p=3)": lambda y: savgol_filter(y, w, 3),
    "SG (p=5)": lambda y: savgol_filter(y, w, 5),
}

peak_heights = {name: [] for name in methods}
rng = np.random.default_rng(5)
for _ in range(n_trials):
    noise = sigma * rng.standard_normal(len(t))
    y = signal_clean + noise
    search = slice(max(0, true_peak_idx - 30), true_peak_idx + 31)
    for name, fn in methods.items():
        yf = fn(y)
        peak_heights[name].append(np.max(yf[search]))

print(f"true peak height = {true_peak_val:.3f}")
for name, vals in peak_heights.items():
    vals = np.array(vals)
    mean = np.mean(vals)
    bias_pct = (mean / true_peak_val - 1) * 100
    print(f"{name:>16}: mean peak={mean:.4f} (bias {bias_pct:+.1f}%), std={np.std(vals):.4f}")

実行結果(window=21相当、ノイズ \(\sigma=0.1\) 、200試行平均、真のピーク高さ=0.997)は次の通りです。

手法平均ピーク高さバイアス標準偏差
Moving Average0.7289-26.9%0.0195
EMA0.6845-31.4%0.0197
SG (p=2)0.9665-3.1%0.0305
SG (p=3)0.9665-3.1%0.0305
SG (p=5)1.0035+0.6%0.0393

移動平均とEMAはいずれもピーク高さを27〜31%も過小評価するのに対し、\(p=2,3\) のサビツキー・ゴーレイフィルタはバイアスをわずか3%程度に抑えています(ただし標準偏差はやや増加)。\(p=5\) はバイアスがほぼゼロになる一方、ノイズに追従しやすくなり標準偏差が最大になっています。なお、SG (p=2)SG (p=3)の結果が小数点以下まで完全に一致している点にも注目してください。これは偶然ではなく、対称窓においては次数を偶数から1つ上げても(\(p \to p+1\) 、\(p\) が偶数)平滑化係数の値自体は変化しない、という良く知られた性質(式6の \(d=0\) 行が同一になる)が数値的に確認された形です。

また、f''(peak)から式(8)を使って移動平均のバイアスを事前に見積もると、以下のように概算できます。

h = 1e-6
f = lambda tt: peak_height * np.exp(-((tt - peak_center) / peak_width) ** 2)
f2 = (f(peak_center + h) - 2 * f(peak_center) + f(peak_center - h)) / h**2
dt = t[1] - t[0]
f2_sample = f2 * dt**2  # サンプル単位の2階差分に変換
m = w // 2
predicted_bias_pct = (f2_sample / 2) * (m * (m + 1) / 3) / true_peak_val * 100
print(f"式(8)による予測バイアス: {predicted_bias_pct:.1f}%  (実測: -26.9%)")

この計算では予測バイアスが約-36.9%となり、実測の-26.9%と同じオーダーで一致します(テイラー2次近似なので、ピーク幅に対して窓が相対的に広い今回の条件では高次項の影響により厳密には一致しませんが、式(8)が定性的にも定量的にも移動平均のバイアスの起源を正しく説明していることが確認できます)。

微分推定の精度:ノイズ耐性とバイアスのトレードオフ

サビツキー・ゴーレイフィルタの重要な特徴として、平滑化と同時に微分を推定できます。式(6)より、\(d\) 次微分の推定値は係数ベクトル \(\mathbf{c}_d\) と観測データ \(\mathbf{x} = \mathbf{s} + \mathbf{e}\) (\(\mathbf{s}\) : 真の信号、\(\mathbf{e}\) : iidノイズ、分散 \(\sigma^2\) )の内積 \(\hat{x}^{(d)}(0) = \mathbf{c}_d^T \mathbf{x}\) で与えられるため、その分散は次のように解析的に求まります(ノイズゲインと呼ばれます)。

\[ \mathrm{Var}\big[\hat{x}^{(d)}(0)\big] = \sigma^2 \, \mathbf{c}_d^T \mathbf{c}_d = \sigma^2 \sum_{n=-m}^{m} c_{d,n}^2 \tag{9} \]

まずこの理論式を、純粋なホワイトノイズに対してSG微分フィルタを適用し、出力の経験分散と比較することで検証します。

import numpy as np
from scipy.signal import savgol_coeffs

rng = np.random.default_rng(0)
sigma = 1.0
N = 200000
noise = sigma * rng.standard_normal(N)

cases = [(11, 2, 0), (21, 2, 0), (41, 2, 0),
         (11, 3, 1), (21, 3, 1), (41, 3, 1),
         (11, 4, 2), (21, 4, 2), (41, 4, 2)]

for w, p, d in cases:
    c = savgol_coeffs(w, p, deriv=d, use='dot')
    theory = sigma**2 * np.sum(c**2)
    filtered = np.convolve(noise, c[::-1], mode='valid')  # dot規約でのフィルタ適用
    emp = np.var(filtered)
    print(f"w={w:3d} p={p} d={d}: theory={theory:.6f}  empirical={emp:.6f}")

実行結果は以下の通りで、理論値と経験値がいずれも1%未満の差で一致し、式(9)を数値的に確認できました。

windoworderderiv理論 \(\sigma^2\sum c^2\)経験分散
11200.2074590.206531
21200.1075510.107034
41200.0549330.054761
11310.0603790.060377
21310.0082490.008215
41310.0010930.001083
11420.0653650.065790
21420.0022620.002254
41420.0000770.000076

このノイズゲインは窓幅 \(w\) を大きくするほど急速に減少しますが、その減少速度は微分次数が高いほど急峻です。上表で \(w=11 \to 41\) (約3.7倍)の変化を見ると、平滑化(\(d=0\) )ではノイズゲインが約3.8倍しか減らないのに対し、1次微分(\(d=1\) )では約55倍、2次微分(\(d=2\) )では約850倍も減少しています。これは高次微分ほど、ノイズを抑えるために広い窓を使うメリットが大きい(その代わりバイアスへの影響も大きい)ことを意味します。

一方で窓を広げるほど、局所多項式では捉えきれない信号の高次成分によるバイアスが増えます。ノイズレベルに応じて最適な窓幅がどうシフトするかを、既知の解析的な1次微分を持つ信号で確認します。

import numpy as np
from scipy.signal import savgol_filter

t = np.linspace(0, 1, 500)
dt = t[1] - t[0]
signal_clean = np.sin(2 * np.pi * 3 * t) + 0.5 * np.exp(-((t - 0.3) / 0.02) ** 2)
dy_true = 2 * np.pi * 3 * np.cos(2 * np.pi * 3 * t) + \
          0.5 * (-2 * (t - 0.3) / 0.02**2) * np.exp(-((t - 0.3) / 0.02) ** 2)

windows_b = [5, 7, 9, 11, 15, 21, 31, 41, 61, 81]
noise_levels = [0.02, 0.1, 0.3]
polyorder = 3
m_exclude = 30

for sigma in noise_levels:
    rmses = []
    for w in windows_b:
        rng = np.random.default_rng(int(sigma * 1000) + w)
        errs = []
        for _ in range(60):
            noise = sigma * rng.standard_normal(len(t))
            y = signal_clean + noise
            dy = savgol_filter(y, w, polyorder, deriv=1, delta=dt)
            e = dy[m_exclude:-m_exclude] - dy_true[m_exclude:-m_exclude]
            errs.append(np.sqrt(np.mean(e**2)))
        rmses.append(np.mean(errs))
    best_idx = int(np.argmin(rmses))
    print(f"sigma={sigma}: best window={windows_b[best_idx]}, RMSE={rmses[best_idx]:.4f}")

実行結果(1次微分、polyorder=3、内部領域のみのRMSE、60試行平均)は次の通りです。上図(b)にこのRMSE曲線をプロットしています。

ノイズ \(\sigma\)最適な窓幅最小RMSE
0.02310.8641
0.1412.1455
0.361〜81(ほぼ横ばい)3.8606

ノイズが小さいとき(\(\sigma=0.02\) )は狭めの窓(31)が最適ですが、ノイズが大きくなるにつれて最適な窓幅は単調に広がり(41 → 61〜81)、\(\sigma=0.3\) では61を超えるとほとんど改善しなくなります。これは式(9)のノイズゲイン低減効果と、窓を広げることによるバイアス増加が釣り合う点が、ノイズレベルに応じてシフトすることを直接示しています。微分推定では、ノイズが多い観測ほど、通常の平滑化以上に大きな窓幅を選ぶ必要があるという実務上の指針が得られます。

周波数応答の可視化

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import savgol_coeffs, freqz

# フィルタ係数の取得
for w, p in [(11, 2), (21, 3), (41, 3)]:
    coeffs = savgol_coeffs(w, p)
    freq, response = freqz(coeffs, worN=2048)
    plt.plot(freq / np.pi, 20 * np.log10(np.abs(response)),
             label=f'w={w}, p={p}')

plt.xlabel('Normalized Frequency (×π rad/sample)')
plt.ylabel('Gain (dB)')
plt.title('Savitzky-Golay Filter Frequency Response')
plt.legend()
plt.grid(True)
plt.ylim(-60, 5)
plt.tight_layout()
plt.show()

他のフィルタとの使い分け

用途推奨フィルタ理由
ピークを保存した平滑化サビツキー・ゴーレイ多項式フィッティングにより形状保存
リアルタイム平滑化EMA\(O(1)\) の計算コスト、因果的
特定周波数帯域の除去バターワース / ノッチ急峻な遮断特性
ノイズ統計が既知ウィーナー / カルマン統計的最適性
微分の推定サビツキー・ゴーレイ平滑化微分が一体
非定常信号ウェーブレット時間-周波数解析

近年の研究動向

サビツキー・ゴーレイフィルタは60年以上前の手法ですが、固定の窓幅・多項式次数では信号全体に最適なパラメータを選べないという課題(本記事の「窓幅・多項式次数の選択指針」節で実測した通り)に対応するため、2023年以降も応用分野で改良が続いています。

  • 適応窓幅化とスペクトル分析への応用: Altynbekovら(2023)は、テラヘルツ吸収スペクトルのノイズ除去において、スペクトル全体で窓幅を固定するのではなく、スライディングウィンドウ的に窓を変化させるサビツキー・ゴーレイフィルタの変種を提案しています。また、大気ガス混合物の吸収スペクトル解析では、標準的なサビツキー・ゴーレイフィルタと独立成分分析(ICA)を組み合わせた適応フィルタが提案されており、SO2/H2Oガス混合物の実験データにおいて、濃度推定の相対誤差が標準的な固定パラメータのサビツキー・ゴーレイフィルタと比べて約3.7倍小さくなったと報告されています(Atmospheric and Oceanic Optics誌)。これらはいずれも、強い吸収線と弱い吸収線とでは最適な平滑化パラメータが異なるという、本記事で導出したバイアス・分散トレードオフと同じ問題意識に基づいています。
  • ラマン分光データでの深層学習との比較: Gilら(2023, Journal of Raman Spectroscopy)は、シミュレーションデータで学習した単層畳み込みニューラルネットワークによるラマンスペクトルのノイズ除去を、サビツキー・ゴーレイフィルタと比較検証しました。SN比が低い条件では畳み込みネットワークがサビツキー・ゴーレイフィルタの性能を上回ると報告されている一方、サビツキー・ゴーレイフィルタは依然としてラマン分光の前処理で最も広く使われる手法の一つであり、計算コストの低さと解釈性の高さから実務での採用が続いています。

これらの研究動向を踏まえると、サビツキー・ゴーレイフィルタそのものの数理は本記事で導出した最小二乗多項式フィッティングから変わっていませんが、「窓幅・次数をデータに応じて動的に選ぶ」方向への拡張と、「深層学習ベースの手法との性能比較」の両輪で研究が進んでいると言えます。なお、これらは個別分野の応用論文であり、汎用的な決定版の後継アルゴリズムが確立したわけではない点には注意してください。

おすすめ書籍

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

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

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

関連記事

参考文献

  • Savitzky, A., & Golay, M. J. E. (1964). Smoothing and Differentiation of Data by Simplified Least Squares Procedures. Analytical Chemistry, 36(8), 1627-1639.
  • Schafer, R. W. (2011). What Is a Savitzky-Golay Filter? IEEE Signal Processing Magazine, 28(4), 111-117.
  • Altynbekov, A., et al. (2023). The possibility of increasing the efficiency of terahertz absorption spectra noise reduction using a sliding window variant of Savitzky-Golay filter.
  • Gil, D., et al. (2023). Denoising Raman spectra using a single layer convolutional model trained on simulated data. Journal of Raman Spectroscopy.
  • Adaptive Savitzky–Golay Filter for Denoising Gas Mixture Absorption Spectra. Atmospheric and Oceanic Optics.
  • scipy.signal.savgol_filter documentation