简介:这份资源面向信号处理方向的科研人员与工程师,提供变分模态分解(VMD)的MATLAB实现,用于对实测离散信号进行自适应分解。VMD可将复杂非平稳信号拆解为若干具有不同中心频率的模态分量,相比傅里叶或小波变换更擅长捕捉瞬态与非线性特征,适合噪声抑制、特征提取与信号恢复等场景。压缩包内共1个文件,为VMD.m脚本,整体约2KB,体积轻量,便于直接嵌入现有工程或实验流程。该脚本已在真实采集的离散数据上验证有效,用户只需准备自己的时间序列并设置模态数、迭代次数等参数,即可获得各模态输出,快速上手并理解分解结果。目前已有229人学习关注,适合具备基础MATLAB编程能力、希望将VMD应用于振动、声学或生物医学信号分析的研究者参考使用。
1. 实测信号分解选 VMD:为什么它比 EMD 更值得你花时间
手里有一段实测信号——可能是轴承振动、电机电流、管道压力,也可能是脑电或地震波——你把它丢进 EMD,出来的 IMF 不是模态混叠就是端点飞翼,换个采样率结果又变一遍。这种“玄学”体验,做过信号分解的人多少都踩过。变分模态分解(VMD)之所以在实测信号分解里越来越常见,是因为它把“递归筛分”换成了“变分求解”:一次性把信号拆成若干个中心频率明确的离散模态,每个模态对应一个窄带分量,模态混叠和端点效应都比 EMD 可控得多。这篇笔记面向需要处理实测信号的一线工程师和研究生,从 VMD 的数学骨架讲到 Python 落地、参数怎么调、坑在哪,最后给一套能直接复现的最小工作流。读完你应该能判断:你手里的信号适不适合 VMD,以及怎么把它跑出可信结果。
2. VMD 的变分骨架:从约束优化到离散模态
2.1 变分模态分解到底在解什么问题
VMD 的核心思路,是把信号分解写成一个带约束的变分问题。假设输入信号 $f(t)$ 要被拆成 $K$ 个模态 $u_k(t)$,每个模态围绕自己的中心频率 $\omega_k$ 振荡。为了衡量一个模态“有多窄带”,VMD 用希尔伯特变换把它变成解析信号,再乘以 $e^{-j\omega_k t}$ 把频谱搬到基带,最后取基带信号的 $L^2$ 范数。这个范数越小,说明模态越集中在 $\omega_k$ 附近。
于是优化目标就是让所有模态的基带带宽之和最小:
$$\min_{{u_k},{\omega_k}} \sum_k \left| \partial_t \left[ \left( \delta(t) + \frac{j}{\pi t} \right) * u_k(t) \right] e^{-j\omega_k t} \right|_2^2$$
约束条件是所有模态加起来要还原原信号:$\sum_k u_k = f$。这个约束保证了分解不丢信息,也是 VMD 和普通带通滤波的本质区别——它不是先切频带再拼,而是让模态在优化里自己找位置。
求解用增广拉格朗日乘子法,把约束通过二次惩罚项和拉格朗日乘子吸收进目标函数,再用 ADMM(交替方向乘子法)迭代。每次迭代分三步:更新每个模态的频域表达式、更新每个中心频率、更新拉格朗日乘子。频域更新有闭式解,所以整个算法不需要梯度下降,收敛快且稳定。
这里有个容易被忽略的点:VMD 是在频域做更新的。你输入的实测信号会被 FFT 到频域,模态更新、中心频率更新都在频域完成,最后 IFFT 回时域。这意味着信号长度最好取 2 的幂附近,或者至少让 FFT 不因为补零产生明显边界跳变。实测信号里常见的直流偏置和趋势项,如果不预先去掉,会被当成一个中心频率接近 0 的模态,白白占掉一个 $K$ 名额。
2.2 离散模态分解在代码里长什么样
理论看完,落到代码其实不长。下面是一个不依赖第三方 VMD 库、用 NumPy 从零实现的最小版本,方便你理解每一步在干什么。实际工程里我一般直接用vmdpy,但自己写一遍能帮你定位参数问题。
import numpy as np def vmd(signal, alpha=2000, tau=0, K=5, DC=0, init=1, tol=1e-7, max_iter=500): """ signal: 一维实信号 alpha: 带宽约束惩罚因子,越大模态越窄 tau: 拉格朗日乘子更新步长,0 表示不加噪声容忍 K: 模态个数 DC: 是否把第一个模态强制为中心频率 0(去直流) init: 中心频率初始化方式,1 表示均匀分布 tol: 收敛容差 max_iter: 最大迭代次数 """ N = len(signal) t = np.arange(1, N + 1) / N # 频域准备:解析信号频谱只保留正频率 f_hat = np.fft.fft(signal) f_hat_plus = f_hat.copy() f_hat_plus[:N // 2] = 0 # 只保留正频率部分 # 初始化模态频谱和中心频率 u_hat = np.zeros((K, N), dtype=complex) omega = np.zeros(K) if init == 1: for k in range(K): omega[k] = (0.5 / K) * k if DC == 0 else (0.5 / K) * (k - 1) lambda_hat = np.zeros(N, dtype=complex) # ADMM 主循环 for it in range(max_iter): u_hat_prev = u_hat.copy() for k in range(K): # 计算除第 k 个模态外的残差 residual = f_hat_plus - np.sum(u_hat, axis=0) + u_hat[k] # 频域维纳滤波式更新 u_hat[k] = residual / (1 + alpha * (np.arange(N) / N - omega[k]) ** 2) # 更新中心频率:模态频谱的加权重心 for k in range(K): power = np.abs(u_hat[k, N // 2:]) ** 2 freqs = np.arange(N // 2, N) / N if power.sum() > 0: omega[k] = np.sum(freqs * power) / power.sum() # 更新拉格朗日乘子 if tau > 0: lambda_hat = lambda_hat + tau * (f_hat_plus - np.sum(u_hat, axis=0)) # 收敛判断 diff = np.sum(np.abs(u_hat - u_hat_prev) ** 2) / np.sum(np.abs(u_hat_prev) ** 2) if diff < tol: break # IFFT 回时域 u = np.zeros((K, N)) for k in range(K): u[k] = np.real(np.fft.ifft(u_hat[k])) return u, omega这段代码里几个参数直接决定结果好坏。alpha是带宽约束,默认 2000 适合大多数振动信号;信号采样率高、模态间隔大时可以调到 5000 以上,模态混叠严重时反而要降到 1000 左右让模态宽一点。K是最关键的,后面单独讲。tau设 0 表示不做拉格朗日乘子更新,对含噪实测信号通常够用;如果重构误差大,可以设 1e-6 到 1e-4 之间。DC设 1 会把第一个模态钉在零频,适合有明显直流分量的传感器信号。
跑完拿到u是 K 个时域模态,omega是对应中心频率。判断分解是否合理,先看omega有没有两个值靠得特别近——如果两个中心频率几乎重合,说明 K 给大了,它们在抢同一段频谱。
3. 实测信号跑 VMD:参数怎么定、结果怎么验
3.1 K 值选择:别再用试凑法硬猜
K 是 VMD 里最需要认真对待的参数。K 太小,多个物理分量被塞进一个模态,欠分解;K 太大,一个分量被拆成两个相邻模态,过分解,还会多出没有物理意义的噪声模态。实测信号没有真值,怎么判断?
我一般用三步走。第一步,先看信号频谱的峰个数。对实测振动信号做 FFT,数一下明显高于底噪的谱峰有几簇,K 从这簇数开始试。第二步,对 K 从 2 到 8 各跑一遍,记录每个 K 下的中心频率分布和重构误差。重构误差用 $|f - \sum u_k|_2 / |f|_2$,正常应该在 1e-3 以下;如果某个 K 下误差突然跳大,说明模态之间开始互相抵消。第三步,看相邻中心频率的比值,如果两个 $\omega$ 的差小于较小值的 20%,基本可以判定过分解。
import numpy as np from vmdpy import VMD def scan_k(signal, k_range=range(2, 9), alpha=2000): results = [] for K in k_range: u, u_hat, omega = VMD(signal, alpha, 0, K, 0, 1, 1e-7) recon = np.sum(u, axis=0) err = np.linalg.norm(signal - recon) / np.linalg.norm(signal) omega_sorted = np.sort(omega.flatten()) min_gap = np.min(np.diff(omega_sorted)) if K > 1 else 0 results.append({ 'K': K, 'recon_err': err, 'omega': omega_sorted, 'min_gap': min_gap }) return results跑完把recon_err和min_gap列成表,选重构误差已经足够小、但min_gap还没塌下去的那个 K。经验上轴承故障信号 K 取 4 到 6 居多,电力谐波信号 K 取 3 到 5,脑电这种宽带信号可能要 8 以上。
3.2 惩罚因子 alpha 和噪声容忍 tau 的配合
alpha控制模态带宽,tau控制拉格朗日乘子的更新力度,这两个参数要一起看。alpha偏小,模态带宽大,能容纳频率漂移,但相邻模态容易重叠;alpha偏大,模态窄,频率分辨率高,但对非平稳信号适应性差,模态中心频率会跟着噪声跳。
实测信号里如果底噪明显,我一般先把tau设成 0,让算法不追着噪声更新乘子,然后alpha从 2000 起步。如果分解出来的模态时域波形有明显毛刺,说明alpha太小,模态把噪声也包进去了,往上调到 3000 到 5000。如果模态波形过于光滑、丢掉了冲击成分,说明alpha太大,往下调到 1000 到 1500。
注意:
alpha和K不是独立的。K 增大时,每个模态分到的频带变窄,等效于提高了频率分辨率,此时alpha可以适当调小,否则模态会过度收缩到中心频率附近,丢掉边带信息。
3.3 分解结果怎么验证:三个可量化的指标
跑出模态只是开始,验证才是决定你敢不敢把结果写进报告的关键。我固定看三个指标。
第一个是重构误差,前面提过,低于 1e-3 算合格。第二个是模态间的频谱重叠度,把每个模态的功率谱算出来,两两做归一化互相关,如果某对模态的相关系数超过 0.3,说明它们频带重叠严重,要么减 K,要么调 alpha。第三个是中心频率的物理可解释性,把omega换算成 Hz,对照你的采样率和信号物理背景,看这些频率是不是对应已知的物理过程——轴承的故障特征频率、电机的转频和倍频、管道的声学模态。如果出现一个中心频率既不对应任何已知成分、能量又很低,那大概率是过分解出来的伪模态,可以把它剔除后重新看重构误差是否仍然合格。
def validate_modes(signal, u, fs): K = u.shape[0] recon_err = np.linalg.norm(signal - np.sum(u, axis=0)) / np.linalg.norm(signal) # 模态间频谱重叠 specs = [np.abs(np.fft.rfft(ui)) for ui in u] overlap = np.zeros((K, K)) for i in range(K): for j in range(i + 1, K): a, b = specs[i], specs[j] corr = np.corrcoef(a, b)[0, 1] overlap[i, j] = overlap[j, i] = corr # 中心频率换算 freqs = np.fft.rfftfreq(len(signal), 1 / fs) peak_freqs = [freqs[np.argmax(s)] for s in specs] return recon_err, overlap, peak_freqs这三个指标一起看,基本能挡住大部分“看起来能跑但结果不可信”的情况。
4. 避坑与排查:实测信号分解最常见的五个翻车点
4.1 模态混叠没解决,反而更严重
现象:分解出来的两个模态时域波形长得几乎一样,中心频率也接近。原因:K 给大了,或者alpha太小导致两个模态频带重叠后互相“抢”能量。解决:先把 K 减 1 重跑,如果两个模态合并后重构误差没明显变大,说明原来就是过分解;如果 K 不能减,把alpha往上调 50% 到 100%,逼模态收窄。
4.2 端点飞翼比 EMD 还明显
现象:模态两端出现大幅振荡,和信号主体对不上。原因:VMD 的频域更新默认信号是周期的,实测信号首尾不连续时,FFT 的边界效应会传到模态上。解决:分解前对信号做镜像延拓,左右各延拓信号长度的 10% 到 20%,分解完再裁掉;或者先减去线性趋势项,让首尾幅值接近。
4.3 中心频率初始化导致结果每次不一样
现象:同样的信号和参数,跑两次omega差很多。原因:init=1的均匀初始化在某些 K 下会让 ADMM 收敛到不同局部极小。解决:固定随机种子不是办法,VMD 本身没有随机性;更稳的做法是先用 FFT 峰值位置初始化omega,或者把init设成 2 让算法自己从零频开始搜。如果还是不稳,说明 K 选得不对,回到第 3 章重新扫 K。
4.4 重构误差合格但模态没有物理意义
现象:重构误差 1e-4,但每个模态都像带通噪声,找不到对应物理成分。原因:alpha太大,模态被压得太窄,只保留了中心频率附近的一点点能量,其余全被当成残差丢给其他模态。解决:把alpha降到 500 到 1000,让模态宽一点,再看时域波形有没有出现冲击或调制特征。
4.5 采样率变了参数没跟着变
现象:同一类信号,换个采样率跑,结果完全不对。原因:alpha和频率轴是绑定的,VMD 里的频率是归一化频率(0 到 0.5),采样率变了,同样的alpha对应的实际带宽就变了。解决:换采样率后,alpha按采样率比例缩放。比如从 1 kHz 换到 10 kHz,alpha从 2000 调到 20000 左右,再微调。
5. 把 VMD 接进你的信号处理流水线:一个可复用的封装
前面都是单次分解,实际工程里你面对的是成百上千段实测信号,需要批量跑、批量存、批量出图。我一般把 VMD 封装成一个类,固定几个默认参数,只暴露 K 和 alpha 两个入口,其余走配置文件。
import numpy as np from vmdpy import VMD import json class VMDDecomposer: def __init__(self, fs, K=5, alpha=2000, tau=0, DC=0, tol=1e-7): self.fs = fs self.K = K self.alpha = alpha self.tau = tau self.DC = DC self.tol = tol def decompose(self, signal): # 去线性趋势,抑制端点效应 t = np.arange(len(signal)) coef = np.polyfit(t, signal, 1) signal_detrend = signal - np.polyval(coef, t) u, u_hat, omega = VMD(signal_detrend, self.alpha, self.tau, self.K, self.DC, 1, self.tol) return u, omega.flatten() def batch(self, signals): out = [] for sig in signals: u, omega = self.decompose(sig) recon_err = np.linalg.norm(sig - np.sum(u, axis=0)) / np.linalg.norm(sig) out.append({ 'modes': u.tolist(), 'omega_hz': (omega * self.fs).tolist(), 'recon_err': float(recon_err) }) return out def save(self, results, path): with open(path, 'w', encoding='utf-8') as f: json.dump(results, f, ensure_ascii=False, indent=2)这个封装里有两个我踩坑后加进去的习惯。一是分解前统一去线性趋势,比镜像延拓简单,对大多数缓变趋势信号够用。二是omega存成 Hz 而不是归一化频率,因为下游做故障诊断时,你对照的是轴承故障特征频率表,单位不统一每次都要换算,容易出错。
批量跑的时候,建议先拿 10 段代表性信号试参数,确认 K 和 alpha 稳定后再全量跑。全量跑完把recon_err画成直方图,如果出现双峰,说明信号里有两类不同特性的段,需要分组用不同参数,别一套参数硬套到底。
最后说一个我自己的习惯:每次分解完,把原始信号和所有模态叠在一张图上,再单独把每个模态的包络谱画出来。叠图看重构是否贴合,包络谱看有没有故障特征频率的边带。这两张图花不了几分钟,但能挡住 90% 的“参数跑通了但结论是错的”情况。VMD 不是黑匣子,它的每个输出都能对应到频域上的一段能量,你只要愿意多看一步频谱,就不会被时域波形骗过去。希望帮到你。
本文还有配套的精品资源,点击获取