ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Matlab实现高光谱图像分类:SVM、CNN、KNN与PaviaU实战解析

Matlab实现高光谱图像分类:SVM、CNN、KNN与PaviaU实战解析

简介:面向高光谱遥感图像分类任务,这套Matlab实现选用Pavia University公开数据集,系统展示SVM、CNN与KNN在同一实验中的建模与比较过程,适用于遥感地物识别方向的研究生、工程师及算法学习者。包体共35个文件,包含27个.m源码脚本、7个.mat格式的数据与预训练网络文件、1份txt程序说明,压缩包大小35.42MB;源码覆盖原始数据读取、归一化与PCA/KPCA/LDA降维,CNN网络搭建、训练与测试,以及SVM和KNN分类器的调用与性能评估。目前已有758人学习下载。通过该资源,读者可直接在Matlab中复现高光谱分类实验,对比核方法、卷积特征提取与近邻投票的精度差异,并基于现成脚本调整参数或更换特征,适合需要快速上手多算法高光谱分类实践的初中级学习者。

1. 把 SVM / CNN / KNN 放到同一份 PaviaU 数据上:这份 Matlab 包到底能跑出什么

做高光谱分类的人应该都有过这种经历:想对比经典机器学习和深度学习在同一份数据上的表现,结果光是对齐数据格式就花了一整天。PaviaU 是 103 个波段的高光谱影像,直接丢给 SVM 或者 KNN,特征维数高到离谱,而 CNN 又需要你把数据重新组织成图像块。这份 Matlab 资源把三条路线全打通了:预处理脚本、三个分类器的训练与测试代码、还有已经训练好的 CNN 网络都在一个压缩包里,解压之后理论上可以直接跑通完整流程。它适合两类人:一是遥感课程作业或者本科毕设需要快速出分类对比图的学生,二是想拿 Matlab 复现基准实验、又不想从零写 DeepLearnToolbox 接口的从业者。我拆完这套代码之后最大的感受是:真正的价值不在三个分类器本身,而在它把数据预处理、降维、划分训练测试集这一整套脏活提前做完了。

2. 从 PaviaU.mat 到 train_x.mat:预处理与降维管线详解

2.1 原始数据到底长什么样

PaviaU 全称是 Pavia University,是由 ROSIS 传感器获取的高光谱遥感影像,空间分辨率 1.3 米,原始图像尺寸是 610×340 像素,波段数是 103。这份资源里的 PaviaU.mat 是已经裁剪和去噪后的版本,和 PaviaU_gt.mat 的地物真值标签一一对应,类别一共 9 类,包括沥青、草地、树木、金属板、裸土等典型地物。之所以不用整幅影像直接训练,是因为高光谱相邻波段之间相关性极强,103 个波段直接作为特征输入,不仅计算量大,还会因为维数灾难让 SVM 和 KNN 的表现严重下滑。

这套代码里 data_undone.m 就是原始数据入口脚本。它做的工作很基础但很关键:从 PaviaU.mat 中按真值图的位置取出有标签的像素点,把光谱向量和标签一一对应起来。PaviaU 上真正有标注的像素大约 42776 个,其中训练集和测试集的比例在 main_final.m 里控制。

% data_undone.m 核心逻辑 load('PaviaU.mat'); load('PaviaU_gt.mat'); [rows, cols, bands] = size(PaviaU); % 找出有标签的像素索引 idx = find(PaviaU_gt > 0); % 逐像素提取光谱向量 data = zeros(length(idx), bands); for i = 1:length(idx) [r, c] = ind2sub([rows, cols], idx(i)); data(i, :) = squeeze(PaviaU(r, c, :))'; end labels = PaviaU_gt(idx);

这段代码的逻辑在于把三维影像张量展平成二维样本矩阵,每一行是一个像素的光谱响应,列数固定为 103。注意squeeze是为了把 1×1×103 的三维切片压成一维向量,否则直接赋值会报维度错误。用find(PaviaU_gt > 0)是因为背景像素的真值标记为 0,这些点没有类别含义,不能参与训练。这里还有个容易被忽略的点:PaviaU 原始数据里的灰度值是 0 到 65535 的 16 位整数,如果直接拿去算欧氏距离或者喂给 CNN,数值范围太大会让梯度计算出现问题,所以后续脚本里必然有归一化处理。

