轻量级网络实验:MobileNet、ShuffleNet 与 HybridSN


一、实验内容

二、实验过程

2.1 视频学习

学习笔记如下:

2.1.1 MobileNet

https://blog.csdn.net/2401_89740151/article/details/154255680

2.1.2 ShuffleNet

https://blog.csdn.net/2401_89740151/article/details/154256244

2.1.3 SENet

https://blog.csdn.net/2401_89740151/article/details/154260339

2.1.4 基础知识备忘录

https://blog.csdn.net/2401_89740151/article/details/154262177

https://blog.csdn.net/2401_89740151/article/details/154261921

2.2 代码练习

2.2.1 代码讲解

STEP0. 数据下载与依赖
  • wget 两个 .mat 文件分别是 Indian Pines 的数据立方体像素标签
  • spectral 是处理高光谱的常用包,本文主要用 scipy.io.loadmat 也行。
# 在 Colab 里执行:下载数据与安装依赖
!wget http://www.ehu.eus/ccwintco/uploads/6/67/Indian_pines_corrected.mat
!wget http://www.ehu.eus/ccwintco/uploads/c/c4/Indian_pines_gt.mat
!pip -q install spectral
STEP1. 导入库
  • 组合了科学计算(numpy / scipy)、可视化(matplotlib)、机器学习(sklearn)、PyTorch 全家桶。
  • 后续的 PCA、数据切块、划分训练/测试集都会用到 sklearn。
import numpy as np
import matplotlib.pyplot as plt
import scipy.io as sio

from sklearn.decomposition import PCA
from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix, accuracy_score, classification_report, cohen_kappa_score

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

# 为了可复现:固定随机种子
def set_seed(seed=42):
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
set_seed(42)

device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print("Device:", device)
STEP2. 定义 HybridSN 模型

对照论文

  • 输入[N, 1, 25, 25, 30](N 批量,1 是谱-空立方体单通道的占位)。
  • 3D 卷积 ×3(valid 卷积,无 padding)
    • Conv3D-1:核(3,3,7),输出空间 23×23,光谱深度 24,通道 8
    • Conv3D-2:核(3,3,5),输出 21×21×20,通道 16
    • Conv3D-3:核(3,3,3),输出 19×19×18,通道 32
  • reshape 到 2D:把32 个通道 × 18 个光谱深度合并为 576 个 2D 通道 [N, 576, 19, 19]
  • 2D 卷积 ×1:核(3,3),输出 [N, 64, 17, 17]
  • FC18496 → 256 → 128 → num_classes,中间加 Dropout(0.4)
class HybridSN(nn.Module):
"""
HybridSN: 3D-2D mixed CNN for hyperspectral image classification.
Paper settings for Indian Pines (patch=25, pca_bands=30).
"""
def __init__(self, num_classes=16):
super().__init__()

# -------- 3D part: spectral-spatial joint feature learning --------
# 输入: [N, 1, 25, 25, 30]
# Conv3D-1: out_channels=8, kernel=(3,3,7) -> 输出 [N, 8, 23, 23, 24]
self.conv3d_1 = nn.Conv3d(in_channels=1, out_channels=8, kernel_size=(3,3,7), stride=1, padding=0)

# Conv3D-2: out_channels=16, kernel=(3,3,5) -> 输出 [N, 16, 21, 21, 20]
self.conv3d_2 = nn.Conv3d(8, 16, kernel_size=(3,3,5), stride=1, padding=0)

# Conv3D-3: out_channels=32, kernel=(3,3,3) -> 输出 [N, 32, 19, 19, 18]
self.conv3d_3 = nn.Conv3d(16, 32, kernel_size=(3,3,3), stride=1, padding=0)

# -------- reshape to 2D: (channels * spectral_depth) as 2D channels --------
# 这里不需要层,只在 forward 里做维度变换:
# [N, 32, 19, 19, 18] -> [N, 576, 19, 19],因为 32*18 = 576

# -------- 2D part: spatial feature enhancement --------
# Conv2D-1: in=576, out=64, kernel=3x3, valid -> [N, 64, 17, 17]
self.conv2d_1 = nn.Conv2d(576, 64, kernel_size=3, stride=1, padding=0)

# -------- fully connected --------
# Flatten: 64*17*17 = 18496
self.fc1 = nn.Linear(64*17*17, 256)
self.drop1 = nn.Dropout(p=0.4)
self.fc2 = nn.Linear(256, 128)
self.drop2 = nn.Dropout(p=0.4)
self.fc3 = nn.Linear(128, num_classes)

