简介:稀疏表示是信号处理与计算机视觉中的核心思想,旨在用尽可能少的原子线性组合逼近原始数据,广泛应用于图像去噪、压缩感知与特征提取。字典学习作为获取过完备字典的关键技术,通过交替优化稀疏系数与字典原子,实现数据自适应的表示模型。K-SVD算法凭借逐列SVD更新策略,相比传统MOD方法具有更好的数值稳定性与收敛性,已成为字典学习领域的经典方法。本文从稀疏表示与字典学习的基本原理出发,深入剖析OMP稀疏编码与K-SVD字典更新的数学机制,并给出完整的MATLAB实现与图像去噪实验流程。通过滑动窗口采样图像块、去直流、训练字典与稀疏重建,可以显著提升含噪图像的PSNR,同时保证字典原子的可解释性。文章还总结了参数调优、死原子处理与工程化优化经验,为入门稀疏字典学习与工程实践提供可靠参考。 做图像去噪那阵子,我手头有个老项目用的还是BM3D,效果虽然不错,但导师要求换一种可解释性更强的方案。翻了一圈文献,几乎所有论文都在提稀疏字典学习,尤其是K-SVD。论文看明白之后,真到MATLAB里写实现,还是踩了不少坑。这篇就把我整理好的方案分享出来,从OMP稀疏编码到K-SVD字典逐列更新,再到图像去噪完整Demo,代码都直接可跑,希望能帮正在啃这个方向的同学生省点时间。
1. 先把原理说透:稀疏字典学习到底在解什么问题
1.1 稀疏表示是“用最少的原子拼出原样”
稀疏表示这个事,说白了就是想用极少数的“零件”拼出一幅图像、一段信号或者一个特征向量。想象一个图书馆里有很多本字典,单词很多,但你写一句话只需要挑几个词就够了。这里的“字典”就是一堆基础原子,每一个原子是长度和样本一致的向量;稀疏表示就是希望找少量的原子线性组合,把原来的样本重建出来。
数学上,给定训练样本矩阵 $Y \in \mathbb{R}^{n \times N}$,有一本过完备字典 $D \in \mathbb{R}^{n \times K}$,其中 $K$ 通常大于 $n$,也就是说原子的数量大于信号维度。我们需要求一个稀疏系数矩阵 $X$,让 $D X$ 能够尽量还原 $Y$,同时希望每一列 $x_i$ 的非零元素尽量少。
这个“尽量少”用符号表达就是 $|x_i|_0 \le T_0$,其中 $| \cdot |_0$ 统计非零元素个数,$T_0$ 是我们设定的稀疏度。理解了这个设定,后面所有代码都是围绕它转的。
1.2 整个优化问题的数学表达
字典学习的完整目标函数可以写成:
$$\min_{D, X} | Y - D X |_F^2 \quad \text{s.t.} \quad \forall i,\ |x_i|_0 \le T_0$$
这个式子看起来简单,实际上是一个非凸问题,因为 $D$ 和 $X$ 耦合在一起,没法一次性求全局最优。$L_0$ 范数还带来了组合爆炸,直接求解是NP难的。所以学术界和工程界都采用了同一个套路:交替迭代,把大问题拆成两个小问题。
第一步是固定 $D$,更新 $X$,也就是稀疏编码。给定当前字典,用正交匹配追踪(OMP)等算法为每个样本求一个满足稀疏度约束的系数。第二步是固定 $X$,更新 $D$,也就是字典学习。这个阶段的目标是让字典更好地适应当前系数下的残差。
两个步骤反复交替,每轮都会降低重构误差,迭代若干次之后字典和系数就稳定下来了。实际工程中,10到30轮基本够用,再往后收益很小。
1.3 为什么K-SVD比MOD稳定:逐列更新的门道
早期用的字典更新方法叫MOD(Method of Optimal Directions),它的做法比较粗暴:固定 $X$ 后,直接对整个 $D$ 求一个最小二乘解 $D = Y X^T (X X^T)^{-1}$。MOD的问题在于需要矩阵求逆,当 $X X^T$ 条件数不好时,数值上容易抖动,而且字典的所有原子同时变化,不好解释收敛过程。
K-SVD的改进思路非常直接:不一口气更新整个字典,而是一列一列地更新。更新第 $k$ 列原子时,只查看“哪些样本使用了第 $k$ 列原子”,把这些样本上其他原子贡献的残差算出来,然后对这个残差矩阵做奇异值分解(SVD)。SVD的第一左奇异向量就是新的原子,第一右奇异向量乘以最大奇异值就是新的系数行。
这个做法的妙处在于,SVD给出了最小二乘意义下秩一逼近的最优解,相当于在当前状态下,每列更新都是朝着降低重构误差的方向迈了一步,而且一次只动一个原子,数值上比MOD稳得多。代价是要对所有原子循环一遍,每个原子做一次SVD,计算量比MOD大,但换来了可靠性和可解释性,这也是它到现在还是字典学习入门首选的原因。
2. 核心代码逐行拆解:从OMP到K-SVD的MATLAB实现
2.1 代码工程结构怎么组织
写MATLAB实现不需要搞复杂框架,三个文件就够了:ksvd.m是主函数,负责交替迭代;omp.m负责稀疏编码;updateDict.m负责字典的逐列SVD更新。这样拆开的好处是逻辑清晰,以后想换成其他稀疏编码方法,比如Lasso或者批量最小角回归,只需要替换omp.m就行。
主函数的整体流程在伪代码上是这样的:
- 初始化字典:随机选取训练样本中的若干列,逐列归一化
- 进入迭代循环
- 调用
omp.m,用当前字典求稀疏系数 - 调用
updateDict.m,逐列更新字典 - 计算当前相对重构误差,记录到历史序列里
- 迭代结束,返回字典、系数和误差曲线
下面把每个文件单独拆开讲,细节都在注释里。
2.2 OMP稀疏编码函数的实现
OMP是匹配追踪的升级版。匹配追踪每轮只找和残差内积最大的原子,减掉这个原子的贡献后继续找;OMP的不同在于,每轮找到新原子后,会在当前支撑集上做一次最小二乘,把支撑集上所有系数一起优化。这一下子就减少了重复选择的可能,收敛也快很多。
MATLAB实现:
function X = omp(D, Y, T0) % OMP 正交匹配追踪 % 输入: % D - n x K 字典 % Y - n x N 样本矩阵,每列一个样本 % T0 - 稀疏度上限 % 输出: % X - K x N 稀疏系数矩阵 K = size(D, 2); N = size(Y, 2); X = zeros(K, N); for i = 1:N r = Y(:, i); % 当前残差 idx = zeros(T0, 1); % 支撑集索引 coefLen = 0; % 实际用到的原子数 for t = 1:T0 corr = D' * r; % 所有原子与残差的内积 corr(idx(1:t-1)) = -inf; % 屏蔽已选原子,防止重复 [~, j] = max(abs(corr)); % 找最相关的原子 idx(t) = j; % 在支撑集上做最小二乘,更新系数 actIdx = idx(1:t); coef = D(:, actIdx) \ Y(:, i); r = Y(:, i) - D(:, actIdx) * coef; % 更新残差 coefLen = t; if norm(r) < 1e-8 break; % 残差足够小,提前结束 end end X(idx(1:coefLen), i) = coef; end end几个容易出错的地方:
第一个是corr(idx(1:t-1)) = -inf。如果不屏蔽已经选中的原子,OMP在原子相关性高的时候可能把同一个原子反复选上,从而浪费迭代次数。虽然理论上最小二乘会让已经选入的原子系数不再变化,但实际操作里由于浮点误差,不排除这种可能。
第二个是D(:, actIdx) \ Y(:, i)。MATLAB反斜杠会自动选择合适的求解器,对于过定系统走QR分解,比手写正规方程稳定得多。不要为了省事去用 $(D^T D)^{-1} D^T$,当字典原子之间有较强的相关性时,正规方程的数值条件会很差。
第三个是残差阈值1e-8。这个阈值和信号幅度有关。如果输入样本归一化到0到1之间,$10^{-8}$ 的残差足够小;如果输入是0到255的灰度值,建议把阈值放宽到 $10^{-4}$,否则OMP会一直选到T0个原子,浪费时间。
2.3 K-SVD主循环:字典逐列更新
主函数代码:
function [D, X, errList] = ksvd(Y, K, T0, iters) % K-SVD 字典学习 % 输入: % Y - n x N 训练样本 % K - 字典原子个数 % T0 - 稀疏度 % iters - 迭代次数 % 输出: % D - n x K 学习到的字典 % X - K x N 稀疏系数 % errList - 每轮相对重构误差 n = size(Y, 1); % 初始化:随机选K列,列归一化 D = Y(:, randi(size(Y, 2), 1, K)); D = D ./ (sqrt(sum(D.^2, 1)) + 1e-6); errList = zeros(iters, 1); for it = 1:iters X = omp(D, Y, T0); % 稀疏编码 [D, X] = updateDict(Y, D, X); % 字典更新 relErr = norm(Y - D * X, 'fro') / norm(Y, 'fro'); errList(it) = relErr; fprintf('iter %d, relative error = %.6f\n', it, relErr); end end初始化时从训练样本里随机挑K列,比用随机高斯矩阵更合理。图像块内容天然具备一定的结构,从数据出发的初始字典能让OMP阶段的前几轮误差下降更快。列归一化是为了消除尺度模糊:如果不归一化,原子长度可以任意变化,系数也跟着缩放,训练过程会浪费容量在尺度调整上。
字典更新函数:
function [D, X] = updateDict(Y, D, X) % 字典逐列SVD更新 K = size(D, 2); for k = 1:K % 找到哪些样本使用了第k个原子 I = find(X(k, :)); if isempty(I) continue; % 死原子处理见后文 end % 所有样本的残差 Ek = Y - D * X; % 只保留使用第k个原子的样本 Ek = Ek(:, I); % 取出该原子在当前样本上的系数 xkT = X(k, I); % 对受限残差矩阵做SVD % 这一步本质是对Ek做最优秩一逼近 [U, S, V] = svd(Ek, 'econ'); D(:, k) = U(:, 1); X(k, I) = S(1, 1) * V(:, 1)'; end end这里最核心的就是Ek = Y - D * X。注意,这个残差是在“所有原子贡献都去掉”的基础上计算的,也就是当前字典和系数对应的总残差。然后限定到I这些样本上,把第 $k$ 个原子在这些样本中的贡献又从系数里临时剥离出去,剩下的就是“待第 $k$ 个原子去拟合的部分”。
SVD分解之后,$U$ 的第一列是左奇异向量,$S(1,1)$ 是最大奇异值,$V$ 的第一列是右奇异向量。用 $U(:,1)$ 替代旧原子,用 $S(1,1) V(:,1)^T$ 替代旧系数行,就能保证这一列原子在最小二乘意义下达到了当前状态的最优秩一逼近。
需要注意,svd(Ek, 'econ')是经济型分解,当Ek是 $n \times m$ 且 $m < n$ 时,$V$ 是 $m \times m$,这样取第一列没问题。如果某个原子只被一个样本使用,Ek退化为单列向量,SVD得到的 $U$ 是 $n \times 1$,$V$ 是 $1 \times 1$,S(1,1) * V(:,1)'会得到一个标量,正好更新那个样本的系数。
2.4 冒烟测试:拿合成数据验证
写完三个函数,先用合成数据跑一遍,确认逻辑没问题,再上图像。用一个随机字典生成一组稀疏系数,合成观测数据,然后在不告知真实字典的情况下,用K-SVD去学:
rng(42); n = 20; % 信号维度 K = 40; % 原子个数 T0 = 4; % 稀疏度 N = 500; % 样本数 Dtrue = orth(randn(n, K)); Xtrue = zeros(K, N); for i = 1:N idx = randperm(K, T0); Xtrue(idx, i) = randn(T0, 1) * 2; end Y = Dtrue * Xtrue; [D, X, errList] = ksvd(Y, K, T0, 30); figure; plot(errList, 'o-'); xlabel('iter'); ylabel('relative error'); title('K-SVD convergence');正常情况下,相对误差会迅速下降,最终降到非常接近0。如果降到某个值不再动,说明字典容量或者稀疏度不够,可以调整K和T0。这个合成实验适合用来排查代码bug,因为它有明确的收敛性预期。
3. 用图像去噪练个手:完整可运行的实验脚本
3.1 采样重叠图像块
图像去噪是字典学习最经典的应用之一。思路是:从带噪图像上切出很多小图像块,把这些块作为训练样本,学习一本字典,再对每一个块做稀疏编码,然后重建,最后拼回整幅图。
切块有一个关键细节:要重叠。一次切一个8×8的块、步长为1,叫sliding窗口,这样相邻块之间高度相关,训练样本量大,重建时每个像素会被多个块覆盖,天然带一点平均效果,能压住块效应。MATLAB里可以直接用im2col:
bs = 8; Yall = im2col(noisy, [bs bs], 'sliding'); % Yall 的每一列是一个块展开后的向量,长度64如果整幅图是512×512,sliding模式会得到约 $(512-8+1)^2 \approx 25$ 万个块,全量用来训练太慢。实际操作是随机抽1万到3万块来训练字典,然后对所有块做稀疏编码和重建。抽样不会显著影响字典质量,因为图像块高度冗余。
另一个细节是去直流。每个块先减去自己的平均值,只保留纹理结构。如果不去直流,字典会花很大容量去表示亮度本身,而亮度信息对所有块几乎是常数,浪费原子。直流分量单独存下来,重建的时候加回去。
3.2 去噪与重建主流程
完整去噪脚本如下:
clear; close all; clc; rng(42); % 读图,转灰度、归一化 img = im2double(imread('cameraman.tif')); if size(img, 3) > 1 img = rgb2gray(img); end % 加高斯噪声 sigma = 25 / 255; noisy = img + sigma * randn(size(img)); % 参数设置 bs = 8; % 块大小 K = 256; % 字典原子数 T0 = 8; % 稀疏度 iters = 20; % 训练轮数 Ntrain = 20000; % 训练块数量 % 采样训练块,去直流 Yall = im2col(noisy, [bs bs], 'sliding'); Yall = Yall - mean(Yall, 1); sel = randperm(size(Yall, 2), Ntrain); Ytrain = Yall(:, sel); % 训练字典 [D, ~, errList] = ksvd(Ytrain, K, T0, iters); % 所有块稀疏编码并重建 Xall = omp(D, Yall, T0); recBlocks = D * Xall + mean(im2col(noisy, [bs bs], 'sliding'), 1); % 拼回整幅图(sliding重叠区域自动加权平均) denoised = col2im(recBlocks, [bs bs], size(noisy), 'sliding'); % 评估PSNR psnrDenoise = psnr(denoised, img); fprintf('PSNR = %.2f dB\n', psnrDenoise); figure; subplot(1, 3, 1); imshow(img); title('Original'); subplot(1, 3, 2); imshow(noisy); title('Noisy'); subplot(1, 3, 3); imshow(denoised); title('KSVD Denoised');col2im在sliding模式下会把重叠区域的多个块贡献叠加,最后除以覆盖次数,等价于加权平均。这个聚合方式能有效减少块边界痕迹。
3.3 效果评估和主观感受
以256×256的cameraman为例,加入标准差25/255的高斯噪声,噪声图PSNR大概在20.1dB左右。用以上参数跑完,去噪后的PSNR一般能做到28dB以上,效果肉眼可见地干净,纹理细节比直接均值滤波好得多。如果想再压榨一点效果,可以把迭代次数提高到30,或者把K增到512,但训练时间会明显涨。
需要说明,BM3D这类专用去噪方法的PSNR通常会比K-SVD再高1到2dB,但K-SVD的价值在于字典本身是可解释的,训练出来的每个原子对应一种结构模式,比如边缘、条纹、角点,这在人脸识别、信号稀疏表示、压缩感知等任务里更有用。去噪只是拿它练手的一个入口。
训练出来的字典可以直接可视化:
digit = 16; figure; for i = 1:K subplot(16, 16, i); imshow(reshape(D(:, i), bs, bs), []); end一张256原子的字典,用16×16网格展示,能看到每个小方块都长成一定的方向纹理,而不是随机噪声。这本身就是验证字典学习是否成功的一个直观手段。
4. 调参避坑实录:把K-SVD跑稳的经验总结
4.1 字典初始化和“死原子”处理
初始化看起来不起眼,实际影响不小。如果随机选到的训练块很相似,比如全是平滑区域,初始字典会缺少边缘原子,收敛到后期某些原子可能完全不被使用。这种“死原子”会让字典容量被浪费。
处理死原子有两种常用策略:
- 在
updateDict.m里,如果I为空,用当前重构误差最大的样本替换这个原子,这样能强迫字典去关注最难拟合的样本。 - 如果迭代过程中发现某个原子的稀疏系数行全是0,也可以在每轮结束后统一检查,替换为训练样本中残差最大的那一列。
第二种更主动,但要注意别在OMP完成后立即替换,否则会破坏当前轮次的误差一致性。我比较喜欢在每轮字典更新结束之后做统一清理。代码里可以这样改:
function [D, X] = replaceDeadAtoms(Y, D, X) K = size(D, 2); active = sum(X ~= 0, 2); dead = find(active == 0); if isempty(dead) return; end % 当前残差 R = Y - D * X; for k = dead' [~, j] = max(sum(R.^2, 1)); atom = Y(:, j); nrm = norm(atom); if nrm < 1e-10 atom = randn(size(D, 1), 1); nrm = norm(atom); end D(:, k) = atom / nrm; X(k, :) = 0; X(k, j) = nrm; % 更新残差 R = Y - D * X; end end这个函数可以在主循环里每轮字典更新后调用一次,能明显改善字典利用率和最终重构质量。
4.2 参数范围和默认推荐
先给一张我实测下来的参数推荐表,再逐个解释:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| 块大小 bs | 6~12 | 越大越能捕捉结构,但字典学习越慢 |
| 字典规模 K | 64~512 | 样本维度不高时K取大容易过拟合 |
| 稀疏度 T0 | 3~12 | 和噪声水平强相关,噪声大要适当加大 |
| 迭代次数 iters | 15~30 | 超过30轮收益很小 |
| 训练块数量 | 10000~40000 | 太少字典学不充分,太多训练慢 |
关键trade-off在T0。T0太小,表示能力不够,去噪之后图像会偏模糊,因为细节无法用少量原子表达;T0太大,模型会把噪声部分也当成结构去拟合,去噪效果变差,PSNR反而下降。实际操作中我一般以噪声标准差为参考:$\sigma=15/255$ 时T0取5左右,$\sigma=25/255$ 取8,$\sigma=50/255$ 取12,然后在这个基础上微调。
K的选择和样本维度有关。8×8块展开后维度是64,K取256相当于4倍过完备,够用了。如果K取1024,字典原子之间大量冗余,训练时间成倍增加,但重构误差下降很有限。K的收益不是线性的,256到512能感觉到提升,512到1024基本感觉不到。
4.3 训练样本去直流和归一化的问题
图像块本身包含亮度直流量,这个量对所有块来说几乎不变。如果不做去直流,K-SVD训练出的第一个原子很可能就是一个“近似全1”的原子,专门用来表示平均亮度,剩下的容量才用来学纹理。这本身不是错,但会浪费容量,而且在稀疏编码阶段,所有块都得先用这个原子,再用其他原子补偿细节,T0压力会变大。
所以我的统一做法是:训练前把每个块减去自己的均值,保存这些均值向量;训练完字典之后,对所有块编码时也要把均值加回来。上面的去噪脚本里已经体现了这一点。
归一化这块,用im2double把图像转到0到1范围即可。不要用uint8直接算,SVD和反斜杠在uint8上不可用,而且灰度级0~255的取值范围会让数值条件变差。如果输入数据不在0到1范围,OMP的残差阈值也要相应调整,这个前面OMP一节提过。
4.4 运行报错和性能优化记录
我用R2020b搭的这套代码,在R2018a上也验证过,基本语法没踩坑。有几个容易出问题的地方值得单独说:
svd(Ek, 'econ')在空矩阵或者单个样本时不会报错,但如果Ek存在NaN,结果会直接崩。所以训练数据里要确保没有NaN,图像读进来检查一下是否有坏像素。im2col对内存不友好。512×512的图,8×8块sliding模式会产生25万个列向量,每列64维,总共约1.25亿个浮点数,约100MB。这在老电脑上可能卡。所以训练阶段的im2col可以只抽部分块,或者用'distinct'模式加速,但求质量还是建议sliding。- MATLAB并行工具箱可以加速OMP:把
for i = 1:N改成parfor i = 1:N,前提是每个样本之间没有数据依赖。我在8核机器上测试,N=20000时能快3倍左右。不过要注意,parfor里对X的按列赋值需要改成X(:, i) = ...的方式,或者收集到cell再拼。 - 老版本MATLAB对
randperm第二个参数语义一致,但im2col、col2im这些函数行为很稳定,基本可以放心用。
训练时间实测:256×256图像、20000个训练块、K=256、T0=8、20轮,在普通办公笔记本上大约是20到40秒,取决于CPU。如果时间太慢,优先减少训练块数量到10000,或者把K降到128,质量损失不大。
4.5 后续可以怎么扩展
这套K-SVD实现是稀疏字典学习的一个扎实底座。想继续深入,可以考虑几个方向:
一是判别式字典学习。K-SVD是重构导向的,不关心稀疏系数能不能用于分类。LC-KSVD(Label Consistent KSVD)在目标函数里加了分类误差项,每个原子和类别标签挂钩,训练出来的字典同时具备重构和判别能力。这个方向在人脸识别、SAR图像分类里效果很好。
二是非负字典学习。如果数据本来就非负,比如光谱数据、文本词频,加上非负约束之后字典和系数都更可解释,MATLAB里可以用坐标下降或者投影梯度实现。
三是在线字典学习。K-SVD每次迭代都要处理全部训练样本,数据量大时内存扛不住。Mairal等人提出的在线字典学习每次只取一小批样本,更新字典时用一次梯度步替代SVD,适合流式数据。
四是结合深度学习的展开网络。像LISTA(Learnable ISTA)这种思路,把稀疏编码的迭代过程展开成网络层,参数用数据学出来,推理速度比在线OMP快很多。如果对加速有硬需求,这个方向值得关注。
我后来在几个实际项目里,已经把图像去噪脚本换成了判别字典加在线更新的组合,速度和效果都更适应生产环境。但无论怎么改,K-SVD里的OMP和SVD更新这两个核心思想一直没变,理解它们,是理解整个稀疏表示工具箱的钥匙。
本文还有配套的精品资源,点击获取