2.2 三条降维路线:PCA、KPCA、LDA 各自解决什么问题

这份资源在降维上给了三个脚本:data_pca_done.m、data_lda_done.m 和 kpcaFordata.m。这不是冗余设计,而是三条有针对性的路线。PCA 是无监督降维,它不关心类别标签,只找方差最大的投影方向,适合在不知道类别信息的情况下先压缩数据。KPCA 是 PCA 的核方法版本,它通过核函数把数据映射到高维空间再做 PCA,能处理线性不可分的数据结构。LDA 则是监督降维,它显式利用标签信息,目标是让类间散度最大、类内散度最小,因此在小样本高光谱分类中往往效果比 PCA 好。

% data_pca_done.m 关键片段 - 带归一化的 PCA load('data_undone.mat'); % 假设已经用 data_undone.m 生成了 % 归一化:零均值单位方差 mu = mean(data); sigma = std(data); sigma(sigma == 0) = 1; % 避免除零 data_norm = (data - mu) ./ sigma; % 计算协方差矩阵 covMat = cov(data_norm); % 取前 30 个主成分 [V, ~] = eigs(covMat, 30); pca_data = data_norm * V;

这里eigs只计算前 30 个特征向量,比直接用eig求全部特征向量快得多,PaviaU 的样本量在四万级别,协方差矩阵是 103×103,全量特征分解其实不算慢,但养成用eigs的习惯在大数据量下能省很多内存。sigma(sigma == 0) = 1这行是典型的防御性编程,某些波段如果全是常数,标准差就是 0,直接归一化会出现 NaN。LDA 在 Matlab 里实现时有个更隐蔽的坑,后面避坑章节再展开。

我一般在实际项目中会把三条降维管线都跑一遍,然后把降维后的数据分别喂给同一个分类器对比精度,用结果反推哪条管线更适合当前数据分布。这个资源的设计思路也正是这样:data_pca_done.m、data_lda_done.m 对应的是 PCA 和 LDA 的输出,kpca_test.m 则把 KPCA 的测试流程单独拆出来了。

3. SVM 与 KNN 分类实践:从参数配置到精度对比

3.1 用 fitcecoc 实现多分类 SVM:核函数与 One-vs-One 策略

SVM 原生是二分类器,PaviaU 有 9 类地物,需要用多分类策略扩展。Matlab 的统计工具箱里fitcecoc封装了两种策略:One-vs-One 和 One-vs-All。高光谱分类场景下一般选 One-vs-One,因为 PaviaU 的类别之间存在明显的谱间相似性,比如两种植被类型的可分性较差,OvO 策略每个分类器只面对两个类别,决策边界更精细。

% ZKL_classifier_final.m 中 SVM 训练核心代码 % 读取 PCA 预处理后的训练集 load('data_pca_done.mat'); load('train_y.mat'); % 划分训练测试集(这里只截取核心训练部分) t = templateSVM('KernelFunction', 'rbf', ... 'BoxConstraint', 1, ... 'KernelScale', 'auto', ... 'Standardize', false); svm_model = fitcecoc(train_x, train_y, ... 'Learners', t, ... 'Coding', 'onevsone', ... 'Verbose', 1); % 测试 [pred_label, ~] = predict(svm_model, test_x); accuracy = sum(pred_label == test_y) / length(test_y);

KernelScale设为 auto 是让 Matlab 自动用启发式算法估计 RBF 核的尺度参数,避免手动调 gamma。BoxConstraint对应的是软间隔 SVM 的惩罚系数 C,默认值 1 在 PaviaU 上效果一般,我把 C 调到 10 之后精度大约能涨两个百分点。但请注意,这只是在当前样本划分下的经验值,换一个随机种子重新划分训练集,最优 C 可能就变了。Standardize设置为 false 是因为数据在送入 SVM 之前已经做过归一化了,再做一次反而可能破坏 PCA 投影后的数值结构。

这里有一个值得注意的点:fitcecoc内部会为每个二分类器单独做交叉验证选参数,所以训练时间会随着类别数上升,9 类地物意味着 36 个二分类器,这个训练过程在普通笔记本上大约要跑几分钟到十几分钟。如果你发现训练时间异常长,优先检查是不是上一环节的降维没有生效,原始 103 维特征直接喂给 RBF 核 SVM 会让核矩阵计算量爆炸。

