常见量化算法系列 - Savitzky-Golay 滤波

阿凡
阿凡 2025,大牛市快来了 V:flj99103681

0 People liked this article · 409 views

常见量化算法系列 

Savitzky-Golay 滤波:保峰的"精修磨皮"

文 / 阿凡

如果说高斯平滑是"美图秀秀一键磨皮",那 Savitzky-Golay(简称 SG)滤波就是"专业修图师精修"。

高斯平滑:波峰被削低,波谷被填高(像磨皮过度,五官扁平了)
SG 滤波:保留波峰波谷的高度和宽度(只磨瑕疵,五官依然立体)

为什么?因为它用的不是"加权平均",而是局部多项式拟合

💡 提示:本文所有代码均可直接复制运行,完整源码在文章末尾。


第一部分

从零手搓:SG 滤波完整实现

不调用任何现成库,我们用 NumPy 从零开始,一步一步实现 SG 滤波。整个过程分为 5 个步骤

Step 1:构建范德蒙矩阵

SG 滤波的核心是"在局部窗口内拟合一条多项式曲线"。要拟合曲线,首先要构造范德蒙矩阵(Vandermonde matrix)

假设窗口大小为 5,窗口中心的索引为 0,则窗口内各点的相对位置为:

k = [-2, -1, 0, 1, 2]

用 2 阶多项式(y = a₀ + a₁·k + a₂·k²)去拟合,范德蒙矩阵的每一列就是 k 的 0 次、1 次、2 次幂:

PYTHON · Step 1:范德蒙矩阵
import numpy as np

# 窗口大小 5,中心点索引为 0
window = 5
half = window // 2          # half = 2
k = np.arange(-half, half + 1)   # [-2, -1, 0, 1, 2]

# 用 2 阶多项式拟合
poly_order = 2

# 构建范德蒙矩阵:每一列 = k 的 j 次幂
V = np.vander(k, poly_order + 1, increasing=True)
# V 的形状: (5, 3)
# V = [[ 1, -2,  4],     ← k=-2 时的 [k⁰, k¹, k²]
#       [ 1, -1,  1],     ← k=-1 时的 [k⁰, k¹, k²]
#       [ 1,  0,  0],     ← k= 0 时的 [k⁰, k¹, k²]
#       [ 1,  1,  1],     ← k= 1 时的 [k⁰, k¹, k²]
#       [ 1,  2,  4]]     ← k= 2 时的 [k⁰, k¹, k²]

范德蒙矩阵的本质:每一行代表一个采样点的"多项式基",每一列代表一个幂次。它把"用多项式描述一组点"这件事,转化成了矩阵运算。

Step 2:最小二乘求卷积核

范德蒙矩阵 V 建好后,我们要解的问题是:找到一组多项式系数 a = [a₀, a₁, a₂],使得多项式曲线与窗口内实际数据点的误差平方和最小。

这就是最小二乘法的标准形式:

最小二乘正规方程

(VT · V) · a = VT · y

但我们不需要真的去解某一个窗口的 a!SG 滤波最巧妙的地方在于:

核 心 洞 察

拟合曲线在窗口中心(k=0)的值,恰好等于原始数据的线性组合。这个线性组合的系数只跟"窗口多大"和"用几阶多项式"有关,跟具体数据无关!

所以我们可以提前算好这个系数数组(卷积核),之后每次只需要做加权求和即可。

PYTHON · Step 2:求卷积核
# 求解 (V^T @ V)^(-1) @ V^T
# 这个矩阵的每一行,就是对应导数阶数的卷积核
A = np.linalg.pinv(V.T @ V) @ V.T

# 取第 0 行(对应平滑操作,deriv=0)作为卷积核
coeffs = A[0]
# coeffs ≈ [-0.0857,  0.3429,  0.4857,  0.3429, -0.0857]
# 乘以 35 后就是整数:[-3, 12, 17, 12, -3]
# 归一化(让系数和为 1)
coeffs = coeffs / np.sum(coeffs)
为什么只取第 0 行?

因为我们要求的是平滑值(deriv=0),即拟合曲线在窗口中心的函数值。

如果想求一阶导数(deriv=1),就取 A[1];求二阶导数(deriv=2),就取 A[2]。同一个矩阵,搞定所有事情。

Step 3:边界填充

卷积核宽度为 5,在信号两端会"悬空"。我们需要在两端补一些数据,让窗口能完整滑动。

PYTHON · Step 3:边界填充
# 窗口半宽
pad = window // 2    # pad = 2

# 用最近邻值填充两端(最稳妥的方式)
x_padded = np.pad(x, pad, mode='nearest')

# 举例:x = [10, 20, 30, 40, 50]
# x_padded = [10, 10, 10, 20, 30, 40, 50, 50, 50]
#                 ↑补2个      原始数据                ↑补2个

Step 4:卷积操作

有了卷积核和填充后的数据,剩下的就是一个滑动加权求和——这正是卷积的定义。

PYTHON · Step 4:卷积
# 对填充后的信号做卷积,valid 模式保证输出长度正确
y = np.correlate(x_padded, coeffs, mode='valid')

# 验证输出长度:
# len(valid) = len(padded) - len(coeffs) + 1
#            = (n + 2*pad) - (2*pad + 1) + 1 = n ✅
手动画一个卷积过程

以窗口 = 5,核 = [-3, 12, 17, 12, -3] / 35 为例:

窗口数据: [100, 105, 110, 108, 112]
卷积核: [-3, 12, 17, 12, -3] 35

y = (-3×100 + 12×105 + 17×110 + 12×108 - 3×112) ÷ 35
  = (-300 + 1260 + 1870 + 1296 - 336) ÷ 35
  = 3790 ÷ 35 ≈ 108.29

注意最外侧的 -3 是负权重——它抵消了中间权重过大导致的"削峰"效应,这就是保峰的数学本质。

Step 5:打包成函数

把上面 4 步合并成一个完整的函数:

PYTHON · 手写 SG 滤波器
def manual_savgol(x, window=5, poly_order=2):
    """手写 Savitzky-Golay 平滑滤波器"""
    x = np.asarray(x, dtype=float)
    half = window // 2

    # Step 1: 构建范德蒙矩阵
    k = np.arange(-half, half + 1, dtype=float)
    V = np.vander(k, poly_order + 1, increasing=True)

    # Step 2: 求卷积核(最小二乘)
    A = np.linalg.pinv(V.T @ V) @ V.T
    coeffs = A[0]  # deriv=0 取平滑核
    coeffs = coeffs / np.sum(coeffs)  # 归一化

    # Step 3: 边界填充
    x_padded = np.pad(x, half, mode='nearest')

    # Step 4: 卷积
    y = np.correlate(x_padded, coeffs, mode='valid')
    return y[:len(x)]
2 S-G 滤波滑动窗口局部拟合原理

运算流程回顾

1. 滑动窗口截取一段局部数据(图中 5 个黑点)

2. 用范德蒙矩阵 + 最小二乘拟合低阶多项式(绿色虚线)

3. 取窗口中心拟合值作为平滑结果(红色圆点)

4. 窗口向右滑动 1 个点,重复计算

核心:不做简单平均,而是局部拟合曲线——多项式贴合原始波形趋势,不会削峰。

第二部分

进阶:用 SG 滤波求导数

还记得我们之前说的吗?矩阵 A 的每一行对应不同阶数的导数。刚才只用了 A[0](平滑),现在把 A[1] 和 A[2] 也利用起来。

PYTHON · 手写 SG 求导
def manual_savgol_deriv(x, window=5, poly_order=2, deriv=1):
    """手写 SG 滤波求导"""
    x = np.asarray(x, dtype=float)
    half = window // 2

    # Step 1: 范德蒙矩阵(同上)
    k = np.arange(-half, half + 1, dtype=float)
    V = np.vander(k, poly_order + 1, increasing=True)

    # Step 2: 取 deriv 对应的行
    A = np.linalg.pinv(V.T @ V) @ V.T
    coeffs = A[deriv]

    # Step 3: 求导核需要乘以阶乘
    import math
    coeffs = coeffs * math.factorial(deriv)

    # Step 4 & 5: 填充 + 卷积(同上)
    x_padded = np.pad(x, half, mode='nearest')
    y = np.correlate(x_padded, coeffs, mode='valid')
    return y[:len(x)]


# 使用示例
velocity = manual_savgol_deriv(prices, deriv=1)    # 一阶导(速度)
accel     = manual_savgol_deriv(prices, deriv=2)    # 二阶导(加速度)

类比开车:
价格 = 高速上的位置
一阶导数 = 车速表上的速度
二阶导数 = 踩油门还是刹车的感觉

为什么求导有用?

一阶导数过零点 → 价格达到局部最高/最低点

二阶导数过零点 → 趋势拐点(由涨转跌、由跌转涨)

杀 手 级 功 能

同一个范德蒙矩阵,取不同行就能得到平滑、一阶导、二阶导——一个算法,多种用途。

3 S-G 卷积核线性运算拆解

三层拆解回顾:

第一层:5 点 2 阶卷积核

归一化系数:[-3, 12, 17, 12, -3] ÷ 35

中心权重最高(17/35),最外侧两个点是负权重(-3/35)——这是保峰的关键。

第二层:窗口内 5 个采样值

y₋₂    y₋₁    yᵢ    yᵢ₊₁    yᵢ₊₂

第三层:加权求和

最终公式

yismooth = (-3·yi-2 + 12·yi-1 + 17·yi + 12·yi+1 - 3·yi+2) ÷ 35

