简介:这份资源面向具备一定深度学习基础、希望上手高光谱图像分类的开发者与研究者,围绕Python与卷积神经网络展开,重点解决1D光谱特征、2D空间特征及3D谱空联合特征的学习与分类问题,适用于遥感、环境监测、医学成像等场景。压缩包共8个文件,以4个py脚本和4个mat数据文件为主,脚本分别对应1DCNN、2DCNN、3DCNN三种网络结构及辅助函数,mat文件则提供KSC与PaviaU两个高光谱数据集及其标签,整体约87.4MB。目前已有3839人学习下载。资源基于TensorFlow-GPU 1.5.0与Keras 2.1.6搭建,覆盖数据预处理、特征提取、模型训练、验证测试与混淆矩阵、准确率、召回率、F1分数等评估环节,读者可据此复现完整分类流程,理解卷积层、池化层、全连接层在高光谱数据上的具体用法,并对照脚本调整学习率、批次大小与卷积核数量等超参数,快速建立从数据到评估的实践认知。
1. 高光谱分类为什么让 CNN 又爱又恨
高光谱图像分类这个方向,我最早接触是在一个遥感解译的模拟项目里。当时拿到一份公开数据集,图像尺寸不大,但波段数有 200 多个,第一反应是“这不就是把 2D 卷积换成 3D 卷积的事吗”。结果跑了一版基线,训练集准确率冲到 99%,测试集却惨不忍睹——这就是高光谱分类最经典的坑:小样本 + 高维特征 + 光谱冗余,三者叠加,模型极容易过拟合。
高光谱图像和普通 RGB 图像的本质区别在于:每个像素不是 3 个通道,而是几十到几百个连续波段,形成一条光谱曲线。这条曲线能反映地物材质,比如植被、水体、建筑在特定波段上有明显吸收峰。卷积神经网络在这里的价值,是同时提取空间纹理和光谱特征——2D 卷积管空间,1D 卷积管光谱,3D 卷积两者一起管。但波段多了之后,相邻波段高度相关,直接全喂给网络,参数量爆炸,训练样本却往往只有几百个标注像素。
所以这篇文章要解决的问题很具体:用 Python 和 CNN 把高光谱图像分类跑通,并且让测试集精度站得住。适合已经会写 PyTorch 或 TensorFlow 基础代码、但没处理过高光谱数据的读者。我会从数据读取、样本构造、模型选型一路讲到训练参数和排错,中间给可直接抄的代码块。先立住一个判断:高光谱分类不是“把 RGB 分类代码改个输入通道数”就能交差的,样本构造方式比模型结构更决定成败。
2. 数据读取与样本构造:决定上限的一步
2.1 高光谱数据长什么样,怎么读进内存
常见的高光谱数据集格式有.mat、.tif和.hdr+ 原始二进制。以最通用的.mat为例,里面通常有两个关键变量:data是三维数组(H, W, B),labels是二维数组(H, W),每个整数代表一类地物,0 通常表示未标注。读进来之后第一件事不是急着切块,而是确认波段顺序和无效值。
import scipy.io as sio import numpy as np # 读取 .mat 格式高光谱数据 # 常见变量名:data / X / img,labels / gt / y mat = sio.loadmat('dataset.mat') data = mat['data'].astype(np.float32) # 形状 (H, W, B) labels = mat['labels'].astype(np.int32) # 形状 (H, W) print('数据形状:', data.shape) print('标签类别:', np.unique(labels)) # 检查无效值:高光谱常见 0 或 -9999 表示背景 invalid_mask = (data <= 0).all(axis=2) print('无效像素占比: {:.2%}'.format(invalid_mask.mean()))这段代码的逻辑说明:data转float32是为了后续归一化不丢精度;labels转int32是因为 PyTorch 的交叉熵损失要求标签为int64,但中间处理用int32更省内存。参数上,invalid_mask用all(axis=2)判断“所有波段都小于等于 0”的像素,这类像素在部分数据集中是背景,不能当训练样本。如果无效像素占比超过 30%,说明数据裁剪有问题,需要回去检查原始文件。
注意:有些数据集的波段顺序是
(B, H, W),直接按(H, W, B)处理会导致空间维度错乱。读完后打印shape并和数据集说明对照,这一步不能省。
2.2 样本切块:为什么不能按像素单独训练
高光谱分类如果按像素单独训练,就是把每个像素的光谱向量(B,)喂给 1D 卷积或全连接。这种做法在早期论文里常见,但缺点很明显:完全丢弃空间信息,相邻像素之间的纹理关联没了,而且标注样本少的时候,每个像素独立看待,噪声像素直接污染梯度。
主流做法是以标注像素为中心切空间块,比如取11×11或15×15的邻域,得到一个(11, 11, B)的立方体。这样每个样本既包含中心像素的光谱,也包含周围的空间纹理。切块时有两个参数必须调:块大小和边界填充方式。
import numpy as np def extract_patches(data, labels, patch_size=11, remove_zero=True): """ 以标注像素为中心提取空间-光谱块 data: (H, W, B) labels: (H, W) patch_size: 奇数,如 11 表示 11x11 remove_zero: 是否剔除全零背景块 返回: patches (N, patch_size, patch_size, B), targets (N,) """ margin = patch_size // 2 # 边界用 reflect 填充,避免边缘样本丢失 padded = np.pad(data, ((margin, margin), (margin, margin), (0, 0)), mode='reflect') patches, targets = [], [] ys, xs = np.where(labels > 0) for y, x in zip(ys, xs): patch = padded[y:y + patch_size, x:x + patch_size, :] if remove_zero and (patch <= 0).all(): continue patches.append(patch) targets.append(labels[y, x]) patches = np.stack(patches).astype(np.float32) targets = np.array(targets, dtype=np.int64) return patches, targets逻辑说明:np.pad用reflect模式而不是补零,是因为补零会在边缘引入虚假的“黑色地物”,干扰光谱曲线。margin取patch_size // 2,保证中心像素始终在块的正中央。remove_zero剔除全零块,防止背景噪声进入训练集。参数上,patch_size从 11 开始试,太小(如 5)空间信息不足,太大(如 21)会引入过多异类像素,尤其在地物边界处。我一般先用 11 跑基线,再根据验证集精度微调。
2.3 归一化与数据增强:两个容易做反的细节
高光谱数据的归一化不是简单除以 255。不同波段量纲差异大,有的波段反射率在 0 到 1 之间,有的在 0 到 10000 之间。常见做法是逐波段标准化,即对每个波段单独减均值除标准差。但这里有个坑:均值和标准差必须只用训练集算,然后应用到验证集和测试集,否则就是数据泄露。
from sklearn.model_selection import train_test_split # 先切分,再算统计量 X_train, X_test, y_train, y_test = train_test_split( patches, targets, test_size=0.8, stratify=targets, random_state=42 ) # 逐波段标准化:只在训练集上 fit mean = X_train.mean(axis=(0, 1, 2), keepdims=True) # (1,1,1,B) std = X_train.std(axis=(0, 1, 2), keepdims=True) + 1e-8 X_train = (X_train - mean) / std X_test = (X_test - mean) / std参数说明:test_size=0.8在高光谱里很常见,因为标注样本本来就少,训练集可能只占 10% 到 20%。stratify=targets保证每个类别在训练集和测试集里比例一致,避免某类样本全被分到一边。keepdims=True让均值和标准差的形状保持(1,1,1,B),方便广播。1e-8防止除零。
数据增强方面,高光谱不能随便用 RGB 那套颜色抖动。可用的增强包括:随机水平翻转、随机垂直翻转、随机旋转 90 度。这些操作不改变光谱曲线,只改变空间排列,对分类是安全的。不要用颜色变换或裁剪,会破坏光谱连续性。
3. 模型选型:2D、3D 还是混合卷积
3.1 三种卷积结构的适用边界
高光谱 CNN 的模型结构大致分三类:1D CNN只处理光谱向量,2D CNN把波段当通道处理空间块,3D CNN同时在空间和光谱维度卷积。三者的计算量和精度差异很大,选型取决于样本量和波段数。
| 结构 | 输入形状 | 参数量 | 适合场景 | 主要缺点 |
|---|---|---|---|---|
| 1D CNN | (B,) | 小 | 样本极少,只关心光谱 | 丢弃空间信息 |
| 2D CNN | (patch, patch, B) | 中 | 样本中等,空间纹理重要 | 光谱维当通道,未显式建模 |
| 3D CNN | (patch, patch, B, 1) | 大 | 样本充足,光谱空间联合 | 易过拟合,训练慢 |
| 混合 2D+3D | 先 3D 后 2D | 较大 | 追求精度,有调参经验 | 结构复杂,超参多 |
我的经验是:标注样本少于 500 时,优先用 2D CNN 加光谱注意力,不要直接上 3D。3D 卷积在样本少的时候,训练集精度和测试集精度能差 20 个点以上,血泪经验。如果样本在 1000 以上,可以试混合结构,先用 3D 卷积压缩光谱维,再用 2D 卷积提空间特征。
3.2 一个可复现的 2D CNN 基线
下面这个模型结构不复杂,但在多个公开高光谱数据集上都能跑到可用的基线精度。核心思路:先用 1×1 卷积降波段维,再用 3×3 卷积提空间特征,最后全局池化分类。
import torch import torch.nn as nn class HSICNN(nn.Module): def __init__(self, in_bands, num_classes, patch_size=11): super().__init__() # 1x1 卷积压缩光谱维,减少后续计算量 self.spectral_reduce = nn.Sequential( nn.Conv2d(in_bands, 64, kernel_size=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True) ) # 空间特征提取 self.spatial = nn.Sequential( nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(1) # 全局池化,输出 (N,128,1,1) ) self.classifier = nn.Linear(128, num_classes) def forward(self, x): # x: (N, B, patch, patch) x = self.spectral_reduce(x) x = self.spatial(x) x = x.flatten(1) return self.classifier(x)逻辑说明:输入需要从(N, patch, patch, B)转成(N, B, patch, patch),因为 PyTorch 的Conv2d要求通道在前。spectral_reduce用 1×1 卷积把 200 多个波段压到 64,这一步参数量是B×64,比直接 3×3 卷积小得多。AdaptiveAvgPool2d(1)替代全连接前的展平,减少参数量,也降低过拟合风险。参数上,64和128是通道数,样本少时可以减半;patch_size只影响输入尺寸,不影响模型结构。
3.3 训练参数怎么设:学习率、批次与早停
高光谱训练最容易翻车的地方是学习率。因为样本少,梯度噪声大,学习率设 0.01 经常直接发散。我一般从1e-3开始,配合ReduceLROnPlateau,验证集损失不降就减半。
from torch.utils.data import TensorDataset, DataLoader # 转成 PyTorch 张量,注意维度置换 X_train_t = torch.tensor(X_train).permute(0, 3, 1, 2) # (N,B,patch,patch) X_test_t = torch.tensor(X_test).permute(0, 3, 1, 2) y_train_t = torch.tensor(y_train) y_test_t = torch.tensor(y_test) train_loader = DataLoader(TensorDataset(X_train_t, y_train_t), batch_size=32, shuffle=True) test_loader = DataLoader(TensorDataset(X_test_t, y_test_t), batch_size=64, shuffle=False) model = HSICNN(in_bands=X_train.shape[-1], num_classes=len(np.unique(y_train))) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=10 ) criterion = nn.CrossEntropyLoss()参数说明:batch_size=32是样本少时的折中,太大梯度更新次数少,太小训练不稳定。weight_decay=1e-4是 L2 正则,高光谱必备,不加的话训练集精度很快到 100%。patience=10表示验证损失 10 个 epoch 不降就减学习率。早停策略我一般设patience=30,即 30 个 epoch 验证精度不升就停,保留验证集最好的权重。
注意:验证集不能和测试集混用。高光谱数据量小,很多人把测试集当验证集调参,最后报的精度是“调出来的”,换一组数据就崩。正确做法是训练集、验证集、测试集三分开,验证集用于早停和调参,测试集只在最后跑一次。
4. 避坑与排查:高光谱 CNN 的五个典型翻车现场
4.1 训练集精度 99%,测试集 60%:过拟合的三种解法
现象:训练 loss 一路降到 0.01,训练精度 99% 以上,但测试精度卡在 60% 到 70%,验证 loss 先降后升。
原因:高光谱标注样本少,模型参数量远大于样本量,网络直接“背下”了训练集。加上波段间高度相关,模型学到的是噪声而非泛化特征。
解决:第一,加Dropout,在全连接前设p=0.5;第二,加weight_decay,从1e-4试到1e-3;第三,减小模型通道数,把128降到64。三招一起上,通常能把训练集和测试集的精度差压到 10 个点以内。
4.2 损失变成 NaN:归一化和学习率的联合排查
现象:训练几个 batch 后 loss 突然变成nan,梯度爆炸。
原因:常见两个:一是归一化没做,某些波段数值极大,卷积后激活值溢出;二是学习率太大,Adam 的更新步长超过数值稳定范围。
解决:先确认逐波段标准化是否只在训练集上算的,再检查输入里有没有inf或nan。然后把学习率降到1e-4,加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。如果还不行,把BatchNorm换成GroupNorm,小批次下更稳定。
4.3 某几类精度极低:类别不平衡与光谱混淆
现象:总体精度 85%,但某两类(比如草地和农田)的召回率不到 50%,混淆矩阵里互相错分。
原因:这两类地物的光谱曲线在大部分波段上几乎重合,只在少数窄波段有差异。如果切块时空间邻域混入了对方像素,模型更难区分。
解决:第一,检查这两类的样本数是否远少于其他类,如果是,用WeightedRandomSampler或类别权重加权的交叉熵;第二,把patch_size从 11 降到 7,减少邻域混入;第三,在模型里加光谱注意力模块,让网络自动关注差异波段。常见做法是加一个SEBlock,对通道维做注意力。
4.4 验证集精度波动大:随机种子与数据划分
现象:同样的代码,换一次随机种子,测试精度差 5 到 10 个点。
原因:高光谱数据量小,不同的训练/测试划分会导致某些类在测试集里样本极少,精度估计方差大。
解决:不要只跑一次。用StratifiedKFold做 5 折交叉验证,报平均精度和标准差。如果标准差超过 3 个点,说明数据划分影响太大,需要增加样本或改用更稳定的划分方式。固定随机种子只能保证可复现,不能降低方差。
4.5 推理速度慢:3D 卷积的显存与计算优化
现象:用 3D CNN 训练时显存不够,或者推理一张大图要几分钟。
原因:3D 卷积的计算量是 2D 的kernel_size倍,波段数 200 多时,中间特征图显存占用极大。
解决:第一,先用 1×1×1 卷积把波段维从 200 降到 32 或 64;第二,把大图切成不重叠的块批量推理,不要整图一次前向;第三,如果只是分类不是分割,用 2D CNN 加光谱降维,精度损失通常不到 2 个点,速度提升 5 倍以上。
5. 进阶技巧:用光谱注意力把精度再推一截
5.1 为什么加注意力比换模型更划算
高光谱分类的精度瓶颈往往不在空间特征,而在哪些波段对当前类别有用。比如水体在近红外波段反射率极低,植被在红边波段有陡升,这些差异集中在少数波段。普通卷积对所有波段一视同仁,注意力机制可以让网络自己学波段权重。加一个轻量的通道注意力模块,参数量增加不到 5%,精度通常能涨 1 到 3 个点,比换更深的 3D 网络划算得多。
5.2 一个可插入的 SE 模块实现
class SEBlock(nn.Module): """通道注意力:对光谱维做重加权""" def __init__(self, channels, reduction=16): super().__init__() self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(channels, channels // reduction, bias=False), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels, bias=False), nn.Sigmoid() ) def forward(self, x): # x: (N, C, H, W) b, c, _, _ = x.size() y = self.pool(x).view(b, c) y = self.fc(y).view(b, c, 1, 1) return x * y # 通道加权逻辑说明:AdaptiveAvgPool2d(1)把每个通道的空间信息压成一个标量,fc先降维再升维,Sigmoid输出 0 到 1 的权重,最后乘回原特征图。reduction=16是压缩比,通道数 64 时中间层 4 维,参数量极小。插入位置一般在spectral_reduce之后、spatial之前,让网络先对光谱通道重加权,再提空间特征。
5.3 验证注意力是否生效:看权重分布
加完注意力不能只看精度涨没涨,还要确认它真的在学波段权重。方法很简单:取一批测试样本,前向到 SE 模块,把y的权重向量保存下来,按类别求平均,看不同类别的权重分布是否有差异。
# 假设 model 里 se 模块可访问,注册 hook 抓取权重 weights = {} def hook_fn(module, input, output): weights['se'] = output.detach().cpu() handle = model.se.register_forward_hook(hook_fn) model.eval() with torch.no_grad(): _ = model(X_test_t[:64]) handle.remove() # weights['se'] 形状 (64, C, 1, 1),按类别平均 se_weights = weights['se'].squeeze(-1).squeeze(-1) # (64, C) for cls in np.unique(y_test[:64]): idx = y_test[:64] == cls print('类别', cls, '平均权重前5通道:', se_weights[idx].mean(0).topk(5).indices.tolist())如果不同类别的高权重通道有明显差异,说明注意力在起作用;如果所有类别权重几乎一样,说明模块没学到东西,需要检查学习率是否太小,或者注意力插入位置不对。
5.4 我踩过的注意力坑
最早加 SE 模块时,我把它放在spectral_reduce之前,结果精度反而降了。原因是原始波段数 200 多,SE 的fc中间层只有 12 维,压缩太狠,权重全被压成 0.5 左右,等于没加。后来改成先 1×1 卷积降到 64,再接 SE,精度才涨上去。另一个坑是reduction设成 8 时,小样本数据集上过拟合明显,验证 loss 波动大,改成 16 后稳定。所以注意力模块不是“加上就好”,插入位置和压缩比都要根据波段数和样本量调。
我现在的习惯是:任何高光谱分类任务,先跑一个不带注意力的 2D CNN 基线,记录测试精度和混淆矩阵;然后加 SE,只改一个变量,对比精度和权重分布。如果精度没涨或者权重分布无差异,先查插入位置和reduction,不要急着换模型。这套流程帮我省了很多“玄学调参”的时间。希望帮到你。
本文还有配套的精品资源,点击获取