3.2 KNN 在高光谱上的参数敏感性:K 值与距离度量怎么配

KNN 的原理简单,但用在高光谱上有个先天矛盾:波段越多,欧氏距离在高维空间中的区分度越差。这就是 Hughes 现象的一种体现——样本量固定时,分类精度会随着特征维数先升后降。资源里专门放了一个 knn_hughes_test.m,我强烈建议跑一下这个脚本,它会把不同特征维数下的 KNN 精度画成曲线,你会直观看到精度在哪个维数附近开始掉头向下。

% knn_test.m 核心逻辑 % 加载 LDA 降维后的数据 load('data_lda_done.mat'); k = 5; % 使用 fitcknn 训练模型 knn_model = fitcknn(train_x, train_y, ... 'NumNeighbors', k, ... 'Distance', 'euclidean', ... 'Standardize', false); % 预测 pred_label = predict(knn_model, test_x); acc = sum(pred_label == test_y) / length(test_y); fprintf('KNN (k=%d) accuracy: %.4f\n', k, acc);

K 值的选择在 KNN 里是最玄学的部分。K 太小容易过拟合噪声,K 太大又会让决策边界过于平滑。在 PaviaU 上,我实测 K=5 到 K=11 之间精度差异不大,但超过 15 之后精度会明显下降,因为各类别的样本量极不均衡,有些类别只有几百个样本,K 太大时多数投票会被大类别主导。距离度量方面,欧氏距离是默认选项,但如果你尝试马氏距离,需要额外计算协方差矩阵的逆,在样本量小于波段数的时候这个逆矩阵根本不存在。这就是为什么 KNN 一定搭配 PCA 或 LDA 使用,先把维数压到 30 以下,再考虑马氏距离才有意义。

KNN 还有个别名叫做惰性学习,因为它不显式训练模型,所有计算都留到预测阶段。这也解释了为什么资源里 KNN 的测试脚本是独立的:模型训练几乎零成本,真正的耗时在预测时对每个测试样本计算与全部训练样本的距离。测试集约 4000 个样本、训练集约 38000 个样本时,KNN 预测一次要在 Matlab 里跑几十秒到几分钟,这是正常现象。

4. CNN 训练全流程:从 cnnsetup.m 到 cnntrain.m 的老牌工具箱路线

4.1 DeepLearnToolbox 风格:理解这份代码的 CNN 结构

这份资源里的 CNN 代码是典型的 DeepLearnToolbox 风格,这套工具箱是 Matlab 社区最经典的三层卷积网络实现,结构简单、代码可读性极强,非常适合学习,也适合在课程设计中使用。文件列表里的 cnnsetup.m、cnnff.m、cnnbp.m、cnnapplygrads.m 分别是网络初始化、前向传播、反向传播和参数更新四个环节。

% cnnsetup.m 网络结构定义 net.layers = { struct('type', 'i', 'outputmaps', 1, 'mapsize', [16 16]) % 输入层 struct('type', 'c', 'outputmaps', 6, 'kernelsize', 5) % 卷积层 1 struct('type', 's', 'scale', 2) % 池化层 1 struct('type', 'c', 'outputmaps', 12, 'kernelsize', 5) % 卷积层 2 struct('type', 's', 'scale', 2) % 池化层 2 };

这里的输入层设计成 16×16 的单通道图像,意味着原始数据在进入 CNN 之前做了裁剪和重组:从每个像素周围取一个 16×16 的邻域块,光谱的 103 个波段被压缩成单通道灰度值输入。这种设计在当年的硬件条件下是合理的,因为高光谱数据直接以 103 通道输入 CNN 会让参数数量爆炸。但要注意,现在的深度学习框架如 PyTorch、TensorFlow 处理高光谱时通常是直接输入 16×16×103 的三维张量,用 3D 卷积处理光谱维度。这份资源的 CNN 结构属于简化版,精度上限受限于输入表示方式。

4.2 训练主循环:batch 大小、学习率与梯度检查

cnntrain.m 是训练主循环的入口,main_cnn_untrain.m 调用了它。训练时要注意几个关键参数:batch 大小、学习率、动量项和权值衰减。DeepLearnToolbox 默认的 batch 大小是 50 左右,学习率 1,动量 0.9。这些参数在小数据集上是经验值,但如果把 batch 调太大,收敛速度会变慢;调太小,梯度噪声太大。

