简介:这是一份基于MATLAB的在线字典学习实现代码,面向机器学习初学者和图像/信号处理方向的研究者,用于解决如何从序列数据中在线学习一组基字典的问题。与批处理方式不同,在线学习逐样本更新模型,适合内存有限或数据流场景,该代码正好演示了这一过程。压缩包共5个文件,包含4个MATLAB脚本(.m)和1个.mat数据文件,整体仅1.11MB;其中.m文件分别承担主流程、代价函数计算、字典更新等核心模块,数据文件提供示例输入,结构清晰便于对照学习。读者通过运行demo可以观察cost函数随迭代的变化,理解损失最小化与字典更新之间的协同关系;同时可通过调整参数测试不同数据下的稀疏编码效果。目前已有693人学习下载,特别适合希望动手验证在线学习原理、提升MATLAB编程与无监督学习实践能力的开发者。
1. 在线字典学习:把“学完再更新”改成“边学边更新”
之前在调一个图像去噪项目,整批 K-SVD 在 512×512 的灰度图上单次迭代要扫完所有 patch,内存卡得死死的,跑几轮就恨不得换机器。换成在线字典学习之后,每次只吃一个或一小批 patch 就更新一次字典,训练十几轮下来,速度和效果反而都上去了。这套代码就是一个用 MATLAB 写的在线字典学习实现:输入一堆训练图像,切块后边遍历边更新过完备字典,最终得到一组能对图像块做稀疏表示的原子,供去噪、重构、特征提取这类下游任务复用。适合做稀疏表示、图像复原、压缩感知方向但又不想上 CUDA 和深度学习的开发者。
2. 项目架构与技术选型:一次迭代训练和整批训练的本质差异
2.1 为什么用在线方式而不是整批 K-SVD
字典学习的数学目标很直白:给定训练样本集 Y = [y₁, y₂, …, y_m],每个 y ∈ R^n,要找一个字典 D ∈ R^{n×K}(K 通常比 n 大,形成过完备),使得每个样本都能用少数原子线性组合逼近:
min_{D,A} ‖Y - DA‖_F² + λ‖A‖₁, s.t. ‖d_j‖₂ ≤ 1
整批 K-SVD 的套路是:先用 OMP 对所有样本求稀疏系数,再用 SVD 逐个原子更新,反复交替直到收敛。问题在于每次“稀疏编码”阶段都要把全部样本过一遍,内存占用和计算量随样本数线性上涨。我最早就是在这里翻车的:512×512 图切成 8×8 patch,步长 1,能得到 25 万个训练列,OMP 一次要解 25 万个最小二乘,MATLAB 里直接卡成黑匣子。
在线方式的思路完全不同。它不追求一次迭代处理全部数据,而是每一轮随机抽一个或一小批 patch,做完稀疏编码立刻更新字典,再进入下一个样本。更新公式用的是随机梯度下降的变体:
D ← D + η_t · (y_t - Dα_t) · α_tᵀ
其中 η_t 是随迭代衰减的学习率。这样做有三个实际好处:第一,内存只跟单批大小有关,不跟总样本数挂钩;第二,数据可以流式进来,训练中途来新图也能继续学;第三,天然带随机性,反而更不容易陷入局部最优。这套代码就是把“采样→稀疏编码→字典更新→正则化”四步封装成主循环,每一步做成独立函数,方便替换实现。
2.2 目录结构与文件职责
拿到代码包解压后,目录结构长这样:
online-dictionary-learning/ ├── config.m # 全局参数配置 ├── demo_denoise.m # 一键演示:训练字典+图像去噪 ├── src/ │ ├── onlineDictLearn.m # 在线字典学习主循环 │ ├── ompSparseCode.m # OMP 稀疏编码 │ ├── updateDictionary.m # 随机梯度字典更新 │ ├── patchExtract.m # 图像切块/合并 │ ├── normalizeAtoms.m # 原子归一化 │ └── computePSNR.m # 重建质量评估 ├── data/ │ └── train_images/ # 训练用灰度图 └── results/ # 训练日志和重建结果每个文件只干一件事,这是这套代码最值得抄的地方。config.m 集中管理所有超参数,改参不用翻主循环;onlineDictLearn.m 只负责迭代逻辑,不掺图像 IO;patchExtract.m 管图像和矩阵之间的转换。我后来做二次开发,基本只改 config 和替换某个 src 函数,不用动整个项目。
2.3 数据流与参数约定
整体数据流可以概括成一条链:
灰度图 → 随机切 patch → 列向量化 → OMP稀疏编码 → 梯度更新字典 → 原子归一化 → 下一批训练阶段完全不保留整图,而是用 patchExtract 把图切成小块再随机打乱,喂给主循环。参数约定集中在 config.m 里,核心几个是:
| 参数名 | 含义 | 典型值 | 影响 |
|---|---|---|---|
| patchSize | 图像块边长 | 8 | 太小原子抓不住结构,太大计算量暴涨 |
| dictSize | 字典原子数 K | 256 | 过完备度,K 越大表示能力越强但训练越慢 |
| sparsity | 稀疏度 L | 10 | OMP 每次选几个原子 |
| batchSize | 每次更新的样本数 | 64 | 梯度估计稳定性 |
| learningRate | 初始学习率 η₀ | 0.01 | 太大震荡,太小收敛慢 |
| numEpochs | 遍历数据的轮数 | 10 | 训练总时长 |
这里有个容易被忽略的点:learningRate 不是恒定的。在线学习理论要求学习率满足衰减条件,代码里默认用 η_t = η₀ / (1 + t/numEpochs),这样前期快速调整,后期精细收敛。这个衰减策略比固定学习率稳得多,是这套代码能快速收敛的关键之一。
3. 核心模块拆解:一个样本从进入到字典更新的完整链路
3.1 主循环:onlineDictLearn.m
主循环是整个项目的骨架,负责把采样、稀疏编码、字典更新串起来。代码不长,但每一行都有讲究:
function [D, history] = onlineDictLearn(Y, K, params) % Y: 训练矩阵,每列是一个patch的列向量 % K: 字典原子个数 % params: 结构体,包含sparsity, batchSize, learningRate等 % 返回: 训练好的字典 D (n*K),history记录每轮重构误差 n = size(Y, 1); D = randn(n, K) ./ sqrt(n); % 随机初始化并保证列范数~1 D = normalizeAtoms(D); numSamples = size(Y, 2); t = 0; % 全局迭代计数 history.errors = []; history.learningRates = []; for epoch = 1:params.numEpochs % 每轮重新打乱样本顺序,增加随机性 order = randperm(numSamples); for i = 1:params.batchSize:numSamples idx = order(i:min(i+params.batchSize-1, numSamples)); Y_batch = Y(:, idx); % 取一小批样本 t = t + 1; % 1. 稀疏编码:固定D,求A A_batch = zeros(K, length(idx)); for j = 1:length(idx) A_batch(:, j) = ompSparseCode(Y_batch(:, j), D, params.sparsity); end % 2. 字典更新:固定A,梯度下降更新D eta = params.learningRate / (1 + t / numSamples); D = updateDictionary(D, Y_batch, A_batch, eta); % 3. 每隔若干步记录误差 if mod(t, 50) == 0 err = norm(Y_batch - D * A_batch, 'fro') / norm(Y_batch, 'fro'); history.errors(end+1) = err; history.learningRates(end+1) = eta; fprintf('epoch %d, iter %d, rel err %.4f, lr %.5f\n', ... epoch, t, err, eta); end end end end逻辑说明:外层循环控制 epoch,每轮训练前用 randperm 打乱样本顺序,避免模型记住数据排列。内层按 batchSize 切块,每批样本依次完成“OMP 求稀疏系数 → 按梯度方向更新字典”。这一步是标准的块坐标下降:固定 D 解 A,固定 A 解 D,交替逼近原目标函数。学习率 η 随迭代数衰减,t 越大步长越小,后期只做微调而不破坏已学好的原子。
参数说明:K 是字典宽度,决定过完备程度;sparsity 控制每个 patch 用几个原子表示,太小欠拟合、太大会把噪声也编进去;batchSize 平衡梯度估计的稳定性和计算开销,我一般取 64,显存紧张时降到 32。history 结构体把误差和学习率都记录下来,训练完可以画曲线判断是否收敛。
3.2 稀疏编码:OMP 的 MATLAB 实现
稀疏编码是字典学习里最耗时的环节。OMP(正交匹配追踪)是贪心算法里最常用的实现:每次选出和当前残差最相关的原子,做一次最小二乘,更新残差,重复 L 次。代码里刻意不调用现成工具箱,纯手写,方便看清楚每一步:
function alpha = ompSparseCode(y, D, sparsity) % y: 单样本列向量 % D: n*K 字典 % sparsity: 最大非零系数个数 % 返回: K*1 稀疏系数向量alpha residual = y; alpha = zeros(size(D, 2), 1); selected = false(size(D, 2), 1); % 记录哪些原子已被选中 for iter = 1:sparsity % 计算所有原子与残差的内积,找最相关的 innerProd = D' * residual; innerProd(selected) = -inf; % 已选原子不再重复选 [~, j] = max(abs(innerProd)); selected(j) = true; % 用选中的原子构成子字典,做最小二乘 Dj = D(:, selected); coef_temp = Dj \ y; % 注意是解满秩最小二乘 residual = y - Dj * coef_temp; % 提前终止:残差足够小就不必达到sparsity if norm(residual) < 1e-6 break; end end alpha(selected) = coef_temp; end逻辑说明:每一轮先做原子与残差的相关性检测,用 selected 掩码把已选原子屏蔽,再用反斜杠运算符求解最小二乘。这里的关键坑位是:每次选新原子后,所有已选原子的系数都要重新算一遍,不能只保留新增系数,否则解不是最小二乘意义下的最优。代码里 coef_temp 每次都用 Dj \ y 整体重算,代价略高但解更准。残差阈值 1e-6 是提前终止条件,实际使用时如果噪声较大,这个阈值可以放宽到 1e-4,否则 OMP 会把噪声当成信号编进去。
参数说明:sparsity 不宜设得比 n 还大,8×8 patch 的维度是 64,稀疏度一般取 10 到 20。值得注意的是 OMP 每轮都在做 Dj \ y,当 Dj 过大时计算量会涨,实际项目中如果发现 OMP 是瓶颈,可以把反斜杠替换成预计算的 Gram 矩阵版本,这是后话。
3.3 字典更新与原子归一化
拿到一批样本的稀疏系数后,字典更新就走随机梯度下降。这一步的公式是 D ← D + η·(y-Dα)·αᵀ,本质是用重构误差的反向传播来调整原子方向:
function D = updateDictionary(D, Y_batch, A_batch, eta) % Y_batch: n*batchSize 样本矩阵 % A_batch: K*batchSize 稀疏系数矩阵 % eta: 当前学习率 residual = Y_batch - D * A_batch; % 批量重构误差 % 对每个样本单独做梯度累积,避免大矩阵运算 for i = 1:size(Y_batch, 2) grad = residual(:, i) * A_batch(:, i)'; D = D + eta * grad; end D = normalizeAtoms(D); % 每步更新后保持列范数为1 end function D = normalizeAtoms(D) % 每个原子归一化到单位范数,防止梯度更新导致原子无限膨胀 colNorms = sqrt(sum(D.^2, 1)); colNorms(colNorms < eps) = 1; % 防止除零 D = D ./ colNorms; end逻辑说明:grad 是外积形式,表示重构误差对当前字典的梯度方向。更新完所有批内样本后统一做一次归一化,这一步不是可有可无——如果不归一化,目标函数里 ‖d_j‖₂ ≤ 1 的约束会被破坏,原子范数会越学越大,稀疏系数会越学越小,最终数值不稳定。这里使用逐样本循环而不是矩阵乘法累积梯度,是为了避免同时把整个 batch 的梯度矩阵存在内存里,对超大 batch 更友好。
参数说明:eta 从主循环传入,已经是衰减后的值。实际调试时如果发现 loss 曲线震荡,先看 eta 是否太大;如果收敛太慢,看 eta 是否太小。这两个症状都是用学习率调的典型问题,和深度学习里完全一样。
3.4 图像切块与重建质量评估
训练数据来自图像,但进入字典学习的是列向量。patchExtract 负责把图像切成重叠块并可选地做归一化:
function [patches, positions] = patchExtract(img, patchSize, step) % img: 二维灰度图 % patchSize: 块边长 % step: 滑窗步长,step=1时得到最大重叠块 % 返回: patches为样本矩阵,positions记录每块的左上角坐标 [H, W] = size(img); rows = 1:step:(H - patchSize + 1); cols = 1:step:(W - patchSize + 1); numPatches = length(rows) * length(cols); patches = zeros(patchSize * patchSize, numPatches); positions = zeros(numPatches, 2); k = 0; for r = rows for c = cols block = img(r:r+patchSize-1, c:c+patchSize-1); k = k + 1; patches(:, k) = block(:); % 列向量化 positions(k, :) = [r, c]; end end % 减去每块的DC分量,让字典学纹理而非亮度 means = mean(patches, 1); patches = patches - means; end逻辑说明:把每个 patch 拉成列向量后,减去该块均值。这是字典学习里一个非常容易被忽略而后果严重的预处理操作——图像块的 DC 分量(即亮度均值)差异巨大,如果不减掉,字典原子会花大量精力去表示不同亮度,而不是纹理和边缘结构。减去后,重建时把 DC 单独存下来,叠回去即可。
重建质量评估 computePSNR 不复杂,但要注意输入取值范围。如果图像是 double 类型、像素范围 0 到 255,PSNR 公式里的峰值就取 255;如果归一化到 0 到 1,峰值取 1。经常有人混用导致 PSNR 虚高或虚低,这是评测里的暗坑。
4. 运行指南:从准备数据到训练收敛的完整流程
4.1 环境准备与数据组织
跑这套代码只需要 MATLAB,不需要额外工具箱。建议使用 R2019b 及以上版本,主要是为了兼容 string 数组和结构化网格语法;如果老版本跑不动,把代码里用字符串拼接的部分改成 sprintf 即可。项目对图像数据没有特殊要求,灰度或彩色均可,彩色图会先被转成灰度再切块,运行时在 demo_denoise.m 里会自动处理。
数据组织方式上,把训练图放进 data/train_images/ 目录,命名随意,代码会遍历目录下所有 .png/.jpg 文件。这一步常见的翻车现场是路径带中文或空格,MATLAB 对含中文路径的兼容性一言难尽,建议项目根目录和目标图片路径都保持纯英文。我之前的习惯是在项目根目录建一个setup.m,里面用addpath(genpath(pwd))把 src 目录一次性加进搜索路径,避免每次启动都手动调路径。
4.2 训练配置与一键运行
demo_denoise.m 承担两个角色:既演示训练流程,又顺带验证字典的去噪能力。核心骨架如下:
% 1. 加载训练图并切块 imgs = loadTrainImages('./data/train_images'); Y = []; for i = 1:length(imgs) patches = patchExtract(imgs{i}, 8, 4); % 步长4,控制样本量 Y = [Y, patches]; end Y = double(Y); % 2. 设置参数并训练 params.patchSize = 8; params.dictSize = 256; params.sparsity = 10; params.batchSize = 64; params.learningRate = 0.01; params.numEpochs = 20; [D, history] = onlineDictLearn(Y, params.dictSize, params); % 3. 训练曲线可视化 figure; plot(history.errors); title('Relative Reconstruction Error'); xlabel('Iteration (x50)'); ylabel('Rel Err');运行方式就是在 MATLAB 命令行里直接敲demo_denoise,跑完会在 results 目录生成训练误差曲线和去燥前后的对比图。训练时间取决于数据量:我用 5 张 512×512 图、步长 4、epoch 设 20,大概 3 到 5 分钟跑完,中途会打印类似这样的日志:
epoch 1, iter 50, rel err 0.3421, lr 0.0098 epoch 1, iter 100, rel err 0.2817, lr 0.0096 epoch 2, iter 150, rel err 0.1932, lr 0.0094如果看到 rel err 持续下降然后放缓,说明训练正常;如果曲线往上走,大概率是学习率过大或数据没做去均值处理。
4.3 训练过程的监控与收敛判断
收敛判断不能只看 loss 曲线,还要看字典本身的质量。我会在训练结束后做两件事:第一,用displayDictionary(D)把 256 个原子画成 16×16 的图像网格,肉眼扫一遍。学好的字典应该是一堆方向各异的边缘、条纹和纹理基元,如果全是噪点或重复的亮块,说明训练失败。第二,检查原子激活率——统计所有训练样本用到每个原子的次数,如果大量原子从未被选中,说明 dictSize 设得过大或者学习率衰减太快,原子还没来得及被“用”就冻结了。
这套代码在 demo 里自带了一个实用功能:把含噪声的测试图切块,用训练好的字典做稀疏编码,再用稀疏系数重建,计算 PSNR。这个闭环才是“字典学好了”的真正证明——字典本身误差低不代表下游任务效果好,因为字典可能把噪声也编码进去了。去噪场景下,训练字典用的应该是干净图,噪声图只在评测阶段出现。
5. 避坑与排查:在线字典学习最常见的五个经典翻车现场
5.1 原子“饿死”:字典里一半原子从来不被选中
现象:训练结束画出字典,发现大量原子非常相似,都是整体灰蒙蒙一片,或者干脆和初始值没什么差别;统计激活率时,相当一部分原子计数为 0。
原因:在线学习按随机顺序遍历样本,某些原子在初期就没被选中,梯度永远为 0,于是冻结在初始状态。这属于在线学习特有的“冷启动”问题,整批 K-SVD 因为每轮都扫全部样本,反而不容易出现。
解决:最有效的办法是“原子替换”——每隔一定迭代数统计原子激活率,把激活率最低的原子替换成当前残差最大的样本方向。代码里可以在主循环里加一个判断:
if mod(t, 500) == 0 counts = sum(A_batch ~= 0, 2); [~, deadIdx] = sort(counts, 'ascend'); for jj = 1:min(3, length(deadIdx)) r = Y(:, randi(size(Y, 2))) - D * ompSparseCode(Y(:, randi(size(Y, 2))), D, params.sparsity); D(:, deadIdx(jj)) = r / norm(r); end end这个技巧叫“dead atom recycling”,我在项目里加了之后,字典利用率从 60% 提到 95% 以上。
5.2 重构误差不降反升:学习率参数在作怪
现象:训练日志里 rel err 前期下降,跑到中途突然跳高,然后一直下不来。
原因:学习率 η_t 衰减速度不够快。在线算法的学习率理论上要求满足 ∑η_t = ∞ 且 ∑η_t² < ∞,前一个条件保证能走到最优解附近,后一个条件保证不会一直在最优解附近震荡。代码默认的衰减是 η₀/(1+t/m),如果 η₀ 设成 0.1 甚至更大,后期步长仍然偏大,误差曲线就会像心电图。
解决:把初始学习率降到 0.005 到 0.01;同时把衰减函数改成 η_t = η₀ / (1 + t)^0.8,这是一个经验上很稳的衰减形式。改完参数后,重新看训练曲线,应该呈现单调下降后趋于平稳的形状。
5.3 字典学出来全是亮度模板,学不到纹理
现象:显示的字典原子全是中间亮、四周暗的斑点,几乎看不到方向和边缘。
原因:切块后没有减去 DC 分量。图像块的亮度均值差异远大于纹理差异,字典为了最小化重构误差,把大部分原子都用来表示不同亮度层级,真正有用的纹理原子只占了少数。
解决:在 patchExtract 中强制去均值,把均值作为单独信息保存。重建时先对稀疏系数重建结果加回均值,再拼回图像。这个预处理改动只影响训练阶段,对下游任务没有额外负担,却直接决定了字典质量。
5.4 OMP 耗时感人:稀疏编码成了整个流程的瓶颈
现象:训练日志里迭代很快,但每个 epoch 耗时越来越长,整个训练跑到一半就不想等了。
原因:OMP 每轮都要对已选原子做一次最小二乘,随着稀疏度 sparsity 增大,Dj \ y 的规模线性膨胀。如果 sparsity 设成 20 以上,且字典 K 很大,计算量会成倍上涨。另一个常见原因是把 sparsity 设得过高,明明残差早就降到阈值以下,还在继续选原子。
解决:第一,在 OMP 里保留提前终止条件,残差 norm 小于 1e-5 就跳出;第二,用 Gram 矩阵预计算 DᵀD,在 OMP 迭代中用查表替代重复计算 Dᵀ 与残差的内积。第二个优化在原子数超过 256 时收益非常明显。
5.5 随机种子导致结果不可复现
现象:第一次跑去噪 PSNR 是 32.5dB,第二次跑变成 31.8dB,什么都没改,觉得是玄学。
原因:randperm 打乱样本顺序、randn 初始化字典,每次运行都不一样。在线学习的结果本身受样本顺序影响,同一份数据两次训练会得到不同字典,下游 PSNR 自然不一样。
解决:在 config.m 里固定随机种子,训练前加一句rng(42)。这样至少保证同一份数据、同一参数下的结果是可复现的。真正严谨的调参流程应该是:固定随机种子后,再比学习率、稀疏度、字典规模这些参数,否则两个参数的差异会和随机种子带来的差异混在一起,根本分不清谁起了作用。
6. 二次开发:把在线字典接到自己的图像复原流水线里
6.1 改造一:用训练好的字典做图像去噪
拿到训练好的字典 D 后,把它移植到自己的项目里只需要三步:加载字典 → 对含噪图切块 → 对每个 patch 做稀疏编码再重建。下面这段代码可以直接嵌到任何图像处理脚本里:
% 含噪图像 im_noisy,训练好的字典 D,参数 patches = patchExtract(im_noisy, 8, 4); n = size(patches, 2); recon = zeros(size(patches)); for i = 1:n alpha = ompSparseCode(patches(:, i), D, 10); recon(:, i) = D * alpha; end im_denoised = colfilt2d(recon, 8, 4, size(im_noisy));去噪效果很大程度上取决于 sparsity 的选择:噪声越强,sparsity 应该设得越小,因为噪声通常会在高频方向被少量原子捕捉,限制稀疏度等于在系数层面做低通滤波。这个改造几乎是零成本,唯一要改的是把训练阶段和推理阶段的 patchExtract 参数保持一致,包括步长和是否去均值。
6.2 改造二:从去噪扩展到压缩感知重构
完整读完这套代码的另一种价值,是理解“稀疏表示 + 字典”这个范式可以迁移到压缩感知:观测向量 y = Φx,其中 Φ 是测量矩阵,x 在字典 D 下稀疏。重构时求解 min ‖α‖₁ s.t. y = ΦDα。把上面 OMP 里的 D 替换成 ΦD 就可以复用全部代码,只是残差计算变成观测域而不是像素域。这个方向适合做信号压缩、MRI 重建之类的项目,也是我在这套代码上做过收获最大的一次扩展。
从那以后,我每次拿到一个新的字典学习项目,都会强制自己先跑一遍默认配置、画出字典网格、检查原子激活率,再谈调参。这三个动作加起来不到两分钟,却能省掉后面一整天的试错时间。希望这套代码和这份拆解能帮你在稀疏表示的路上少踩几个坑。
本文还有配套的精品资源,点击获取