简介:面向计算机、人工智能等专业学生的PyTorch车型识别课程设计项目,完整覆盖深度学习模型训练主流程;代码经测试可稳定运行,曾获答辩平均分94.5分,既可用于课设/毕设参考,也适合入门者从数据加载到模型训练逐步实践。压缩包共15个文件,以10个Python脚本为主,辅以3个Markdown说明、1个依赖清单和1个gitignore配置,整体仅21KB,结构紧凑便于通读。项目按启动器、自定义数据加载器、网络模型、学习率/损失率调整、训练可视化五大模块组织,内置resnet与mobile_net等经典网络,并提供CLR循环学习率策略、flops计算量基准测试、logger日志记录以及transfor数据预处理脚本;启动器支持灵活参数配置,可直接复现训练过程。数据加载器支持自定义数据集读取,学习率调整模块方便进行对比实验,训练可视化则有助于观察训练动态。已有237人学习下载,适合需要系统掌握PyTorch数据管线、模型搭建、训练调参与结果可视化的学习者,也便于在此基础上扩展其他分类任务。
1. 从一份课程设计到可复用的训练框架
做计算机视觉的应该都有过这种体验:拿到一份公开的模型训练代码,跑通是一回事,跑出论文里那个精度是另一回事。这份以车型识别为载体的PyTorch训练工程,我拆完之后最大的感受是——它解决的不是"怎么把ResNet跑起来",而是"怎么组织一次正经的深度学习训练实验"。它把原本散落在各个脚本里的功能收敛成了五个模块:启动器负责参数分发,数据加载器处理样本供给,网络模型层做特征提取,学习率/损失率调整控制收敛节奏,训练可视化让你看清loss曲线而不是盲调。对正在做课程设计的学生,或者想把手头分类任务工程化的一线开发,这套结构可以直接抄。我后面会按实际训练流程逐步拆解每个模块的职责和关键参数,并给出可以直接运行的代码片段。
2. 数据管线设计:dataset.py与transfor.py的协同逻辑
2.1 为什么先看数据加载而不是网络结构
多数人拿到PyTorch项目习惯先翻模型定义,但在这个工程里,决定训练上限的反而是数据管线。车型识别不同于ImageNet那种通用分类,它存在类间相似度高(比如帕萨特和迈腾)、拍摄角度差异大、光照条件不稳定三个典型问题。如果数据加载环节不做针对性处理,网络结构再先进也学不到区分性特征。
我在看dataset.py时注意到它对标签的处理方式——如果你跑过PyTorch官方ImageNet示例,会发现这里没有简单地把文件夹名映射成整数,而是额外返回了一个索引映射字典。这个设计值得展开:训练集和验证集如果各自用os.listdir取文件夹顺序,一旦文件系统返回顺序不一致,类别编号就会错位。常见做法是在第一次遍历时生成class_to_idx并保存为JSON,之后所有数据集实例都从这个文件加载映射。
# dataset.py 核心片段(整理后) import json, os from torch.utils.data import Dataset from PIL import Image class CarDataset(Dataset): def __init__(self, root_dir, transform=None, class_map_path=None): self.samples = [] self.class_to_idx = {} self.transform = transform if class_map_path and os.path.exists(class_map_path): with open(class_map_path, 'r') as f: self.class_to_idx = json.load(f) else: # 首次运行:扫描子目录并固定类别顺序 for label_idx, cls_name in enumerate(sorted(os.listdir(root_dir))): self.class_to_idx[cls_name] = label_idx for cls_name, idx in self.class_to_idx.items(): cls_dir = os.path.join(root_dir, cls_name) for img_name in os.listdir(cls_dir): self.samples.append((os.path.join(cls_dir, img_name), idx)) def __len__(self): return len(self.samples) def __getitem__(self, index): img_path, label = self.samples[index] image = Image.open(img_path).convert('RGB') if self.transform: image = self.transform(image) return image, label这段代码的逻辑说明:__init__阶段先尝试读取已有的类别映射文件,不存在才新建,这样多次实验的标签语义保持一致。__getitem__每次返回图像张量和标签索引,PyTorch DataLoader会在此基础上自动做batch组装、打乱和并行加载。
参数层面,构造CarDataset时transform参数必须传入由transfor.py组合好的增强流水线。我把类别数通过len(self.class_to_idx)暴露,这个值会直接传给后面的网络模型修改全连接层维度,是数据管线与模型模块之间的关键接口。
2.2 transfor.py里的数据增强策略
车型识别场景下,我推荐采用以下增强组合,它参考了train.py中实际使用的流水线配置:
# transfor.py 增强流水线(适用于224x224输入) from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2), transforms.RandomRotation(degrees=10), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这段代码的用意是:RandomResizedCrop随机裁剪并缩放到224像素,模拟不同距离下拍摄到的车辆目标尺寸变化;ColorJitter扰动亮度对比度,应对阴天或逆光场景。验证集只用Resize(256) + CenterCrop(224),保证评测结果可复现,不引入随机性。
注意模型在ImageNet上的预训练权重要求输入经过上述Normalize的均值和标准差,如果你的模型是自己从零训练的,这组归一化参数要重新统计训练集的RGB通道均值。
2.3 常见踩坑点
- 类别文件夹中不能有隐藏文件(如
.DS_Store),否则os.listdir会把非图片文件也当类别处理 - 如果样本数量严重不均衡(比如某车型图片数只有别的车十分之一),需要在dataset.py中实现
WeightedRandomSampler,否则模型会偏向样本量大的类别 - 对于二阶段训练,fine-tune时要不要冻结backbone,取决于数据集大小:小数据集建议冻结前几层,只训练最后几层和分类头,防止过拟合
3. run.py与start.py:训练编排的主控逻辑
3.1 启动器参数怎么设计
start.py是整个工程的入口,它做的事情本质上是参数解析后进行训练流程编排。这里有一个很多初学者容易忽略的点:好用的启动器不是把所有参数堆在命令行,而是把参数分组,每组负责一个关注点。
# 一个典型的启动方式(支持命令行覆盖默认值) python start.py --model mobilenet_v3 --dataset ./data/cars \ --batch_size 64 --epochs 50 --lr 0.001 \ --optimizer adamw --scheduler clr \ --pretrained True --freeze_backbone False上述命令的参数含义:--model指定backbone类型(对应mobilenet.py或resnet.py里的实现);--dataset指向数据集根目录;--batch_size根据显存调整,8GB显存跑MobileNetV3建议64,跑ResNet34建议32;--lr是初始学习率;--scheduler clr表示使用循环学习率(对应clr.py模块)。
启动器内部会先做环境检查,比如CUDA是否可用、数据集路径是否存在、类别数是否能被batch_size整除等。这些前置校验能帮你把报错时间从训练开始后半小时提前到启动前5秒。
3.2 run.py中的训练循环骨架
run.py承载的是标准PyTorch训练循环,很多项目把这段逻辑直接散写在main函数里,而这里被封装成了train_one_epoch和validate两个函数。
# run.py 训练循环核心 def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for inputs, labels in loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) return running_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() running_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for inputs, labels in loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) running_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) return running_loss / total, correct / total逻辑说明:训练模式必须调model.train(),验证模式必须调model.eval(),这影响BatchNorm和Dropout的行为。Loss计算采用CrossEntropyLoss,它在内部做了softmax和log运算,不需要在网络输出后再手动加softmax。
在run.py里,每个epoch结束后还会做这三个动作:记录当前epoch的lr到日志、把模型权重保存为checkpoint_{epoch}.pth、把训练和验证的loss/accuracy传给logger模块。如果需要断点续训,加载checkpoint后恢复model.state_dict()和optimizer.state_dict()即可。
3.3 训练过程中的显存管理
如果遇到OOM,常见做法是减少batch_size,但一个更有效的办法是检查是否有变量占用了显存而没释放。下面这个习惯可以避免大量后续排查时间:
# 在验证循环前主动释放缓存(不必须,但遇到OOM时好用) if torch.cuda.is_available(): torch.cuda.empty_cache()注意,empty_cache()只是清空未使用的缓存块,如果你的模型本身就超出显存,这个操作救不了你。此时考虑梯度累积或混合精度训练。
4. 网络模型选型:MobilenetV3与ResNet的工程权衡
4.1 这份工程里为何两者兼备
数据集里同时有mobile_net.py和resnet.py,这不是代码冗余,而是对应了两种完全不同的部署场景。MobileNetV3是为移动端设计的轻量网络,在参数精度和速度上做了大量优化,适合车载摄像头这类算力受限的设备;ResNet系列则是通用视觉任务的稳定基线,ResNet34在中等规模数据集上的表现和调试便利性都有保障。
4.2 修改头部分类器的正确姿势
使用torchvision.models库提供的预训练模型时,需要把模型最后的全连接层输出维度改成车型类别数:
# resnet.py 中修改分类头的做法 import torchvision.models as models def build_resnet34(num_classes, pretrained=True): model = models.resnet34(weights='IMAGENET1K_V1' if pretrained else None) in_features = model.fc.in_features # ResNet34的fc层输入维度是512,这里动态获取避免硬编码 model.fc = torch.nn.Linear(in_features, num_classes) return model同理,MobileNetV3的修改方式是替换model.classifier[3]这个线性层:
# mobile_net.py 中修改分类头的做法 def build_mobilenet_v3(num_classes, pretrained=True): model = models.mobilenet_v3_large(weights='IMAGENET1K_V1' if pretrained else None) in_features = model.classifier[-1].in_features model.classifier[-1] = torch.nn.Linear(in_features, num_classes) return model这两段代码的关键在于in_features的提取方式。很多人把512或1280这类数字写死,但换网络结构时就容易出错——动态获取永远比硬编码健壮。
4.3 冻结主干微调的两种策略
- 策略一:完全微调,所有层都参与梯度更新。适合数据集与ImageNet分布差距大的场景。
- 策略二:冻结部分层,只训练高层和分类头。显存占用低,训练快,适合小数据集。
在start.py中看到--freeze_backbone这个开关,实现方式如下:
# 冻结backbone参数的实现 if freeze_backbone: for name, param in model.named_parameters(): if 'fc' not in name and 'classifier' not in name: param.requires_grad = False # 需要手动将分类层的requires_grad置为True for param in model.fc.parameters(): param.requires_grad = True选择策略的参考标准:如果你的训练集每个类别低于500张图片,先试策略二,用小学习率(比如lr=1e-3)把分类头训到收敛,再解冻backbone用lr=1e-4微调几个epoch。
4.4 计算量与参数量的benchmark
这个工程里提供了flops_benchmark.py,这个工具值得每个做模型选型的人用起来:
# flops_benchmark.py 使用示例 from thop import profile import torch def compute_flops(model, input_size=(1, 3, 224, 224)): model.eval() dummy_input = torch.randn(input_size) flops, params = profile(model, inputs=(dummy_input,)) print(f"FLOPs: {flops / 1e9:.2f}G") print(f"Params: {params / 1e6:.2f}M")我在项目实践中用这个脚本做过对比:MobileNetV3-Large的FLOPs约是ResNet34的1/4到1/3,参数量也小一个量级。如果你的应用场景是服务器端离线识别,用ResNet34获取更高的精度上限;如果未来有端侧部署的可能性,MobileNetV3会让你后面的量化压缩工作轻松很多。
5. clr.py与logger.py:收敛控制与训练状态监控
5.1 循环学习率(CLR)为什么比固定学习率有效
在训练过程中,最让人头疼的不是模型不收敛,而是收敛到局部极小值后精度就再也不动了。clr.py实现的循环学习率策略不同于传统的StepLR或CosineAnnealingLR,它的核心思想是让学习率在一个范围内周期性变化,帮助模型跳出鞍点。
以triangular模式为例,学习率在base_lr和max_lr之间线性增加再线性减少:
# clr.py 简化的循环学习率实现 class CyclicLR: def __init__(self, optimizer, base_lr=1e-4, max_lr=1e-2, step_size=2000, mode='triangular'): self.optimizer = optimizer self.base_lr = base_lr self.max_lr = max_lr self.step_size = step_size self.mode = mode self.iteration = 0 def get_lr(self): cycle = int(self.iteration / (2 * self.step_size)) x = self.iteration - cycle * 2 * self.step_size if x <= self.step_size: ratio = x / self.step_size else: ratio = 1.0 - (x - self.step_size) / self.step_size if self.mode == 'triangular': return self.base_lr + (self.max_lr - self.base_lr) * abs(ratio) return self.base_lr def step(self): lr = self.get_lr() for param_group in self.optimizer.param_groups: param_group['lr'] = lr self.iteration += 1逻辑说明:step_size是半个周期包含的迭代步数,base_lr和max_lr构成学习率的上下边界。实际操作中,base_lr可以设为正常训练学习率的1/3或1/5,max_lr设为3到5倍。
使用CLR的经验参数表如下,这个配置在车型识别任务上表现稳定:
| 参数 | 建议值 | 说明 |
|---|---|---|
| base_lr | 1e-4 | 小于常规固定学习率,保证后期收敛稳定 |
| max_lr | 1e-3 | 大于常规学习率,加速前期收敛 |
| step_size | 2~4倍的每epoch迭代数 | 让学习率在一个epoch内完成上升和下降 |
| mode | triangular2 | 每个周期峰值递减,前期激进后期精细 |
需要提醒的是,训练结束前要把学习率降到base_lr附近再评估模型,否则在max_lr附近的参数状态并非最优。
5.2 logger.py如何组织训练日志
logger模块的核心职责是把训练过程中的标量数据记录下来,供后续绘图和分析使用。如果不做任何封装,直接print出来的信息在训练几十个epoch后基本没法追溯。
# logger.py 写入训练指标 import json, time from collections import OrderedDict class TrainLogger: def __init__(self, log_path): self.log_path = log_path self.history = OrderedDict() self.start_time = time.time() def log(self, epoch, train_loss, train_acc, val_loss, val_acc, lr): if epoch not in self.history: self.history[epoch] = {} self.history[epoch]['train_loss'] = train_loss self.history[epoch]['train_acc'] = train_acc self.history[epoch]['val_loss'] = val_loss self.history[epoch]['val_acc'] = val_acc self.history[epoch]['lr'] = lr self.history[epoch]['time'] = round(time.time() - self.start_time, 2) def save(self): with open(self.log_path, 'w') as f: json.dump(self.history, f, indent=2)这段代码做的事:每个epoch记录一次训练loss/acc、验证loss/acc,以及当前学习率,方便同时对比配置变更前后曲线的差异。日志文件用JSON格式保存,比txt文本更容易做后续的数据分析和可视化。
5.3 训练过程的实时可视化
如果只用命令行打印loss而不做可视化,你很难判断"loss下降变慢"是正常现象还是学习率设置有问题。TensorBoard是一个好方案,SummaryWriter在工程中对应的实现:
from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter(log_dir='runs/car_classification') # 在每个epoch结束时写入指标 writer.add_scalar('Loss/Train', train_loss, epoch) writer.add_scalar('Accuracy/Train', train_acc, epoch) writer.add_scalar('Loss/Validate', val_loss, epoch) writer.add_scalar('Accuracy/Validate', val_acc, epoch) writer.add_scalar('LR', current_lr, epoch) # 训练结束后关闭 writer.close()注意,如果发现TensorBoard页面里没有数据,第一件事是检查log_dir路径是否包含中文或空格,因为这会导致事件文件写入失败而不是报错。另外,多个实验共用同一个log_dir时会显示在同一组曲线中,可以在log_dir加时间戳后缀区分。
6. 训练后的验证:用它做一次端到端的精度评估
模型训练完成后,最容易被忽视的操作是在随机种子固定、不做任何增强的情况下评估最终精度。下面是利用工程中现有模块做推理验证的完整流程:
# 加载训练好的权重并做单张图片预测 import torch from PIL import Image from torchvision import transforms device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 构建与训练时完全一致的结构 model = build_mobilenet_v3(num_classes=12, pretrained=False) checkpoint = torch.load("checkpoints/epoch_50.pth", map_location=device) model.load_state_dict(checkpoint["model_state_dict"] if "model_state_dict" in checkpoint else checkpoint) model.to(device).eval() # 单张图片预处理 def predict_image(model, img_path, class_names): img = Image.open(img_path).convert("RGB") img_tensor = val_transform(img).unsqueeze(0).to(device) with torch.no_grad(): output = model(img_tensor) prob = torch.softmax(output, dim=1) top1_idx = torch.argmax(prob, dim=1).item() confidence = prob[0][top1_idx].item() return class_names[top1_idx], confidence class_names = ["奥迪A6L", "宝马3系", "大众迈腾", "丰田凯美瑞"] # 按训练时类别编号 # 注意:class_names的顺序必须和训练时class_to_idx一致这段验证代码的逻辑说明:加载权重时用map_location="cuda"或"cpu"做设备映射,避免从GPU训练切换到CPU推理时报错;torch.softmax加在模型输出后,得到每个类别的概率分布。取top1对应的类别索引,再从class_names列表反查类别名。
最后的class_names顺序必须严格与训练时的类别编号一致,否则预测结果就是错位的。一个稳妥办法是训练结束时把class_to_idx字典随checkpoint一起保存:
torch.save({ "model_state_dict": model.state_dict(), "class_to_idx": dataset_train.class_to_idx, "epoch": epoch, }, "checkpoints/epoch_50_with_meta.pth")后续做部署或二次开发时,直接读取class_to_idx恢复类别名映射,避免手动维护。结合flops_benchmark.py核算FLOPs、logger中记录的收敛曲线,这套工程可以作为一个标准模板,套用到其他细粒度图像分类任务。
本文还有配套的精品资源,点击获取