def forward(self, x):
# x: [N, 1, 25, 25, 30]
x = F.relu(self.conv3d_1(x)) # -> [N, 8, 23, 23, 24]
x = F.relu(self.conv3d_2(x)) # -> [N, 16, 21, 21, 20]
x = F.relu(self.conv3d_3(x)) # -> [N, 32, 19, 19, 18]

# rearrange to 2D: merge (C, spectral_depth) as 2D channels
# 当前 x 维度顺序是 [N, C(32), H(19), W(19), D(18)]
x = x.permute(0, 1, 4, 2, 3).contiguous() # -> [N, 32, 18, 19, 19]
x = x.view(x.size(0), 32*18, 19, 19) # -> [N, 576, 19, 19]

x = F.relu(self.conv2d_1(x)) # -> [N, 64, 17, 17]

x = torch.flatten(x, 1) # -> [N, 18496]
x = self.drop1(F.relu(self.fc1(x))) # -> [N, 256]
x = self.drop2(F.relu(self.fc2(x))) # -> [N, 128]
x = self.fc3(x) # -> [N, num_classes] (logits)
return x
STEP3. 数据预处理与样本立方体
  • PCA(沿光谱维):把 200 个原始波段压成 30 个主成分(论文 IP 用 30)。
  • padding:抽 25×25 的空间窗口时,边缘像素没有完整邻域,需要先在四周补零。
  • createImageCubes:把整幅影像切成样本块(X)和中心像素标签(y)。
  • splitTrainTestSet:按 30% 训练 / 70% 测试进行划分,并做 stratify=y 保持类比例。
  • PyTorch 的维度要求(Conv3d):[N, C=1, H, W, B],因此要 transpose 到这个顺序。
  • 实验结果如图1.
# -------- 读取 .mat 原始数据 --------
data = sio.loadmat('Indian_pines_corrected.mat')['indian_pines_corrected'] # [145,145,200]
gt = sio.loadmat('Indian_pines_gt.mat')['indian_pines_gt'] # [145,145]
print("Hyperspectral data shape:", data.shape, "Label shape:", gt.shape)

# -------- 沿光谱维做 PCA,压到 B 个主成分 --------
def applyPCA(X, numComponents=30):
# X: [H, W, D]
H, W, D = X.shape
X_2d = X.reshape(-1, D) # -> [H*W, D]
pca = PCA(n_components=numComponents, whiten=True, random_state=42)
X_pca_2d = pca.fit_transform(X_2d) # -> [H*W, B]
X_pca = X_pca_2d.reshape(H, W, numComponents) # -> [H, W, B]
return X_pca

# -------- 边界 padding,方便后续取 25x25 的窗口 --------
def padWithZeros(X, margin=12):
# margin = (window_size // 2)
H, W, C = X.shape
padded = np.zeros((H + 2*margin, W + 2*margin, C), dtype=X.dtype)
padded[margin:margin+H, margin:margin+W, :] = X
return padded

# -------- 将整幅图切成 patch(样本)与对应标签 --------
def createImageCubes(X, y, windowSize=25, removeZeroLabels=True):
margin = windowSize // 2
X_padded = padWithZeros(X, margin=margin)
H, W, C = X.shape

# 预分配:样本个数 = H*W
patchesData = np.zeros((H * W, windowSize, windowSize, C), dtype=np.float32)
patchesLabel = np.zeros((H * W), dtype=np.int64)
idx = 0

for r in range(margin, margin + H):
for c in range(margin, margin + W):
patch = X_padded[r - margin:r + margin + 1, c - margin:c + margin + 1, :]
patchesData[idx] = patch
patchesLabel[idx] = y[r - margin, c - margin]
idx += 1

# 去掉标签为 0(背景/未标注)的样本
if removeZeroLabels:
mask = patchesLabel > 0
patchesData = patchesData[mask, ...]
patchesLabel = patchesLabel[mask] - 1 # 让类别从 0 开始
return patchesData, patchesLabel

# -------- 执行预处理流水线 --------
pca_bands = 30
patch_size = 25
test_ratio = 0.7

X_pca = applyPCA(data, numComponents=pca_bands) # [145,145,30]
X_cubes, y_vec = createImageCubes(X_pca, gt, windowSize=patch_size, removeZeroLabels=True)
print("Cubes:", X_cubes.shape, "Labels:", y_vec.shape)

