CV 医学影像分类、分割、目标检测,之【血细胞分类】项目拆解
- 医学影像分类
- 导入包
- 第1行:`from PIL import Image`
- 第2行:`import torch`
- 第3行:`from torch.utils import data`
- 第4行:`import numpy as np`
- 第5行:`from torchvision import transforms`
- 第6行:`import torchvision`
- 第7行:`import matplotlib.pyplot as plt`
- 第8行:`import torch.nn.functional as F`
- 第9行:`import torch.nn as nn`
- 第10行:`from tqdm import tqdm`
- 第12-21行:train_transformer定义
- 第13行:`transforms.RandomHorizontalFlip(0.2)`
- 第14行:`transforms.RandomRotation(68)`
- 第15行:`transforms.RandomGrayscale(0.2)`
- 第16行:`transforms.Resize((256,256))`
- 第17行:`transforms.ToTensor()`
- 第18-20行:`transforms.Normalize(mean=[0.5,0.5,0.5], std=[0.5,0.5,0.5])`
- 第22-29行:test_transformer定义
- 第32-36行:加载训练数据集
- 第38-42行:加载测试数据集
- 第45行:查看类别名称
- 第47行:查看类别索引映射
- 第49-53行:创建反向映射
- 第55行:设置批量大小
- 第56-61行:创建训练数据加载器
- 第62-67行:创建测试数据加载器
- 第69行:获取一批数据
- 第71行:取出第一张图片
- 第73行:调整维度顺序
- 第75行:有问题的代码
- 第76行:数据反标准化
- 第77行:显示单张图片
- 第78行:创建多子图画布
- 第79-85行:批量显示图片
- 分解:plt.subplot(2,4,i+1)
- 分解:plt.title(id_to_class.get(label.item()))
- 分解:plt.imshow(img)
- 第86行:定义神经网络类
- 第87行:初始化函数
- 第88行:调用父类初始化
- 第90-94行:定义第一层
- 分解:nn.Conv2d(3,32,kernel_size=3)
- 分解:nn.ReLU()
- 分解:nn.MaxPool2d(2,2)
- 第95-99行:定义第二层
- 第100-104行:定义第三层
- 第106-111行:定义第四层
- 第112-116行:定义全连接层
- 第118行:定义前向传播函数
- 第119-122行:前向传播过程
- 第123行:打印调试信息
- 第125行:特征展平
- 第126行:全连接分类
- 第128行:返回结果
- 第130行:获取测试数据
- 第132行:创建模型实例
- 第133行:测试前向传播
- 第134行:查看预测结果
- 第135行:定义优化器
- 第136行:定义损失函数
- 第137-139行:GPU设置
- 第141行:定义训练函数
- 第142-144行:初始化统计变量
- 第145行:设置训练模式
- 第146行:开始训练循环
- 第147行:数据移到GPU
- 第148行:前向传播
- 第149行:计算损失
- 第150行:清零梯度
- 第151行:反向传播
- 第152行:更新参数
- 第154行:开始无梯度计算
- 第155行:获取预测类别
- 第156行:统计正确数量
- 第157-158行:统计总数和损失
- 第160-161行:计算训练epoch统计
- 第164-166行:初始化测试统计
- 第167行:设置评估模式
- 第168行:无梯度测试
- 第169行:测试循环
- 第170-171行:测试数据移到GPU
- 第172-175行:测试统计
- 第177-178行:计算测试epoch统计
- 第180-182行:保存优秀模型
- 第185-191行:打印训练结果
- 第193行:返回统计结果
- 第195行:设置训练轮数
- 第197-201行:初始化记录列表
- 第203-210行:主训练循环
- 第212-214行:绘制训练曲线
- 第216行:保存完整模型
- 第218-219行:加载模型测试
- 第221-222行:获取测试数据
- 第224-227行:模型预测
- 第229行:获取预测类别
- 第230-235行:转换预测结果
- 第238-239行:查看前8个预测
- 第241行:创建可视化画布
- 第242-248行:可视化预测结果
- 整体代码分析:
- ---
- 数据增强适用性分析
医学影像分类
数据可以在百度飞桨搜索医学影像分类数据集。
主解法:卷积神经网络图像分类
- 卷积操作:y = f(W * x + b),其中*表示卷积运算
- 池化操作:降采样 y = max(x_region) 或 avg(x_region)
- 全连接:y = Wx + b
- 损失函数:CrossEntropy = -Σ y_true * log(y_pred)
问题特征:多类别图像分类问题,需要从血细胞图像中识别4种不同类型
与同类算法的主要区别:相比传统机器学习方法,CNN能自动提取图像特征;相比更复杂的网络(如ResNet),这是基础的4层卷积结构
解法 = 数据预处理子解法(图像标准化特征) + 特征提取子解法(空间层次特征) + 分类决策子解法(高维映射特征)
子解法1:数据预处理
- 之所以用数据增强和标准化,是因为医学图像数据量有限且需要统一输入格式的特征
- 例子:RandomHorizontalFlip(0.2) 增加数据多样性
子解法2:层次特征提取
- 之所以用多层卷积+池化,是因为血细胞形态具有从局部纹理到整体形状的空间层次特征
- 例子:第1层提取边缘,第4层提取完整细胞形态
子解法3:分类映射
- 之所以用全连接层,是因为需要将空间特征映射到类别概率空间的高维变换特征
- 例子:2561414 → 1024 → 4的降维分类
- 子解法逻辑链分析
决策树形式:
输入图像
├── 数据预处理分支
│ ├── 训练时:增强变换
│ └── 测试时:标准变换
├── 特征提取分支(串联链条)
│ ├── Layer1: Conv3→ReLU→MaxPool (3→32通道)
│ ├── Layer2: Conv3→ReLU→MaxPool (32→64通道)
│ ├── Layer3: Conv3→ReLU→MaxPool (64→128通道)
│ └── Layer4: Conv3→ReLU→MaxPool (128→256通道)
└── 分类决策分支
├── 特征展平
├── FC1: 256*14*14→1024
└── FC2: 1024→4
逻辑关系:链条式串联,每层输出作为下层输入
- 隐性方法分析
发现的隐性方法:渐进式通道扩张法
关键步骤定义:
- 通道数按2的幂次递增(3→32→64→128→256)
- 空间维度同步递减(256→128→64→32→16→14)
- 这不是标准教科书方法,而是经验性的特征提取策略
隐性方法本质: 用通道扩张补偿空间信息损失,实现信息密度的重新分布
- 隐性特征分析
发现的隐性特征:特征图尺寸自适应特征
逐行对比分析:
# 隐性步骤组合(第89-96行)
x=self.layer1(x) # 256→127
x=self.layer2(x) # 127→62
x=self.layer3(x) # 62→30
x=self.layer4(x) # 30→14
print(x.shape) # 监控维度变化
x=x.view(-1,256*14*14) # 基于最终尺寸展平
隐性特征定义: 这种连续的尺寸变化形成了"特征密度梯度",不是单纯的降维,而是信息重组过程
方法的潜在局限性
架构局限: 固定的4层结构可能对复杂细胞形态表达能力不足
泛化局限: 缺乏残差连接,深度受限,可能存在梯度消失
数据局限: 简单的数据增强可能不足以应对真实医疗环境的变异
解释性局限: 缺乏注意力机制,难以解释分类依据
鲁棒性局限: 对图像质量、光照条件敏感
多题一解的通用解题思路
通用特征: 具有空间层次结构的多类别视觉识别问题
共用解法名称: 层次卷积特征提取法
适用题目类型:
- 医学影像分类(X光、CT、细胞学)
- 物体识别(小样本、形态差异明显)
- 质量检测(缺陷分类、等级判定)
解题流程:
- 数据标准化与增强
- 多尺度卷积特征提取
- 池化降维与信息压缩
- 全连接分类映射
- 交叉熵损失优化
核心思想: 通过卷积层次提取从细节到整体的视觉特征,用池化控制信息流,最终映射到语义空间进行分类决策。
导入包
from PIL import Image
import torch
from torch.utils import data
import numpy as np
from torchvision import transforms
import torchvision
import matplotlib.pyplot as plt
import torch.nn.functional as F
import torch.nn as nn
from tqdm import tqdm
第1行:from PIL import Image
问1:为什么要导入PIL的Image?
答1:因为需要处理图片文件。
问2:什么是PIL?
答2:Python Imaging Library,Python的图像处理库。
问3:为什么叫"库"?
答3:因为它是别人写好的工具集合,就像现实中的图书馆,我们可以借用里面的书(功能)。
问4:Image具体能做什么?
答4:能打开、保存、修改图片格式。
问5:为什么这里导入了Image但后面没直接用?
答5:因为torchvision内部会调用PIL来处理图片,这是依赖关系。
第2行:import torch
问1:torch是什么?
答1:PyTorch深度学习框架的核心模块。
问2:什么是"框架"?
答2:就像盖房子的框架,提供基本结构,让你在上面搭建自己的模型。
问3:为什么叫torch?
答3:因为它是Torch(火炬)的Python版本,寓意照亮AI之路。
问4:torch和tensorflow有什么区别?
答4:torch更灵活,像乐高积木可以随意拼装;tensorflow更像预制板房,结构固定但高效。
第3行:from torch.utils import data
问1:utils是什么意思?
答1:utilities的缩写,工具的意思。
问2:为什么要单独导入data?
答2:因为data模块专门处理数据加载,避免导入整个utils。
问3:data模块具体做什么?
答3:把数据变成深度学习模型能"吃"的格式,就像把食材切好装盘。
问4:为什么深度学习需要特殊的数据格式?
答4:因为模型需要批量处理,就像工厂流水线需要统一规格的零件。
第4行:import numpy as np
问1:为什么要导入numpy?
答1:因为需要处理数字数组运算。
问2:为什么起别名np?
答2:因为numpy太长,np是约定俗成的简写,就像"人工智能"简称"AI"。
问3:numpy和Python原生list有什么区别?
答3:numpy像专业计算器,运算快;list像算盘,功能全但慢。
问4:为什么图像处理需要numpy?
答4:因为图像本质是数字矩阵,numpy专门处理矩阵运算。
第5行:from torchvision import transforms
问1:torchvision是什么?
答1:torch的视觉处理扩展包,专门处理图像数据。
问2:为什么叫vision?
答2:vision是视觉的意思,这个包专门给计算机"眼睛"用的。
问3:transforms是什么意思?
答3:变换的意思,把图片从一种形态变成另一种形态。
问4:为什么图片需要变换?
答4:因为原始图片大小不一、格式不同,需要统一"包装"才能喂给模型。
问5:这和PS修图有什么区别?
答5:PS是手动美化;transforms是自动标准化,目的不是美观而是让机器更好理解。
第6行:import torchvision
问1:为什么第5行已经导入transforms,这里还要导入整个torchvision?
答1:因为后面还需要用torchvision的其他功能,比如datasets。
问2:这样导入会不会重复?
答2:不会,Python很聪明,重复导入只执行一次。
问3:为什么不一开始就导入整个torchvision?
答3:这是编程习惯,先导入具体需要的,再导入整体,代码更清晰。
第7行:import matplotlib.pyplot as plt
问1:matplotlib是干什么的?
答1:Python的画图库,就像Excel的图表功能。
问2:pyplot是什么?
答2:matplotlib的画图接口,让画图变简单。
问3:为什么叫plt?
答3:plot(画图)的缩写,约定俗成的简称。
问4:为什么机器学习需要画图?
答4:因为人眼比数字更容易看出规律,画图帮助理解数据和结果。
问5:这和Excel画图有什么区别?
答5:Excel适合简单图表;matplotlib像专业绘图软件,能画复杂的科学图形。
第8行:import torch.nn.functional as F
问1:nn是什么缩写?
答1:neural network,神经网络。
问2:functional是什么意思?
答2:函数式的,提供各种现成的神经网络函数。
问3:为什么叫F?
答3:functional的首字母,简洁明了。
问4:functional和面向对象有什么区别?
答4:functional像工具箱,拿来即用;面向对象像搭积木,需要先组装。
问5:这个F后面会用来做什么?
答5:提供激活函数、损失函数等,就像调料包,给模型"调味"。
第9行:import torch.nn as nn
问1:这个nn和上面F里的nn有什么区别?
答1:这个nn是类(Class),F是函数(Function);nn用来搭建模型结构,F用来执行具体操作。
问2:为什么需要两套工具?
答2:就像盖房子,nn是砖头水泥(材料),F是锤子电钻(工具)。
问3:什么时候用nn,什么时候用F?
答3:定义模型结构用nn,临时计算用F。
第10行:from tqdm import tqdm
问1:tqdm是什么?
答1:一个进度条库,显示程序运行进度。
问2:为什么需要进度条?
答2:因为深度学习训练很慢,进度条让人知道还要等多久,避免焦虑。
问3:为什么导入两次tqdm?
答3:第一个是模块名,第二个是函数名,就像"从张三家里借张三"。
问4:没有进度条会怎么样?
答4:程序还是会跑,只是不知道进度,就像坐没有报站的公交车。
第12-21行:train_transformer定义
训练数据不足导致模型过拟合和泛化能力差
数据增强解法 = 几何变换增强(因为图像空间不变性特征) + 颜色空间增强(因为光照不变性特征) + 尺寸标准化(因为输入统一性特征) + 数值归一化(因为数值稳定性特征)
子解法1:几何变换增强
- RandomHorizontalFlip(0.2) - 随机水平翻转
- RandomRotation(68) - 随机旋转
- 之所以用几何变换,是因为图像空间不变性特征:同一物体在不同角度、翻转状态下本质相同
例子:一张猫的照片,向左看和向右看、旋转30度,仍然是猫
子解法2:颜色空间增强
- RandomGrayscale(0.2) - 随机灰度化
- 之所以用颜色变换,是因为光照不变性特征:同一物体在不同光照条件下本质相同
例子:一朵红玫瑰,在黑白照片中仍然是玫瑰
子解法3:尺寸标准化
- Resize((256,256)) - 统一图像尺寸
- 之所以用尺寸标准化,是因为输入统一性特征:神经网络需要固定维度输入
例子:无论原图是1920x1080还是800x600,都要变成256x256才能输入网络
子解法4:数值归一化
- ToTensor() + Normalize(mean=[0.5,0.5,0.5], std=[0.5,0.5,0.5])
- 之所以用数值归一化,是因为数值稳定性特征:将[0,255]像素值转换为[-1,1]范围,避免梯度爆炸
例子:原始RGB值(255,128,64)转换为(1.0,0.0,-0.5)
train_transformer = transforms.Compose([
transforms.RandomHorizontalFlip(0.2), # 以20%的概率随机水平翻转图像
transforms.RandomRotation(68), # 随机旋转图像,角度范围为-68到68度
transforms.RandomGrayscale(0.2), # 以20%的概率将图像转换为灰度图
transforms.Resize((256,256)), # 将图像尺寸调整为256x256像素
transforms.ToTensor(), # 将PIL图像转换为张量格式
transforms.Normalize(mean=[0.5,0.5,0.5], std=[0.5,0.5,0.5]) # 标准化张量,将像素值从[0,1]范围转换为[-1,1]范围
])
问1:Compose是什么意思?
答1:组合的意思,把多个变换操作串联起来。
问2:为什么要串联?
答2:就像工厂流水线,一张图片依次经过多道工序处理。
问3:这和函数嵌套有什么区别?
答3:Compose更优雅,避免了f(g(h(x)))这种难读的嵌套写法。
第13行:transforms.RandomHorizontalFlip(0.2)
问1:RandomHorizontalFlip做什么?
答1:随机水平翻转图片,就像照镜子。
问2:0.2是什么意思?
答2:概率,表示20%的图片会被翻转。
问3:为什么要翻转?
答3:因为血细胞左右翻转后还是同一类,增加数据多样性。
问4:什么是数据增强?
答4:用各种变换人工制造更多训练数据,就像一个苹果拍出多个角度的照片。
问5:为什么不设置50%或100%?
答5:太高会让模型过度依赖翻转,太低增强效果不明显,20%是经验值。
第14行:transforms.RandomRotation(68)
问1:RandomRotation做什么?
答1:随机旋转图片。
问2:68是什么单位?
答2:角度,表示最大旋转68度。
问3:为什么选择68度而不是90度?
答3:68度让细胞看起来还是自然的,90度可能过度失真。
问4:旋转会不会改变细胞的特征?
答4:不会改变本质特征,因为显微镜下的细胞本来就可能以任何角度出现。
问5:这个旋转是双向的吗?
答5:是的,可以顺时针也可以逆时针,范围是[-68, +68]度。
第15行:transforms.RandomGrayscale(0.2)
问1:RandomGrayscale做什么?
答1:随机把彩色图片变成灰度图片。
问2:为什么要变灰度?
答2:让模型学会不依赖颜色信息,专注形状特征。
问3:血细胞不是有颜色特征吗?
答3:有,但形状更重要,防止模型过度依赖颜色而忽略形状。
问4:灰度图和彩色图在计算上有什么区别?
答4:灰度图只有1个通道,彩色图有3个通道(RGB),计算量更小。
问5:20%的概率合适吗?
答5:合适,保留了大部分颜色信息,又增加了对形状的关注。
第16行:transforms.Resize((256,256))
问1:Resize做什么?
答1:把图片调整到指定大小。
问2:为什么选择256×256?
答2:这是2的8次方,计算机处理2的幂次大小更高效。
问3:原始图片大小不一样怎么办?
答3:都会被强制拉伸或压缩到256×256,可能会轻微变形。
问4:为什么不保持原始比例?
答4:因为神经网络需要固定输入尺寸,就像工厂模具要求统一规格。
问5:变形会不会影响识别?
答5:轻微变形影响不大,因为细胞大致是圆形,拉伸后还是椭圆。
第17行:transforms.ToTensor()
问1:ToTensor做什么?
答1:把PIL图片转换成PyTorch张量。
问2:什么是张量?
答2:多维数组,就像Excel的三维表格。
问3:为什么要转换?
答3:因为PyTorch模型只能处理张量,不能直接处理图片。
问4:转换过程发生了什么?
答4:像素值从0-255范围变成0-1范围,同时改变数据结构。
问5:为什么要除以255?
答5:标准化到[0,1]区间,让数值更适合神经网络计算。
第18-20行:transforms.Normalize(mean=[0.5,0.5,0.5], std=[0.5,0.5,0.5])
问1:Normalize做什么?
答1:标准化,让数据分布更适合神经网络。
问2:mean=[0.5,0.5,0.5]是什么意思?
答2:RGB三个通道的平均值都设为0.5。
问3:std=[0.5,0.5,0.5]是什么意思?
答3:RGB三个通道的标准差都设为0.5。
问4:为什么都设为0.5?
答4:这样标准化后数值范围变成[-1,1],以0为中心,神经网络更容易学习。
问5:标准化的数学公式是什么?
答5:(像素值 - 0.5) / 0.5,把[0,1]区间映射到[-1,1]区间。
问6:为什么神经网络喜欢[-1,1]区间?
答6:因为激活函数(如tanh)在0附近梯度最大,学习效果最好。
第22-29行:test_transformer定义
处理阶段 | 是否使用随机变换 | 目的 | 处理步骤 |
---|---|---|---|
训练时 | ✅ 大量随机变换 | 增加数据多样性,防止过拟合 | 随机翻转+旋转+颜色变换+尺寸调整+归一化 |
测试时 | ❌ 无随机变换 | 保证结果一致性和可重复性 | 仅尺寸调整+格式转换+归一化 |
为什么测试时不用随机变换?
- 一致性要求:同一张图片每次测试都应该得到相同结果
- 公平比较:所有测试样本使用相同的预处理标准
- 真实场景:实际应用时用户输入的图片不会被随机变换
test_transformer = transforms.Compose([
transforms.Resize((256,256)), # 将图像尺寸调整为256x256像素
transforms.ToTensor(), # 将PIL图像转换为张量格式
transforms.Normalize(mean=[0.5,0.5,0.5], std=[0.5,0.5,0.5]) # 标准化张量,将像素值从[0,1]范围转换为[-1,1]范围
])
问1:test_transformer和train_transformer有什么区别?
答1:test_transformer少了前3个数据增强操作。
问2:为什么测试时不需要数据增强?
答2:因为测试时要看模型的真实性能,不能"作弊"改变原始数据。
问3:这就像考试和练习的区别吗?
答3:对!练习时可以用各种方法(数据增强),考试时必须原题原样(原始数据)。
问4:为什么还要保留Resize、ToTensor、Normalize?
答4:因为这些是必需的格式转换,不是数据增强。
问5:如果测试时也用数据增强会怎样?
答5:结果会不稳定,同一张图可能每次预测不同,无法评估真实性能。
第32-36行:加载训练数据集
# 使用ImageFolder类加载图像分类数据集
train_dataset = torchvision.datasets.ImageFolder(
# 参数1: root - 数据集根目录路径
# 这个路径下应该包含按类别分组的子文件夹
'E:/blood-cells/dataset2-master/dataset2-master/images/TRAIN',
# 参数2: transform - 数据预处理/增强函数
# 将之前定义的训练变换应用到每张图像上
transform=train_transformer
)
问1:ImageFolder是什么?
答1:PyTorch提供的数据集加载器,专门处理按文件夹分类的图片。
问2:为什么叫ImageFolder?
答2:因为它假设每个子文件夹代表一个类别。
问3:文件夹结构是怎样的?
答3:TRAIN文件夹下有4个子文件夹,每个代表一种血细胞类型。
问4:ImageFolder怎么知道哪个文件夹是哪个类别?
答4:根据文件夹名字自动分配,按字母顺序编号0,1,2,3。
问5:transform=train_transformer做什么?
答5:告诉ImageFolder加载图片时要用哪种变换处理。
问6:这个路径只在这台电脑有效吗?
答6:是的,这是绝对路径,换电脑需要修改。
第38-42行:加载测试数据集
test_dataset=torchvision.datasets.ImageFolder(
'E:/blood-cells/dataset2-master/dataset2-master/images/TEST',
transform=test_transformer
)
问1:这和train_dataset有什么区别?
答1:路径不同(TEST vs TRAIN),变换不同(test_transformer vs train_transformer)。
问2:为什么要分开训练集和测试集?
答2:避免"背答案",就像学生不能用考试题来练习。
问3:TEST文件夹结构和TRAIN一样吗?
答3:应该一样,都有相同的4个血细胞类型子文件夹。
问4:如果TEST文件夹类别不全怎么办?
答4:会出错,因为类别编号对不上。
第45行:查看类别名称
train_dataset.classes # 获取数据集中所有类别的名称列表
问1:classes属性包含什么?
答1:所有类别的名称列表,按文件夹名排序。
问2:为什么要查看classes?
答2:确认类别加载正确,了解具体有哪些血细胞类型。
问3:这个顺序重要吗?
答3:非常重要!这个顺序决定了后面的类别编号。
问4:如果有5个文件夹但只想用4个怎么办?
答4:需要手动删除不要的文件夹,或者用其他方法过滤。
第47行:查看类别索引映射
train_dataset.class_to_idx # 获取类别名称到数字索引的映射字典
问1:class_to_idx是什么?
答1:字典,记录类别名称到数字编号的映射。
问2:为什么需要数字编号?
答2:因为神经网络只能处理数字,不能直接处理文字。
问3:编号规则是什么?
答3:按类别名称字母顺序,从0开始编号。
问4:这个映射关系固定吗?
答4:只要文件夹名不变,映射关系就固定。
问5:为什么要打印出来看?
答5:确认映射正确,避免后面解释结果时搞错类别。
第49-53行:创建反向映射
id_to_class = {} # 创建空字典,用于存储索引到类别名称的反向映射
for k, v in train_dataset.class_to_idx.items(): # 遍历类别到索引的字典,k是类别名,v是索引号
print(k, v) # 打印类别名称和对应的索引号
id_to_class[v] = k # 将索引号作为键,类别名作为值,存入反向映射字典
id_to_class # 显示完整的索引到类别名称的映射字典
问1:为什么要创建id_to_class?
答1:因为模型输出是数字,我们需要把数字转回类别名称。
问2:这个循环做了什么?
答2:遍历class_to_idx字典,把键值对调换。
问3:k和v分别代表什么?
答3:k是类别名称(key),v是编号(value)。
问4:为什么要print(k,v)?
答4:检查映射关系,确保理解正确。
问5:字典推导式能实现同样功能吗?
答5:能,可以写成:{v:k for k,v in train_dataset.class_to_idx.items()}
问6:为什么不直接用train_dataset.classes[编号]?
答6:也可以,但字典查找更直观,代码更清晰。
第55行:设置批量大小
Batch_size=64
问1:什么是批量大小?
答1:一次性喂给模型的图片数量。
问2:为什么不一张一张处理?
答2:因为GPU擅长并行计算,批量处理更高效,就像洗衣机一次洗多件衣服。
问3:为什么选择64?
答3:64是2的6次方,GPU处理2的幂次更高效,且平衡了内存使用和计算效率。
问4:批量大小太大或太小有什么问题?
答4:太大可能内存不够;太小GPU利用率低,训练慢。
问5:这个64是固定的吗?
答5:不是,可以根据GPU内存和数据集大小调整。
第56-61行:创建训练数据加载器
dl_train = torch.utils.data.DataLoader( # 创建训练数据加载器对象
train_dataset, # 传入训练数据集对象
batch_size=Batch_size, # 设置每个批次的样本数量
shuffle=True # 设置为True,每个epoch都会随机打乱数据顺序
)
问1:DataLoader做什么?
答1:把数据集包装成可迭代的批次,就像把苹果装进篮子。
问2:为什么需要DataLoader?
答2:因为dataset只是数据仓库,DataLoader是传送带,负责运输。
问3:shuffle=True是什么意思?
答3:每个epoch开始时随机打乱数据顺序。
问4:为什么要打乱顺序?
答4:避免模型学到数据的顺序规律,就像洗牌避免记牌。
问5:不打乱会怎样?
答5:模型可能过拟合数据顺序,泛化能力差。
问6:DataLoader还有其他参数吗?
答6:有,比如num_workers(多线程)、drop_last(丢弃不完整批次)等。
第62-67行:创建测试数据加载器
dl_test=torch.utils.data.DataLoader(
test_dataset,
batch_size=Batch_size,
shuffle=True
)
问1:测试时为什么也要shuffle=True?
答1:这里其实应该是False,测试时不需要打乱。
问2:测试时打乱有什么影响?
答2:不影响最终准确率,但会让结果分析变复杂。
问3:什么时候测试需要shuffle?
答3:几乎不需要,除非做随机采样分析。
问4:这是代码的bug吗?
答4:不算bug,但不是最佳实践。
第69行:获取一批数据
img, label = next(iter(dl_train)) # 从训练数据加载器中获取一个批次的数据(图像和标签)
问1:iter()做什么?
答1:把DataLoader转换成迭代器。
问2:next()做什么?
答2:从迭代器取出下一个元素(一批数据)。
问3:为什么要这样获取数据?
答3:测试DataLoader是否正常工作,查看数据格式。
问4:img和label分别是什么?
答4:img是图片张量(64张图片),label是对应的类别编号。
问5:这个操作会消耗数据吗?
答5:会,下次遍历dl_train时这批数据不会重复出现(除非重新创建迭代器)。
问6:img的形状是什么?
答6:应该是[64, 3, 256, 256],即批量大小×通道数×高×宽。
第71行:取出第一张图片
im = img[0] # 取出批次中的第一张图像(索引为0)
问1:img[0]是什么?
答1:批次中的第一张图片。
问2:im的形状是什么?
答2:[3, 256, 256],即3个通道,256×256像素。
问3:为什么要单独取出一张?
答3:为了可视化显示,matplotlib一次只能显示一张图片。
问4:这个顺序是通道×高×宽吗?
答4:是的,这是PyTorch的默认格式(CHW)。
问5:为什么不是高×宽×通道?
答5:因为卷积运算更适合通道在前的格式。
第73行:调整维度顺序
im = im.permute(1, 2, 0) # 改变张量维度顺序:从[C,H,W]变为[H,W,C],适合显示
问1:permute做什么?
答1:重新排列张量的维度顺序。
问2:(1,2,0)是什么意思?
答2:把原来的维度0放到最后,1、2放到前面。
问3:为什么要调整顺序?
答3:因为matplotlib期望HWC格式(高×宽×通道),而PyTorch是CHW格式。
问4:调整前后分别是什么形状?
答4:调整前[3,256,256],调整后[256,256,3]。
问5:这样调整数据会丢失吗?
答5:不会,只是重新排列,数据完全保持不变。
问6:还有其他方法调整维度吗?
答6:有,比如transpose(),但permute()更灵活。
第75行:有问题的代码
im = im.numpy() # 将PyTorch张量转换为NumPy数组(注:原代码"im-im.numpy()"有误)
问1:这行代码想做什么?
答1:看起来想转换成numpy数组,但写错了。
问2:正确的写法应该是什么?
答2:应该是 im = im.numpy()
问3:im - im.numpy()会发生什么?
答3:张量减去numpy数组,结果还是张量,值都变成0。
问4:这是打字错误吗?
答4:很可能是,作者可能想写 im = im.numpy()
问5:为什么要转换成numpy?
答5:因为matplotlib更适合处理numpy数组。
第76行:数据反标准化
im = (im + 1) / 2 # 将像素值范围从[-1,1]转换为[0,1],用于正常显示
问1:为什么要+1再除以2?
答1:因为前面标准化把[0,1]变成了[-1,1],现在要变回[0,1]。
问2:这个公式怎么推导的?
答2:标准化公式是(x-0.5)/0.5,反过来就是x*0.5+0.5,即(x+1)/2。
问3:为什么要变回[0,1]?
答3:因为matplotlib显示图片需要[0,1]或[0,255]范围的像素值。
问4:如果不反标准化会怎样?
答4:图片显示会很奇怪,负值被截断,颜色失真。
问5:这个操作对所有像素都一样吗?
答5:是的,对每个像素值都做相同的线性变换。
第77行:显示单张图片
plt.imshow(im)
问1:imshow做什么?
答1:在屏幕上显示图片。
问2:为什么叫imshow?
答2:image show的缩写,显示图像。
问3:im现在是什么格式?
答3:应该是numpy数组,形状[256,256,3],值在[0,1]范围。
问4:如果im还是张量会怎样?
答4:matplotlib会自动转换,但可能有警告。
问5:imshow能显示什么格式的数据?
答5:可以显示灰度图[H,W]、彩色图[H,W,3]、或[H,W,4](带透明度)。
问6:显示后需要plt.show()吗?
答6:在Jupyter中不需要,在普通Python脚本中需要。
第78行:创建多子图画布
plt.figure(figsize=(12,8))
问1:figure做什么?
答1:创建一个画布,用来放置多个子图。
问2:figsize=(12,8)是什么意思?
答2:画布大小,宽12英寸,高8英寸。
问3:为什么要指定大小?
答3:因为要放8张图片,需要足够大的画布才能看清楚。
问4:英寸是什么单位?
答4:1英寸≈2.54厘米,这是matplotlib的默认单位。
问5:不指定figsize会怎样?
答5:使用默认大小,可能太小导致图片挤在一起看不清。
问6:这个大小是最终显示的大小吗?
答6:取决于屏幕DPI,实际像素大小=英寸×DPI。
第79-85行:批量显示图片
# 图像显示代码
for i, (img, label) in enumerate(zip(img[:8], label[:8])): # 遍历前8张图像和对应标签,i是索引
img = (img.permute(1, 2, 0).numpy() + 1) / 2 # 转换图像维度[C,H,W]→[H,W,C],转numpy,反标准化[-1,1]→[0,1]
plt.subplot(2, 4, i+1) # 创建2行4列的子图,当前位置为i+1(从1开始)
plt.title(id_to_class.get(label.item())) # 设置子图标题为对应的类别名称
plt.imshow(img) # 显示当前图像
问1:enumerate做什么?
答1:给循环加上索引编号,返回(索引, 元素)对。
问2:zip(img[:8],label[:8])做什么?
答2:把前8张图片和对应的8个标签配对。
问3:为什么只取前8张?
答3:因为一批有64张,全显示太多,8张刚好排成2×4网格。
问4:img.permute(1,2,0).numpy()又做了什么?
答4:把每张图片从CHW格式转成HWC格式,再转成numpy数组。
问5:为什么要重复这个操作?
答5:因为这是在循环里处理每张图片,之前只处理了第一张。
分解:plt.subplot(2,4,i+1)
问1:subplot做什么?
答1:在当前画布上创建子图区域。
问2:2,4,i+1这三个数字是什么意思?
答2:2行4列的网格,当前是第i+1个位置。
问3:为什么是i+1而不是i?
答3:因为subplot编号从1开始,而i从0开始。
问4:如果i超过8会怎样?
答4:会出错,因为2×4=8,只有8个位置。
问5:子图位置是怎么排列的?
答5:从左到右,从上到下:1,2,3,4 在第一行,5,6,7,8 在第二行。
分解:plt.title(id_to_class.get(label.item()))
问1:label.item()做什么?
答1:把张量中的单个数值提取出来,变成Python数字。
问2:为什么需要.item()?
答2:因为label是张量,id_to_class字典的键需要Python数字。
问3:id_to_class.get()和id_to_class[]有什么区别?
答3:get()更安全,如果键不存在返回None而不是报错。
问4:这个标题显示什么?
答4:显示这张图片对应的血细胞类型名称。
问5:如果label.item()在字典中不存在会怎样?
答5:用get()会返回None,title显示"None";用[]会报KeyError。
分解:plt.imshow(img)
问1:这个imshow和之前的有什么不同?
答1:这个是在子图中显示,之前是在独立图中显示。
问2:每次循环都会覆盖前面的图吗?
答2:不会,因为每次都用了不同的subplot。
问3:这8张图片来自同一批次吗?
答3:是的,都来自刚才next(iter(dl_train))获取的那批数据。
问4:每次运行这段代码,显示的图片都一样吗?
答4:不一样,因为DataLoader有shuffle=True,每次批次不同。
第86行:定义神经网络类
class Net(nn.Module):
问1:为什么要用class定义网络?
答1:因为神经网络是复杂对象,需要包含结构定义和前向传播逻辑。
问2:为什么继承nn.Module?
答2:nn.Module是PyTorch所有神经网络的基类,提供了训练、保存等基础功能。
问3:继承是什么概念?
答3:就像孩子继承父母的特征,Net继承了nn.Module的所有能力。
问4:为什么叫Net而不是其他名字?
答4:这是约定俗成,Net、Model、Network都常用,表示神经网络。
问5:不继承nn.Module可以吗?
答5:不行,PyTorch的优化器、损失函数等都需要nn.Module的接口。
第87行:初始化函数
def __init__(self):
问1:__init__是什么?
答1:Python的构造函数,创建对象时自动调用。
问2:为什么有两个下划线?
答2:这是Python的魔法方法命名规则,表示特殊方法。
问3:self是什么?
答3:指向当前对象实例,就像"我自己"的意思。
问4:为什么没有其他参数?
答4:这个网络结构固定,不需要外部参数定制。
问5:什么时候会调用__init__?
答5:当执行model = Net()时自动调用。
第88行:调用父类初始化
super(Net,self).__init__()
问1:super()做什么?
答1:调用父类(nn.Module)的初始化方法。
问2:为什么要调用父类初始化?
答2:因为nn.Module需要初始化内部的参数管理系统。
问3:Net,self这两个参数是什么意思?
答3:Net是当前类名,self是当前实例,告诉super找哪个父类。
问4:不调用super().init()会怎样?
答4:会报错,因为缺少nn.Module的基础设施。
问5:这是固定写法吗?
答5:在PyTorch中是的,所有自定义网络都需要这行。
第90-94行:定义第一层
# CNN网络层定义
self.layer1 = nn.Sequential( # 定义第一个卷积层组合
nn.Conv2d(3, 32, kernel_size=3), # 卷积层:输入3通道,输出32通道,卷积核大小3x3
nn.ReLU(), # ReLU激活函数,增加非线性
nn.MaxPool2d(2, 2) # 最大池化层:2x2窗口,步长2,降维并提取主要特征
)
问1:nn.Sequential做什么?
答1:把多个层按顺序组合成一个模块。
问2:为什么叫Sequential?
答2:Sequential意思是顺序的,数据按顺序流过这些层。
问3:self.layer1是什么?
答3:把这个组合模块保存为对象的属性,方便后面调用。
分解:nn.Conv2d(3,32,kernel_size=3)
问1:Conv2d是什么?
答1:二维卷积层,专门处理图像数据。
问2:3,32,3这三个参数分别是什么?
答2:输入通道数3(RGB),输出通道数32,卷积核大小3×3。
问3:为什么输入是3个通道?
答3:因为输入是彩色图片,有红绿蓝3个颜色通道。
问4:32个输出通道是什么意思?
答4:这一层学习32种不同的特征模式,比如边缘、纹理等。
问5:3×3卷积核有什么特点?
答5:能检测局部特征,计算量适中,是最常用的卷积核大小。
问6:卷积运算具体怎么计算?
答6:3×3的卷积核在图像上滑动,每个位置做乘积求和。
分解:nn.ReLU()
问1:ReLU是什么?
答1:激活函数,全称Rectified Linear Unit(修正线性单元)。
问2:ReLU的数学公式是什么?
答2:f(x) = max(0, x),即负数变0,正数不变。
问3:为什么需要激活函数?
答3:增加非线性,让网络能学习复杂模式,否则多层线性变换还是线性。
问4:为什么选择ReLU而不是其他激活函数?
答4:ReLU计算简单,避免梯度消失,训练效果好。
问5:ReLU有什么缺点?
答5:可能出现"死神经元"问题,某些神经元永远不激活。
分解:nn.MaxPool2d(2,2)
问1:MaxPool2d做什么?
答1:最大池化,在每个2×2区域中取最大值。
问2:为什么要池化?
答2:降低数据维度,减少计算量,增强平移不变性。
问3:平移不变性是什么意思?
答3:物体在图像中稍微移动,特征依然能被检测到。
问4:(2,2)参数是什么意思?
答4:池化窗口大小2×2,步长也是2×2(默认等于窗口大小)。
问5:池化后图像尺寸怎么变化?
答5:长宽都减半,如256×256变成128×128。
问6:为什么取最大值而不是平均值?
答6:最大值保留最强的特征响应,更适合特征检测。
第95-99行:定义第二层
self.layer2 = nn.Sequential( # 定义第二个卷积层组合
nn.Conv2d(32, 64, kernel_size=3), # 卷积层:输入32通道,输出64通道,卷积核大小3x3
nn.ReLU(), # ReLU激活函数
nn.MaxPool2d(2, 2) # 最大池化层:2x2窗口,进一步降维
)
问1:这层和layer1有什么区别?
答1:输入通道从3变成32,输出通道从32变成64。
问2:为什么输入是32?
答2:因为layer1的输出是32个通道,作为layer2的输入。
问3:为什么输出变成64?
答3:随着网络加深,通常增加通道数来学习更复杂的特征。
问4:这种通道数翻倍的设计有什么道理?
答4:空间尺寸减半,通道数翻倍,保持信息总量大致不变。
问5:这层处理后图像尺寸是多少?
答5:128×128再池化变成64×64。
第100-104行:定义第三层
self.layer3 = nn.Sequential( # 定义第三个卷积层组合
nn.Conv2d(64, 128, kernel_size=3), # 卷积层:输入64通道,输出128通道,卷积核大小3x3
nn.ReLU(), # ReLU激活函数
nn.MaxPool2d(2, 2) # 最大池化层:2x2窗口,继续降维
)
问1:第三层继续了什么规律?
答1:通道数继续翻倍(64→128),保持3×3卷积和2×2池化。
问2:为什么要继续加深网络?
答2:更深的层能学习更高级的特征,从边缘→纹理→形状→物体。
问3:这层处理后图像尺寸是多少?
答3:64×64池化后变成32×32。
问4:通道数从64变128有什么意义?
答4:128个通道能学习128种不同的复杂特征组合。
问5:每一层学到的特征有什么不同?
答5:浅层学简单特征(边缘),深层学复杂特征(细胞形态)。
第106-111行:定义第四层
self.layer4 = nn.Sequential( # 定义第四个卷积层组合
nn.Conv2d(128, 256, kernel_size=3), # 卷积层:输入128通道,输出256通道,卷积核大小3x3
nn.ReLU(), # ReLU激活函数
nn.MaxPool2d(2, 2) # 最大池化层:2x2窗口,最后一次降维
)
问1:第四层的通道数规律还在继续吗?
答1:是的,128→256,继续翻倍。
问2:这是最后一个卷积层吗?
答2:是的,后面是全连接层。
问3:这层输出的空间尺寸是多少?
答3:32×32池化后变成16×16。
问4:为什么在这里停止添加卷积层?
答4:空间尺寸已经很小,再池化特征会损失太多空间信息。
问5:256个通道算多吗?
答5:对于这个简单网络来说算多的,能提取丰富的高级特征。
第112-116行:定义全连接层
self.fc = nn.Sequential( # 定义全连接层组合
nn.Linear(256*14*14, 1024), # 全连接层:输入256*14*14=50176个特征,输出1024个神经元
nn.ReLU(), # ReLU激活函数
nn.Linear(1024, 4) # 全连接层:输入1024个神经元,输出4个类别(血细胞的4种类型)
)
问1:为什么要用全连接层?
答1:卷积层提取特征,全连接层负责分类决策。
问2:256x14x14是什么意思?
答2:最后一层卷积的输出尺寸,256个通道×14×14像素。
问3:等等,不是应该16×16吗?
答3:这里可能有问题,理论上应该是16×16,但代码写的是14×14。
问4:为什么会出现这个差异?
答4:可能是卷积没有padding,每次卷积都会减少尺寸。
问5:我们来计算一下实际尺寸:
答5:
- 输入:256×256
- layer1: Conv3×3无padding → 254×254,pool → 127×127
- layer2: Conv3×3无padding → 125×125,pool → 62×62
- layer3: Conv3×3无padding → 60×60,pool → 30×30
- layer4: Conv3×3无padding → 28×28,pool → 14×14
问6:所以14×14是对的?
答6:是的!因为卷积没有padding,每次都会损失边缘像素。
分解:nn.Linear(256x14x14,1024)
问1:Linear是什么?
答1:全连接层,每个输入都连接到每个输出。
问2:256x14x14=?
答2:256×14×14 = 50,176个输入特征。
问3:1024是什么?
答3:隐藏层神经元数量,中间表示的维度。
问4:为什么选择1024?
答4:经验值,足够大能表达复杂特征,又不会过度复杂。
问5:这层有多少个参数?
答5:50,176×1024 + 1024 = 51,380,224个参数(权重+偏置)。
问6:参数这么多会有什么问题?
答6:容易过拟合,需要足够的训练数据。
分解:nn.Linear(1024,4)
问1:为什么输出是4?
答1:因为有4种血细胞类型,需要4个输出神经元。
问2:这4个输出代表什么?
答2:每个输出代表对应类别的置信度分数。
问3:为什么不用softmax激活?
答3:因为后面会用CrossEntropyLoss,它内部包含softmax。
问4:这层有多少参数?
答4:1024×4 + 4 = 4,100个参数。
问5:最终输出的数值范围是多少?
答5:理论上是(-∞, +∞),通过softmax转换成概率。
第118行:定义前向传播函数
def forward(self,x):
问1:forward函数做什么?
答1:定义数据在网络中的流动路径。
问2:为什么叫forward?
答2:相对于反向传播(backward),这是前向传播过程。
问3:x参数是什么?
答3:输入的图像批次,形状[batch_size, 3, 256, 256]。
问4:为什么要定义forward?
答4:这是nn.Module的要求,定义了如何计算输出。
问5:PyTorch怎么知道调用这个函数?
答5:当执行model(x)时,PyTorch自动调用__call__,进而调用forward。
第119-122行:前向传播过程
x=self.layer1(x)
x=self.layer2(x)
x=self.layer3(x)
x=self.layer4(x)
问1:这4行代码做了什么?
答1:让数据依次通过4个卷积层,逐步提取特征。
问2:每行的x形状如何变化?
答2:
- 输入x: [batch, 3, 256, 256]
- layer1后: [batch, 32, 127, 127]
- layer2后: [batch, 64, 62, 62]
- layer3后: [batch, 128, 30, 30]
- layer4后: [batch, 256, 14, 14]
问3:为什么可以直接用=赋值?
答3:PyTorch会自动管理计算图,重新赋值不会丢失梯度信息。
问4:这种写法简洁吗?
答4:很简洁,清晰表达了数据流动过程。
第123行:打印调试信息
print(x.shape)
问1:为什么要打印shape?
答1:调试用,确认卷积后的尺寸是否符合预期。
问2:这行在生产环境中需要吗?
答2:不需要,这是开发调试代码。
问3:会打印什么内容?
答3:会打印 torch.Size([batch_size, 256, 14, 14])。
问4:这行影响训练吗?
答4:不影响训练,只是额外的输出信息。
第125行:特征展平
x=x.view(-1,256*14*14)
问1:view函数做什么?
答1:改变张量的形状,类似numpy的reshape。
问2:-1是什么意思?
答2:自动计算这个维度的大小,保持总元素数不变。
问3:为什么要展平?
答3:全连接层需要一维输入,不能处理二维的空间特征图。
问4:展平前后形状如何变化?
答4:从[batch, 256, 14, 14]变成[batch, 50176]。
问5:展平会丢失空间信息吗?
答5:会丢失空间位置关系,但保留所有特征值。
问6:为什么在这里展平而不是更早?
答6:因为卷积层需要空间结构,只有在最后分类时才展平。
第126行:全连接分类
x=self.fc(x)
问1:这行做了什么?
答1:通过全连接层将特征映射到4个类别的分数。
问2:输出形状是什么?
答2:[batch_size, 4],每行是一个样本的4个类别分数。
问3:这些分数的含义是什么?
答3:每个数值表示模型对该类别的置信度,数值越大越可能是该类别。
第128行:返回结果
return x
问1:为什么要返回x?
答1:这是函数的输出,后续用于计算损失和预测。
问2:返回的x就是最终预测吗?
答2:是原始分数,还需要通过softmax转换成概率,或argmax得到类别。
第130行:获取测试数据
img,label=next(iter(dl_train))
问1:为什么又重新获取数据?
答1:测试刚定义好的模型是否能正常工作。
问2:这和之前第69行有什么区别?
答2:之前是为了可视化,现在是为了测试模型前向传播。
问3:这会获取到相同的数据吗?
答3:不一定,因为DataLoader有shuffle=True,每次iter都可能不同。
问4:为什么用dl_train而不是dl_test?
答4:都可以,这里只是测试模型结构,用哪个数据集都行。
第132行:创建模型实例
model=Net()
问1:这行代码做了什么?
答1:创建Net类的实例,调用__init__方法初始化网络。
问2:现在模型有参数了吗?
答2:有,PyTorch自动随机初始化所有卷积和全连接层的权重。
问3:参数是怎么初始化的?
答3:默认使用kaiming初始化(适合ReLU)或xavier初始化。
问4:可以自定义初始化吗?
答4:可以,在__init__中添加初始化代码,或定义后手动初始化。
问5:模型现在占用多少内存?
答5:大约200MB+,主要是全连接层的5千万个参数。
第133行:测试前向传播
pred=model(img)
问1:model(img)发生了什么?
答1:调用模型的__call__方法,进而调用forward函数。
问2:img的形状是什么?
答2:[64, 3, 256, 256],64张256×256的彩色图片。
问3:pred的形状是什么?
答3:[64, 4],64个样本,每个有4个类别分数。
问4:这个过程会训练模型吗?
答4:不会,只是前向传播,没有反向传播和参数更新。
问5:会打印什么调试信息?
答5:会打印torch.Size([64, 256, 14, 14]),来自forward中的print。
第134行:查看预测结果
pred
问1:这会显示什么?
答1:显示64×4的张量,包含所有预测分数。
问2:这些数值有什么特点?
答2:由于模型未训练,数值基本是随机的,可能有正有负。
问3:怎么解释这些数值?
答3:每行4个数代表一张图片对4个类别的置信度评分。
问4:哪个类别的预测最高?
答4:需要用torch.argmax找到每行最大值的索引。
问5:这些预测有意义吗?
答5:没有,因为模型还没训练,纯属随机猜测。
第135行:定义优化器
optim = torch.optim.Adam(model.parameters(), lr=0.001)
# 创建Adam优化器,传入模型参数,设置学习率为0.001
问1:优化器是什么?
答1:负责根据梯度更新模型参数的算法。
问2:为什么选择Adam?
答2:Adam结合了momentum和RMSprop的优点,收敛快且稳定。
问3:model.parameters()返回什么?
答3:模型中所有需要训练的参数(权重和偏置)。
问4:lr=0.001是什么?
答4:学习率,控制每次参数更新的步长。
问5:学习率大小有什么影响?
答5:太大可能发散,太小收敛慢,0.001是常用的安全值。
问6:还有其他优化器吗?
答6:有SGD、RMSprop、AdaGrad等,Adam是最流行的。
第136行:定义损失函数
loss_fn=nn.CrossEntropyLoss()
问1:CrossEntropyLoss是什么?
答1:交叉熵损失,专门用于多分类问题。
问2:为什么适合多分类?
答2:它结合了softmax和负对数似然,能给出概率分布。
问3:交叉熵的数学公式是什么?
答3:-Σ y_true * log(softmax(y_pred)),y_true是独热编码。
问4:为什么不用均方误差?
答4:交叉熵更适合分类,能提供更好的梯度信号。
问5:输入需要是概率吗?
答5:不需要,CrossEntropyLoss内部会自动计算softmax。
问6:标签需要独热编码吗?
答6:不需要,直接用类别索引(0,1,2,3)即可。
第137-139行:GPU设置
if torch.cuda.is_available():
model.to('cuda')
torch.cuda.is_available()
问1:torch.cuda.is_available()做什么?
答1:检查当前环境是否有可用的GPU。
问2:model.to(‘cuda’)做什么?
答2:把模型的所有参数移动到GPU内存中。
问3:为什么要用GPU?
答3:GPU有成千上万个核心,并行计算深度学习任务比CPU快几十倍。
问4:如果没有GPU会怎样?
答4:代码会跳过to(‘cuda’),继续用CPU训练,但会很慢。
问5:数据也需要移到GPU吗?
答5:是的,后面训练时每批数据都要用.to(‘cuda’)移到GPU。
问6:第139行为什么又调用一次?
答6:可能是为了在notebook中显示GPU是否可用。
第141行:定义训练函数
def fit(epoch, model, trainloader, testloader):
问1:为什么要定义fit函数?
答1:把训练逻辑封装成函数,便于重复调用和代码组织。
问2:这4个参数分别是什么?
答2:epoch(当前轮数)、model(模型)、trainloader(训练数据)、testloader(测试数据)。
问3:为什么需要trainloader和testloader?
答3:trainloader用于训练,testloader用于验证效果。
问4:fit函数会返回什么?
答4:会返回训练和测试的损失、准确率指标。
问5:这种设计有什么好处?
答5:逻辑清晰,可以在循环中多次调用,便于调试和修改。
第142-144行:初始化统计变量
correct = 0
total = 0
running_loss = 0
问1:这些变量用来做什么?
答1:统计训练过程中的准确率和损失。
问2:correct统计什么?
答2:预测正确的样本数量。
问3:total统计什么?
答3:总的样本数量。
问4:running_loss统计什么?
答4:累计的损失值。
问5:为什么要累计这些数据?
答5:计算整个epoch的平均准确率和平均损失。
第145行:设置训练模式
model.train()
问1:model.train()做什么?
答1:把模型设置为训练模式。
问2:训练模式和评估模式有什么区别?
答2:训练模式启用dropout和batch normalization的随机性。
问3:这个模型有dropout吗?
答3:这个简单模型没有,但调用train()是好习惯。
问4:不调用train()会怎样?
答4:对这个模型没影响,但复杂模型可能表现异常。
问5:什么时候用eval()模式?
答5:测试和推理时,确保模型行为的确定性。
第146行:开始训练循环
for x, y in tqdm(trainloader):
问1:这个循环做什么?
答1:遍历训练数据的每一个批次进行训练。
问2:x和y分别是什么?
答2:x是图片批次,y是对应的标签批次。
问3:tqdm的作用是什么?
答3:显示进度条,让我们知道训练进度。
问4:每次循环处理多少数据?
答4:64张图片(Batch_size=64)。
问5:总共会循环多少次?
答5:取决于训练集大小,如果有6400张图,就是6400÷64=100次。
第147行:数据移到GPU
x, y = x.to('cuda'), y.to('cuda')
问1:为什么要移动数据到GPU?
答1:因为模型在GPU上,数据也必须在GPU上才能计算。
问2:x.to(‘cuda’)具体做了什么?
答2:把图片张量从CPU内存拷贝到GPU内存。
问3:这个操作耗时吗?
答3:有一定耗时,但比CPU计算省下的时间多得多。
问4:如果忘记移动数据会怎样?
答4:会报错,提示张量不在同一设备上。
问5:可以提前把所有数据移到GPU吗?
答5:理论上可以,但GPU内存有限,通常只移动当前批次。
第148行:前向传播
y_pred = model(x)
问1:这行做了什么计算?
答1:把64张图片输入模型,得到64×4的预测分数。
问2:这和之前的pred=model(img)有什么区别?
答2:这是在训练循环中,会记录梯度;之前只是测试。
问3:y_pred的形状是什么?
答3:[64, 4],64个样本每个有4个类别分数。
问4:这个过程需要梯度吗?
答4:需要,PyTorch自动构建计算图为反向传播做准备。
问5:GPU上的计算比CPU快多少?
答5:对于深度学习,通常快10-100倍。
第149行:计算损失
loss = loss_fn(y_pred, y)
问1:loss_fn计算什么?
答1:计算预测分数y_pred和真实标签y之间的交叉熵损失。
问2:y的形状是什么?
答2:[64],每个元素是0-3的类别索引。
问3:y_pred需要是概率吗?
答3:不需要,CrossEntropyLoss会自动计算softmax。
问4:loss是标量还是张量?
答4:是标量,代表这个批次的平均损失。
问5:损失值的范围是多少?
答5:理论上[0, +∞),随机猜测约为ln(4)≈1.386。
第150行:清零梯度
optim.zero_grad()
问1:为什么要清零梯度?
答1:因为PyTorch默认累积梯度,不清零会叠加上次的梯度。
问2:梯度累积有什么用?
答2:可以模拟更大的批次,但通常我们要每次清零。
问3:不清零会怎样?
答3:梯度越来越大,模型训练会发散。
问4:什么时候需要梯度累积?
答4:当GPU内存不够放大批次时,可以累积几个小批次的梯度。
问5:zero_grad()清零什么?
答5:清零模型所有参数的.grad属性。
第151行:反向传播
loss.backward()
问1:backward()做什么?
答1:计算损失对所有参数的梯度,也就是求偏导数。
问2:这个计算是如何进行的?
答2:通过链式法则,从输出层向输入层逐层计算梯度。
问3:为什么叫反向传播?
答3:因为梯度计算方向与前向传播相反,从后往前。
问4:计算出的梯度存在哪里?
答4:存在每个参数的.grad属性中。
问5:这个过程耗时吗?
答5:通常比前向传播慢2-3倍,因为需要存储中间结果。
第152行:更新参数
optim.step()
问1:step()做什么?
答1:根据计算出的梯度更新模型参数。
问2:更新公式是什么?
答2:对于Adam优化器,公式很复杂,简化版是 θ = θ - lr * grad。
问3:为什么用step这个名字?
答3:因为这是梯度下降的一步。
问4:参数更新后梯度怎么办?
答4:梯度还在,下次zero_grad()时才清零。
问5:step()和backward()顺序能颠倒吗?
答5:不能,必须先计算梯度再更新参数。
第154行:开始无梯度计算
with torch.no_grad():
问1:no_grad()是什么意思?
答1:在这个代码块中不计算梯度,节省内存和计算。
问2:为什么要无梯度计算?
答2:因为下面只是统计准确率,不需要反向传播。
问3:这能节省多少内存?
答3:能节省很多,因为不需要存储计算图和中间结果。
问4:无梯度计算会影响结果吗?
答4:不影响前向传播的结果,只是不能反向传播。
问5:什么时候用no_grad?
答5:推理、验证、测试时都应该用,只有训练时需要梯度。
第155行:获取预测类别
y_pred = torch.argmax(y_pred, dim=1)
问1:argmax做什么?
答1:找到每行最大值的索引,即预测的类别。
问2:dim=1是什么意思?
答2:在第1个维度(列方向)找最大值,即每个样本的4个分数中找最大的。
问3:y_pred的形状如何变化?
答3:从[64, 4]变成[64],每个元素是0-3的类别索引。
问4:为什么要用argmax而不是softmax?
答4:因为只需要知道类别,不需要概率分布。
问5:这个操作改变原来的y_pred吗?
答5:改变了,原来的分数被类别索引覆盖。
第156行:统计正确数量
correct += (y_pred == y).sum().item()
问1:(y_pred == y)返回什么?
答1:返回布尔张量,True表示预测正确,False表示错误。
问2:.sum()做什么?
答2:计算True的数量,即正确预测的数量。
问3:.item()的作用是什么?
答3:把张量中的标量值提取成Python数字。
问4:correct += 是什么意思?
答4:累加到correct变量中,统计总的正确数量。
问5:这个统计是批次级别还是样本级别?
答5:样本级别,统计每个样本是否预测正确。
第157-158行:统计总数和损失
total += y.size(0)
# 累加当前批次的样本数量,y.size(0)返回批次大小
running_loss += loss.item()
# 累加当前批次的损失值,loss.item()将张量转换为Python数值
问1:y.size(0)是什么?
答1:返回第0维的大小,即批次大小64。
问2:为什么累加y.size(0)?
答2:统计处理过的样本总数。
问3:loss.item()和loss有什么区别?
答3:loss是张量,loss.item()是Python浮点数。
问4:running_loss统计什么?
答4:累计所有批次的损失值。
问5:这些统计用来做什么?
答5:计算整个epoch的平均准确率和平均损失。
第160-161行:计算训练epoch统计
epoch_loss = running_loss / len(trainloader.dataset) # 计算当前epoch的平均损失:总损失除以训练集样本总数
epoch_acc = correct / total # 计算当前epoch的准确率:预测正确数除以总样本数
问1:为什么用len(trainloader.dataset)而不是len(trainloader)?
答1:dataset是样本总数,trainloader是批次数,我们要算每个样本的平均损失。
问2:如果有6400个样本,100个批次,这两个值分别是多少?
答2:len(trainloader.dataset)=6400,len(trainloader)=100。
问3:epoch_loss的含义是什么?
答3:整个训练epoch中每个样本的平均损失。
问4:epoch_acc的含义是什么?
答4:整个训练epoch的准确率,范围0-1。
问5:为什么要计算epoch级别的统计?
答5:观察模型在整个数据集上的表现,比单个批次更稳定。
第164-166行:初始化测试统计
test_correct = 0
test_total = 0
test_running_loss = 0
问1:为什么要单独定义测试变量?
答1:避免和训练统计混淆,分别记录训练和测试的性能。
问2:这些变量的作用和训练的类似吗?
答2:完全类似,只是用于测试集的统计。
问3:为什么测试也要统计损失?
答3:损失能反映模型的拟合程度,不只是准确率。
第167行:设置评估模式
model.eval()
问1:model.eval()做什么?
答1:把模型设置为评估模式,关闭训练时的随机性。
问2:和model.train()有什么区别?
答2:eval()模式下dropout不工作,batch norm使用全局统计。
问3:这个模型有dropout或batch norm吗?
答3:没有,但调用eval()是好习惯,确保行为一致。
问4:忘记调用eval()会怎样?
答4:对这个模型没影响,但复杂模型可能影响测试结果。
问5:eval()模式是永久的吗?
答5:不是,下次调用train()就会切换回训练模式。
第168行:无梯度测试
with torch.no_grad():
问1:测试时为什么要no_grad?
答1:测试不需要更新参数,无梯度计算节省内存且更快。
问2:no_grad()能节省多少内存?
答2:通常能节省一半以上的GPU内存。
问3:测试速度会提高多少?
答3:通常提高30-50%,因为不需要构建计算图。
问4:no_grad()影响model.eval()吗?
答4:不影响,两者独立工作,都是为了确保测试的正确性。
第169行:测试循环
for x, y in tqdm(testloader):
问1:这个循环和训练循环有什么区别?
答1:结构类似,但在no_grad()和eval()模式下运行。
问2:测试循环需要tqdm吗?
答2:不是必需的,但能显示测试进度,用户体验更好。
问3:测试批次大小和训练一样吗?
答3:一样,都是64,但测试时可以用更大批次节省时间。
第170-171行:测试数据移到GPU
x, y = x.to('cuda'), y.to('cuda')
y_pred = model(x)
问1:测试时为什么也要移到GPU?
答1:因为模型在GPU上,数据必须在同一设备才能计算。
问2:测试的前向传播和训练有区别吗?
答2:计算过程相同,但不会构建梯度计算图。
问3:y_pred的形状是什么?
答3:[64, 4],和训练时一样。
第172-175行:测试统计
loss = loss_fn(y_pred, y)
y_pred = torch.argmax(y_pred, dim=1)
test_correct += (y_pred == y).sum().item()
test_total += y.size(0)
test_running_loss += loss.item()
问1:测试也需要计算损失吗?
答1:需要,损失反映模型对测试数据的拟合程度。
问2:这些计算和训练完全一样吗?
答2:完全一样,只是变量名不同(加了test_前缀)。
问3:测试损失的意义是什么?
答3:如果测试损失远大于训练损失,说明过拟合。
问4:argmax操作会影响原来的y_pred吗?
答4:会覆盖,但没关系,因为后面不需要原始分数了。
第177-178行:计算测试epoch统计
epoch_test_loss = test_running_loss / len(testloader.dataset)
epoch_test_acc = test_correct / test_total
问1:这和训练统计有什么区别?
答1:除了变量名,计算方式完全相同。
问2:测试准确率通常比训练准确率如何?
答2:通常略低,因为测试集是模型没见过的数据。
问3:如果测试准确率比训练高怎么办?
答3:可能是偶然,或者测试集比训练集简单。
第180-182行:保存优秀模型
if epoch_acc>0.95:
model_state_dict=model.state_dict()
torch.save(model_state_dict,'./{}{}.pth'.format(epoch_acc,epoch_test_acc))
问1:为什么要检查epoch_acc>0.95?
答1:只保存表现优秀的模型,避免保存太多文件。
问2:model.state_dict()是什么?
答2:模型的所有参数字典,包含权重和偏置。
问3:为什么不直接保存model?
答3:state_dict更轻量,只保存参数不保存结构。
问4:文件名格式是什么意思?
答4:以训练准确率和测试准确率命名,如"0.96_0.94.pth"。
问5:.pth是什么格式?
答5:PyTorch的标准模型保存格式。
问6:这种命名有什么好处?
答6:从文件名就能看出模型性能,便于选择最佳模型。
第185-191行:打印训练结果
print('epoch: ', epoch,
'loss: ', round(epoch_loss, 3),
'accuracy:', round(epoch_acc, 3),
'test_loss: ', round(epoch_test_loss, 3),
'test_accuracy:', round(epoch_test_acc, 3)
)
问1:round(epoch_loss, 3)做什么?
答1:将损失值四舍五入到小数点后3位,便于阅读。
问2:为什么要打印这么多信息?
答2:监控训练进度,观察是否过拟合或欠拟合。
问3:理想的打印结果是什么样的?
答3:训练和测试损失都下降,准确率都上升且差距不大。
问4:如果测试准确率不增长怎么办?
答4:可能过拟合了,需要调整学习率、加正则化等。
问5:这个print在每个epoch都会执行吗?
答5:是的,让我们实时看到训练进展。
第193行:返回统计结果
return epoch_loss, epoch_acc, epoch_test_loss, epoch_test_acc
问1:为什么要返回这些值?
答1:供外部代码使用,比如绘制训练曲线。
问2:返回的顺序重要吗?
答2:重要,调用时需要按这个顺序接收。
问3:不返回会怎样?
答3:函数外部无法获取训练统计,只能看打印信息。
问4:这4个值的数据类型是什么?
答4:都是Python浮点数。
第195行:设置训练轮数
epochs = 30
问1:epoch是什么意思?
答1:一个epoch表示模型看完整个训练集一遍。
问2:为什么选择30轮?
答2:经验值,足够让模型收敛,又不会过度训练。
问3:30轮意味着什么?
答3:每张训练图片都会被用来训练30次。
问4:轮数太少或太多有什么问题?
答4:太少可能欠拟合(没学好),太多可能过拟合(死记硬背)。
问5:怎么判断合适的轮数?
答5:观察验证损失,开始上升时就该停止了。
第197-201行:初始化记录列表
train_loss = []
train_acc = []
test_loss = []
test_acc = []
问1:为什么要创建这些空列表?
答1:记录每个epoch的训练历史,用于后续分析和可视化。
问2:这些列表最终会包含什么?
答2:每个列表包含30个数值,对应30个epoch的统计。
问3:为什么要分别记录训练和测试指标?
答3:比较训练和测试曲线,判断是否过拟合。
问4:不记录这些历史有什么问题?
答4:无法分析训练过程,难以调试和优化模型。
第203-210行:主训练循环
for epoch in range(epochs): # 遍历所有训练轮数,从0到epochs-1
epoch_loss, epoch_acc, epoch_test_loss, epoch_test_acc = fit(epoch, # 调用fit函数执行一个epoch的训练和测试
model, # 传入模型对象
dl_train, # 传入训练数据加载器
dl_test) # 传入测试数据加载器
train_loss.append(epoch_loss) # 将当前epoch的训练损失添加到训练损失列表中
train_acc.append(epoch_acc) # 将当前epoch的训练准确率添加到训练准确率列表中
test_loss.append(epoch_test_loss) # 将当前epoch的测试损失添加到测试损失列表中
test_acc.append(epoch_test_acc) # 将当前epoch的测试准确率添加到测试准确率列表中
问1:range(epochs)产生什么?
答1:产生0到29的数字序列,对应30个训练轮次。
问2:fit函数在这里做什么?
答2:执行一个完整的训练和测试epoch,返回4个统计值。
问3:为什么要把返回值添加到列表?
答3:保存训练历史,用于后续的损失曲线绘制。
问4:这个循环会运行多长时间?
答4:取决于数据集大小和硬件,可能几分钟到几小时。
问5:如果中途想停止怎么办?
答5:可以用Ctrl+C中断,或者修改epochs的值。
问6:每个epoch的顺序是什么?
答6:训练(所有批次)→ 测试(所有批次)→ 打印结果 → 下一轮。
第212-214行:绘制训练曲线
plt.plot(range(1, epochs+1), train_loss, label='train_loss')
plt.plot(range(1, epochs+1), test_loss, label='test_loss')
plt.legend()
问1:为什么用range(1, epochs+1)而不是range(epochs)?
答1:让x轴从1开始而不是0,更符合人的习惯(第1轮、第2轮…)。
问2:label参数做什么?
答2:给每条线设置标签,用于图例显示。
问3:plt.legend()的作用是什么?
答3:显示图例,说明哪条线代表什么。
问4:这个图能看出什么信息?
答4:能看出训练和测试损失的变化趋势,判断是否过拟合。
问5:理想的曲线是什么样的?
答5:两条线都下降且趋势相似,测试损失不应该上升。
问6:如果测试损失上升说明什么?
答6:可能过拟合了,模型在训练集上表现好但泛化差。
第216行:保存完整模型
torch.save(model,'Bloodcell.pkl')
问1:这和前面的模型保存有什么区别?
答1:前面只保存参数(state_dict),这里保存整个模型结构+参数。
问2:为什么要保存完整模型?
答2:方便后续直接加载使用,不需要重新定义网络结构。
问3:.pkl是什么格式?
答3:Python的pickle格式,可以序列化任何Python对象。
问4:完整模型保存有什么缺点?
答4:文件更大,且与PyTorch版本绑定,兼容性差。
问5:什么时候用哪种保存方式?
答5:实验时用完整保存方便,生产环境用state_dict更稳定。
第218-219行:加载模型测试
model=torch.load('Bloodcell.pkl')
model
问1:torch.load做什么?
答1:从文件加载之前保存的完整模型。
问2:加载后model是什么状态?
答2:和保存时完全一样,包含训练好的参数。
问3:为什么要重新加载?
答3:验证保存和加载是否正常,模拟实际使用场景。
问4:第219行的model会显示什么?
答4:显示模型的结构信息,包含所有层的定义。
问5:加载的模型能直接用于预测吗?
答5:能,但需要先调用model.eval()设置为评估模式。
第221-222行:获取测试数据
img,label=next(iter(dl_test))
img.shape
问1:为什么要重新获取测试数据?
答1:用训练好的模型进行实际预测测试。
问2:这次用dl_test而不是dl_train?
答2:是的,用测试集验证模型的泛化能力。
问3:img.shape会显示什么?
答3:torch.Size([64, 3, 256, 256]),确认数据格式正确。
第224-227行:模型预测
img=img.to('cuda')
model.eval()
pred=model(img)
pred.shape
问1:为什么要先移动数据到GPU?
答1:因为模型在GPU上,数据必须在同一设备。
问2:model.eval()的作用是什么?
答2:设置评估模式,确保预测结果稳定。
问3:pred会是什么?
答3:[64, 4]的张量,64个样本每个有4个类别分数。
问4:这些分数表示什么?
答4:模型对每个类别的置信度,分数越高越可能是该类别。
第229行:获取预测类别
pred_re=torch.argmax(pred, dim=1)
问1:这行代码做了什么?
答1:从每个样本的4个分数中找出最大值的索引,即预测的类别。
问2:pred_re的形状是什么?
答2:[64],包含64个预测类别(0-3的整数)。
问3:为什么叫pred_re而不是pred_result?
答3:可能是pred_result的简写,re表示result。
问4:dim=1的含义是什么?
答4:在第1个维度(列方向)找最大值,即每行找最大的分数。
问5:argmax和max有什么区别?
答5:argmax返回索引位置,max返回具体数值。
第230-235行:转换预测结果
pred_re
pred_re=pred_re.cpu().numpy()
pred_re=pred_re.tolist()
pred_re
问1:第230行pred_re会显示什么?
答1:显示GPU上的张量,包含64个预测类别索引。
问2:.cpu()做什么?
答2:把张量从GPU内存移回CPU内存。
问3:为什么要移回CPU?
答3:因为后续处理(numpy转换、可视化)在CPU上进行。
问4:.numpy()的作用是什么?
答4:把PyTorch张量转换成numpy数组。
问5:.tolist()做什么?
答5:把numpy数组转换成Python列表。
问6:为什么要做这么多转换?
答6:不同的处理步骤需要不同的数据格式。
问7:最终pred_re是什么格式?
答7:Python列表,包含64个整数(0-3),如[2, 1, 0, 3, …]。
第238-239行:查看前8个预测
for i in pred_re[0:8]:
print(id_to_class[i])
id_to_class[pred_re[0:8][1]]
问1:pred_re[0:8]是什么?
答1:前8个预测结果,对应要可视化的8张图片。
问2:id_to_class[i]做什么?
答2:把数字索引转换成类别名称,如0→"EOSINOPHIL"。
问3:这个循环会打印什么?
答3:打印前8张图片的预测类别名称。
问4:第239行是什么意思?
答4:获取第2张图片(索引1)的预测类别名称。
问5:为什么用pred_re[0:8][1]而不是pred_re[1]?
答5:这是不好的写法,pred_re[1]更简洁清晰。
第241行:创建可视化画布
plt.figure(figsize=(16,8))
问1:为什么用(16,8)这么大的尺寸?
答1:因为要显示8张图片加上标题,需要足够大的空间。
问2:这比之前的(12,8)大了多少?
答2:宽度增加了4英寸,总面积增加了33%。
问3:为什么需要更大的画布?
答3:因为要显示预测结果和真实标签,标题更长需要更多空间。
第242-248行:可视化预测结果
img=img.cpu()
for i,(img,label) in enumerate(zip(img[:8],label[:8])):
img=(img.permute(1,2,0).numpy()+1)/2
plt.subplot(2,4,i+1)
pred_title=id_to_class[pred_re[0:8][i]]
plt.title('R:{},P:{}'.format(id_to_class.get(label.item()),pred_title))
plt.imshow(img)
问1:第242行为什么要img.cpu()?
答1:把图片张量从GPU移回CPU,因为matplotlib需要CPU数据。
问2:这个循环和之前的可视化有什么区别?
答2:增加了预测结果显示,标题包含真实标签和预测标签。
问3:img变量会冲突吗?
答3:会有命名冲突,循环内的img覆盖了外部的img批次。
问4:pred_title是什么?
答4:第i张图片的预测类别名称。
问5:'R:{},P:{}'格式的含义是什么?
答5:R代表Real(真实),P代表Predicted(预测)。
问6:这种标题格式有什么好处?
答6:一眼就能看出预测是否正确,便于评估模型效果。
分解标题生成:
问1:id_to_class.get(label.item())做什么?
答1:获取真实标签对应的类别名称。
问2:为什么用.get()而不是直接索引?
答2:.get()更安全,避免键不存在时报错。
问3:label.item()的作用是什么?
答3:把张量中的标量值提取成Python数字。
问4:pred_re[0:8][i]能简化吗?
答4:可以写成pred_re[i],更简洁。
问5:如果预测正确,标题会是什么样?
答5:如"R:NEUTROPHIL,P:NEUTROPHIL",R和P相同。
问6:如果预测错误,标题会是什么样?
答6:如"R:NEUTROPHIL,P:EOSINOPHIL",R和P不同。
整体代码分析:
问1:这个可视化的主要目的是什么?
答1:直观展示模型的预测效果,看哪些预测对了哪些错了。
问2:从这个可视化能得到什么信息?
答2:模型的准确率、容易混淆的类别、预测错误的原因等。
问3:如果大部分预测都错了说明什么?
答3:模型没有训练好,可能需要更多epoch或调整参数。
问4:如果预测都对了说明什么?
答4:模型训练效果好,但也要用更多测试数据验证。
问5:这种可视化在实际项目中有用吗?
答5:非常有用,是调试和验证模型的重要手段。
问6:还有其他可视化方式吗?
答6:有,比如混淆矩阵、ROC曲线、特征图可视化等。
整个代码学习完毕!
总结问题:这个血细胞分类项目的完整流程是什么?
答:
- 数据准备:导入库,定义数据变换,加载数据集
- 网络定义:4层CNN + 2层全连接的分类网络
- 训练设置:优化器、损失函数、GPU配置
- 训练循环:30个epoch的训练和验证
- 结果分析:保存模型,绘制曲线,可视化预测
—
数据增强适用性分析
我的任务是… | 几何变换 | 颜色调整 | 混合技术 | 特别注意 |
---|---|---|---|---|
识别图片内容 (分类) |
🟢 大胆用 | 🟢 大胆用 | 🟢 大胆用 | 不要破坏图片本质 |
找物体位置 (检测) |
🟡 小心用 | 🟢 正常用 | 🔴 基本别用 | 保护位置信息 |
精确分割区域 (分割) |
🔴 几乎别用 | 🟡 小心用 | 🔴 完全别用 | 像素级精度 |
识别文字内容 (OCR) |
🔴 基本别用 | 🟢 大胆用 | 🔴 完全别用 | 保持文字完整 |
A1 几何变换 | A2 颜色调整 | A3 混合技术 | |
---|---|---|---|
B1 图像分类 (最宽容) |
✅ 随便转 • 可以转45-90度 • 翻转概率50% • 大幅缩放OK 原理:猫转任何角度都是猫 |
✅ 随便调 • 亮度±50% • 对比度±50% • 可以变灰度 原理:红苹果绿苹果都是苹果 |
✅ 各种混合 • MixUp混合图片 • CutMix拼接图片 • 遮挡也OK 原理:看到一部分就能分类 |
B2 目标检测 (比较挑剔) |
⚠️ 小心转 • 只能转5-15度 • 翻转要谨慎30% • 轻微缩放 原理:位置信息很重要 |
✅ 可以调 • 亮度±30% • 对比度±30% • 适度调色 原理:颜色不影响位置 |
❌ 基本禁用 • MixUp会乱标注 • CutMix可能切断物体 • 用专用技术 原理:混合破坏位置信息 |
B3 语义分割 (很严格) |
❌ 几乎不能转 • 最多转2-5度 • 翻转看情况 • 微小变形 原理:每个像素都要准确 |
⚠️ 轻微调 • 亮度±20% • 对比度±20% • 小心调色 原理:不能影响边界 |
❌ 完全禁用 • 任何混合都破坏像素标签 • 用对抗样本 原理:像素级精度要求 |
B4 文字识别 (最严格) |
❌ 基本不能转 • 禁止翻转 • 最多转3度 • 透视变换OK 原理:文字有固定方向 |
✅ 大力调色 • 亮度±40% • 对比度±50% • 锐化处理 原理:清晰度比颜色重要 |
❌ 完全禁用 • 任何混合都让文字乱码 • 遮挡导致缺字 原理:文字需要完整 |
为什么不同任务需要不同的数据增强?
想象一下,数据增强就像给照片"化妆":
- 分类任务就像认人:无论这个人化什么妆、换什么角度拍照,你都能认出这是谁
- 检测任务就像找人:你不仅要认出是谁,还要知道这个人在照片的哪个位置
- 分割任务就像P图:你要精确到每一个像素点属于哪个部分
- OCR任务就像读字:你要一个字一个字准确识别,不能有任何错误
不同的任务对"化妆"的容忍度完全不同!
1. 图像分类:最宽容的任务
为什么分类任务可以"随便折腾"?
核心原理:只要能看出"这是什么"就行,不管位置、角度、颜色怎么变。
就像认人:
- 一个人站着是人,躺着也是人
- 在阳光下是人,在阴影里也是人
- 看到半张脸也能认出是人
推荐的增强方法:
✅ 几何变换 - 可以"大胆变换"
旋转:可以转45度甚至90度
→ 为什么?猫无论什么角度都是猫
翻转:50%概率翻转
→ 为什么?左边的狗和右边的狗都是狗
缩放:可以放大缩小很多
→ 为什么?大狗小狗都是狗
✅ 颜色变换 - 可以"随意调色"
亮度:可以调很亮或很暗
→ 为什么?白天的车和晚上的车都是车
对比度:可以调得很鲜艳或很暗淡
→ 为什么?鲜艳的花和褪色的花都是花
灰度化:可以变成黑白照片
→ 为什么?彩色苹果和黑白苹果都是苹果
✅ 混合技术 - 可以"拼接组合"
MixUp:把两张图混合在一起
→ 为什么?半只猫+半只狗的特征仍然可以学习
CutMix:把一张图的一部分贴到另一张图上
→ 为什么?看到部分特征就能判断类别
2. 目标检测:比较挑剔的任务
为什么检测任务要"小心翼翼"?
核心原理:不仅要知道"是什么",还要知道"在哪里",位置信息很重要。
就像找人:
- 如果照片转得太厉害,可能找不到人在哪
- 如果把两个人的照片混在一起,就分不清谁在哪了
推荐的增强方法:
⚠️ 几何变换 - 要"小心变换"
旋转:只能轻微转动(15度以内)
→ 为什么?转太多会让边界框变得奇怪
翻转:要谨慎使用
→ 为什么?有些物体有方向性(比如文字、车辆行驶方向)
缩放:不能变化太大
→ 为什么?小物体可能会消失,大物体可能会超出边界
✅ 颜色变换 - 相对安全
亮度、对比度:可以适度调整
→ 为什么?颜色变化不影响物体位置
❌ 混合技术 - 基本禁用
MixUp:不能用
→ 为什么?两个场景混合后,边界框就乱了
CutMix:要非常小心
→ 为什么?可能把完整的物体切断了
✅ 特殊技术 - 检测专用
马赛克拼接:把4张图拼成1张
→ 好处:增加小物体,丰富场景
复制粘贴:把物体复制到别的位置
→ 好处:增加物体数量,提高检测能力
3. 语义分割:最严格的任务
为什么分割任务"不能容忍任何差错"?
核心原理:要精确到每一个像素点,一个像素都不能错。
就像精密手术:
- 每一刀都要精确,不能有偏差
- 任何抖动都可能造成严重后果
推荐的增强方法:
❌ 几何变换 - 几乎禁用
旋转:只能微微转动(5度以内)
→ 为什么?每个像素都要对应准确
翻转:要看具体情况
→ 为什么?有些器官不能翻转(比如心脏在左边)
普通缩放:很危险
→ 为什么?可能破坏精确的边界
✅ 特殊变换 - 小心使用
弹性变形:轻微的"橡皮筋"式变形
→ 为什么?可以模拟器官的自然形变
对抗样本:加入很微小的噪声
→ 为什么?提高模型对小干扰的抵抗力
✅ 颜色变换 - 相对安全
亮度、对比度:小幅调整
→ 为什么?不影响分割边界
❌ 混合技术 - 完全禁用
MixUp、CutMix:都不能用
→ 为什么?像素标签会完全混乱
4. OCR识别:最"死板"的任务
为什么OCR任务"一点都不能错"?
核心原理:文字有固定的形状和方向,任何改变都可能让字变成另一个字。
就像写字:
- “b"翻转过来就变成"d”
- “6"转180度就变成"9”
- 字如果模糊了就认不出来
推荐的增强方法:
❌ 几何变换 - 严格限制
水平翻转:绝对禁止
→ 为什么?"b"翻转成"d","p"翻转成"q"
旋转:只能微微转动(3度以内)
→ 为什么?模拟拍照时轻微的倾斜
缩放:可以适度缩放
→ 为什么?模拟远近距离拍摄
✅ 光照变换 - 大力推荐
亮度:可以大幅调整
→ 为什么?模拟不同光照条件下拍摄
对比度:可以大幅调整
→ 为什么?让文字更清晰或模拟模糊情况
锐化:增强文字清晰度
→ 为什么?模拟高清扫描
✅ 透视变换 - 特殊推荐
透视变换:模拟拍照角度
→ 为什么?现实中很少正对着文字拍照
❌ 混合技术 - 完全禁用
任何混合都不行
→ 为什么?文字混合后就读不出来了
5. 实用选择指南
🤔 如何快速判断用什么增强?
问自己几个问题:
问题1:我的任务对位置敏感吗?
- 不敏感(分类)→ 可以大胆变换
- 一般敏感(检测)→ 小心变换
- 非常敏感(分割、OCR)→ 几乎不变换
问题2:我的任务对颜色敏感吗?
- 不敏感(大多数任务)→ 可以调色
- 敏感(交通标志)→ 小心调色
问题3:我的任务能容忍混合吗?
- 能容忍(分类)→ 用MixUp、CutMix
- 不能容忍(其他)→ 禁用混合
📋 快速配置表
分类任务 - 最宽松
✅ 大角度旋转、随意翻转、强烈颜色变化、各种混合
⚠️ 不要过度到破坏图像本质
检测任务 - 中等严格
✅ 小角度旋转、适度颜色调整、专用技术(马赛克、复制粘贴)
❌ 大角度旋转、混合技术
分割任务 - 很严格
✅ 微小旋转、轻微颜色调整、弹性变形、对抗样本
❌ 大变换、任何混合
OCR任务 - 最严格
✅ 光照调整、透视变换、微小旋转
❌ 翻转、大旋转、任何混合、模糊处理
🎯 记住这个口诀
分类任务不怕折腾,怎么变都认得出
检测任务要看位置,变换不能太过分
分割任务像做手术,每个像素都重要
OCR任务最要小心,文字一变就不对
这样理解是不是清楚多了?关键是要理解每个任务的本质需求,然后选择合适的"化妆"程度!