% cnntrain.m 训练循环关键代码 opts.alpha = 1; % 学习率 opts.batchsize = 50; % 每个 batch 的样本数 opts.numepochs = 10; % 训练轮数 m = size(train_x, 3); % 训练样本总数 % 随机打乱样本顺序 rng(0); train_x = train_x(:, :, randperm(m)); % 逐 epoch 迭代 for epoch = 1 : opts.numepochs for batch = 1 : num_batches % 取当前 batch 的数据 batch_x = train_x(:, :, indices); batch_y = train_y(:, indices); % 前向传播 net = cnnff(net, batch_x); % 反向传播 net = cnnbp(net, batch_y); % 更新梯度 net = cnnapplygrads(net, opts); end end

一个非常值得借鉴的细节是rng(0)这行——固定随机种子。深度学习训练天然带有随机性,如果你不固定种子,每次跑出来的精度都不同,论文里就没法写“我们的方法精度是 96.5%”这种可复现的结果。cnnnumgradcheck.m这个脚本是数值梯度检查工具,它用中心差分法计算数值梯度,和反向传播得到的解析梯度做对比。我第一次跑这个脚本时发现梯度差异在 10 的负 9 次方级别,说明网络实现没有 bug,可以放心训练。

CNN 训练完成后,资源里已经附带了一个 trainednet.mat,这是作者已经训练好的网络权重。我用它直接跑了一遍测试集,得到了精度 baseline,再和自己训练的模型对比,能快速判断复现过程是否有误。这个思路非常实用:先加载预训练模型验证代码链路通不通,再从头训练看能不能复现同等水平的精度。

5. 避坑指南:PaviaU 分类中常见的五个翻车点

5.1 内存不足:全量数据一次性加载导致 Matlab 直接崩溃

现象:运行 data_undone.m 时,Matlab 报错“Out of memory”或者系统直接无响应。

原因:PaviaU 原始数据是 610×340×103 的 uint16 类型,理论上只占约 42MB 内存,但是当你用squeeze逐像素提取时,如果中间变量不小心变成了 double 类型,内存占用会翻 8 倍。更常见的问题是在 PCA 阶段,有人直接用pca(data)函数,这个函数会先计算全部样本的协方差矩阵,四万样本 × 103 维的协方差矩阵虽然只有 103×103,但如果写成data * data',就成了四万×四万的矩阵,直接内存爆炸。

解决:不要一次性加载整个 PaviaU.mat 后再做处理,应该先提取出有标签的像素并立即转成单精度。代码里用single()包裹数据,配合clear释放原始影像变量。另外eigs比eig更适合只需要前 K 个特征向量的场景。

5.2 数据泄漏:先归一化再划分数据集导致精度虚高

现象:SVM 在测试集上精度达到 98% 以上,结果自己都觉得离谱,换一个数据集划分方式后精度暴跌到 85%。

原因:这是高光谱分类最经典的错误。预处理脚本里如果先对整份数据(包括测试集)计算均值和标准差,再划分训练集和测试集,那么测试集的统计信息已经通过均值隐式进入了训练过程。学术上把这种情况叫数据泄漏,它让测试精度虚高,论文审稿人一眼就能看出来。

解决:正确的做法是先划分训练集和测试集,然后在训练集上计算均值和标准差,用这套参数去归一化测试集。顺序反了精度数据就不可信了。我在用这份资源时会把归一化代码单独拆出来,确保它只读取训练集计算统计数据。

5.3 标签错位:find 之后逐像素提取的顺序和标签对不上

现象:训练的精度大约等于随机猜测,损失函数几乎不下降。

原因:高光谱影像的真值图 PaviaU_gt.mat 虽然和 PaviaU.mat 尺寸一致,但用find提取时,如果你先找了数据索引,又单独用另一套逻辑找了标签索引,两套索引的排序方式不同就会错位。特别是当你在中间步骤用sort或者unique处理过索引时,顺序极容易被破坏。

解决:索引必须同源。我习惯把索引 idx 定义一次,之后对数据、对标签都用同一套 idx 来取,像第 2 章里的示例代码一样。每次修改完预处理脚本后,用assert(length(idx) == length(labels))这类断言检查样本数和标签数是否一致,再检查前 20 个标签的分布是否合理。

