简介:面向城市遥感智能解译的本科毕业设计项目,基于改进U-Net++算法构建高分辨率建筑物变化检测系统,解决传统方法在复杂场景下建筑物提取精度不足、边界模糊等痛点。资源内含完整Python源码,涵盖LEVIR数据集上的训练、验证与损失曲线可视化等环节,并融入SE注意力模块、嵌套U-Net损失计算、剪枝技术、加权FocalLoss等创新点,能够增强特征通道关注,缓解正负样本不均衡,同时通过深层监督与网络剪枝平衡精度和速度;训练阶段还针对CUDA Out of Memory做了优化,便于常规显存设备运行。编码器可灵活选择,并支持VGG19等预训练主干适配不同任务。压缩包共11个文件,包括6个py源码、2个tex说明文档、2个7z补充资料和1个汇总代码zip,整体仅21KB,轻量便携。目前已有207人浏览/学习,读者可获得详细注释的改进模型代码、说明文档与算法改进思路整理,既可辅助毕设论文撰写,也能作为遥感变化检测方向的开源基线进行二次开发与拓展。
1. 项目速览:一份能直接跑的改进U-Net++遥感变化检测毕设
每年毕业设计,遥感建筑物变化检测都是热门方向。很多人拿到源码第一步就卡住:环境装不上、显存不够、训练loss不降,最后只能把别人的模型硬套在自己数据集上。这个项目不太一样,它是一份把"改进算法"真正落到代码里的Python工程——在U-Net++骨架上,把SE通道注意力、嵌套深度监督、剪枝推理、加权FocalLoss和显存优化全部串进了训练与验证流程,解压后照着说明文档就能跑。
适合两类人:一是做遥感变化检测相关毕设,需要一套能复现、能在答辩时讲清楚创新点的代码;二是想研究改进U-Net++的开发者,想看看SE模块、嵌套损失、剪枝这些改动到底怎么和训练管线接在一起。接下来我会从工程结构、核心算法、实操运行三个角度把它拆开,最后聊二次开发的改造思路。
2. 工程结构拆解:从压缩包里的文件看完整运行链路
拿到压缩包先别急着解压跑训练,花五分钟把文件认一遍能省掉后面很多麻烦。这个项目不复杂,核心就是两个训练验证脚本加一个绘图脚本,再加一份说明文档,但每一份文件都对应一个明确的阶段,搞清楚整体结构,不管训练还是改代码都不会晕。
2.1 文件清单与职责划分
先看压缩包里第一层有哪些东西,我用表格梳理一下:
| 文件 | 作用 | 什么时候用 |
|---|---|---|
| project_code_upload.zip | 完整工程代码打包 | 解压后作为工作目录 |
| LEVIR_train.py | 训练入口,负责数据加载、构建模型、损失计算、参数更新 | 跑训练时 |
| LEVIR_vaild.py | 验证入口,加载训练好的权重,算指标、输出结果图 | 训练完成后验证时 |
| LineGraph.py | 读取训练日志,画loss曲线和F1曲线 | 任何时候想看曲线时 |
| 说明文档.7z | 论文正文、环境配置、使用说明 | 配环境、写论文时 |
| VGG19_encoder.tex | LaTeX格式的VGG19编码器结构参数表 | 写论文插图时 |
注意看一下,LEVIR_vaild.py里"vaild"是valid的拼写变体,属于作者笔误,但不影响Python导入和执行,不用改。脚本名里的LEVIR对应那套公开的高分辨率遥感建筑物变化检测数据集,训练脚本和验证脚本就是围绕这套数据组织的。说明文档.7z解压后一般会包含环境要求、数据目录结构、训练参数说明和实验结果分析,第一次跑之前建议先把文档里"环境配置"那一节读完,比直接执行脚本省事得多——很多同学上来就python LEVIR_train.py,结果torchvision版本对不上,跑两行就翻车。
注意:优先把
说明文档.7z解压出来核对版本要求,而不是先跑代码。这类项目最常见的启动失败原因就是依赖版本冲突。
2.2 训练-验证-可视化:三段式流程如何串起来
整个项目的执行链路是:先跑LEVIR_train.py得到权重文件和日志,再跑LEVIR_vaild.py加载权重做验证并输出指标,最后用LineGraph.py把日志画成曲线图,直接放进论文。三个脚本各管一段,中间靠"权重文件 + 日志文件"衔接,不需要人工干预。
数据流方向是:前时相图像 + 后时相图像 → 数据增强 → 输入改进U-Net++ → 输出变化概率图 → 与标签计算损失 → 反向传播 → 保存最优权重 → 验证阶段输出检测结果图和F1 / IoU指标。这里面最关键的设计是训练和验证共用同一套模型定义,在训练脚本里改了网络结构,验证脚本不用同步改,避免了两边模型不一致的低级错误。
数据加载部分,遥感变化检测的数据组织方式和普通分类任务不太一样。常见做法是每个样本包含两张同一地点不同时间的图像和一张二值变化标签,三个文件按名字一一对应。Dataset的__getitem__里要做的是同时读两张图、做同步的数据增强(比如同时翻转),然后返回给训练循环。参考实现长这样:
import os import torch from PIL import Image from torch.utils.data import Dataset from torchvision import transforms class ChangeDetectionDataset(Dataset): """遥感变化检测数据集:每次返回一个图像对和对应的变化标签""" def __init__(self, image_dir, label_dir, transform=None): self.image_dir = image_dir self.label_dir = label_dir self.transform = transform # 假设文件名格式为 A_001.png(前时相)、B_001.png(后时相)、L_001.png(标签) self.names = sorted([f[2:] for f in os.listdir(image_dir) if f.endswith('.png')]) def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img1 = Image.open(os.path.join(self.image_dir, 'A_' + name)).convert('RGB') img2 = Image.open(os.path.join(self.image_dir, 'B_' + name)).convert('RGB') label = Image.open(os.path.join(self.label_dir, 'L_' + name)).convert('L') # 同一组数据用同一个随机种子做增强,保证前后时相和标签的空间位置对齐 if self.transform is not None: seed = torch.randint(0, 10000, (1,)).item() torch.manual_seed(seed) img1 = self.transform(img1) torch.manual_seed(seed) img2 = self.transform(img2) torch.manual_seed(seed) label = self.transform(label) return img1, img2, label这段代码里最值得注意的就是增强的同步:前时相、后时相、标签三张图必须用同一个随机种子做翻转或裁剪,否则图像对的空间位置就错位了,模型学到的全是错位噪声。很多刚接触变化检测的同学直接在Dataset里对三张图分别transform,结果标签和图像对不上,训练loss怎么都降不下去,查半天才发现是数据增强不同步。这个坑在遥感任务里属于高频翻车点。
除了同步增强,还有个细节:label用convert('L')转成单通道灰度图,因为变化标签本质就是二值图(0表示没变,1表示变了),不需要三通道。有的实现会在这一步把label二值化,比如把大于127的像素全部置为1,这也是常见做法,取决于数据集原始标注格式。
2.3 为什么选U-Net++做底座而不是普通U-Net
项目名称里写的是"改进UNet",但摘要里明确提到"嵌套U-Net(嵌套的损失计算)",指向的就是U-Net++(Nested U-Net)结构。U-Net++和普通U-Net的核心区别在于解码器不再只从同层编码器拿特征,而是把中间层用密集跳跃连接全部串起来,每个解码节点都接收来自不同分辨率的特征。
这个设计对遥感建筑物变化检测有两个直接好处。第一,建筑物边界模糊、阴影遮挡多,多尺度特征融合能让模型同时看到高分辨率的边界细节和低分辨率的语义信息,对小目标变化更敏感。第二,U-Net++天然支持深度监督(deep supervision),每个子网络输出都能算一次损失,梯度可以直接传到浅层编码器,收敛更快。项目里说的"嵌套的损失计算"就是这个机制。
编码器选VGG19,核心原因是迁移学习。VGG19在ImageNet上训练好的权重非常成熟,torchvision一行就能加载,感受野较大、层级规整,作为遥感特征的编码器效果稳定。而且项目里专门给了VGG19_encoder.tex,说明作者在论文里对编码器结构做了完整描述,方便你复现和改写。后面在核心算法章节我会具体讲VGG19编码器怎么嵌进模型,以及怎么换掉它。
3. 核心算法拆解:五处改进分别改在哪、为什么这么改
这一章是整个项目最值钱的部分。毕设答辩时老师问"你的创新点是什么",答案就在这一章里。我把项目的五处改进按照"编码器 → 注意力 → 网络结构 → 损失函数 → 工程优化"的顺序拆开讲,每一处都给出代码逻辑和参数含义,方便你对照项目源码看。
3.1 VGG19编码器:迁移学习怎么省下大量训练时间
项目里专门有一个VGG19_encoder.tex文件,说明文档里用LaTeX写了VGG19编码器的结构表。代码里VGG19承担的是编码器角色,负责把输入图像逐步下采样成高维特征图。之所以不随机初始化直接从零训练,是因为遥感图像虽然和自然图像有差异,但底层特征(边缘、纹理、形状)是通用的,用ImageNet预训练权重做初始化,相当于让模型站在一个已经会看图的起点上继续学。
加载VGG19的常见做法是直接用torchvision,然后按MaxPool的位置把features切成5个stage,每个stage对应编码器的一层分辨率输出:
import torch import torch.nn as nn from torchvision.models import vgg19_bn def build_vgg19_encoder(pretrained=True): """构建VGG19编码器,按MaxPool位置切分成5个stage""" vgg = vgg19_bn(pretrained=pretrained) features = vgg.features stages = [] current = [] # 遇到MaxPool2d就认为当前stage结束,把收集到的层打包成一个Sequential for layer in features: if isinstance(layer, nn.MaxPool2d): current.append(layer) stages.append(nn.Sequential(*current)) current = [] else: current.append(layer) return nn.ModuleList(stages) def freeze_encoder_stages(encoder, frozen_stages=4): """冻结前 frozen_stages 个stage的参数,只训练后面的层""" for i, stage in enumerate(encoder): if i < frozen_stages: for p in stage.parameters(): p.requires_grad = False return encoderpretrained=True表示加载ImageNet预训练权重,如果网络下载困难,可以先把权重文件下载到本地,再把pretrained设为False,最后手动load_state_dict。frozen_stages控制冻结前几个stage,按我的经验,编码器前4个stage可以冻结,只微调最后一个stage和整个解码器,这样显存占用和训练时间都能明显下降,且不会让浅层特征被遥感图像的大分布差异带偏。这个值是经验参数,项目里可能直接固定了某个数,你可以按自己的数据集规模来调。
注意:遥感图像如果包含近红外等额外波段,输入通道就不是3了。此时需要把VGG19第一个卷积层改成对应通道数,并将该层权重做平均或复制处理。项目里如果用的是普通RGB影像,则不需要这一步。
3.2 SE模块:在哪个位置插入通道注意力
SE模块是Squeeze-and-Excitation的缩写,核心思想是让网络自动学习每个特征通道的重要程度,把重要通道放大、不重要通道抑制。在遥感变化检测里,建筑物和道路、裸地在某些波段上特征很像,通道注意力能帮助模型提高区分性强的特征通道的权重。
嵌入位置的选择比SE模块本身实现更关键。常见位置有三个:编码器每个stage输出后、解码器每次上采样后、以及跳跃连接上。项目里采用的是第一类思路,在每个编码器stage输出后插入SE模块。理由是编码器特征经过注意力重标定后再传给解码器,能在特征融合前就把噪声通道压下去。SE模块的实现不算复杂:
import torch import torch.nn as nn class SELayer(nn.Module): """Squeeze-and-Excitation通道注意力模块""" def __init__(self, channel, reduction=16): super(SELayer, self).__init__() # Squeeze:全局平均池化把每个通道压缩成一个标量 self.avg_pool = nn.AdaptiveAvgPool2d(1) # Excitation:两个全连接层学习通道权重,中间用ReLU,最后用Sigmoid输出0~1权重 self.fc = nn.Sequential( nn.Linear(channel, channel // reduction, bias=False), nn.ReLU(inplace=True), nn.Linear(channel // reduction, channel, bias=False), nn.Sigmoid() ) def forward(self, x): b, c, _, _ = x.size() # 得到每个通道的权重,形状为 (b, c, 1, 1) y = self.avg_pool(x).view(b, c) y = self.fc(y).view(b, c, 1, 1) # 与原始特征逐通道相乘 return x * y.expand_as(x)reduction参数控制中间层压缩比例,16是论文常用的默认值,代表第一个全连接层把通道数压缩到1/16。这个值不是越大越好,通道数本来就少的浅层stage用16可能导致中间层维度太小,信息损失过多,所以有些实现会对不同stage做适配:浅层用8,深层用16。SE模块的参数量很小,一个stage插入一个,整个网络显存开销增加不到1%,属于性价比非常高的改进。
3.3 嵌套U-Net与剪枝:训练和推理用两套结构
项目里说的"嵌套U-Net(嵌套的损失计算)和剪枝技术"是U-Net++最标志性的两个设计。理解这两个,基本就理解了整个模型骨架。
嵌套结构指解码器不是简单的倒金字塔,而是有多条不同深度的解码路径。模型同时维护多个尺度的特征节点,每个节点既接收编码器同尺度的特征,也接收来自相邻更小尺度上采样后的特征。这种密集连接让每个解码节点都拥有多感受野信息。训练阶段,模型对多个子网络分支都计算损失并求和,梯度从多个深度回传;推理阶段则只保留最深层主路径,其余子网络全部剪掉。这样做的结果是训练更充分、收敛更稳,而推理速度几乎和普通U-Net一样。
def forward(self, x1, x2, is_training=True): # 双时相输入在通道维拼接,变成6通道输入 x = torch.cat([x1, x2], dim=1) # 编码器:逐stage提取特征,x_0_0到x_4_0表示不同分辨率层级 x_0_0 = self.encoder_stage1(x) x_1_0 = self.encoder_stage2(x_0_0) x_2_0 = self.encoder_stage3(x_1_0) x_3_0 = self.encoder_stage4(x_2_0) x_4_0 = self.encoder_stage5(x_3_0) # 解码器:密集跳跃连接,x_i_j表示第i层第j个节点 x_0_1 = self.up_conv1_2(torch.cat([x_0_0, self.up_1_0(x_1_0)], dim=1)) x_1_1 = self.up_conv2_2(torch.cat([x_1_0, self.up_2_0(x_2_0)], dim=1)) x_2_1 = self.up_conv3_2(torch.cat([x_2_0, self.up_3_0(x_3_0)], dim=1)) x_3_1 = self.up_conv4_2(torch.cat([x_3_0, self.up_4_0(x_4_0)], dim=1)) x_0_2 = self.up_conv1_3(torch.cat([x_0_0, x_0_1, self.up_1_1(x_1_1)], dim=1)) x_1_2 = self.up_conv2_3(torch.cat([x_1_0, x_1_1, self.up_2_1(x_2_1)], dim=1)) x_2_2 = self.up_conv3_3(torch.cat([x_2_0, x_2_1, self.up_3_1(x_3_1)], dim=1)) x_0_3 = self.up_conv1_4(torch.cat([x_0_0, x_0_1, x_0_2, self.up_1_2(x_1_2)], dim=1)) x_1_3 = self.up_conv2_4(torch.cat([x_1_0, x_1_1, x_1_2, self.up_2_2(x_2_2)], dim=1)) x_0_4 = self.up_conv1_5(torch.cat([x_0_0, x_0_1, x_0_2, x_0_3, self.up_1_3(x_1_3)], dim=1)) if is_training: # 训练阶段返回多个子网络输出,分别参与损失计算 return [x_0_1, x_0_2, x_0_3, x_0_4] else: # 推理阶段剪枝:只保留最深层主路径,速度和普通U-Net一致 return x_0_4这个forward逻辑就是U-Net++的浓缩版。is_training是关键开关:训练时返回四个不同深度的输出,验证或推理时只返回最深的x_0_4,剪枝就在这一步体现。实际项目里,代码可能会用循环和模块列表来组织,避免写这么长一串,但核心思想完全一致——训练多监督、推理单路径。需要提醒的是,训练时返回多个输出,意味着损失函数也要对应写成"嵌套损失",这一点下一节会细讲。
3.4 加权FocalLoss:类别不平衡的应对方案
遥感变化检测里,"没有变化"的像素通常占绝对多数,"变化"区域可能只占整张图的百分之几甚至更少。用普通交叉熵损失,模型只要全部预测成"没变化"就能拿到很低的loss,但实际检测能力为零。FocalLoss就是为这种极端类别不平衡设计的。
FocalLoss在交叉熵基础上加了一个调制因子(1-p_t)^gamma,让模型更关注难分类样本,也就是那些预测概率不高但确实是变化的像素。项目里还提到了"加权",我的理解是在FocalLoss基础上再叠加一个类别权重系数,给正类别更高的权重,双管齐下:
import torch import torch.nn as nn import torch.nn.functional as F class WeightedFocalLoss(nn.Module): """加权FocalLoss:alpha控制正负样本权重,gamma控制难易样本权重""" def __init__(self, alpha=0.75, gamma=2.0): super(WeightedFocalLoss, self).__init__() self.alpha = alpha self.gamma = gamma def forward(self, pred, target): # pred是sigmoid输出,target是0/1标签 bce = F.binary_cross_entropy(pred, target, reduction='none') p_t = pred * target + (1 - pred) * (1 - target) # 预测正确的概率 modulating_factor = (1.0 - p_t) ** self.gamma # 类别权重:alpha给正样本,(1-alpha)给负样本 alpha_weight = self.alpha * target + (1 - self.alpha) * (1 - target) loss = alpha_weight * modulating_factor * bce return loss.mean()alpha=0.75的意思是:正类别(变化区域)的损失权重是0.75,负类别是0.25,相当于把变化像素的重要性放大了三倍。gamma=2.0是论文里的标准值,gamma越大,模型对已经分对的简单样本越不敏感。实际调参时,我会先用alpha=0.5、gamma=0跑一遍,这等价于普通加权交叉熵,再逐步调大gamma观察验证集F1变化,而不是一上来就跳到一个激进组合。项目摘要里注明"部分实验已完成",说明加权FocalLoss还不是最终定稿版本,这部分改起来空间很大,后面二次开发章节我会展开讲。
3.5 显存优化:从CUDA OOM到稳定训练
摘要里特别写了一条"在训练和验证阶段,一定程度上解决CUDA Out of memory",这几乎是每个跑遥感分割的人都会撞上的问题。高分辨率遥感图像动辄几百MB一张,即使裁剪成256x256,batch稍大就会爆显存。
常见解法按性价比排序是:梯度累积、混合精度训练、验证时关闭梯度计算、减小输入尺寸。项目里"一定程度上解决",我判断是组合了其中两种以上。梯度累积的思路是把一个大batch拆成多个小batch,先算梯度不更新,累积几步后再更新一次参数,效果上接近大batch,但显存峰值只有小batch的水平:
accumulation_steps = 4 # 累积4个batch再更新一次参数 optimizer.zero_grad() for i, (img1, img2, label) in enumerate(train_loader): img1, img2, label = img1.cuda(), img2.cuda(), label.cuda() outputs = model(img1, img2, is_training=True) # 取最后一个子网络输出算主损失,浅层分支的损失按嵌套损失逻辑叠加 loss = weighted_focal_loss(outputs[3], label) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意loss除以了accumulation_steps,如果不除,等效batch size变大但学习率没变,训练会不稳定。混合精度训练是另一个高性价比方案,用torch.cuda.amp可以把浮点计算降到16位,显存几乎减半,训练速度在支持Tensor Core的显卡上还能提升。项目大概率用了AMP,因为这是解决OOM最直接的手段之一。验证阶段则务必用with torch.no_grad():包住整个验证循环,这是最常见的显存杀手,下一章实操部分会再提到。
4. 训练与验证实操:两个脚本加一个绘图脚本怎么配合
这一章解决的是"怎么把人家的项目跑起来"。我会从训练入口的参数配置讲起,再到验证阶段的指标计算,最后是LineGraph.py怎么把日志变成论文插图。照着这个顺序走一遍,基本就能把整个项目跑通。
4.1 训练入口:参数配置与数据准备
打开LEVIR_train.py,从上往下扫,一般依次是:导入依赖、定义全局参数、加载数据、构建模型、训练循环。全局参数通常集中在文件开头定义,下面列一下典型的参数和合理取值:
| 参数 | 常见取值 | 说明 |
|---|---|---|
| learning_rate | 1e-3 到 1e-4 | 用了预训练编码器时建议从1e-4起步 |
| batch_size | 4 到 16 | 取决于显存,梯度累积可以让它设小 |
| num_epochs | 100 到 200 | 遥感变化检测数据量不大,训练一般不会太久 |
| image_size | 256 或 512 | 裁剪尺寸,越大细节越好但显存占用越高 |
| loss_type | weighted_focal 或 bce+dice | 项目核心损失是加权FocalLoss |
| save_dir | ./weights | 权重和日志输出目录 |
训练循环里建议重点关注三件事:模型切换train模式、梯度清零和更新的时机、每个epoch结束保存最优权重。最优权重的判定标准一般是验证集F1最高,而不是训练loss最低,因为loss低只代表拟合了训练集。训练脚本的骨架常见这样:
model.train() for epoch in range(start_epoch, num_epochs): epoch_loss = 0.0 for i, (img1, img2, label) in enumerate(train_loader): img1 = img1.cuda() img2 = img2.cuda() label = label.cuda().float() outputs = model(img1, img2, is_training=True) # 嵌套损失:四个子网络输出都参与,深层分支权重更大 loss = 0.1 * criterion(outputs[0], label) \ + 0.1 * criterion(outputs[1], label) \ + 0.3 * criterion(outputs[2], label) \ + 0.5 * criterion(outputs[3], label) loss.backward() optimizer.step() optimizer.zero_grad() epoch_loss += loss.item() scheduler.step(epoch_loss) torch.save(model.state_dict(), f'./weights/epoch_{epoch}.pth')嵌套损失的权重分配是按常见做法给的,项目里可能直接取平均,也可能只取最后一层的loss。核心逻辑是:浅层子网络输出分辨率更高但语义偏弱,所以权重低;深层子网络语义强,权重高。如果你在二次开发时改了模型深度,这组权重也要相应调整,否则梯度会被某个分支主导。训练过程中建议同时把每个epoch的loss和F1记录到txt或csv文件里,后面LineGraph.py要用。
4.2 验证阶段:指标计算与结果保存
LEVIR_vaild.py要做的事情是加载训练好的权重,对验证集逐张预测,计算Precision、Recall、F1和IoU。变化检测本质上是逐像素二分类,所以这些指标和语义分割完全一致。注意一个细节:模型输出的是概率图,不是最终的0/1标签,必须选一个阈值把概率图二值化。固定阈值0.5最省事,但更稳妥的做法是在验证集上遍历0.1到0.9,选F1最高的那个阈值。这个操作往往能让F1涨1到2个点,项目里如果没做,你完全可以自己补上。
def validate(model, val_loader, threshold=0.5): model.eval() # 切到eval模式,让BN层用全局统计量 f1_scores = [] with torch.no_grad(): # 关闭梯度计算,省显存且加快速度 for img1, img2, label in val_loader: img1, img2, label = img1.cuda(), img2.cuda(), label.cuda() prob = model(img1, img2, is_training=False) # 剪枝后的单路径输出 prob = torch.sigmoid(prob) pred = (prob > threshold).float() tp = ((pred == 1) & (label == 1)).sum().float() fp = ((pred == 1) & (label == 0)).sum().float() fn = ((pred == 0) & (label == 1)).sum().float() precision = tp / (tp + fp + 1e-6) recall = tp / (tp + fn + 1e-6) f1 = 2 * precision * recall / (precision + recall + 1e-6) f1_scores.append(f1.item()) return sum(f1_scores) / len(f1_scores)threshold既是阈值参数也是调试参数:预测结果里变化区域明显偏多时,调大threshold能减少虚检;漏检严重时,调小一些。这个参数在写论文做对比实验时很有用,你可以在相同模型下通过调整阈值得到P-R曲线的不同位置,甚至画出PR曲线放在论文里。
验证阶段最常见的问题有两个:一是忘了model.eval()导致BN层统计量错乱,二是忘了torch.no_grad()导致显存被翻倍占用。项目既然专门提了解决验证阶段的OOM,说明这两点已经处理过了,你改代码时要保持住。
4.3 可视化:LineGraph.py把日志变成论文插图
LineGraph.py一般负责画两类曲线:训练/验证loss曲线,以及验证F1曲线。它通常直接读取训练时保存的日志文件,用matplotlib画图。对毕设来说,这个脚本的价值是省去手动记录数据的麻烦,论文里的"模型收敛性分析"那一节正好需要这几张图。
运行方式一般是python LineGraph.py,它会读取指定路径下的日志文件然后生成png图片。如果执行后提示找不到数据,先检查日志文件路径和文件名是否匹配。这类脚本通常把路径写死在文件开头,找不到就改成实际路径。画图时建议把训练loss和验证loss画在同一张图里,用两个不同颜色区分,这样能直观看到模型是否过拟合——如果验证loss先降后升,而训练loss一直降,就是典型的过拟合信号,需要加正则或者缩小模型容量。
到现在为止,训练、验证、可视化三个环节已经全部串起来了。整个实验流程基本不需要手工干预,接下来就可以开始改模型、跑对比实验了。
5. 避坑记录:遥感变化检测从源码到跑通的五个常踩问题
这一章把我自己复现这类项目时踩过的坑整理一下,全部按照"现象 → 原因 → 解决"来写,方便你遇到问题时快速对照排查。
5.1 验证阶段CUDA out of memory
现象:训练没崩,模型加载进验证脚本却直接报out of memory,GPU显存瞬间被吃满。
原因:验证循环里没有开torch.no_grad(),模型前向计算仍然保存了中间梯度信息,显存占用和训练时一样高;或者验证时batch_size设置得比训练还大;另一个常见原因是训练和验证交替进行,前一个epoch的显存缓存没有释放。
解决:验证循环外面包with torch.no_grad():,把验证batch_size降到1;在训练代码里每个epoch结束用torch.cuda.empty_cache()清理缓存。跑之前用nvidia-smi确认没有其他进程占用显存,这也是个容易被忽略的坑。
5.2 训练loss在降,验证F1却一直不动
现象:loss曲线稳步下降到0.2以下,但验证F1始终在0.3左右徘徊,模型看起来"学会了"但实际上没用。
原因:类别不平衡导致的假收敛。数据集里"不变"像素占绝大比例,模型把所有像素预测成"不变"就能拿到很低的loss,但"变化"区域几乎没被召回,F1自然上不去。这是变化检测最典型的翻车方式。
解决:分别看验证集的precision和recall。如果recall极低,说明模型在正类别上几乎失效,把加权FocalLoss的alpha调大,让正样本的梯度占比更高。同时检查threshold是否过大,很多情况下F1卡住只是二值化阈值选得太高了。
5.3 预测概率图看不出变化区域
现象:模型输出的概率图颜色非常单一,要么全部接近0,要么全部在0.5附近,看不到明显的"变化区域高亮"。
原因:模型最后一层没有配合sigmoid做输出。常见错误是在训练时损失函数内部做了sigmoid计算,但验证时直接输出logits没有再过sigmoid;或者权重加载路径不对,加载了还在训练早期的中间权重。
解决:确认训练和验证对模型输出的处理一致。训练时如果用了带sigmoid的损失函数,验证时就要对logits显式做sigmoid;如果想看中间结果,加载最后一个epoch保存的权重,而不是看第一个保存的。这类问题排查顺序:先确认权重路径,再确认sigmoid位置,最后确认数据归一化方式一致。
5.4 加载预训练权重报size mismatch
现象:加载VGG19或模型权重时,报size mismatch for xxx: expected channel ... got channel ...。
原因:最常见的是改动过模型输入通道数或解码器输出维度,导致当前模型定义和保存的权重对不上。比如把三通道输入改成四通道(加了近红外波段),或者修改了解码器结构后输出通道数和旧权重不一致。
解决:如果只是输入通道变了,可以先加载预训练权重,再对被改动的卷积层做通道复制或截断处理。更省事的做法是冻结编码器前四层,跳过与改动层相关的权重,让它们从头学。如果模型结构和原版完全一致,则优先检查是不是加载了不同epoch的权重文件。
5.5 换了更大的GPU反而训练变慢
现象:从4GB显存换到12GB的卡,batch调大了,但每个epoch时间反而变长,甚至明显变慢。
原因:batch变大后迭代次数变少,但每次迭代的数据加载和增强时间也变长了,GPU处理完一批后在等数据,训练变成IO密集型而不是计算密集型。加上学习率没按batch比例调整,收敛速度也不理想。
解决:batch_size翻倍后,学习率通常也要按线性缩放规则相应调大,比如batch从4变8,学习率从1e-4调到2e-4。同时把DataLoader的num_workers调高,让数据加载并行。显存有富余时,优先加大输入分辨率而不是batch,因为分辨率对遥感小目标检测的收益更直接。
6. 二次开发思路:改结构、换损失、做对比实验的具体步骤
把这套代码改造成你自己的实验框架,核心就三件事:换编码器、组合损失函数、管理好每次实验。这三件事做扎实,毕设的对比实验和消融实验就都有素材了。
6.1 把VGG19换成ResNet34或EfficientNet做消融实验
答辩时老师几乎必问"为什么选VGG19",所以你需要对比实验来支撑选择。换编码器的方式很直接:把build_vgg19_encoder里面的vgg19_bn换成resnet34,去掉最后的avgpool和fc,把stage1到stage4作为编码器的四个阶段,通道数分别是64、128、256、512,需要与U-Net++解码器的通道定义一一对齐。如果换EfficientNet,用timm库更省事,timm.create_model('efficientnet_b0', features_only=True)会自动返回每个stage的输出。我一般会写一个编码器工厂函数,用一个字符串参数控制加载哪个backbone,这样对比实验只需要改配置不用改模型代码。
6.2 损失函数改成FocalLoss + Dice组合
加权FocalLoss处理类别不平衡很有效,但缺点是对区域一致性和边界平滑没有约束。DiceLoss对前景区域的重叠度敏感,两者组合可以互补。最终loss可以写成criterion = 0.7 * focal_loss + 0.3 * dice_loss,两个权重用验证集来调。改的时候注意DiceLoss在"变化区域为空"的图上会算出0/0,实现时要加平滑项,防止反向传播出现NaN。建议先单独跑加权FocalLoss做基线,再跑组合损失,两边F1对比才有说服力。
6.3 实验管理:每个实验留下可回溯的记录
最后分享三个让实验不迷路的习惯:第一,每个实验建一个独立目录,保存模型权重、训练日志和当时的参数配置,不要所有权重堆在一个文件夹里;第二,在训练脚本里记录文件哈希或者手动写一行注释说明本次改动,方便回溯到底改了哪些地方;第三,验证集上除了F1还要保存precision和recall,这样能判断模型是偏向漏检还是虚检,调参时方向更明确。
很多人在毕业设计里吃亏,不是模型不够好,而是改了一堆东西最后说不清楚自己到底改了什么。从那以后我每次改网络结构,都强制先写一行注释说明改动目的,再动代码;每个实验跑到一半,把当时的loss曲线截图存档,方便对比。希望帮到你。
本文还有配套的精品资源,点击获取