
做完这个Indian Pines的CNN分类项目我把整个过程的经验、坑、和代码思路整理成文。内容会尽量沿着“为什么这么做、具体怎么做、踩了什么坑、最后怎么调通”的顺序来写。使用PyTorch设计CNN处理Indian Pines遥感图像一个可复现的完整实践高光谱遥感图像的分类是遥感领域的老大难问题而Indian Pines数据集几乎是所有入门深度学习做遥感分类的人绕不开的第一个标准数据集。这篇文章把整个流程从头到尾讲清楚包括数据怎么读、patch怎么取、CNN怎么搭、训练怎么调、指标怎么算以及我在实际调试过程中遇到的各种坑。适合刚接触PyTorch、想在遥感图像上跑通一个深度学习项目的同学参考。先交代一下背景。Indian Pines是美国印第安纳州一块农业区域的AVIRIS高光谱影像空间尺寸145x145像素原始波段224个去掉吸水带和噪声波段后常用200个波段空间分辨率约20米。数据集中一共包含16个地物类别包括苜蓿、玉米、小麦、大豆、树木、草地等另外还有大量未标注区域。这个数据集之所以经典一是尺寸小、训练快适合用来验证算法二是类别分布极不均衡样本数量从最少的20个到最多的2468个都有能逼着你去思考类别不均衡问题三是光谱信息丰富可以直接对比不同特征提取方式的效果。我要做的核心工作可以拆成三块把原始高光谱数据转换成CNN能吃的格式设计一个能利用光谱-空间信息的卷积网络再训练并评估模型性能。下面按实际推进顺序来写。1. 项目整体设计与思路拆解1.1 核心需求从“像素分类”到“空间-光谱联合分类”传统遥感图像分类方法比如支持向量机、随机森林大多是基于单像素的光谱向量做分类。每个像素都对应一条高维光谱曲线这相当于一个一维特征向量。这类方法的局限性很明显只看单个点的光谱忽略了地物在空间上的连续性和纹理信息。在真实遥感影像里同一类地物通常是成片出现的邻域像素往往属于同一类别这种空间上下文信息对分类很有价值。所以这次设计CNN的核心思路就是让模型同时看到两样东西中心像素的光谱信息以及它周围邻域像素构成的空间纹理。实现方式很直接取以目标像素为中心的一个固定大小的邻域patchpatch的尺寸比如13x13波段数200那这个patch的形状就是(13, 13, 200)。把这个三维数组喂给CNN让卷积核在空间维上提取纹理在光谱维上提取波段相关性最后输出每个类别的概率。这里有几个关键选择需要提前想清楚邻域窗口多大合适窗口太小空间信息不够窗口太大边界像素会缺失而且计算量和显存都上升。如何处理图像边缘的像素边缘像素取不到完整邻域常见做法是零填充或者镜像填充或者干脆丢掉边缘像素。光谱维度怎么处理可以直接把所有200个波段作为通道输入也可以先用主成分分析PCA把200维降到30维左右再作为通道输入。降维的好处是显著降低计算量坏处是可能丢掉部分判别信息。我在实验中对比了直接使用200波段和PCA降到30波段两种方案结论是在Indian Pines这种小数据集上PCA降维后再做分类准确率不一定下降甚至因为减少了过拟合风险而表现得更好。计算速度却有明显提升这对调参阶段非常友好。1.2 技术选型为什么用PyTorch而不是其他框架PyTorch在国内深度学习圈的使用率这几年一直很高选择它有几个非常实际的理由动态计算图让调试变得非常直观。你可以随时print中间张量的shape可以断点暂停后继续训练这种体验在开发调试阶段特别重要。torchvision和torch.utils.data等工具链完善Dataset、DataLoader、模型保存加载都有成熟方案不需要写太多胶水代码。社区资料极其丰富任何CNN结构都能找到参考实现遇到问题搜索解决方案也容易。对GPU的支持完善配合CUDA可以快速训练。即使只有CPUIndian Pines这种小数据集也能跑得动只不过慢一些。在环境配置方面我的建议是优先使用Anaconda创建独立环境避免和系统其他Python环境冲突。具体安装步骤后面会单独讲这里先不展开。1.3 训练与评估方案设计Indian Pines最常用的评估方式是按像素划分训练集和测试集。常见比例有10%训练、90%测试或者30%训练、70%测试。这里有个非常容易踩的坑必须保证划分是按像素级别做的而且要保证同一类样本在训练集和测试集中都有分布。更严格一点有些论文会要求按区域划分避免空间相关性导致数据泄漏不过对于入门项目先按像素随机划分即可但要清楚这种做法的局限性。评估指标上除了整体准确率Overall Accuracy, OA还需要看平均准确率Average Accuracy, AA和Kappa系数。OA是正确分类像素占总测试像素的比例但Indian Pines类别不均衡严重如果某个大类占比很高模型把大类分对了、小类全错OA可能仍然很高这就掩盖了问题。AA先计算每一类的分类准确率再对所有类取平均能更好地反映小类表现。Kappa系数则衡量分类结果与随机分类相比的一致性程度数值越接近1说明分类越可靠。2. 环境搭建与数据预处理2.1 PyTorch环境配置从Anaconda到GPU版本如果你是从零开始搭环境我的建议是按照下面这套流程走踩坑最少# 创建Python 3.9环境3.8-3.11都可以但3.9兼容性最稳 conda create -n pytorch_env python3.9 # 激活环境 conda activate pytorch_env接下来是安装PyTorch。GPU版本和CPU版本安装命令不一样这里最容易出问题的是CUDA版本不匹配。我的建议是先去NVIDIA官网查一下自己显卡支持的CUDA版本然后到PyTorch官网选择对应的安装命令。如果只是学习用、没有NVIDIA显卡装CPU版本也能跑通整个流程# GPU版本示例CUDA 11.8 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # CPU版本示例 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu装完验证一下是否能正常导入并检测到CUDAimport torch print(torch.__version__) print(torch.cuda.is_available()) if torch.cuda.is_available(): print(torch.cuda.get_device_name(0))如果torch.cuda.is_available()返回True说明GPU环境正常。注意一个常见问题即使装了GPU版PyTorch如果CUDA驱动版本过低或者Anaconda环境里的cudatoolkit版本不对is_available()也可能返回False。解决方案通常是升级NVIDIA驱动或者用conda安装cudatoolkitconda install cudatoolkit11.3 cudnn8.2 -c conda-forge另外建议环境里装好jupyter notebook或者直接用VS Code写代码配合交互式调试体验更好。还需要安装scipy、numpy、matplotlib、scikit-learn等常用库。2.2 Indian Pines数据读取MAT文件处理Indian Pines数据集通常以MAT格式提供一份是图像数据文件一份是标签文件。具体文件名一般是Indian_pines_corrected.mat和Indian_pines_gt.mat。用Python读取MAT文件非常简单import scipy.io as sio import numpy as np # 读取高光谱图像数据shape: (145, 145, 200) data sio.loadmat(Indian_pines_corrected.mat)[indian_pines_corrected] # 读取标签数据shape: (145, 145) labels sio.loadmat(Indian_pines_gt.mat)[indian_pines_gt] print(数据形状:, data.shape, 数值范围:, data.min(), -, data.max()) print(标签形状:, labels.shape, 类别数:, len(np.unique(labels)) - 1)有两个细节需要说明。第一数据文件的键名是固定的如果你的文件名不同读取时要改成对应的键名不要想当然用文件名做键名。第二这部分数据经常会从第三方网站下载不同来源的文件可能包含不同的预处理有的版本是已经去噪的corrected版本有的版本是非corrected版本建议统一使用corrected版本数值稳定性更好。默认的数值范围是0到10000左右对神经网络的训练非常不友好所以必须先归一化。2.3 数据归一化与训练集划分归一化我通常用两种方式全局最小最大归一化把整个三维数组缩放到[0, 1]区间。逐波段标准化每个波段单独计算均值和标准差然后做z-score标准化。对于Indian Pines我更推荐第二种方式。因为高光谱数据的各波段动态范围差异很大某些波段可能整体值偏高某些偏低全局归一化会压缩低值波段的对比度。逐波段标准化能保留每个波段的相对信息def standardize(data): # data shape: (H, W, C) h, w, c data.shape data data.reshape(-1, c) mean data.mean(axis0) std data.std(axis0) # 防止除零 std[std 0] 1.0 data (data - mean) / std return data.reshape(h, w, c) data_std standardize(data)关于训练集划分我在这里给出一个可复现的写法。按像素随机抽取训练样本但必须保证每个类别都有样本进入训练集from sklearn.model_selection import train_test_split # 把所有有标签的像素对收集起来 X [] y [] for i in range(labels.shape[0]): for j in range(labels.shape[1]): if labels[i, j] ! 0: X.append([i, j]) y.append(labels[i, j]) X np.array(X) y np.array(y) # 按类别分层采样保证每个类都有训练样本 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.9, stratifyy, random_state42 )这里test_size0.9表示90%做测试、10%做训练。这是一个比较常见的比例。stratify参数很重要它会按照原始类别比例进行分层抽样避免出现某个小类在训练集中一个样本都没有的情况。如果你不加stratifyIndian Pines里某些只有几十个样本的类别比如苜蓿只有46个样本有概率被完全分到测试集。2.4 提取邻域PatchCNN输入的构造这是整个预处理里最关键的环节。我们需要为每个训练像素提取一个以它为中心的邻域patch例如patch_size13那么邻域半径就是6。对于图像边缘的像素无法取到完整邻域常见的做法是用零填充zero padding扩展图像边界保证所有像素都能取到patch。或者干脆丢弃边缘像素缺点是会损失边界样本。我实际操作时选择了零填充保留全部像素。具体代码def extract_patches(data, labels, coords, patch_size13): data: 标准化后的三维数组 (H, W, C) labels: 标签二维数组 (H, W) coords: 需要提取patch的像素坐标列表 [(row, col), ...] patch_size: 邻域窗口大小必须是奇数 返回: patch数组 shape (N, patch_size, patch_size, C)以及对应的标签 half patch_size // 2 h, w, c data.shape # 先对数据做零填充 padded np.pad(data, ((half, half), (half, half), (0, 0)), modeconstant) patches [] ys [] for idx, (row, col) in enumerate(coords): # 因为填充了 half原始坐标 (row, col) 在填充后的坐标是 (rowhalf, colhalf) r row half cl col half patch padded[r-half:rhalf1, cl-half:clhalf1, :] patches.append(patch) ys.append(labels[row, col]) return np.array(patches), np.array(ys)这里有一个非常重要的细节提取patch必须统一用填充后的数据坐标否则边缘像素会错位。我第一次写的时候没有把坐标偏移算进去结果边缘像素的patch内容完全错误训练出来的模型表现极差。搞了很久才发现是坐标偏移的bug。对Indian Pines来说patch_size的选择范围一般在9到27之间。窗口太小空间上下文不足窗口太大类别边界处的patch会混入过多其他类别的信息而且训练样本之间重叠区域增大带来虽然不明显但确实存在的数据泄漏风险。我在对比实验中发现patch_size13在Indian Pines上是一个不错的平衡点。这个参数的敏感性很高后面调参时可以优先试着改它。另外为了满足PyTorch的输入格式要求最后要把patch数组的维度从(N, H, W, C)调整成(N, C, H, W)也就是把波段维度放到第二个位置这是PyTorch卷积层的默认通道位置。接着把数据转换成float32类型PyTorch默认要求浮点类型是float32如果直接用float64会报类型错误X_train_patches, y_train_labels extract_patches(data_std, labels, X_train, patch_size13) X_test_patches, y_test_labels extract_patches(data_std, labels, X_test, patch_size13) # (N, H, W, C) - (N, C, H, W) X_train_patches X_train_patches.transpose(0, 3, 1, 2).astype(np.float32) X_test_patches X_test_patches.transpose(0, 3, 1, 2).astype(np.float32)2.5 数据增强策略小数据集的救命稻草Indian Pines的训练样本数量很少即使按10%划分总共也只有约1000个训练样本。CNN在这种数据规模下很容易过拟合。最常见的增强手段有随机翻转对patch做水平翻转、垂直翻转。随机旋转按90度、180度、270度旋转。随机噪声给patch叠加高斯噪声。需要注意这些增强操作必须同时作用于patch和对应的标签吗其实不需要因为标签是中心像素的类别翻转和旋转不会改变中心像素的语义类别所以可以放心使用。但裁剪类增强不能用因为中心像素的位置会改变。我用PyTorch实现一个简单增强流程在训练时动态处理batchimport random def augment(batch_patches): batch_size batch_patches.size(0) augmented [] for i in range(batch_size): img batch_patches[i] # (C, H, W) # 50%概率水平翻转 if random.random() 0.5: img torch.flip(img, dims[2]) # 50%概率垂直翻转 if random.random() 0.5: img torch.flip(img, dims[1]) # 30%概率随机旋转90度的整数倍 if random.random() 0.3: k random.choice([1, 2, 3]) img torch.rot90(img, k, dims[1, 2]) augmented.append(img) return torch.stack(augmented)实际测试下来增强确实能提升几个百分点的准确率尤其是对样本数少的类别帮助更大。但别指望增强能解决所有问题对于样本数极少的类别比如少于50个样本的类别效果仍然有限。3. CNN模型设计从原理到PyTorch实现3.1 模型架构选型2D-CNN还是3D-CNN这个问题是我在开始设计模型时首先面临的。两种方案各有适用场景2D-CNN的思路是把光谱维度当作图像的通道使用二维卷积核只提取空间特征。这种方案在通道数比较多的情况下计算量偏大因此通常会先做PCA降维把200波段降到30个左右让通道数大大减少。2D-CNN结构简单、训练快对Indian Pines这种小数据集友好。3D-CNN的思路是同时使用三维卷积核对空间维和光谱维进行联合卷积。它的优势是可以直接建模光谱维度的局部相关性不需要PCA降维。但3D-CNN的参数数量和计算量都显著大于2D-CNN对Indian Pines这种小训练集来说如果不做好正则化过拟合风险很高。我在项目里同时实现了这两种方案最终版本采用了先PCA降维再使用2D-CNN的组合原因是训练速度快、结构简单可控、类别小样本表现更稳定。如果你有充足算力也可以考虑近年论文里常见的混合结构比如先用3D-CNN提取光谱-空间联合特征再用2D-CNN进一步提取空间特征这也是经典模型HybridSN的基本思想。3.2 2D-CNN方案PCA降维加卷积分类先对200个波段做PCA降维保留前30个主成分。PCA可以在空间维上做也可以把每个像素当成一个样本、每个波段当成一个特征去做。后者更标准from sklearn.decomposition import PCA def apply_pca(data, n_components30): data: (H, W, C) 返回降维后的数据 (H, W, n_components) h, w, c data.shape # 把空间位置展开 data_2d data.reshape(-1, c) pca PCA(n_componentsn_components) data_pca pca.fit_transform(data_2d) data_pca data_pca.reshape(h, w, n_components) return data_pca, pca data_pca, pca_model apply_pca(data_std, n_components30)注意PCA模型只应该在训练数据上拟合测试数据应该用训练数据的PCA变换参数直接变换而不应该重新fit。否则就是数据泄漏。虽然在这个项目里对结果影响不大但养成这个习惯很重要。具体做法是把所有有标签像素的像素向量收集起来用它们fit PCA再对整个数据做transform。PCA降维后每个patch的空间形状是(13, 13, 30)对应PyTorch的channel数是30。接下来是2D-CNN的结构。我用了三层卷积加两层全连接import torch.nn as nn class CNN2D(nn.Module): def __init__(self, n_channels30, n_classes16, patch_size13): super(CNN2D, self).__init__() self.features nn.Sequential( # 第一层卷积输入30通道输出64通道 nn.Conv2d(n_channels, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), # 第二层卷积 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 第三层卷积 nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) # 计算全连接层的输入维度 # 输入patch_size13经过一次最大池化变成6再经过一次变成3 self.classifier nn.Sequential( nn.Flatten(), nn.Linear(256 * 3 * 3, 128), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(128, n_classes), ) def forward(self, x): x self.features(x) x self.classifier(x) return x这里有几个设计细节值得解释。BatchNorm层是我强烈建议保留的它能让每一层输入的分布更稳定显著加快收敛速度同时还带有轻微的正则化效果对防止过拟合有帮助。Dropout放在全连接层之间比例为0.5这是比较常见的设置。卷积层后面我没放Dropout而是靠BatchNorm和池化来控制过拟合。全连接层的输入维度计算需要根据patch_size和池化层参数来推导。patch_size13时经过一个步长为2的最大池化变成6再经过一个变成3所以最后的特征图尺寸是3x3。如果你改大了patch_size这里的维度就要重新算。我自己就经常在改patch_size后忘记更新这个维度导致运行时报错。3.3 3D-CNN方案直接利用光谱维度如果你不想做PCA降维可以尝试3D-CNN。这里给出一个可运行的简洁结构它把原始的200个波段当作光谱维用三维卷积同时提取空间和光谱特征import torch.nn as nn class CNN3D(nn.Module): def __init__(self, n_bands200, n_classes16, patch_size13): super(CNN3D, self).__init__() # 输入: (batch, 1, patch_size, patch_size, n_bands) self.conv1 nn.Conv3d(1, 8, kernel_size(3, 3, 7), padding(1, 1, 3)) self.bn1 nn.BatchNorm3d(8) self.conv2 nn.Conv3d(8, 16, kernel_size(3, 3, 5), padding(1, 1, 2)) self.bn2 nn.BatchNorm3d(16) self.conv3 nn.Conv3d(16, 32, kernel_size(3, 3, 3), padding(1, 1, 1)) self.bn3 nn.BatchNorm3d(32) self.pool nn.MaxPool3d(kernel_size(2, 2, 2), stride(2, 2, 2)) # 计算特征图尺寸 # patch_size13, 经过一次池化变成6再经过一次变成3 # 波段数200, 经过一次池化变成100再经过一次变成50 # 但这里只池化一次实际尺寸要按模型里池化次数计算 self.classifier nn.Sequential( nn.Flatten(), nn.Linear(32 * 6 * 6 * 50, 128), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(128, n_classes), ) def forward(self, x): # x shape: (batch, 1, patch_size, patch_size, n_bands) x self.pool(torch.relu(self.bn1(self.conv1(x)))) x self.pool(torch.relu(self.bn2(self.conv2(x)))) x torch.relu(self.bn3(self.conv3(x))) x self.classifier(x) return x注意3D-CNN对显存的要求明显更高尤其是当波段数很多时中间特征图的体积会快速膨胀。在Indian Pines上如果patch_size设得稍大GPU显存可能直接不够用。所以实际使用中我往往会在大规模实验前先降采样一部分波段比如每隔两个波段取一个把200维降到100维甚至67维在速度和精度之间找一个平衡。3D-CNN的实际精度与2D-CNNPCA方案相比在我跑的实验中并没有优势。这个结果其实符合预期Indian Pines训练样本太少3D-CNN参数更多更容易过拟合。但3D-CNN的价值在于它不需要人工降维对于PCA指标贡献不明确的场景比如需要分析波段重要性它会是更好的选择。3.4 损失函数与优化器选择分类问题最常用的损失函数是交叉熵CrossEntropyLoss。PyTorch的nn.CrossEntropyLoss内部已经包含了softmax操作所以模型最后的输出不需要手动加softmax直接在训练时算loss在预测时用argmax获取类别索引即可criterion nn.CrossEntropyLoss()因为Indian Pines的类别不均衡很严重我建议在交叉熵中加入类别权重。权重可以设为各类样本数的倒数让少样本类别在loss中的贡献更大from collections import Counter counts Counter(y_train) weights [] for cls in range(1, 17): # 类别1到16 if cls in counts: weights.append(len(y_train) / (16 * counts[cls])) else: weights.append(0.0) # 除以最大值做归一化避免权重过大导致训练不稳定 weights torch.tensor(weights, dtypetorch.float32) criterion nn.CrossEntropyLoss(weightweights)不过需要注意类别权重太大可能让模型偏向少样本类别反而把多样本类别的准确率拉下来。所以权重不能给太极端常见做法是把样本数归一化后再开根号缓和权重差异。优化器我推荐Adam它对学习率的敏感度相对较低开箱即用的表现都还不错。初始学习率一般设置在0.001附近。如果模型收敛不稳定可以换成AdamW它对权重衰减的处理更规范。另外配合ReduceLROnPlateau做学习率自动调整当验证集loss多轮不再下降时把学习率乘以0.5或0.1optimizer torch.optim.AdamW(model.parameters(), lr0.001, weight_decay1e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.1, patience10 )4. 训练流程与评估实现4.1 Dataset与DataLoader构建PyTorch推荐用Dataset和DataLoader来管理数据不要手动写batch循环。定义一个高光谱patch数据集类from torch.utils.data import Dataset, DataLoader class HyperspectralDataset(Dataset): def __init__(self, patches, labels): self.patches patches # numpy array (N, C, H, W) self.labels labels # numpy array (N,) def __len__(self): return len(self.labels) def __getitem__(self, idx): x torch.from_numpy(self.patches[idx]) y int(self.labels[idx]) - 1 # 标签从1开始转为0开始 return x, y这里有个细节标签值从1到16而PyTorch分类任务的标签要从0开始所以需要减去1。如果不做这一步CrossEntropyLoss会报错或者表现异常。DataLoader设置train_dataset HyperspectralDataset(X_train_patches, y_train_labels) test_dataset HyperspectralDataset(X_test_patches, y_test_labels) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size128, shuffleFalse)shuffleTrue在训练时打乱样本顺序很重要能避免模型把同一类的连续样本当成一个整体来学。测试集不需要打乱保持顺序即可。4.2 训练循环完整可复现代码训练循环我习惯写成函数方便反复调用。这里给出一份完整的代码def train_one_epoch(model, loader, criterion, optimizer, device, use_augmentationFalse): model.train() running_loss 0.0 correct 0 total 0 for inputs, targets in loader: inputs, targets inputs.to(device), targets.to(device) # 训练时做数据增强 if use_augmentation: inputs augment(inputs) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, targets) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) _, preds torch.max(outputs, 1) correct (preds targets).sum().item() total targets.size(0) epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_acc def evaluate(model, loader, criterion, device): model.eval() running_loss 0.0 correct 0 total 0 all_preds [] all_targets [] with torch.no_grad(): for inputs, targets in loader: inputs, targets inputs.to(device), targets.to(device) outputs model(inputs) loss criterion(outputs, targets) running_loss loss.item() * inputs.size(0) _, preds torch.max(outputs, 1) correct (preds targets).sum().item() total targets.size(0) all_preds.extend(preds.cpu().numpy()) all_targets.extend(targets.cpu().numpy()) epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_acc, np.array(all_preds), np.array(all_targets)训练主循环加上早停机制避免白跑很多轮def train_model(model, train_loader, test_loader, criterion, optimizer, scheduler, device, epochs80, patience15): best_acc 0.0 best_model_state None no_improve 0 for epoch in range(epochs): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, device, use_augmentationTrue ) val_loss, val_acc, _, _ evaluate(model, test_loader, criterion, device) # 学习率调整 if scheduler is not None: if isinstance(scheduler, torch.optim.lr_scheduler.ReduceLROnPlateau): scheduler.step(val_loss) else: scheduler.step() if epoch % 1 0: print(fEpoch {epoch1}/{epochs} | fTrain Loss: {train_loss:.4f} Acc: {train_acc:.4f} | fVal Loss: {val_loss:.4f} Acc: {val_acc:.4f}) # 早停检查 if val_acc best_acc: best_acc val_acc best_model_state model.state_dict().copy() no_improve 0 else: no_improve 1 if no_improve patience: print(fEarly stopping at epoch {epoch1}) break model.load_state_dict(best_model_state) return model, best_acc早停的核心是监控验证集准确率连续patience轮没有提升就停止训练并回滚到最佳模型参数。这个机制在小数据集上非常有效能避免训练末期模型在验证集上开始退化。4.3 评估指标OA、AA、Kappa完整计算上面的evaluate函数返回了所有预测和真实标签接下来就能计算完整指标了from sklearn.metrics import accuracy_score, cohen_kappa_score, confusion_matrix def evaluate_full(preds, targets, num_classes16): # targets和preds都是0到15的整数 oa accuracy_score(targets, preds) # 计算每一类准确率 class_accs [] for cls in range(num_classes): mask targets cls if mask.sum() 0: acc (preds[mask] cls).mean() class_accs.append(acc) else: class_accs.append(0.0) aa np.mean(class_accs) kappa cohen_kappa_score(targets, preds) # 混淆矩阵方便分析哪几类容易混 cm confusion_matrix(targets, preds) return oa, aa, kappa, cm oa, aa, kappa, cm evaluate_full(all_preds, all_targets, num_classes16) print(fOA: {oa:.4f}, AA: {aa:.4f}, Kappa: {kappa:.4f})打印混淆矩阵时建议把类别名也带上这样能更直观地看到哪些类之间容易混淆。我在Indian Pines上观察到的典型问题是大豆和玉米这两个大类容易互相混因为它们在光谱上确实比较接近而且都是农业植被。4.4 实验记录与结果分析我在实验中主要使用2D-CNN加PCA降维方案patch_size13PCA保留30个主成分训练集比例10%。一组有代表性的结果如下不同随机种子下会有些波动方法OAAAKappa备注原始光谱SVM参考基准约0.80约0.70约0.76传统方法来自文献常见值PCA302D-CNN无增强约0.83约0.73约0.80基础模型PCA302D-CNN有增强约0.87约0.79约0.84加入翻转旋转增强PCA302D-CNN类别权重约0.88约0.82约0.86增强权重AA提升明显从表格里能清晰看出两个规律第一数据增强提升了所有指标尤其是AA说明小类在高增强下受益更多第二类别权重对OA影响不大但显著拉高了AA这正是因为少样本类别得到了更多关注。我把测试集上的预测结果重新映射回145x145的二维图像网格生成了分类结果图。虽然Indian Pines图像分辨率很低、肉眼看不出太多细节但分类图还是能反映出模型对空间纹理的使用程度。如果模型把同类地物区域分类得比较完整、边缘整齐说明空间patch信息被充分利用了。5. 常见问题与避坑指南5.1 类别不平衡小类直接被无视Indian Pines里有的类别只有二三十个样本比如类别9燕麦只有20个样本类别16石钢塔只有93个。模型如果不做任何处理很容易把所有像素都预测成样本最多的大类牺牲小类的准确率来换取整体损失下降。解决手段按效果排序类别权重直接在损失函数上加权实现最简单效果立竿见影。重采样对小类进行过采样例如复制小类样本词多次或者对大类进行欠采样。过采样配合patch重叠实际操作时注意别把完全相同的样本重复放进同一batch就行。数据增强对小类样本做更多增强相当于策略性的过采样。Focal Loss如果交叉熵加权重还不够可以改用Focal Loss它会让模型更关注难分类样本。我在项目中首先使用类别权重效果就很明显AA从0.73提升到0.82左右。但如果你的目标是某个很小类的分类准确率建议再加过采样。5.2 过拟合验证集准确率上不去Indian Pines训练样本太少模型很快就能在训练集上达到接近100%的准确率但验证集准确率可能在40轮左右就开始停滞甚至下降。这是典型过拟合信号。我的实际做法排序如下先增大Dropout和权重衰减全连接层Dropout从0.5加到0.6weight_decay从1e-4调到5e-4。做数据增强翻转旋转这招非常有效。减小模型容量比如把第二层CNN的通道数从128降到64。检查是不是epoch跑太多配合早停机制设定patience15基本能自动止损。还有一个容易被忽略的点训练集和测试集必须有代表性。如果训练集和测试集的像素来自同一块连续区域空间相关性会让验证集准确率虚高而部署到新区域时效果大跌。所以更严谨的做法是区域级划分而不是像素级随机划分。这个在入门阶段可以不必太纠结但心里要清楚。5.3 显存不足与训练太慢的优化如果GPU显存不够常见报错是CUDA out of memory。解决办法减小batch_size比如从64减到32或16。减小patch_size从13减到9对精度的影响并不算太大。压缩波段数比如PCA从30降到20或者3D-CNN前做光谱降采样。使用梯度累积gradient accumulation技巧在小batch下累积多个step的梯度再更新参数。如果只有CPU训练会慢很多。建议把PCA降维后的特征维度减小减少卷积通道数batch_size设小一点然后耐心等待。Indian Pines的2D-CNN在CPU上跑一轮大约需要几秒到十几秒总的实验时间也可以接受。关于梯度累积简单补一段代码accumulation_steps 4 optimizer.zero_grad() for i, (inputs, targets) in enumerate(loader): outputs model(inputs) loss criterion(outputs, targets) / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()这样相当于用4个batch的梯度合起来更新一次参数效果近似于batch_size扩大4倍但显存占用不变。5.4 训练不收敛或者loss为NaN训练不收敛比较常见的原因是学习率设置太高。Adam的默认学习率0.001在大多数情况下可以但对某些网络结构可能偏高可以把学习率降到0.0003或0.0001试试。loss变成NaN的原因通常是数据问题。检查输入是否含有NaN或Inf用np.isnan(x).any()检查。是否做了除零操作标准化时某个波段标准差恰好为0需要加个小常数避免。学习率太大导致梯度爆炸降低学习率。有一次我把标准化的std直接设为0然后运行PCA把0放进了分母结果数据里出现大量NaN训练loss直接崩掉。这类问题极其隐蔽建议在数据预处理完、输入模型前先打印一下数据的全矩阵统计信息print(NaN count:, np.isnan(X_train_patches).sum()) print(Inf count:, np.isinf(X_train_patches).sum()) print(Min:, X_train_patches.min(), Max:, X_train_patches.max())5.5 PCA拟合的泄漏问题这个坑很多人容易忽略。PCA必须在全部像素上拟合还是在训练集上拟合严格来说应该在训练集的像素向量上拟合PCA然后用这个PCA模型去变换测试集。如果先用全部有标签像素拟合PCA再划分训练集和测试集实际上测试集的信息已经通过PCA的拟合过程泄露到了特征提取阶段。虽然本项目用sklearn实现PCA时为了方便我是在全部数据上fit的但这个习惯如果带到更大规模的数据处理上会产生隐患。正确的顺序是# 收集所有有标签像素 labeled_pixels data_std[labels 0].reshape(-1, 200) # 不打乱顺序先fit但这里演示用训练集坐标fit X_train_pixels data_std[X_train[:, 0], X_train[:, 1], :] pca PCA(n_components30) pca.fit(X_train_pixels) # 变换全部数据 data_pca pca.transform(data_std.reshape(-1, 200)).reshape(145, 145, 30)这样才是数据不出泄漏的正确姿势。你在跑其他数据集、尤其是有时间维度的系列数据时这个问题会更严重。5.6 分类结果可视化与判读生成的分类图如果直接看会发现零散噪点很多。这是因为个别像素被错误分类在空间上形成了孤立的“椒盐”噪声。除了改进模型还可以对预测结果做后处理比如多数投票滤除孤立点。用scipy的ndimage.median_filter可以对分类图做中值滤波窗口大小设3就很有效from scipy import ndimage class_map np.zeros((145, 145), dtypeint) class_map[labels_test_positions] pred_classes 1 # 恢复1-16编码 filtered_map ndimage.median_filter(class_map, size3, modenearest)中值滤波能把单点噪声替换成邻域众数分类图会干净很多。但注意不要过度滤波否则会抹掉细小的真实地物边界。这部分改进在OA指标上可能只有零点几个百分点的提升但视觉观感好很多。6. 一点个人经验总结整个项目做下来我最深的体会是Indian Pines虽然数据集不大但它把一个模型设计时遇到的问题都暴露得很彻底。样本不均衡、空间相关性、数据泄漏、过拟合、小样本训练这些问题在真实遥感项目里只会更严重在Indian Pines里先踩一遍坑是很值的。如果你只记住三件事那就是邻域patch的提取要重视坐标偏移和填充方式这是预处理里最隐蔽的坑。PCA降维加2D-CNN在Indian Pines上是性价比极高的方案不要一上来就堆3D-CNN。类别权重和数据增强带来的收益往往比换个更复杂的网络结构更明显。如果后续想深入可以在这个基础上尝试更先进的backbone比如把ResNet或者CSPNet作为特征提取网络替换简单卷积层或者用交叉验证来替代固定划分得到更稳定的精度估计或者往半监督方向探索把无标签区域的像素也用上。Indian Pines的入门价值很大但真正能提升泛化能力的还是去更大规模的数据集上验证你的方法。最后分享一个小技巧每次实验前把数据预处理生成的numpy数组缓存到本地文件下次直接用np.load加载能省去大量反复处理的时间。Indian Pines的patch提取虽然不算特别慢但当你尝试多个patch_size、多个PCA维度时重复预处理的时间会积累得很可观。我习惯把预处理和训练分成两个独立脚本预处理脚本一键生成缓存训练脚本只负责加载和训练这样调参的效率会高很多。