简介:DICTOL-master是一套围绕低秩字典学习与FDDL快速算法实现的完整工程代码包,面向机器学习、计算机视觉方向的研究者与进阶开发者,用于解决高维图像数据稀疏表示与分类任务中的字典训练问题。压缩包约57.61MB,共446个文件,主体为MATLAB脚本、MEX编译模块和C源文件,其中MATLAB脚本实现字典学习主流程,MEX模块加速底层稀疏编码,C源文件便于二次开发;另含MATLAB数据文件、测试图片、PDF/TEX文档与readme说明,目录结构覆盖数据预处理、字典初始化、交替最小化、稀疏编码到性能评估的完整环节。已有190人浏览学习,适合具备MATLAB与稀疏表示基础、希望复现低秩字典学习实验或对比不同算法效果的读者。包内不仅包含FDDL核心实现,还提供COPAR、DLSI、ODL等算法的代价函数与优化代码,以及OMP稀疏编码的MEX加速版本;学习者可直接在标准数据集上运行,结合附带文档与中间结果分析,理解低秩约束与稀疏编码的协同机制,并据此调优字典尺寸、正则化系数等超参数,提升图像分类精度。
1. 低秩字典学习不是常规字典学习加个正则:DICTOL 的入手价值
低秩字典学习做稀疏表示和图像去噪实验时,最容易被当成「字典学习加了一个秩惩罚项」而一笔带过。实际把 DICTOL 这个源码包完整跑一遍会发现,差别是结构性的:低秩约束让字典原子之间出现共享方向,字典容量下降,数据量不足时重建更稳,噪声偏大时反而更抗干扰,这跟「限制变多、效果变差」的直觉正好相反。DICTOL 把 KSVD 这类基准算法和 DLR_SR、DLR_RP 这类低秩实现放在同一个仓库里,自带合成数据、人脸图像块实验和评价脚本,适合两类人:一是要复现低秩字典学习做算法对比的研究生,二是手里有一批图像矩阵、想用更省容量的字典做特征表示的工程师。这篇文章从算法设计、去噪实验、源码改造到避坑,把 DICTOL 的完整使用路径走一遍。
2. 把「秩」写进目标函数:DICTOL 的算法设计、依赖安装与包结构
2.1 从 KSVD 到低秩字典学习:约束加在哪里
字典学习的经典形式是给定样本矩阵 X,求解一个过完备字典 D 和稀疏系数矩阵 A,让重建误差尽量小。KSVD 的目标函数可以写成:
min ||X - D A||_F² ,约束是每一列 a_i 的非零元素个数不超过 T。
KSVD 用交替迭代求解:先固定 D,用 OMP 之类的稀疏编码方法求 A;再固定 A,逐列更新字典原子。这个流程在 DICTOL 里被保留下来,作为所有低秩算法的基准。关键点在第二步:KSVD 逐列更新原子时,每一列都独立处理,原子之间没有显式关联。
低秩字典学习改的是 D 本身。它在目标函数里对字典加了一个秩约束或者秩惩罚项,形式上接近:
min ||X - D A||_F² + λ * rank(D)
rank(D) 直接优化是不可行的,秩函数非凸、不可导,DICTOL 的做法是用核范数近似秩,或者在做完字典更新之后对 D 做一次固定秩投影。前者温和,后者干脆。DICTOL 里两个代表性算法对应两种思路:DLR_SR 走核范数正则的路线,DLR_RP 走随机投影加速的路线,数据维度和原子数都很大时,DLR_RP 的矩阵运算开销明显更低。
理解这个约束的实际意义比记住公式重要。过完备字典通常 K 远大于信号维度 N,原子很多,但低秩约束意味着这 K 个原子实际上被压在一个低维子空间里,原子之间共享结构,而不是各自独立。图像块场景里,这等价于告诉模型「自然图像块的主要变化模式没有那么多」,先抓住主要模式,再让稀疏系数去表达残差。
2.2 安装与基本调用:先看环境版本再跑 demo
DICTOL 是开源字典学习库的 master 源码包,拿到手第一件事不是跑 demo,而是确认环境。依赖是 numpy、scipy、matplotlib 这三个,都是老牌库,但版本需要注意。
# 解压之后先探测依赖,避免直接跑 demo 暴雷 cd DICTOL-master python -c "import numpy, scipy, matplotlib; print(numpy.__version__, scipy.__version__)" # 核心库如果能直接 import,说明路径没问题 python -c "import dictol; print(dictol.__file__)"我一般不用 pip 去安装这类研究性质的工具包,而是直接让脚本在源码目录下运行。原因很实际:低秩字典学习做实验免不了改内部逻辑,用 pip 装进 site-packages 里,每改一次要重装一次,在源码目录下改完直接跑是最省事的。
这里最容易踩的第一个坑是 scipy 版本。DICTOL 仓库里部分图像处理代码用过 scipy.misc 的 imresize,而 SciPy 1.0 之后这个模块就被移除了。如果 import 阶段报错,说明 scipy 版本太高,后面第 5 章我会专门给替换方案。先把环境调到能 import dictol,就成功了一半。
2.3 核心迭代流程:交替更新的两个阶段
不管 KSVD 还是低秩版本,迭代骨架都是一样的两个阶段。下面这段是我把 DICTOL 的实验逻辑抽出来之后的模板,函数名只做示意,具体签名以你自己那份源码为准,但流程一定是这样的:
# 交替更新骨架:固定一个变量,更新另一个,循环迭代 def alt_update(X, D, T=5, max_iter=20): # X: 样本矩阵,每一列是一个样本 # D: 字典,每一列是一个原子 A = np.zeros((D.shape[1], X.shape[1])) for it in range(max_iter): # 阶段一:固定 D,做稀疏编码 # 常见做法是 OMP,也可以用 lasso 近似 A = sparse_coding(X, D, T) # 阶段二:固定 A,更新字典 # KSVD 在这里逐列更新;低秩版本在这里加投影步骤 D = update_dict(X, A, D) # 投影之后必须重新归一化原子 # 否则字典列的尺度会漂移,稀疏编码阶段会跟着乱 D = normalize_atoms(D) return D, Asparse_coding 负责在给定字典下找最稀疏的系数组合,DICTOL 内部实现里这一步通常用批量正交匹配追踪。update_dict 是整个算法的心脏:KSVD 版本在这里对误差矩阵做 SVD 分解来更新单个原子;低秩版本则会在更新完字典之后,对 D 整体做一次奇异值投影或者核范数收缩。normalize_atoms 这一步经常被忽略,但少了它,迭代到后面字典范数越来越大,稀疏系数的幅度会失真,重建结果看起来就是一片噪声。
写实验代码时,我建议把 max_iter 作为显式参数暴露出来,对比算法时保证所有方法用同一迭代次数,不然低秩版本多跑十轮赢过 KSVD,说服力会打折扣。
3. 用 DICTOL 跑一个图像去噪实验:从图像块到 PSNR 对比
3.1 拆图像块与加噪声:输入矩阵的形状规则
DICTOL 的输入矩阵格式非常固定:每一列是一个样本。图像去噪的第一步就是把一张完整图像切成重叠的小块,每一块拉成列向量,拼成样本矩阵。块尺寸选 8x8 还是 16x16,直接影响字典学到的内容。
import numpy as np from skimage import io from skimage.util import random_noise # 读取灰度图,转成 float 便于处理 img = io.imread("灰度图.png", as_gray=True) h, w = img.shape ps = 8 # 块边长,去噪任务常用 8 或 16 stride = 4 # 步长,步长越小样本越多,内存压力也越大 cols = [] for i in range(0, h - ps, stride): for j in range(0, w - ps, stride): cols.append(img[i:i+ps, j:j+ps].reshape(-1)) X = np.array(cols).T # 每一列是一个块,维度是 ps*ps X_noisy = random_noise(X, mode="gaussian", var=0.01)ps 取 8,每个样本是 64 维;取 16,则是 256 维。字典的第一维必须和这个对齐。stride 控制样本量和重叠程度:stride 越小,样本越多,字典学得越充分,但矩阵变大之后内存会先撑不住。我一般先用 stride 等于 ps 的一半起步,样本不够再加密。
加噪声时如果直接对列向量矩阵加高斯噪声,得到的噪声强度是逐元素独立的,等价于对原图加噪声后再切块。DICTOL 的去噪实验里噪声方差通常设成 0.01 到 0.05,对应轻度到明显的噪声干扰。
3.2 训练 KSVD 与低秩字典:核心参数的选取
样本矩阵准备好之后,用 DICTOL 跑两个字典:一个 KSVD 基准,一个低秩版本。初始字典用随机噪声列生成即可,但随机种子必须固定,否则两个算法在不同初始字典下对比没有意义。
# 固定随机种子,保证对比实验可复现 np.random.seed(0) D0 = np.random.rand(X.shape[0], 256) # 256 个原子,典型过完备倍数是 4 倍 # 先跑 KSVD 基准 D_ksvd, A_ksvd = run_ksvd(X_noisy, D0, T=5, iters=10) # 再跑低秩版本,lambda 控制秩惩罚强度 D_dlr, A_dlr = run_dlr_sr(X_noisy, D0, lambda_dlr=0.5, T=5, iters=10)run_ksvd 和 run_dlr_sr 在 DICTOL 仓库里是对应 demo 脚本里的封装函数,传入参数的结构类似。lambda_dlr 是低秩惩罚的权重,这一步是整个实验最需要调的参数,不同的噪声水平下它是会变的。
| 参数 | 推荐范围 | 作用 |
|---|---|---|
| 块边长 ps | 8 到 16 | 太小学到噪声,太大学到纹理细节 |
| 字典原子数 | 2 到 4 倍样本维度 | 过完备程度,越大容量越大 |
| 稀疏度 T | 5 到 10 | 越大表示越精细,太小重建出伪影 |
| lambda_dlr | 0.1 到 1 | 秩惩罚强度,越大字典越低秩 |
| 迭代轮数 | 10 到 30 | 太少收敛不了,太多收益递减 |
T 取 5 时,每个块只用 5 个原子重建,稀疏性很强。T 取 10 时重建精度高但噪声保留也多,去噪场景下 T 偏大反而不好。lambda_dlr 的规律是单调但不是越大越好:太小低秩约束不起作用,字典退化成普通 KSVD;太大原子被压成少数几个平滑模式,细节恢复不了。
3.3 重建去噪与效果解读:结果好坏不能只看原子
训练完字典,丢弃噪声样本,用干净样本训出的字典重建加噪数据,是去噪实验的标准流程。重建方式是把稀疏系数和字典乘回去,然后把重叠块拼回图像,重叠区域取平均。
# 重建去噪后的图像块矩阵 X_hat = D_ksvd @ A_ksvd # KSVD 版本 X_hat_dlr = D_dlr @ A_dlr # 低秩版本 def psnr(clean, noisy): mse = np.mean((clean - noisy) ** 2) return 10 * np.log10(1.0 / mse) # 图像像素归一化到 [0,1] 时 MAX=1 print("KSVD PSNR:", psnr(X, X_hat)) print("DLR PSNR:", psnr(X, X_hat_dlr))像素值归一化到 0 到 1 区间时,PSNR 公式里 MAX 取 1。这个细节很多人会漏,如果像素范围是 0 到 255,MAX 要取 255,同一个 MSE 算出来的 PSNR 完全不同。对比实验里两边用同一套公式就行,但报告数值时必须写清楚像素范围。
看结果有个常见误区:直接可视化字典原子,看到低秩字典的原子全是平滑块,就认为算法失败了。低秩约束本来就会让原子共享结构、看起来更模糊,判断标准应该是重建之后的 PSNR,而不是字典原子的视觉锐度。噪声方差大时,低秩版本通常能比 KSVD 高出 1dB 上下,而且块与块之间的接缝伪影更少,这是因为低秩约束抑制了原子对噪声的过拟合。
4. DICTOL 源码包二次开发:改约束、换数据、接自己的实验
4.1 源码导航:examples 和 dictol 模块各管什么
这份压缩包的目录结构值得先摸一遍,很多改造成果实际上就是改几个文件里的小函数。以典型的 master 包布局来看:
DICTOL-master/ ├── examples/ # 实验入口,先从这里读 demo │ ├── demo_ksvd.py # 基准算法 demo │ ├── demo_dlr_sr.py # 低秩字典学习 demo │ └── demo_dlr_rp.py # 随机投影加速版本 ├── dictol/ # 核心代码目录 │ ├── ksvd.py # K-SVD 实现及公共工具 │ ├── dlr.py # 低秩约束相关实现 │ └── utils/ # 图像块、数据加载、评价指标具体文件名和你手中的版本可能有出入,但套路一致:examples 里的脚本是可以直接改参数跑的,dictol 目录下是实际算法。二次开发时我倾向于把 examples 里的 demo 脚本复制一份出来改,而不是直接改原文件,这样每次实验有据可查,对比不同版本时也很方便。
examples 目录是理解这份源码的捷径。先跑一遍 demo_dlr_sr.py,看它加载了什么数据、传了什么参数、输出了什么结果,再回到 dictol 目录里看对应函数实现。新人容易直接从算法文件开始读,读半天不知道入口在哪,效率很低。
4.2 把核范数投影换成固定秩投影:一个可上手的改动点
低秩字典学习和普通字典学习的关键差异在字典更新那一步。DLR_SR 实现里,更新完字典之后会做一次奇异值操作。想验证低秩约束真的在起作用,最直接的办法是把这个操作换成固定秩投影,观察重建效果的变化方向。
# 对字典 D 做固定秩投影:只保留前 k 个奇异值 def low_rank_project(D, k=50): U, S, Vt = np.linalg.svd(D, full_matrices=False) # 只保留前 k 个奇异值,其余置零 S_k = np.zeros_like(S) S_k[:k] = S[:k] D_new = U @ np.diag(S_k) @ Vt return D_new # 换成核范数软阈值版本,gamma 相当于第 2 章里的 lambda_dlr def nuclear_norm_shrink(D, gamma=0.1): U, S, Vt = np.linalg.svd(D, full_matrices=False) S_shrunk = np.sign(S) * np.maximum(np.abs(S) - gamma, 0) return U @ np.diag(S_shrunk) @ Vt硬投影会让字典严格落在某个低秩子空间里,秩是确定的;软阈值则保留全部方向,但对小奇异值方向做衰减。两者的实验效果有差异:硬投影收敛更快,但秩选的不好容易丢细节;软阈值更平滑,多了一个 gamma 参数要调。想快速验证低秩约束带来的增益,先把硬投影跑通,再把 k 从 10 扫到 100,看重建误差随 k 的变化曲线。
这个改动在 DICTOL 源码里对应字典更新函数末尾的几行。找到 SVD 分解那一段,把原有的奇异值处理方式换成上面任意一种即可。改完跑对比实验时记得把 KSVD 和改动后的版本放在同一个随机种子、同一迭代次数下,不然结果差异说不清楚是算法带来的还是初始化带来的。
4.3 换成自定义数据集的三个注意点
DICTOL 的 demo 脚本默认加载内置的人脸或合成数据,换成自己的图像数据时,有三个点最容易出错。
第一个是数据格式。DICTOL 内部约定样本按列存放,如果你的数据是从文件夹里读出来的图片,需要先把每张图切块并铺平成列向量。下面这个函数是通用做法:
def load_my_images(paths, ps=16, stride=8): cols = [] for p in paths: im = io.imread(p, as_gray=True) h, w = im.shape for i in range(0, h - ps, stride): for j in range(0, w - ps, stride): cols.append(im[i:i+ps, j:j+ps].flatten()) return np.array(cols).T # 返回样本矩阵,列是样本第二个是数据均衡。如果用于分类实验,每个类别的图像数量要大致相当,否则低秩字典会偏向样本多的类别,稀疏编码阶段也会被大类别主导。去噪实验则没有这个问题,只需要保证图像内容多样性。
第三个是均值消减。低秩约束对 DC 分量非常敏感,图像块的均值如果不去掉,字典的第一主方向会被平均亮度占据,真正的结构信息反而排到后面的奇异值里。我习惯在切块之后对每个列向量减去该列的均值,实验效果差很多时可以优先检查这一步有没有做。
5. 从现象到原因:DICTOL 实战中的五条避坑记录
5.1 环境与安装层的坑
现象一:import dictol 报错,提示 No module named scipy.misc。
原因:DICTOL 仓库比较早,部分图像预处理代码用了 scipy.misc 里的 imresize,而 SciPy 1.0 之后 imresize 被移除了。这是这份源码包在新环境里最常见的翻车点。
解决:不要去降级 scipy,直接用 skimage 的 resize 替换。找到源码里调用 imresize 的地方,替换成下面的形式:
from skimage.transform import resize # 原来的写法(新 scipy 里已不存在) # from scipy.misc import imresize # im = imresize(img, (h, w)) # 替换后的写法 im = resize(img, (h, w), anti_aliasing=True)skimage 的 resize 返回浮点数组,范围 0 到 1,和原 imresize 的输出范围不同。替换后如果发现图像变成全黑或全白,检查一下数据类型和范围,做一次归一化即可。
现象二:python 直接跑 examples 里的脚本报 SyntaxError,指向 print 语句或 xrange。
原因:仓库代码最早面向 Python 2 写的,部分 demo 脚本没有完全迁移到 Python 3。
解决:优先用 Python 3 跑核心算法,单独处理语法兼容。少数脚本可以手动改:print 后面加括号,xrange 换成 range,iteritems 换成 items。改动量通常很小,核心算法文件基本已经是 Python 3 兼容的了,报错的集中在 demo 脚本里。我一般先尝试直接运行,看到 SyntaxError 再对着报错行修,比提前把全部代码扫一遍更高效。
5.2 实验设计与效果层的坑
现象三:把 stride 设成 1 跑去噪实验,内存直接撑爆。
原因:stride 为 1 时,一张 512x512 的图像会切出大约 25 万个重叠块,每个块 64 维,样本矩阵 X 就是 64 x 250000,后续矩阵乘法和 SVD 的内存开销会失控。
解决:stride 设为 ps 的一半甚至更大,先跑通再加密。如果样本量确实不够,可以随机采样一部分列参与训练,而不是全部塞进去。DICTOL 的矩阵运算对内存的占用是超线性增长的,样本数翻倍,内存占用可能翻四倍,这是低秩投影里 SVD 操作决定的。
现象四:低秩字典在分类任务上准确率还不如随机初始化、明显低于 KSVD。
原因:检查是否漏了均值消减。图像数据没有做零均值化时,低秩约束会把最主要的秩成分用来表达平均亮度,真正对分类有区分度的结构信息被压缩到后续的奇异值里,分类自然差。
解决:每个样本列减去均值,或者对所有特征做标准化。做完之后再看低秩字典的奇异值分布,会发现前几个奇异值的占比明显下降,说明结构信息被释放出来了。这个坑在我自己复现 DICTOL 分类实验时几乎必踩,现在每次接新数据集,第一步固定检查均值。
现象五:低秩字典训练结束后,可视化原子发现全是一团平滑块,认为代码跑错了。
原因:不是 bug,低秩约束的作用就是把原子往共享模式上压,原子看起来平滑是正常现象。把低秩字典和 KSVD 字典放在一起对比,平滑差异恰恰证明约束在生效。
解决:不要用肉眼看原子判断好坏,看重建 PSNR 或者分类准确率。想定量确认低秩约束生效,对学到的 D 做一次 SVD,打印奇异值曲线。如果奇异值快速衰减到接近零,说明 D 确实落在低秩子空间里,实验没有白跑。
6. 效果到底怎么验证:一条 lambda 曲线和一个基准习惯
验证低秩约束的作用,最直接的工具是 lambda 扫描曲线。固定稀疏度 T、字典大小、迭代轮数和随机种子,把 lambda_dlr 从 0 一路调到 2,记录每个取值下的重建 PSNR。lambda 为 0 时低秩字典退化成普通字典学习,相当于内置了 KSVD 基准:
| lambda_dlr | 字典有效秩 | 去噪 PSNR | 现象 |
|---|---|---|---|
| 0 | 接近满秩 | 基准值 | 相当于 KSVD |
| 0.1 | 略有下降 | 略高或持平 | 约束开始生效 |
| 0.5 | 明显下降 | 通常最高 | 平衡点 |
| 1.0 | 很低 | 开始回落 | 细节被过度压缩 |
| 2.0 | 个位数 | 明显下降 | 欠拟合,原子过平滑 |
这张表配合每个 lambda 对应的字典奇异值衰减曲线一起看,能回答两个问题:低秩约束到底有没有用,以及当前数据下该用多大的惩罚强度。只看一个 lambda 的结果就下结论,很容易被随机性误导。
从那以后,我每次用字典学习相关的代码,第一件事就是把基准算法用相同数据、相同迭代次数、相同随机种子完整跑一遍,再动新算法。lambda 扫描曲线也成了一个固定动作,不管实验最终目标是什么,先花十分钟把这条曲线拉出来,后面所有的参数讨论都站在同一个参照系上。这套习惯是从 DICTOL 项目里磨出来的,希望对你有帮助。
本文还有配套的精品资源,点击获取