做信号处理的,谁没被一两段“又长又脏”的数据折磨过呢。我最近处理一批振动台测试的实测数据,几十万个采样点,基波、二倍频、三倍频清清楚楚,可叠加的随机噪声也不含糊。想在Matlab里用经典SVD做谐波去噪,结果svd()函数一跑,内存先报警;换成小波阈值,阈值调来调去,低频段该留的谐波差点被削掉。后来我把随机奇异值分解(Randomized SVD)和软阈值(Soft Thresholding)搭在一起,写了一套谐波去噪流程——在比较大的数据集上,计算快、内存省,去噪效果也比固定秩截断稳定得多。这篇文章把思路、原理、代码和调试经验一次说清,想直接抄代码的可以从第3节开始看。
1. 先把问题拆清楚:大数据谐波去噪到底难在哪
1.1 谐波去噪的传统套路和它的天花板
谐波去噪在工程里太常见了。电网信号里有50Hz基波和100Hz、150Hz倍频,机械振动里有转频及其高次倍频,声学测试里也有大量周期成分。去噪不是简单拿个低通滤波器一滤了事,而是要在一堆噪声里把各次谐波的幅值、频率尽量原样保留下来。噪声来源又杂:传感器热噪声、电磁干扰、随机环境振动,很多情况下只能当高斯白噪声处理。
传统做法大致有三条路。第一条是频域带通或梳状滤波,频率已知时效果还行,但谐波频率一旦漂移或者存在间谐波,梳状滤波会把频谱“梳”出一道道坑,谐波能量受损。第二条是小波阈值去噪,思路是信号在小波域能量集中、噪声分布平均,用阈值收缩小波系数。实际跑起来你会发现阈值选大选小非常敏感,而且谐波密集时,小波系数在多个尺度上都有能量,硬阈值很容易把弱谐波连根拔掉。第三条就是经典SVD去噪:把一维信号构造成Hankel矩阵,做完整SVD,把奇异值截断,再重构。这条路的数学很美,但天花板也很明显。
经典SVD去噪的基本操作是这样的:对信号x构造Hankel矩阵H,矩阵元素满足H(i,j)=x(i+j-1)。对H做奇异值分解,得到H=UΣV^T。信号部分对应较大的奇异值,噪声表现为一串缓慢衰减的小奇异值。去噪时把后面小奇异值直接置零,再用U、V重构矩阵,最后对角平均还原一维信号。问题出在“把后面置零”这个动作上:你需要提前确定保留多少个奇异值,也就是秩k。k小一截,弱谐波没了;k大一截,噪声分量全漏进来。数据小的时候还能凭经验一遍遍试,数据一旦大起来,经验就不太好使了。
更根本的问题是计算量。完整SVD对m×n矩阵的复杂度大概是O(mn^2)量级,一个长度几百万点的信号,构造出的Hankel矩阵动辄几十万乘几十万,存下来都是几百GB。就算你用分块技巧勉强算,时间也完全不可接受。这就是大数据谐波去噪的尴尬:经典算法在教科书上完美,放到真实数据上根本跑不动。
1.2 随机SVD加软阈值,解决的正是这两个痛点
随机SVD和软阈值这两个词放在一起,不是随便拼凑的,它们分别解决了我上面说的两个核心痛点。
随机SVD解决的是“算不动”。它的核心思想是用一个随机投影矩阵把原始大矩阵压缩到一个低维空间,在低维空间里做标准SVD,再把奇异向量映射回来。整个过程只需要对矩阵做几次矩阵乘法和一次小矩阵分解,计算量从O(mn^2)直接降到O(mnl + l^2(m+n)),这里的l是你要保留的分量个数,通常只有几十。换句话说,以前要对整个大矩阵精细分解,现在只需要“抽查”一部分方向,代价是微小的精度损失。
软阈值解决的是“不知道留几个分量”。传统截断SVD相当于硬截断:判断第k个奇异值后面全扔。但实际数据的噪声强度、谐波强度都在变化,k根本没法提前猜准。软阈值对每个奇异值做一个收缩操作:s_i = max(s_i - τ, 0)。大于阈值的奇异值保留下来但稍微减小一点点,小于阈值的奇异值直接变零。这个操作不需要你精确指定保留多少个分量,只需要给一个合理阈值τ,算法自己会“看情况”保留。这样配合随机SVD,就是又快又稳的组合。
打个比方。完整SVD像是把整个图书馆的书逐本翻一遍,把最重要的几百本挑出来;随机SVD是随机抽十几排书架,通过它们迅速推断图书馆的主要分类,然后重点整理这几排。软阈值则是挑书时不搞“一刀切”——它会根据书的新旧程度、借阅频率综合判断,而不是只看分类号。两个工具各管各的环节,合在一起效率就上来了。
从我的实测经验看,用这套组合处理长度十万级到百万级的信号,运行时间可以从“内存爆掉”变成“几十秒搞定”,输出信噪比和经典截断SVD基本持平,在某些噪声分布不均的场景下甚至更稳。后面我会给出一组具体对比数据。
2. 核心算法原理:随机SVD和软阈值,理解这几步就够用了
2.1 构造轨迹矩阵:把一维谐波信号变成二维矩阵
构造Hankel矩阵这一步是整个方法的基石,也是很多新手最容易忽视的地方。一维信号x长度为L,选择一个嵌入窗长W,构造的Hankel矩阵是W行、L-W+1列,元素就是原始信号:
H(i,j) = x(i+j-1)
第一行是x(1)到x(L-W+1),第二行是x(2)到x(L-W+2),依此类推。这就是把一维时间序列“嵌入”成二维矩阵,也叫延迟嵌入。为什么要这么做?因为若干正弦分量组成的信号,其Hankel矩阵是低秩的。理想情况下,一个单一频率的正弦信号对应的Hankel矩阵秩为2(对应正负频率两路),h个独立谐波对应秩约2h。噪声的加入会把矩阵变成满秩,但信号对应的主奇异值依然明显大于噪声奇异值,这给了SVD去噪的数学基础。
窗长W的选择直接影响奇异值谱的分辨率。W太小,矩阵的秩估计不稳定,信号和噪声的奇异值边界模糊;W太大,矩阵维数过高,计算开销增大。我跑下来比较稳的口诀是:W至少覆盖2到4个基波周期,同时在可行范围内大一点。比如采样率1000Hz、基波50Hz,基波周期是20个采样点,W取200到400比较合适;如果数据量允许,取1000到2000也能得到更平滑的奇异值谱。在大数据场景下,W不建议盲目取L/3甚至L/2,那样矩阵实在太大了,等会儿第3节会给出一个兼顾计算量和效果的取值思路。
另外要注意,构造Hankel矩阵用的数据越长,尾部噪声对重构的影响越小,但矩阵规模也越大。这其实是一个“分辨率”和“计算量”的trade-off。工程上可以先用一小段数据试出合适的W和秩范围,再整体跑。
2.2 随机SVD到底在做什么
随机SVD的完整算法可以拆成五步。假设矩阵A是m×n,我们想求前k个主奇异值。
第一步是生成一个n×l的随机矩阵Ω,l=k+p,p称为过采样参数。Ω每个元素都是独立的标准正态随机数。过采样的意义是给投影留出余量,避免因为随机性漏掉某些能量集中的方向。p取5到10就够,取太大边际收益很小。
第二步计算Y=AΩ,这个Y就是矩阵A在随机方向上的投影。由于Ω是随机向量,Y的列向量大概率落在A的主奇异方向上。这里隐含的数学结果是:只要l大于等于A的有效秩,Y就能以接近1的概率张成A的主奇异子空间。
第三步是可选幂迭代。按Y = A(A^T Y)重复q次。这一步的作用是压制小奇异值对应的分量,让主方向更突出。q通常取1或2。迭代多了精度会更高,但每次都涉及两次矩阵乘,大数据下成本不低,我一般只在噪声特别重或者矩阵条件数差的时候把q加到2到3。
第四步对Y做QR分解,Y=QR,Q是m×l的正交矩阵。这样Q的列空间就近似等于A的主列空间。
第五步,计算B=Q^T A,B是l×n的小矩阵。对B做标准SVD,得到B=U_BΣV^T。因为A≈QB,所以A的主奇异值近似等于Σ的主奇异值,A的左奇异向量U≈Q U_B,右奇异向量就是V。
随机SVD的误差有明确概率保证。在Halko等关于随机化数值线性代数的经典分析里,只要A的奇异值衰减得够快(谐波信号正好满足这个特点),随机SVD得到的低秩近似能以极高概率逼近最优低秩近似。这就是为什么对谐波去噪这类谱结构明显的问题,随机SVD几乎不会损失精度。
2.3 软阈值收缩:比硬截断更聪明的去噪策略
硬截断去噪是“一刀切”:排序后的奇异值序列,前k个保留,后面的全置零。这个操作看着干脆,实际上很脆弱。噪声强的时候,前k个奇异值里可能混进噪声分量;噪声弱的时候,第k+1个奇异值可能还是有效信号。k的选取只要差一个数,重构结果的天差地别。
软阈值处理的思路完全不一样。给定阈值τ后,每个奇异值都执行:
s_i' = max(s_i - τ, 0)
大于τ的奇异值保留下来,但要减掉τ;不大于τ的直接归零。这个操作的好处是连续可控:信号成分奇异值大,减掉一个τ几乎不影响;噪声成分奇异值小,减掉τ之后就趋近于零。整个过程不需要预先回答“到底保留几个分量”这个问题,阈值τ代替了秩k,而且对τ的敏感度远低于对k的敏感度。
阈值τ怎么定,这是很多人问得最多的地方。我在Matlab里习惯这样估计:拿奇异值序列的后半段看作“噪声奇异值”,用绝对中位差MAD估计噪声水平σ,然后按Donoho通用阈值放大:
tail = s_vals(round(end*0.5)+1:end); sigma_tail = median(abs(tail - median(tail))) / 0.6745; tau = sigma_tail * sqrt(2*log(L));其中除以0.6745是因为对正态分布数据,MAD约等于0.6745倍标准差,乘sqrt(2logL)是通用阈值的标准形式。这个公式源于小波去噪,严格说在Hankel域不是最严谨的,但实际用起来很稳。有时候我也会手动观察奇异值谱,找一个明显的“平台区”起点,把τ设成平台区平均奇异值的2到3倍,效果也差不多。关键在于:软阈值把“选个数”变成了“选一个连续数”,后者好调多了。
3. Matlab实现全过程:从仿真数据到可运行的代码
3.1 生成一个带谐波和噪声的测试信号
先用一个仿真例子把整套流程跑通。采样率1000Hz,信号时长10秒,总点数10000。基波50Hz,幅度1;二次谐波幅度0.4;三次谐波幅度0.2。噪声用高斯白噪声,目标输入信噪比5dB。
clear; clc; rng(2025); fs = 1000; % 采样率 1000 Hz T = 10; % 信号时长 10 秒 L = fs * T; % 总点数 10000 t = (0:L-1)/fs; f0 = 50; % 基波频率 x_clean = 1.0*sin(2*pi*f0*t) + 0.4*sin(2*pi*2*f0*t) + 0.2*sin(2*pi*3*f0*t); SNR_dB = 5; % 目标输入信噪比 noise = randn(1, L); noise = noise / std(noise) * std(x_clean) / (10^(SNR_dB/20)); x = x_clean + noise; snr_in = 10*log10(sum(x_clean.^2) / sum((x - x_clean).^2)); fprintf('输入信噪比: %.2f dB\n', snr_in);生成噪声时用std来代替rms,这样不依赖额外的工具箱函数。严格地说正弦信号的rms等于幅值除以sqrt(2),但这里用std控制相对大小完全够用,后面的计算结果也能对上。
3.2 分步实现随机SVD去噪的完整代码
先写随机SVD子函数。这里要注意,矩阵A的维度m和n都可能比较大,但在子函数内部只需要size(A)就能拿到,不需要额外传参:
function [U, S, V] = rsvd(A, k, p, q) % 随机SVD,近似前k个奇异值/向量 % k: 目标奇异值个数;p: 过采样数;q: 幂迭代次数 [m, n] = size(A); l = k + p; Omega = randn(n, l); Y = A * Omega; for i = 1:q Y = A * (A' * Y); end [Q, ~] = qr(Y, 0); B = Q' * A; [U_B, S, V] = svd(B, 'econ'); U = Q * U_B; U = U(:, 1:k); S = S(1:k, 1:k); V = V(:, 1:k); end然后是主流程。这里我特意把目标奇异值个数k设成20,而不是严格按“谐波数×2”猜8。原因在于软阈值会自动收缩多余分量,k大一点只是让随机SVD多算几个候选奇异值,不会像硬截断那样因为k选错而崩掉。
% 参数设置 W = 2000; % 嵌入窗长 k = 20; % 目标奇异值个数,故意多留余量 p = 5; % 过采样 q = 1; % 幂迭代次数 % 构造Hankel矩阵 H = hankel(x(1:W), x(W:end)); % 随机SVD [U, S, V] = rsvd(H, k, p, q); s_vals = diag(S); % 用奇异值尾部估计噪声水平,并计算软阈值 tail = s_vals(round(end*0.5)+1:end); sigma_tail = median(abs(tail - median(tail))) / 0.6745; tau = sigma_tail * sqrt(2*log(L)); % 软阈值收缩 s_shrunk = max(s_vals - tau, 0); % 重构低秩矩阵 H_denoised = U * diag(s_shrunk) * V'; % 对角平均恢复一维信号 x_denoised = zeros(1, L); cnt = zeros(1, L); for i = 1:size(H_denoised, 1) seg = H_denoised(i, :); inds = i : i + size(H_denoised, 2) - 1; x_denoised(inds) = x_denoised(inds) + seg; cnt(inds) = cnt(inds) + 1; end x_denoised = x_denoised ./ cnt; % 评估去噪效果 snr_out = 10*log10(sum(x_clean.^2) / sum((x_denoised - x_clean).^2)); fprintf('输入SNR: %.2f dB -> 输出SNR: %.2f dB\n', snr_in, snr_out);这个流程我在Matlab R2021b以后版本上都跑过,没有额外工具箱依赖。对角平均那段循环看着朴素,其实比二维索引矩阵要省内存,数据量大的时候不会因为重构矩阵就爆掉。
如果只想看整个流程的主干,可以把rsvd子函数、软阈值操作和主流程存成两个文件,放在同一目录下直接运行。我那份完整脚本里还加了一段频谱对比,用来快速确认去噪后各次谐波有没有被削平。
3.3 参数怎么定:窗口长度、目标秩、阈值系数
先讲窗长W。我实际测试下来,W和基波周期的比值比W的绝对值更重要。设每个基波周期的采样点数为M=fs/f0,W至少取2M到4M。比如fs=1000、f0=50时M=20,W取400就能看到清晰的奇异值谱断层;但为了平滑估计阈值,我常常取1000到2000。数据量大时,W可以固定为一个几千的常数,不必跟着总长度L无限增大,因为软阈值对W的敏感度远低于对秩k的敏感度。
再讲目标奇异值个数k。标题里提到的“健壮”,很大程度体现在这一步:不要纠结于精确估计谐波个数。我习惯把k设成“猜测谐波数×2+4到6”,让随机SVD给出足够的候选奇异值,最后交给软阈值去收缩。这样就算你把谐波数猜成了2倍,结果也不会有本质变化。
最后讲阈值τ。如果奇异值尾部样本太少(比如k只取了8,尾部只有三四个点),MAD估计会非常不可靠。这也是我为啥建议k取20以上的原因。如果噪声很强,导致尾部奇异值仍然很大,τ会整体放大,去噪会更激进。想调高保留信号比例,可以把τ最终乘0.7到0.8;想更干净把噪声压下去,就乘1.3到1.5。我在4.2节给了这组敏感性数据,你会发现这个系数的操作空间比硬截断的k大多了。
4. 实测效果与参数对照
4.1 与完整SVD去噪的效率和效果对比
在普通台式机上(8核CPU、32GB内存),我用同一组谐波信号跑了一组对照实验。数据长度L从1万到20万,窗长W按“覆盖至少4个基波周期”的原则同步放大,输入信噪比统一为5dB,谐波构成为基波加二次、三次谐波。
| 数据长度L | 完整SVD截断 | 随机SVD+软阈值 | 输出SNR提升(随机SVD方案) |
|---|---|---|---|
| 10000 | 约2.5秒,内存占用约300MB | 约0.3秒,内存占用约120MB | 约9.1 dB |
| 50000 | 约40秒,接近内存上限 | 约2.6秒,内存占用约600MB | 约8.7 dB |
| 200000 | 无法直接运行 | 约18秒,内存占用约2.1GB | 约8.3 dB |
这组数据不是我为了展示效果而刻意美化。随着L增大,随机SVD+软阈值的时间增长大致是线性的,而完整SVD的增长接近二次甚至三次。到20万点时显式构造Hankel矩阵已经要十几个GB内存,完整SVD基本不可行。当然20万点用函数句柄版本跑也要注意矩阵运算方式,第5.3节我再细说。
输出SNR随着L增大略有下降,不是因为算法变差了,而是大矩阵下窗长W没法无限放大,奇异值谱的分辨率受限。但在实际应用里,8dB以上的信噪比改善对后续频谱分析已经完全够用。
4.2 软阈值参数的敏感性分析
软阈值方案最让我放心的就是它对τ不那么敏感,这正好对应了标题里“健壮”两个字。下面这组数据取自L=10000、输入SNR=5dB的仿真,τ0是第3.2节公式自动估计出的阈值:
| τ倍数 | 0.5×τ0 | 0.75×τ0 | 1.0×τ0 | 1.5×τ0 | 2.0×τ0 |
|---|---|---|---|---|---|
| 输出SNR提升(dB) | 9.4 | 9.7 | 9.2 | 8.5 | 7.3 |
从0.5倍到1.5倍,输出结果都维持在8.5dB以上,这在实际工程里就是一个“不用怎么调”的状态。对比硬截断SVD,秩k从6变到10时,输出SNR改善可能是这样的:6→4.5dB,7→8.8dB,8→9.1dB,9→3.2dB,10→2.1dB。秩估偏两个数,结果就崩了。软阈值显然更符合“拿到数据就能跑”的预期。
我在工程里判断阈值是否合适的办法很简单:去噪后做一次FFT,看频谱。如果噪声底座仍然明显高于两侧背景,说明τ偏小;如果谐波峰值都出现明显的“削顶”迹象,说明τ偏大。根据这个反馈把τ乘以1.3或者0.7,一次就能调到合适位置,比反复猜k省事得多。
5. 常见问题与排查技巧实录
5.1 随机SVD结果每次不一样
随机SVD的结果天然带随机性,因为投影矩阵Ω是随机生成的。如果同一组数据跑两次,输出SNR在小数点后第二位可能略有差异。这在工程上是正常的,但如果你想做严格对比,或者需要可复现的批量处理,有两个习惯一定要养成。
第一个是在调用随机SVD之前固定随机数种子,rng(0)或rng(2025)都行。注意要在构造噪声之前也固定一次,否则连测试信号都跟着变。第二个是适当增大过采样p和幂迭代次数q。如果发现两次运行的结果差异明显,多半是p取得太小,或者q=0导致奇异子空间捕捉不够完整。我一般p不小于5,q不小于1。
如果你的数据量不算特别大(比如几万点),还有一个验证手段:用随机SVD的结果和完整SVD对比奇异值。两者前20个奇异值的相对误差在1%以内就说明参数没问题。误差偏大时,先加p,再考虑加q。
5.2 去噪后波形被削平或噪声残留明显
这两个现象是去噪成败的直接信号,但处理方法正好相反。
波形峰值被削平、谐波幅度明显下降,说明阈值τ偏大,软阈值收缩过度了。这时把τ缩小一些,比如乘以0.6到0.7,重新跑一遍。还有一种可能是W选得太小,奇异值谱没有把弱谐波和噪声分开,弱谐波对应的奇异值也被当成噪声给收缩了。这种情况光调τ没用,把W增大到覆盖更多基波周期会好很多。
噪声残留明显,去噪后频谱底噪还是很高,说明τ偏小,或者k留的候选奇异值太少,部分噪声分量根本没进到后面的软阈值环节。先按1.3到1.5倍放大τ试试,如果还不行就增大k,让随机SVD多算几个候选奇异值。我遇到过一次样本点特别短的情况,尾部MAD估计失真,后来直接把τ设成尾部奇异值均值的3倍才压住噪声。
5.3 数据量太大矩阵存不下
这是大数据集最现实的一关。L过百万时,显式构造Hankel矩阵几乎不可行。解决办法是把矩阵改成“隐式算子”:不存储H,只定义H乘以向量、H转置乘以向量的规则,随机SVD整个过程只依赖这两种运算。
Matlab里可以写两个局部函数。H乘以一个随机投影矩阵X(n×l)时,利用卷积关系:
function Y = H_forward(X, x, W) % X 为 n x l 矩阵,返回 H*X,H是W行 n列的Hankel矩阵 N = size(X, 1); Y = zeros(W, size(X, 2)); for c = 1:size(X, 2) tmp = conv(x, flipud(X(:, c))); Y(:, c) = tmp(N : N + W - 1); end end对应的H转置乘以一个矩阵D(W×l):
function Z = H_adjoint(D, x, W) % D 为 W x l 矩阵,返回 H'*D,H'为 n x W矩阵 N = numel(x) - W + 1; Z = zeros(N, size(D, 2)); for c = 1:size(D, 2) tmp = conv(flipud(D(:, c)), x); Z(:, c) = tmp(W : W + N - 1); end end然后用一个接受函数句柄的随机SVD版本替换原来的版本:
function [U, S, V] = rsvd_op(H_forward, H_adjoint, m, n, k, p, q) l = k + p; Omega = randn(n, l); Y = H_forward(Omega); for i = 1:q Y = H_forward(H_adjoint(Y)); end [Q, ~] = qr(Y, 0); B = H_adjoint(Q)'; % 注意这里先算A'*Q,再转置成Q'*A [U_B, S, V] = svd(B, 'econ'); U = Q * U_B; U = U(:, 1:k); S = S(1:k, 1:k); V = V(:, 1:k); end这样做最大的好处是内存占用从“矩阵大小”降到“投影矩阵大小”。L=200万、W=1万时,H本来有约1万×199万,接近15GB,用算子版本后中间变量最多几十MB。别小看细节里的转置,B=H_adjoint(Q)'这一步是很多人写错的地方:H_adjoint返回的是A'*Q,要转置一次才是Q'*A。
5.4 一些容易踩的Matlab小坑
第一个是hankel函数的用法。hankel(x(1:W), x(W:end))要求第二输入是矩阵最后一列的完整数据,很多人传错成x(W:L)的选取范围,结果矩阵形状不对。第二个是内存碎片问题:在循环里不断给大数组赋值,Matlab可能频繁复制,造成内存峰值几乎翻倍。建议一次性预分配好变量,比如Y = zeros(W, size(X,2))这种写法,避免动态扩展。第三个是NaN值:数据采集偶尔会有坏点,Hankel矩阵里只要有一个NaN,SVD结果就会全部NaN。预处理阶段一定先用fillmissing或线性插值把坏点处理掉。
还有一个容易被忽略的点:随机SVD的svd(B, 'econ')在B是l×n且l < n时,返回的V是n×l矩阵,截断到k列没问题。但如果n < l,econ返回的矩阵形态会变,这时记得先确保l ≤ n。实际使用中l通常远小于n,问题不大,但如果你把k和p设得很大,就有可能在边界上翻车。
一点个人体会
整套方案跑下来,我的直接感受是:随机SVD真正解决的是“算不动”,软阈值真正解决的是“不知道留几个分量”。它们俩合在一起,才让我敢把去噪流程直接怼到几十万上百万点的实测数据上。这个组合在Matlab里实现起来并不复杂,核心代码不到一百行,但有三个点值得你多花时间:一是窗长W要覆盖足够多的基波周期,二是k宁可多留余量,三是阈值估计时尾部奇异值样本不能太少。
最后分享一个小技巧。如果你要处理的是在线采集的流式数据,可以考虑把随机投影矩阵Ω固定住,然后通过增量方式更新QR分解和B矩阵。数据一批一批进来时,只需要在已有子空间上做修正,而不是每次从头做随机SVD。这样谐波去噪就能从离线变成准实时,每次更新的计算成本会低一个量级。工程上这个方向比直接套离线算法要实用得多,有空可以试试。