X_train, X_test, y_train, y_test = train_test_split(
X_cubes, y_vec, test_size=test_ratio, stratify=y_vec, random_state=42
)
print("Train:", X_train.shape, y_train.shape, "| Test:", X_test.shape, y_test.shape)

# -------- 调整为 Conv3d 期望的形状 --------
# 期望: [N, C=1, H=25, W=25, B=30]
X_train = X_train[:, np.newaxis, :, :, :] # 添加通道维 -> [N,1,25,25,30]
X_test = X_test[:, np.newaxis, :, :, :]

# 转成 tensor
X_train_t = torch.from_numpy(X_train).float()
X_test_t = torch.from_numpy(X_test).float()
y_train_t = torch.from_numpy(y_train).long()
y_test_t = torch.from_numpy(y_test).long()

print("X_train_t:", X_train_t.shape, "X_test_t:", X_test_t.shape)
图1 预处理结果
图1 预处理结果
STEP4. PyTorch 数据集与加载器

讲解

  • 自定义 Dataset 仅需实现 __len____getitem__
  • DataLoader 负责打乱、组 batch、搬到 GPU 前的拼接。
class TrainDS(torch.utils.data.Dataset):
def __init__(self, X, y):
self.X = X
self.y = y
def __len__(self):
return self.X.shape[0]
def __getitem__(self, idx):
return self.X[idx], self.y[idx]

trainset = TrainDS(X_train_t, y_train_t)
testset = TrainDS(X_test_t, y_test_t)

train_loader = torch.utils.data.DataLoader(trainset, batch_size=256, shuffle=True, num_workers=2, pin_memory=True)
test_loader = torch.utils.data.DataLoader(testset, batch_size=256, shuffle=False, num_workers=2, pin_memory=True)
STEP5. 训练
  • 标准 supervised 分类:CrossEntropyLoss。
  • 每个 epoch 统计平均 loss;评估函数可在每个 epoch 末跑一次(此处为简单起见只在最后评估)。
  • 实验结果如图2.
num_classes = len(np.unique(y_vec))
net = HybridSN(num_classes=num_classes).to(device)

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(net.parameters(), lr=1e-3)

def train_model(model, loader, epochs=100):
model.train()
for epoch in range(1, epochs+1):
running = 0.0
for inputs, labels in loader:
inputs = inputs.to(device, non_blocking=True)
labels = labels.to(device, non_blocking=True)

optimizer.zero_grad()
logits = model(inputs)
loss = criterion(logits, labels)
loss.backward()
optimizer.step()

running += loss.item() * inputs.size(0)

epoch_loss = running / len(loader.dataset)
if epoch % 10 == 0 or epoch == 1:
print(f"[Epoch {epoch:3d}] loss: {epoch_loss:.4f}")

train_model(net, train_loader, epochs=100)
print("Finished Training.")
图2 训练结果
图2 训练结果
STEP6. 测试与指标
  • OA(Overall Accuracy):总体准确率。
  • AA(Average Accuracy):逐类准确率的平均值。
  • Kappa:考虑随机一致性的修正指标。
  • 生成 classification_report(含 precision/recall/F1)。
  • 实验结果如图3.
@torch.no_grad()
def predict_all(model, loader):
model.eval()
preds, gts = [], []
for inputs, labels in loader:
inputs = inputs.to(device, non_blocking=True)
logits = model(inputs)
pred = torch.argmax(logits, dim=1).cpu().numpy()
preds.append(pred)
gts.append(labels.numpy())
preds = np.concatenate(preds)
gts = np.concatenate(gts)
return preds, gts

def AA_and_each_class_acc(conf_mtx):
"""返回每一类的精度,以及它们的平均(AA)"""
diag = np.diag(conf_mtx) # 每类 TP
per_class = diag / conf_mtx.sum(axis=1) # 每类正确 / 该类总数
return per_class, np.nanmean(per_class)

y_pred, y_true = predict_all(net, test_loader)
oa = accuracy_score(y_true, y_pred)
cm = confusion_matrix(y_true, y_pred)
each_acc, aa = AA_and_each_class_acc(cm)
kappa = cohen_kappa_score(y_true, y_pred)
report = classification_report(y_true, y_pred, digits=4)

