PyTorch图像分类实战:课堂行为识别中的数据整理、ResNet18微调与评估
简介基于Python深度学习的课堂行为图像识别分类项目源码适合作为毕业设计、期末大作业或图像分类入门实践面向有基础Python学习者也可作为算法实训与课程设计的参考案例。压缩包共1206个文件其中1183张jpg图片构成课堂行为数据集覆盖多种典型行为场景14个py文件为主要训练、测试与推理脚本并包含数据读取、模型构建、训练评估与结果可视化等模块另有少量png图示、md说明文档等整体约100MB目录结构清晰便于按模块对照学习。项目经过严格调试评审分达95分以上可确保直接运行代码划分规范便于二次开发能够帮助读者快速理解图像预处理、模型构建、训练评估以及分类结果可视化等完整流程进而迁移到其他图像分类任务。目前已有569人浏览学习适合需要完整项目参考或快速搭建图像识别任务的同学下载使用。1. 当教室照片只以行为命名深度学习分类项目该从哪入场这份基于 Python 的深度学习图像识别源码最初拿在手里就是一堆 JPGjiaoliu (1).jpg、sleep (4).jpg、xth0 (188).jpg类别挂在文件名前缀上既没有标注 CSV也没有划分好的训练验证目录。很多人拿到这种课堂行为识别项目会直接去挑模型但真正决定毕业设计或期末大作业分数的是数据整理、迁移学习和分类评估这条完整链路。我按工程顺序把项目重新走了一遍从原始图片目录做到混淆矩阵输出整体跑通后训练代码、验证代码和推理代码各自独立替换成自己的行为类别就能复用。这篇博文就是这一次拆解过程的记录重点放在 ImageFolder 数据组织、ResNet18 微调、类别不平衡处理以及分类报告解读上对正在做图像分类大作业的人可以直接照搬对已经在做分类任务的工程师也有几个可以带走的调试习惯。2. 原始 JPG 规范化用文件名映射出 torchvision 能直接读的目录先把图片从“文件名带类别”改成“目录即类别”的布局。PyTorch 里读取图像分类数据最省事的接口是torchvision.datasets.ImageFolder它要求目录长成数据集根目录/类别子目录/*.jpg的样子。ImageFolder会自动扫描子目录把每个目录名映射成整数标签比如jiaoliu对应 0sleep对应 1xth对应 2。之后DataLoader每轮迭代返回的是(图像张量, 标签索引)二元组训练循环里可以直接喂给模型不用自己写数据加载器。2.1 为什么不用 CSV 标注而用目录结构写 CSV 不是不行但要多维护路径与标签的对应关系路径写错一列就全乱。这个项目里文件名本身带类别前缀用一段脚本整理目录比手写标注表更不容易出错。目前源码里主要有三类课堂行为我沿用原始命名把图片归到jiaoliu、sleep、xth三个子目录。整理脚本如下。import shutil from pathlib import Path src_dir Path(raw_images) dst_root Path(dataset) class_map { jiaoliu: jiaoliu, sleep: sleep, xth: xth, } for img_path in src_dir.glob(*.jpg): for prefix, folder in class_map.items(): if img_path.stem.startswith(prefix): dst_dir dst_root / folder dst_dir.mkdir(parentsTrue, exist_okTrue) shutil.copy2(img_path, dst_dir / img_path.name) break这段脚本遍历raw_images下所有 jpg用img_path.stem拿到的文件名主体判断前缀属于哪个类再复制到dataset对应子目录。这里用copy2保留原文件避免整理过程中误删原始照片如果只是几百张图复制不会带来磁盘压力。startswith对xth0 (188).jpg这种文件也有效因为文件名是以xth开头的。后面那个break保证一张图只落进一个类别目录不会重复复制。2.2 看一眼类别数量避免训练到一半才发现不平衡目录整理完以后先用ImageFolder扫一次统计每个类的实际数量。脆弱状态下来回改数据是项目里最消耗时间的操作这一步能提前暴露问题。from collections import Counter from torchvision.datasets import ImageFolder dataset ImageFolder(dataset) counts Counter(label for _, label in dataset.samples) print(counts)输出大致会是这样Counter({0: 296, 1: 38, 2: 188})如果看到sleep类只有 38 张而jiaoliu有 296 张那么后面不管训练什么模型整体都会偏向数量多的类。提前知道这个比例才能在第 4 章对症处理。很多人一上来就把图片直接塞进训练脚本等跑完几个 epoch 才发现验证集里sleep全错再回头补数据增强和损失函数加权浪费的时间比写这段统计脚本多得多。2.3 拆分训练集和验证集留意课堂场景的人物重叠课堂行为图片有个特殊性同一批学生可能连续出现在多帧画面里。如果随机把 20% 的图片放进验证集验证集里很可能出现和训练集高度相似甚至几乎相同的人脸和座位模型在验证集上表现好其实是被数据泄漏撑起来的。常见的做法是按文件粒度随机分配同时固定随机种子保证每次拆分结果一致。下面是拆分脚本。import random import shutil from pathlib import Path random.seed(42) train_ratio 0.8 train_root Path(train) val_root Path(val) for class_dir in Path(dataset).iterdir(): if not class_dir.is_dir(): continue images list(class_dir.glob(*.jpg)) random.shuffle(images) split int(len(images) * train_ratio) for img in images[:split]: dst train_root / class_dir.name / img.name dst.parent.mkdir(parentsTrue, exist_okTrue) shutil.move(str(img), str(dst)) for img in images[split:]: dst val_root / class_dir.name / img.name dst.parent.mkdir(parentsTrue, exist_okTrue) shutil.move(str(img), str(dst))train_ratio取 0.8 是分类任务最常见的配置训练和验证的比例不会差距过大。对sleep这种只有 38 张的类0.8 意味着验证集约 7 张偏少。这种情况我会把sleep单独多复制几次到训练集或者改用sklearn.model_selection.StratifiedKFold做分层切分少类样本在每一折里能保住一个固定比例。另外注意拆分一定要发生在数据增强之前增强只在训练脚本内部做不要写进原始文件否则验证集也被增强污染了。2.4 训练与验证用两套 transforms参数不要一套到底ImageFolder返回的是 PIL 图像需要统一尺寸和归一化。ResNet 系列的默认输入是 224x224用Resize拉过去Normalize的均值和标准差是 ImageNet 预训练模型的统计量这两个数组是固定的。如果为了“好看”随手改成 0.5加载预训练权重以后特征分布对不上微调效果会变差。from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])RandomHorizontalFlip概率取 0.5对课堂场景是安全的交流、睡觉这类行为水平翻转后语义不变相当于白送一倍样本。RandomRotation(10)只转 10 度超过 15 度时课桌和人体的几何形态容易失真分类器会被迫去学旋转噪声。ColorJitter的 brightness 和 contrast 都设 0.2模拟不同教室光照条件下拍摄的明暗变化数值再大画面就会偏色。验证集不能用随机翻转和随机旋转因为验证阶段需要对同一张图给出稳定预测所有的Random*增强都要去掉只保留 Resize、ToTensor 和 Normalize。3. ResNet18 迁移学习课堂行为分类的模型选型与训练参数课堂行为识别从视觉角度是图像分类但类别之间的界限不在物体纹理上而在姿态、动作和人物相对位置上。自定义 CNN 从零训练需要大量数据才能把这些线索学稳课堂行为数据集的图片总量常常只有几百张类别还不均衡。用 5 到 7 层卷积从零开始训练往往训练集准确率能到 100%验证集却只有 70% 上下属于典型的过拟合掩盖了泛化能力不足。3.1 为什么自定义 CNN 在这个项目里不划算在有限的课堂行为数据上直接用预训练模型做迁移学习是最有效的做法。ResNet18 在 ImageNet 上预训练过底层卷积已经学会了边缘、纹理、人脸轮廓这些通用特征迁移到课堂场景时网络只需要在最后几层重新组合特征来区分行为对数据量的需求大幅下降。课堂图像通常不是单目标但画面里的行为语义整体稳定ResNet18 这种中等深度的网络已经能学到“趴桌”和“低头写东西”的差异不一定非要上 ResNet50。ResNet50 参数量更大在这个几百张图的体量下更容易过拟合而且训练和推理都更慢。3.2 特征层冻结与最后一层替换PyTorch 里加载预训练 ResNet18 后先把所有参数requires_grad设成 False再把最后一层换成对应类别数的全连接层。import torch.nn as nn from torchvision import models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_classes 3 for param in model.parameters(): param.requires_grad False model.fc nn.Linear(model.fc.in_features, num_classes)用weightsmodels.ResNet18_Weights.IMAGENET1K_V1比旧的pretrainedTrue更清晰也避免了 torchvision 版本升级后参数名变化带来的警告。model.fc.in_features在 ResNet18 里是 512这里直接读取属性之后想换 ResNet50 或者 ResNet34 都不用改全连接层参数。requires_grad False意味着反向传播只更新最后这个新fc层的参数训练速度快也不会因为样本太少把预训练权重搞坏。冻结训练几个 epoch 之后可以把最后的layer4解冻做微调。for name, param in model.named_parameters(): if name.startswith(layer4): param.requires_grad True这样只有layer4和fc参与参数更新前面的层继续作为 ImageNet 的稳定特征提取器。课堂行为的高层语义差异主要在layer4这个尺度上体现解冻这一层够了。如果想把layer3也打开sleep类样本太少时过拟合风险会明显增加一般不建议一上来就全量微调。3.3 损失函数、优化器与学习率调节import torch import torch.optim as optim criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr1e-3, momentum0.9, weight_decay5e-4) scheduler optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.1)CrossEntropyLoss把 softmax 和对数损失合并在一起模型输出层给 logits 就行不需要额外接 softmax。SGD 的 momentum 0.9 是 ResNet 训练时的经典配置weight_decay5e-4 用来压住全连接层的过拟合。学习率 1e-3 对微调比较稳如果发现验证 loss 振荡明显就降到 5e-4。StepLR每 5 个 epoch 把学习率乘 0.1也就是从 1e-3 到 1e-4 再到 1e-5让网络在后期能更精细地调整权重。3.4 训练循环别忘了 train/eval 切换训练主循环是标准 PyTorch 流程最容易忽略的是model.train()和model.eval()的切换。from torchvision import datasets from torch.utils.data import DataLoader train_dataset datasets.ImageFolder(train, transformtrain_transform) val_dataset datasets.ImageFolder(val, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers2) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers2) for epoch in range(15): model.train() for inputs, labels in train_loader: optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() scheduler.step() model.eval() correct 0 total 0 with torch.no_grad(): for inputs, labels in val_loader: outputs model(inputs) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) print(fepoch {epoch 1} | val acc {correct / total:.4f})batch_size32在 224x224 输入下占显存不多显卡只有 4GB 时改成 16梯度噪声会变大一些。训练集shuffleTrue验证集shuffleFalse避免每次验证顺序波动影响 BatchNorm 统计量。model.eval()一行不能省它让 BatchNorm 使用训练阶段累积的全局均值与方差如果忘了切验证准确率会忽高忽低甚至比训练还好看。torch.no_grad()让验证阶段不保留计算图节省显存也加速推断。4. 分类评估与调试混淆矩阵、类别不平衡和日志排查验收课堂行为识别项目时只用 accuracy 会掩盖很多结构性问题。假设jiaoliu有 296 张、sleep38 张、xth188 张模型把所有图都判成jiaoliuaccuracy 已经能到 56% 以上。把这种结果交上去看起来是跑通了实际上没有任何分类能力。正确的做法是收集验证集所有预测结果输出分类报告和混淆矩阵。4.1 验证集上的分类报告比 accuracy 更能看出问题from sklearn.metrics import classification_report, confusion_matrix model.eval() all_preds [] all_labels [] with torch.no_grad(): for inputs, labels in val_loader: outputs model(inputs) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) target_names [jiaoliu, sleep, xth] print(classification_report(all_labels, all_preds, target_namestarget_names)) print(confusion_matrix(all_labels, all_preds))classification_report输出每个类的 precision、recall、f1-score 以及总体统计量。课堂行为识别里我更看重 recall因为它反映的是“这一类行为有没有被漏掉”。sleep 的 recall 如果低于 0.8说明大量睡觉的照片被误判成其他行为这在行为分析场景中是明显失败。混淆矩阵能进一步看出到底和哪个类混淆常见的是 sleep 被误判为 jiaoliu因为侧趴睡觉的姿态在正面视角下和低头交流很接近。4.2 sleep 类样本太少时的两个处理方向sleep 类只有几十张图时改网络结构没有意义我会先做两件事给 CrossEntropyLoss 按类别数量加权以及用 WeightedRandomSampler 过采样少类样本。class_counts torch.tensor([296, 38, 188], dtypetorch.float32) class_weights class_counts.sum() / class_counts criterion nn.CrossEntropyLoss(weightclass_weights.to(device))这段代码的思路是用类别总数除以每个类数量作为权重样本越少的类权重越大损失函数对误判 sleep 的惩罚比对误判 jiaoliu 更高。另一种方案是在采样层过采样。from torch.utils.data import WeightedRandomSampler class_counts torch.tensor([296, 38, 188], dtypetorch.float32) sample_weights [1.0 / class_counts[label] for _, label in train_dataset.samples] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader DataLoader(train_dataset, batch_size32, samplersampler)WeightedRandomSampler让每个样本被抽到的概率与类别权重成正比这样在一个 epoch 内 sleep 会被多次采样模型看到少类的频率明显增加而且不需要把图片真实复制到磁盘。两种方法可以同时用但注意 CrossEntropyLoss 的weight如果给太极端训练后期 loss 抖动会变大。我一般把权重整体开根号做一次平滑既保留了少类惩罚又不至于让梯度过于震荡。4.3 训练日志里该盯哪些指标训练时不要把每个 batch 的 loss 都打印出来只看每个 epoch 的平均 loss同时把验证准确率一起输出。下面几个现象是我在实际调试中总结出的一套判断逻辑。观察到的现象可能原因调整动作训练 loss 下降但验证 loss 上升过拟合增大 weight_decay 到 1e-3 或减少解冻层训练准确率 100%验证准确率不到 80%严重过拟合回到只训练 fc 层的冻结方案验证 loss 先降后升再降学习率偏大初始学习率降到 5e-4或换余弦退火验证 loss 几乎不下降类别不平衡主导梯度给 CrossEntropy 加 class weight 或换 WeightedRandomSampler验证准确率在 90% 附近反复横跳验证集样本太少改用 StratifiedKFold或减少验证集比例此外选择最佳模型时不要只看验证准确率我用 macro-f1 当选择标准因为它在类别不平衡时对少类样本更敏感。每个 epoch 结束后如果 f1 高于历史最佳就保存当前权重。torch.save(model.state_dict(), fbest_f1_{best_f1:.3f}.pth)保存后缀带上验证指标比如best_f1_0.93.pth评审时一眼就能看出你做了模型选择而不只是随便保存最后一个 epoch。5. 从验证集到真实教室图片单张推理与日志落盘5.1 推理脚本里 transforms 必须和验证一致训练结束后单独留一个推理脚本不依赖训练循环直接加载 state_dict 对单张 JPG 分类。import torch from PIL import Image def load_model(model, weights_path): model.load_state_dict(torch.load(weights_path, map_locationcpu)) model.eval() return model def infer(model, image_path, tf, class_names): img Image.open(image_path).convert(RGB) img tf(img).unsqueeze(0) with torch.no_grad(): logits model(img) prob torch.softmax(logits, dim1)[0] idx torch.argmax(prob).item() return class_names[idx], prob[idx].item()map_locationcpu让模型在没有 GPU 的机器上也能跑。推理时不要用带随机翻转的训练 transform否则同一张图两次推理结果不同必须用和验证阶段完全一致的val_transform。很多隐蔽 bug 来自这里比如训练时验证和推理用了不同尺寸训练集上显示 95% 准确率实际单张图片推理只有 60%。5.2 当模型拿不准时打印 top-2 概率课堂行为的类别边界经常很模糊。侧头交流时手撑着头和睡觉的姿势可能在视觉上几乎没有分界线模型输出 jiaoliu 概率 0.52、sleep 概率 0.41 是很正常的情况。只看 top-1 类别名很容易被当成错误实际上模型已经表达出了不确定性。把 top-2 打出来能让调试更有针对性。probs, idxs torch.topk(prob, k2) for p, i in zip(probs, idxs): print(f{class_names[i.item()]}: {p.item():.4f})top-2 概率差距在 0.1 以内时我会回到数据层面检查看看是不是这两类照片的拍摄角度太一致增强策略没有覆盖应有的姿态变化。把这个分析写进项目说明比硬调阈值或强行改标签更能体现对图像识别问题的理解。5.3 日志同时写终端和磁盘训练脚本跑起来以后终端输出很容易被编辑器或 ssh 会话打断冲掉。我习惯用一条命令把标准输出和错误同时写到文件。python train.py 21 | tee train.log21把标准错误也重定向到标准输出tee同时写终端和train.log。这样即使训练中断或者终端关闭日志文件里也保留了每个 epoch 的 loss 和验证指标。后续复盘类别不平衡处理前后的对比直接翻日志比重新训练快得多这也是整个项目收尾时最实用的工程习惯。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →