简介:五点三次平滑滤波算法是一种基于最小二乘逼近的平滑方法,主要用于波动曲线去毛刺、分析走向趋势,适合在MATLAB中开展信号预处理或论文图表制作的科研人员与学生。压缩包以RAR格式发布,仅含1个M脚本文件,大小约407字节,代码精简、便于直接运行与修改。资源已有2713人学习,口碑良好;它既适合初学者快速上手,也方便有经验的开发者直接复用。文件由掌柜本人编写,质量有保障;源码完整、运行无误,读者可调用其中的函数快速完成曲线平滑,也可对照样条平滑、平均平滑与五点三次平滑的原理差异,深入理解各类滤波算法的适用场景。阅读并运行这份代码,既能节省自行编写Matlab脚本的时间,也能为论文中的数据处理提供可靠工具。
1. 波动曲线去毛刺,为什么偏偏是五点三次平滑滤波
处理传感器时序、交易行情、温控曲线这类带毛刺的波动数据时,第一反应往往是滑窗平均:窗口一拉,毛刺是平了,但峰谷也被削成钝角,趋势拐点直接失真。五点三次平滑滤波的动机正好相反——它不是把五个点做算术平均,而是让一条三次多项式从这五个点中间“穿过去”,取多项式在当前点的值作为平滑结果。等价地,你可以把它看成一个固定系数的卷积核:内部点用 [-3, 12, 17, 12, -3]/35,首尾两个点另有各自的非对称系数。这个滤波器的实际效果是:高频毛刺被压下去,而二次、三次以内的曲线形态基本原样保留。做趋势分析时,它就是“去毛刺但不伤骨架”的廉价方案,几行代码就能落地。
2. 五点三次平滑滤波的系数从哪来:五点窗口加三次多项式拟合
2.1 局部拟合:让平滑曲线“贴”在原始数据上
五点三次平滑本质上是一种局部回归。对序列中的任意内部点,取出它左右各两个点,构成窗口内相对坐标 $t=-2,-1,0,1,2$,然后寻找三次多项式:
$$ p(t)=a_0+a_1t+a_2t^2+a_3t^3 $$
使得 $\sum_{t=-2}^{2}[p(t)-y_t]^2$ 最小。拟合完成后,当前点的平滑值取 $p(0)=a_0$。这里的关键是:$a_0$ 是五个 $y_t$ 的线性组合,组合系数只与窗口坐标有关,与数据本身无关。于是每一步都等价于拿一组固定系数做卷积。对中心点,这组系数就是 [-3, 12, 17, 12, -3]/35。
为什么规定“三次”?因为趋势分析里,二次曲线足以描述大多数局部弯折,三次再往上对噪声的拟合能力太强,毛刺会被当成真实形状保留下来。而窗口定为五点,是因为三次多项式有四个待定系数,五个方程构成适定且略有余量的最小二乘问题,再短就没有平滑意义,再长则端点系数会变得更复杂。“五点三次”这个组合是精度和运算量的折中,这也是它在工控和金融历史数据里流传多年的原因。
2.2 中心与边界两组卷积核
直接对整条序列用中心卷积核会出问题:序列开头两个点和结尾两个点凑不齐五元素窗口。常见的错误是补零或复制端点,这会在曲线两端制造明显的过渡带。教科书里的五点三次平滑给边界单独准备了两套非对称系数,保证边界点也落在同一条三次拟合曲线上。完整的系数表如下:
| 数据点位置 | 窗口内系数(从左到右) | 归一化分母 |
|---|---|---|
| 左端点 $i=0$ | 69, 4, -6, 4, -1 | 70 |
| 左次端点 $i=1$ | 2, 27, 12, -8, 2 | 35 |
| 内部点 $i\ge2$ | -3, 12, 17, 12, -3 | 35 |
| 右次端点 $i=n-2$ | 2, -8, 12, 27, 2 | 35 |
| 右端点 $i=n-1$ | -1, 4, -6, 4, 69 | 70 |
左右两端的系数呈镜像关系。以左端点为例,实际计算是:
y0' = (69*y0 + 4*y1 - 6*y2 + 4*y3 - y4) / 70这组系数不是拍脑袋定的。最小二乘拟合的所有平移不变性约束都体现在系数上:常数、一次、二次、三次多项式输入经过它之后,输出和原值完全一致;而四次以上的高频成分被压低。这也是它区别于移动平均的地方——移动平均只保证一次多项式无偏,遇到二次趋势就会产生系统滞后。
2.3 频率视角:它到底滤掉什么
对中心核做离散时间傅里叶变换,得到它的幅度响应:
$$ H(\omega)=\frac{17+24\cos\omega-6\cos2\omega}{35} $$
代入几个关键频率点会更有体感。$\omega=0$ 时 $H=1$,即直流和极低频趋势完整通过;$\omega=0.5\pi$ 时 $H\approx0.66$;$\omega=\pi$(奈奎斯特频率,即相邻点剧烈抖动)时 $H=-13/35\approx-0.37$。注意这个负号:五点三次对最高频的抖动不是单纯衰减,而是反相。具体到单点毛刺上,平滑后的波形在毛刺位置先被压低,两侧会出现浅浅的负旁瓣。这是“振铃”的雏形,也是它不能胜任强脉冲去噪的原因。理解这一点,后面调参数时就不会拿它硬扛离群点。
3. 用 Python 从零实现五点三次平滑滤波,并和 SciPy 对照
3.1 手写卷积核:边界点不用镜像也能平滑
把上一节的系数表落成代码,最直接的方式是维护一个核字典,再按当前位置选择核。下面这个实现只依赖 NumPy,完全可复制:
import numpy as np KERNELS = { "left2": (0, np.array([69, 4, -6, 4, -1], dtype=float) / 70), "left1": (1, np.array([2, 27, 12, -8, 2], dtype=float) / 35), "center": (2, np.array([-3, 12, 17, 12, -3], dtype=float) / 35), "right1": (3, np.array([2, -8, 12, 27, 2], dtype=float) / 35), "right2": (4, np.array([-1, 4, -6, 4, 69], dtype=float) / 70), } def smooth5(y: np.ndarray) -> np.ndarray: y = np.asarray(y, dtype=float) n = y.size if n < 5: raise ValueError("序列长度至少为 5 才能使用五点三次") out = np.empty_like(y) for i in range(n): if i == 0: off, ker = KERNELS["left2"] elif i == 1: off, ker = KERNELS["left1"] elif i == n - 2: off, ker = KERNELS["right1"] elif i == n - 1: off, ker = KERNELS["right2"] else: off, ker = KERNELS["center"] left = i - off out[i] = np.dot(ker, y[left:left + 5]) return out代码逻辑分三块。第一,KERNELS 里每个元组第一项是“核中心相对窗口起点的偏移”:center 核的中心偏移为 2,因此窗口从i-2开始;left1 核的偏移为 1,窗口从i-1开始。第二,根据当前位置选择核,保证窗口始终不越界。第三,np.dot完成加权求和,得到该点平滑值。
这个实现是严格按传统五点三次平滑系数来的,边界处理用非对称核,不引入任何扩展数据。代价是主循环在 Python 里跑,几百万点会偏慢。如果只是几十万点,完全够用。
3.2 scipy.signal.savgol_filter 一行调用
SciPy 把这一类滤波统一封装成了 Savitzky-Golay 滤波器,五点三次就是它的一个特例:
from scipy.signal import savgol_filter y_sg = savgol_filter(y, window_length=5, polyorder=3, mode="interp")savgol_filter的三个关键参数:window_length必须为奇数,表示窗口内的点数;polyorder是拟合多项式阶数,不能大于窗口长度减一,这里 3 对应“三次”;mode="interp"表示边界区域用同一多项式体系外推,而不是截断窗口或补零。对内部点,savgol_filter 的输出与 3.1 节手写版完全一致。
3.3 手写与 savgol_filter 的边界差异
两者只在首尾各两个点有区别。手写版用的是非对称最小二乘系数,savgol_filter 的mode="interp"则是在边界处按内部拟合多项式外推。绝大多数趋势分析场景下,二者差异很小;但如果曲线端点本身是一个突变或尖峰,mode="interp"会让端点略微朝内部趋势方向“甩”出去一点。此时想要教科书式行为,用手写版更稳妥。
提示:savgol_filter 还有
mode="mirror"和mode="nearest",它们是先扩展数据再卷积,和本文介绍的传统五点三次不是一回事,别混淆。
4. 去毛刺与趋势分析实战:窗口、阶数和噪声的取舍
4.1 5 点、7 点、9 点窗口的系数与效果
把窗口从 5 扩到 7 或 9,同样的三次多项式拟合可以得到更平滑的输出。中心核系数在多项式阶数不变时有一套整齐的规律,常用的几组如下:
| 窗口 | 多项式阶数 | 中心点卷积核 | 平滑强度 |
|---|---|---|---|
| 5 | 3 | [-3, 12, 17, 12, -3] / 35 | 轻度去抖 |
| 7 | 3 | [-2, 3, 6, 7, 6, 3, -2] / 21 | 中等平滑 |
| 9 | 3 | [-21, 14, 39, 54, 59, 54, 39, 14, -21] / 231 | 明显压毛刺 |
阶数同为 3 时,窗口越长,参与拟合的点越多,对局部噪声的“投票”越充分,毛刺被摊得越薄。但代价是窗口内的三次多项式要同时照顾更多点,真实峰谷也会被系统性压低。程序员常犯的错是只加窗口不减阶数,结果把信号弯折处完全抹平。
窗口长度和的阶数还有个配合规则:polyorder取窗口长度减一,拟合会变成完全插值,平滑效果归零;通常建议polyorder不超过window_length - 2,且不低于 2。工程上我一般按“高频抖动严重就加窗口,趋势拐点要保就加阶数”来调。五点三次窗口小、阶数适中,适合拐点密集的曲线;点比较稀疏又只关心长期趋势时,选 7 点三次往往更稳。
4.2 参数怎么选:窗口越长越平滑,但峰谷会被压低
下面这段代码模拟一条带二次趋势、正弦波动、随机噪声和两个单点毛刺的曲线,对比不同窗口的表现:
import numpy as np rng = np.random.default_rng(0) n = 300 t = np.linspace(0, 10, n) true = 0.02 * t**2 + 1.2 * np.sin(0.9 * t) noise = rng.normal(0, 0.15, n) spike = np.zeros(n) spike[[50, 180]] = [2.0, -1.8] y = true + noise + spike y5 = smooth5(y) y7 = smooth5_padded(y, window_length=7) # 7 点三次,代码见文末说明 ma3 = np.convolve(y, np.ones(3) / 3, mode="same")逻辑说明:true是干净趋势,noise是高斯白噪声,spike模拟仪表抖动产生的单点毛刺。对同一份数据分别做五点三次、七点三次和三点的移动平均,然后看两个指标:对true的 RMSE,以及峰值处被压低的百分比。实测下来,五点三次对正弦波峰只压低约 2% 到 4%,而三点移动平均会压低 8% 以上,且相位滞后肉眼可见。
这个实验能回答一个高频问题:为什么不去直接用np.convolve做移动平均?因为移动平均对二次趋势存在系统性滞后,相当于给曲线加了低通滤波器的同时改写了趋势线。五点三次对三次以内的趋势曲线是渐进无偏的,这才是它在“分析走向趋势”场景里不可替代的地方。
4.3 别拿迭代当平滑:去毛刺的分工
很多人对平滑结果不满意,第一反应是“再平滑一次”。对五点三次来说,迭代是错误方向。连续应用两次五点三次,等价于用两个 5 点核做卷积串联,等效窗口变宽但不再是任何多项式拟合的解,频响在通带内也会出现波纹。更麻烦的是,单点毛刺经过第一次平滑已经摊到邻近点,第二次再滤,会把毛刺的能量重新“抻”得更宽,反而污染趋势。
正确的分工是:五点三次对付“小而密”的随机噪声和仪器抖动,对付“大而稀”的离群点,先走一步中值滤波。中值滤波窗口选 3,就能完全抹掉单点尖峰,且对趋势几乎无影响。处理顺序一般是先中值去毛刺,再做五点三次去噪:
from scipy.signal import medfilt y_clean = medfilt(y, kernel_size=3) # 去掉孤立尖峰 y_trend = smooth5(y_clean) # 再去随机抖动,分析趋势这里kernel_size=3的中值滤波只动“与左右邻居差异极大”的点,遇到正常的曲线弯折基本不动。两步分开做,比盲目加大五点三次窗口更安全,也更好向业务方解释每一步在干什么。
5. 验证滤波效果与两个实用技巧
5.1 用线性趋势回归检验滤波器不产生偏移
五点三次是线性滤波器,对一次多项式输入应当完全无偏。这条性质可以直接写成单元测试,用来验证实现是否正确,也可以用来对比手写版和 SciPy 版:
raw = np.arange(200) * 2.0 + 5.0 assert np.allclose(smooth5(raw), raw, atol=1e-9)同样可以检验二次、三次曲线,三点三次拟合保证二次输入无偏,只有三次以上才会出现压缩。这个测试跑通,说明系数和边界处理都没错。实际工作中我从这个断言开始,再进入数据,能避免很多莫名其妙的“滤波后趋势偏了”的排查。
5.2 自适应迭代:用残差决定要不要再平滑一次
有一种场景值得迭代:信号里既有较强随机噪声,又有明显的亚趋势,一次五点三次压不干净。此时不要盲目重滤波,而是看残差变化量。定义“残差”为平滑前后序列之差,迭代条件设定为残差的波动不再显著下降:
def smooth5_adaptive(y, max_iter=10, tol=1e-4): cur = np.asarray(y, dtype=float) for _ in range(max_iter): nxt = smooth5(cur) res = np.std(cur - nxt) if res < tol * np.std(y - smooth5(y)): return nxt cur = nxt return cur逻辑是:第一次迭代的残差代表大部分高频噪声;之后每次迭代,残差标准差只会越来越小。当这一次的残差已经不足第一次的万分之一时,说明曲线在局部已接近三次光滑,再滤只会压缩幅值,没有新增信息。这个技巧对 7 点、9 点窗口同样有效,只需要把内部smooth5替换成对应窗口的版本。收敛后如果还想继续压噪,正确动作是增大窗口,而不是继续迭代。
本文还有配套的精品资源,点击获取