系数提前固定:只由窗口长度和多项式阶数决定,和数据无关。
⚡ 高效:预计算核 + 滑动卷积,适合实时处理。
🛡️ 负权重保峰:最外侧的 -3 抵消峰衰减,维持极值高度。
4 SG 滤波完整流程

输入带噪数据 → 设定参数 → 计算卷积核 → 滑动窗口 → 边界处理 → 输出平滑序列

1

输入一维带噪时序数据 y[1:N]

2

设定:窗口长度 L=2m+1、多项式阶数 n(常用 2/3 阶)

3

构建范德蒙矩阵 → 最小二乘求卷积核

4

滑动窗口遍历:① 截取局部数据 ② 卷积核加权求和

5

边界处理(最近邻填充)

6

输出完整平滑序列

5 SG 滤波三大核心特性

1. 降噪

加权平均抑制随机噪声,提升信噪比。

2. 保形

局部多项式拟合,保留峰值高度、拐点位置——SG 滤波的最大优势

3. 高效

预计算核系数,非线性拟合 → 线性卷积,计算开销极低。

第三部分

量化策略中的用途

场景用法
趋势跟踪SG 平滑后的价格作为趋势基准,比均线更灵敏
拐点检测二阶导数过零点 = 潜在买卖信号
指标预处理先 SG 平滑再算 RSI/MACD,减少假信号
多周期分析不同 window 对应不同时间尺度

SG 滤波 vs 加权平均

特性加权平均SG 滤波
原理加权平均局部多项式拟合
波峰保留会削低✅ 基本保留
波谷保留会填高✅ 基本保留
能否求导需额外处理✅ 内置求导
计算速度稍慢
适用场景简单去噪精细分析、拐点检测

第四部分

注意事项

window 必须为奇数:偶数没有明确中心点

poly_order < window:否则方程欠定(未知数比方程多)

window 过大 → 过度平滑,丢失局部信号

window 过小 → 去噪效果不足

不是预测工具:后验平滑,让历史数据更清晰

推荐起始参数window=11, poly_order=3

最 后 的 话

SG 滤波就像一位精修师

高斯平滑把照片整体磨了一层,五官和皮肤一起变模糊。
SG 滤波先看清五官轮廓,再精准磨掉皮肤瑕疵。
结果:噪音少了,趋势信号还在,波峰波谷不变形。

一句话:想要保留信号形状的平滑,选 SG 滤波。

附录

完整手搓代码

PYTHON · 完全手搓 · 不依赖 scipy
"""
Savitzky-Golay 滤波器 —— 纯 NumPy 手写实现
============================================
不调用 scipy.signal.savgol_filter,从零推导每一步。

核心思想:
  在局部窗口内,不是简单"取平均",
  而是先"画一条最合适的多项式曲线",
  再取曲线中心点的值作为平滑结果。
"""

import numpy as np
import math


# ===================================================================
# 第一部分:核心工具函数
# ===================================================================

def _build_coeffs(window, poly_order, deriv=0):
    """
    构建 SG 滤波的卷积核。

    推导过程:
      1. 窗口内有 N=window 个点,相对位置 k = [-m, ..., 0, ..., m]
      2. 用 n 阶多项式 y(k) = a0 + a1*k + ... + an*k^n 去拟合
      3. 最小二乘:min Σ[y_true(k) - y_fit(k)]²
      4. 求导令为 0 → (V^T·V)·a = V^T·y(正规方程)
      5. 解出 a 后,中心点 k=0 的拟合值 = a0
         而 a0 恰好是原始数据的线性组合!
      6. 所以系数 c[k] = (V^T·V)^{-1}·V^T 的第 deriv 行
    """
    if window % 2 == 0:
        raise ValueError("窗口长度必须是奇数(要有明确的中心点)")
    if poly_order >= window:
        raise ValueError("多项式阶数必须 < 窗口长度")

    half = (window - 1) // 2
    k = np.arange(-half, half + 1, dtype=float)

    # 范德蒙矩阵:V[i,j] = k[i]^j
    V = np.vander(k, poly_order + 1, increasing=True)

    # 最小二乘解:直接对 V 做伪逆,避免 (V^T @ V) 条件数恶化
    A = np.linalg.pinv(V)

    # 取对应导数阶数的行
    coeffs = A[deriv]

    # 平滑操作:归一化使系数和为 1
    if deriv == 0:
        coeffs = coeffs / np.sum(coeffs)
    else:
        # 导数操作:乘以阶乘
        coeffs = coeffs * math.factorial(deriv)

    return coeffs


# ===================================================================
# 第二部分:SG 平滑滤波器
# ===================================================================