print("OA:", oa)
print("AA:", aa)
print("Kappa:", kappa)
print(report)
图3 测试与指标显示结果
图3 测试与指标显示结果
STEP7.整图像素级推理与可视化
  • 用滑窗在整幅图上移动,仅对已标注像素做推理并回填。
  • 实验结果如图4.
def full_image_inference(original_cube, gt, model, windowSize=25, pca_components=30):
"""对整幅图做推理,返回预测图(未标注处保持0)。"""
model.eval()
H, W, _ = original_cube.shape
margin = windowSize // 2
padded = padWithZeros(original_cube, margin=margin)

pred_map = np.zeros_like(gt)
with torch.no_grad():
for r in range(H):
for c in range(W):
if gt[r, c] == 0:
continue
patch = padded[r:r+windowSize, c:c+windowSize, :]
# -> [1, 1, 25, 25, B]
patch_t = torch.from_numpy(patch).unsqueeze(0).unsqueeze(0).float().to(device)
logits = model(patch_t)
pred = torch.argmax(logits, dim=1).item() + 1 # 恢复到 1..C 的标注
pred_map[r, c] = pred
return pred_map

# 演示:先把整图也做一次 PCA(与训练时一致的方式)
X_pca_full = applyPCA(data, numComponents=pca_bands)
pred_map = full_image_inference(X_pca_full, gt, net, windowSize=patch_size, pca_components=pca_bands)

plt.figure(figsize=(6,3))
plt.subplot(1,2,1); plt.title("Ground Truth"); plt.imshow(gt, cmap='tab20'); plt.axis('off')
plt.subplot(1,2,2); plt.title("Prediction"); plt.imshow(pred_map, cmap='tab20'); plt.axis('off')
plt.show()
图4 可视化结果
图4 可视化结果

2.2.2 3D卷积和2D卷积的区别

2D 卷积3D 卷积的主要区别在于它们处理的数据维度和能够提取的特征类型不同。

2D 卷积通常用于普通的彩色或灰度图像,其输入数据只有空间维度(高度和宽度)以及通道数(如 RGB 三通道)。卷积核在图像的二维平面上滑动,只能捕捉到空间特征,比如边缘、纹理、形状等。因此,2D 卷积擅长分析图像的平面结构,但无法感知光谱或时间等额外维度上的变化。

3D 卷积则在二维空间的基础上增加了一个“深度”维度。这个深度维可以代表时间(例如视频帧序列)或光谱波段(例如高光谱图像的不同波长)。3D 卷积核会在三维空间中滑动,能够同时整合空间和深度维的信息,提取出更复杂的“体积特征”或“光谱–空间联合特征”。这意味着它不仅能识别图像的形状,还能捕捉在不同波段或时间点上的特征变化。

从计算角度看,3D 卷积比 2D 卷积需要更多参数和更大的计算量,因为卷积核多了一个维度。它的表达能力更强,但训练更慢、对显存要求更高。因此,在实际任务中常常会将两者结合使用:用 3D 卷积在前几层提取联合特征,用 2D 卷积在后几层进一步提炼空间信息。

在 HybridSN 模型中正是采用了这种思路。模型首先使用多层 3D 卷积,从高光谱图像的空间和光谱两个维度同时学习特征;然后再使用 2D 卷积,对提取到的综合特征进行空间增强。这样既能充分利用光谱信息,又能降低计算复杂度,实现了精度和效率的平衡。

2.3 回答相关问题

2.3.1 训练HybridSN,然后多测试⼏次,会发现每次分类的结果都不⼀样,请思考为什么?

  • 随机初始化:权重初值不同导致最终收敛点不同。
  • 数据随机性:训练、测试划分、抽样、DataLoader 的 shuffle 顺序 不同。
  • 随机正则:Dropout等导致训练路径不同。
  • GPU 非确定性:部分 CuDNN 算法是非确定的,且浮点累加顺序不同会有微差。
  • 小数据:少量样本对梯度噪声更敏感,波动更大。

2.3.2 如果想要进⼀步提升⾼光谱图像的分类性能,可以如何改进??

  • 在数据层面,可以改进光谱降维方式。例如,用可学习的 1×1×k 卷积替代固定的 PCA,同时引入多尺度空间窗口,让模型同时看到大范围与局部信息。
  • 在模型结构上,可以加入注意力机制,如在光谱维使用通道注意力(SE 或 CBAM 模块)以强化重要波段的权重,在空间维加入空间注意力增强局部纹理特征;也可以尝试使用深度可分离卷积或残差结构减少参数量并提高表达能力。
  • 在训练层面,可以采用类别平衡损失、光谱或空间数据增强(随机丢弃波段、翻转、旋转等),并结合学习率调度或早停策略提高收敛稳定性。