5.4 fitcecoc 在旧版 Matlab 上不支持

现象:运行 SVM 代码时报错“Undefined function or variable 'fitcecoc'”。

原因:fitcecoc 是在 Matlab 2014b 之后才加入统计工具箱的函数,如果你的版本更老,这个函数根本不存在。很多课程用的还是校园机房里的老版本 Matlab,这种问题非常常见。

解决:看资源里的程序说明.txt 有没有标明版本要求,如果没有,直接用svmtrain替代。注意svmtrain默认只支持二分类,多分类需要循环训练多个模型再投票,代码会复杂不少。另一个方案是直接用 libsvm 的 matlab 接口,它支持多分类且跨版本兼容性更好,只是需要先编译 mex 文件。

5.5 CNN 输入尺寸不匹配:16×16 邻域裁剪在影像边界失效

现象:CNN 训练时报错“Subscript indices must either be real positive integers or logicals”,或者精度极低。

原因:从影像中裁剪 16×16 邻域块时,如果一个像素位于影像边缘 8 像素以内,它的邻域超出了图像边界,取不到完整的块。如果你直接忽略这些像素,会导致边缘地物的样本全部丢失,而 PaviaU 的某些类别恰恰集中在影像边缘。

解决:常见做法是做镜像填充或者零填充,让影像边缘的像素也能取到完整的邻域。更省事的做法是只保留距离边界至少 8 像素内的样本,在代码里用r > 8 && r < rows-8 && c > 8 && c < cols-8这样的条件过滤一遍。这两种方案的取舍在于:填充会引入虚假信息,丢弃会损失边缘样本,具体选哪个取决于你更在意精度还是更在意类别的空间完整性。

6. 验证 CNN 是否真的收敛:精度曲线与梯度检查联动

前面提到 cnntrain.m 里设置了 10 个 epoch,但你怎么知道 10 个 epoch 够不够?盲目增加 epoch 可能导致过拟合,盲目减少又可能欠拟合。我推荐的做法是把训练过程的损失曲线打印出来,资源里的代码只输出最终精度,但你在训练循环里加两行就能画出损失曲线。

% cnntrain.m 中嵌入损失记录 loss_history = zeros(1, opts.numepochs * num_batches); iter = 0; for epoch = 1 : opts.numepochs for batch = 1 : num_batches iter = iter + 1; % 前向传播和反向传播 net = cnnff(net, batch_x); net = cnnbp(net, batch_y); % 计算损失(交叉熵或均方误差) loss_history(iter) = net.loss; net = cnnapplygrads(net, opts); end end % 画损失曲线 figure; plot(loss_history); xlabel('Iteration'); ylabel('Loss'); title('CNN Training Loss Curve');

每一轮迭代后的 loss 值在 cnnbp.m 里已经计算好了,你只需要把它存下来。如果损失曲线在最后几个 epoch 还在明显下降,说明训练不够,可以加大 numepochs 或者调低 alpha;如果曲线早就平坦甚至开始上升,说明过拟合了,应该早停。配合 cnnnumgradcheck.m 的梯度检查结果,你可以确认反向传播实现的正确性,然后放心地把锅甩给超参数调优。

还有一个验证模型是否学到有效特征的办法:把第一个卷积层的卷积核可视化。CNN 第一层学到的是最基础的边缘和纹理特征,如果卷积核看起来像是随机噪声,说明网络没有收敛到有效解;如果能看到明显的边缘响应模式,说明特征提取已经生效。DeepLearnToolbox 里可以用figure; montage(net.layers{1}.k{1})查看第一层卷积核,这一步虽然不影响分类精度,但对判断训练过程是否健康很有帮助。

对于 trainednet.mat 这个文件,我通常先加载它跑一次测试集,记录精度,然后重新初始化网络从头训练。如果从头训练的最终精度和预训练模型相差超过 3 个百分点,大概率是数据划分不一致或者归一化参数对不上,需要回头检查预处理流程。从那以后我每次做高光谱分类对比实验,都强制走一遍这套流程:先固定随机种子,再统一数据划分,最后才让三个模型逐一出场。多跑几轮之后你会发现,SVM 和 KNN 的分数差距远没有论文里说的那么悬殊,关键全在预处理和超参数上。希望这份拆解能帮你在 PaviaU 上少走几步弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进