def savgol_smooth(x, window=11, poly_order=3):
    """
    Savitzky-Golay 平滑滤波器(纯手写)。

    参数:
        x           : 输入的一维信号数组
        window      : 滑动窗口长度(奇数)
        poly_order : 多项式阶数(2 或 3 最常用)

    返回:
        y : 平滑后的信号,长度与输入相同
    """
    x = np.asarray(x, dtype=float)
    if x.ndim != 1:
        raise ValueError("只支持一维信号")

    # Step 1: 构建卷积核
    coeffs = _build_coeffs(window, poly_order, deriv=0)

    # Step 2: 边界填充(最近邻)
    pad = window // 2
    x_padded = np.pad(x, pad, mode='nearest')

    # Step 3: 相关性计算(⚠️ 用 correlate 不用 convolve)
    # convolve 会翻转核,导致求导时符号相反
    y = np.correlate(x_padded, coeffs, mode='valid')

    return y


# ===================================================================
# 第三部分:SG 求导
# ===================================================================

def savgol_deriv(x, window=11, poly_order=3, deriv=1, delta=1.0):
    """
    Savitzky-Golay 求导。

    deriv=1 : 一阶导数(速度/斜率)
    deriv=2 : 二阶导数(加速度/曲率)
    delta   : 横坐标步长(默认 1.0)
    """
    x = np.asarray(x, dtype=float)

    # Step 1: 构建导数卷积核
    coeffs = _build_coeffs(window, poly_order, deriv=deriv)

    # Step 2: 边界填充
    pad = window // 2
    x_padded = np.pad(x, pad, mode='nearest')

    # Step 3: 相关性计算(⚠️ 用 correlate,不用 convolve)
    y = np.correlate(x_padded, coeffs, mode='valid')

    # 若横坐标步长不为 1,除以 delta^deriv
    y = y / (delta ** deriv)

    return y


# ===================================================================
# 第四部分:可视化验证
# ===================================================================

if __name__ == "__main__":
    import matplotlib.pyplot as plt

    # 构造测试信号
    np.random.seed(42)
    t = np.linspace(0, 4 * np.pi, 200)

    # 真实信号:正弦波 + 两个尖峰
    true_signal = (
        2.0 * np.sin(t)
        + 3.0 * np.exp(-((t - 5)**2) / 0.5)
        + 2.5 * np.exp(-((t - 8)**2) / 0.3)
    )

    # 加噪音
    noise = np.random.normal(0, 0.4, size=t.shape)
    noisy_signal = true_signal + noise

    # 对比:普通滑动平均 vs SG 滤波
    w = 15
    pad = w // 2
    ma = np.correlate(
        np.pad(noisy_signal, pad, mode='nearest'),
        np.ones(w) / w,
        mode='valid'
    )[:len(t)]

    sg = savgol_smooth(noisy_signal, window=w, poly_order=3)

    # 画图
    fig, axes = plt.subplots(2, 1, figsize=(12, 8), sharex=True)

    ax1 = axes[0]
    ax1.plot(t, noisy_signal, 'lightgray', label='带噪信号', alpha=0.8)
    ax1.plot(t, true_signal, 'black', line, linewidth=2, label='真实信号')
    ax1.plot(t, ma, 'red', linewidth=2, label='滑动平均(峰被削平)')
    ax1.set_title('滑动平均的问题:峰被削平,拐点变圆')
    ax1.legend(); ax1.grid(True, alpha=0.3)

    ax2 = axes[1]
    ax2.plot(t, noisy_signal, 'lightgray', label='带噪信号', alpha=0.8)
    ax2.plot(t, true_signal, 'black', line, linewidth=2, label='真实信号')
    ax2.plot(t, sg, 'blue', linewidth=2, label='SG 滤波(保峰保谷)')
    ax2.set_title('SG 滤波的优势:保峰高、保拐点、保波形')
    ax2.legend(); ax2.grid(True, alpha=0.3)
    ax2.set_xlabel('采样点')

    plt.tight_layout()
    plt.savefig('savgol_handmade.png', dpi=150)
    print("✅ 对比图已保存为 savgol_handmade.png")

    # 定量对比
    mae_ma = np.mean(np.abs(ma - true_signal))
    mae_sg = np.mean(np.abs(sg - true_signal))
    print(f"\n平均绝对误差 (MAE):")
    print(f"  滑动平均 : {mae_ma:.4f}")
    print(f"  SG 滤波  : {mae_sg:.4f}")
    print(f"  提升幅度 : {(1 - mae_sg/mae_ma)*100:.1f}%")

Published on 2026-06-24 19:01

Disclaimers:

This document is written by 阿凡 Original published on 百果量化交流平台 ,The copyright belongs to the author。

Log in,More exciting content waiting for you to find,Contribute wonderful answers,Participate in comment interaction

go Sign in! No accountgoregister

PT010214
2026-07-20 12:32
大神,看不懂,测试过没?
广告

Included in the column