2.3.3 depth-wise conv 和 分组卷积有什么区别与联系?

  • 分组卷积是将输入通道分为若干组,每组单独做卷积,再将各组的结果拼接起来输出。这样每个输出通道只与一部分输入通道相连接,从而减少了参数量。
  • 深度卷积可以看作是分组卷积的一种极端形式,其中组数输入通道数相等,也就是说每个输入通道只对应一个卷积核,独立提取特征,不与其他通道发生混合。
  • 由于深度卷积无法实现通道间的信息交互,通常会在其后再加一个 1×1 的卷积层(称为 point-wise 卷积),用于在通道维度上重新组合信息。这样的两步结构被称为深度可分离卷积。可以理解为,分组卷积和深度卷积在本质上是相同思想的不同规模:分组卷积控制组数的灵活性,而深度卷积是其最极端的情况。

2.3.4 SENet 的注意⼒是不是可以加在空间位置上?

  • 可以。该类模块通常被称为“通道-空间混合注意力”。
  • SENet 的原始设计是一种通道注意力机制,它通过全局平均池化获得每个通道的全局响应,再通过两个全连接层生成通道权重,从而在通道维度上进行重新加权。原版 SE 模块并不处理空间位置的信息。然而,在后续研究中,人们发现同时关注“通道”和“空间”可以提升特征表达能力。
  • 例如,CBAM 模块就在 SENet 的基础上增加了空间注意力分支,通过对特征图在通道维求平均和最大化后,生成一张空间注意力图来突出重要区域。还有像 scSE 模块,则同时采用通道压缩和空间压缩两种方式,广泛用于医学图像分割。

2.3.5 在 ShuffleNet 中,通道的 shuffle 如何⽤代码实现?

2D:

import torch

def channel_shuffle(x, groups: int):
# x: [N, C, H, W]
N, C, H, W = x.size()
assert C % groups == 0, "C must be divisible by groups"
x = x.view(N, groups, C // groups, H, W) # [N, g, Cg, H, W]
x = x.transpose(1, 2).contiguous() # [N, Cg, g, H, W]
x = x.view(N, C, H, W) # 还原形状,但通道已被打乱
return x

3D只需把形状换成 [N, C, D, H, W] 同理 reshape/transpose 即可:

def channel_shuffle_3d(x, groups: int):
# x: [N, C, D, H, W]
N, C, D, H, W = x.size()
assert C % groups == 0
x = x.view(N, groups, C // groups, D, H, W)
x = x.transpose(1, 2).contiguous()
x = x.view(N, C, D, H, W)
return x

三、问题总结与体会

3.1 问题总结

问题1:
在模型多次训练时,发现每次得到的分类结果存在差异,即使使用相同的数据集与参数设置,最终精度仍会有轻微波动。

原因与方案:
查到这种情况通常是由于随机初始化、数据加载顺序、Dropout 等操作带来的随机性所致。我通过在代码中设置固定的随机种子并关闭 CuDNN 的非确定性计算,可以保证每次运行结果一致。


问题2:
在模型训练过程中,发现显存占用较高、训练速度较慢,尤其是 3D 卷积部分计算开销明显。

原因与方案:
(其实用服务器资源还好)3D 卷积在空间与光谱三个维度上同时进行卷积运算,参数量和计算量都显著高于 2D 卷积。我减小了一定的输入 patch 尺寸以提高计算效率。

3.2 体会

通过实现并训练 HybridSN 模型,我体会到 3D 卷积和 2D 卷积在特征提取中的互补作用:前者能够同时捕捉光谱与空间信息,而后者则能进一步提炼空间结构,使模型在保证准确率的同时降低计算量。在实验过程中,我也遇到了一些问题,例如模型结果不稳定、过拟合以及训练速度较慢等,但通过设置随机种子、加入正则化和改进数据加载方式,这些问题得到了有效缓解。

哎其实整体来说还是有点懵圈的,只能学一点记一点了……也不确定自己写的东西里面有多少问题……


Author: linda1729
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source linda1729 !
评论
  TOC