深度学习——分类任务
随机种子设置工具函数:
它的核心目的是确保实验的可复现性(Reproducibility)。
在深度学习中,由于权重初始化、Dropout、数据洗牌(Shuffle)以及底层计算库(如 cuDNN)的优化策略都涉及随机性,如果不固定种子,每次运行代码得到的结果(如 Loss 曲线、准确率)都会有细微差异。
def seed_everything(seed):
torch.manual_seed(seed) # 1. 设置 CPU 随机种子
torch.cuda.manual_seed(seed) # 2. 设置当前 GPU 随机种子
torch.cuda.manual_seed_all(seed) # 3. 设置所有 GPU 随机种子
torch.backends.cudnn.benchmark = False # 4. 关闭 cuDNN 自动调优
torch.backends.cudnn.deterministic = True # 5. 开启 cuDNN 确定性模式
random.seed(seed) # 6. 设置 Python 原生随机种子
np.random.seed(seed) # 7. 设置 NumPy 随机种子
os.environ['PYTHONHASHSEED'] = str(seed) # 8. 设置 Python 哈希种子
1.1 PyTorch 相关设置
torch.manual_seed(seed):- 作用: 为 PyTorch 的 CPU 随机数生成器设置种子。
- 影响: 影响 CPU 上的操作,如权重初始化、CPU 上的数据增强等。
torch.cuda.manual_seed(seed):- 作用: 为当前选中的单个 GPU 设置随机种子。
- 影响: 如果你只使用一张卡,这行就够了。
torch.cuda.manual_seed_all(seed):- 作用: 为所有可见的 GPU 设置随机种子。
- 影响: 在多卡训练(DataParallel 或 DistributedDataParallel)时必需。虽然调用
manual_seed_all通常涵盖了manual_seed,但为了保险起见,通常两者都写。
1.2 cuDNN 后端设置(关键)
torch.backends.cudnn.benchmark = False:- 默认行为: 如果为
True,cuDNN 会在第一次运行卷积层时,尝试多种不同的算法实现,找到当前硬件下速度最快的一种,并缓存下来。 - 设置为 False: 禁用这种“寻找最快算法”的行为。
- 原因: 因为“最快算法”的选择可能依赖于输入尺寸,且不同次运行可能选择不一致,导致结果不可复现。
- 默认行为: 如果为
torch.backends.cudnn.deterministic = True:- 作用: 强制 cuDNN 使用确定性的卷积算法。
- 原因: 某些 cuDNN 算法为了速度使用了非确定性的原子操作(atomic operations),这会导致多次运行结果有微小差异。开启此项可避免这种情况。
1.3 其他库相关设置
random.seed(seed):- 作用: 设置 Python 标准库
random模块的种子。 - 影响: 如果代码中用到了
import random进行数据打乱或采样,需要此项。
- 作用: 设置 Python 标准库
np.random.seed(seed):- 作用: 设置 NumPy 库的随机种子。
- 影响: 数据处理阶段(如使用 NumPy 进行预处理、增强)通常依赖此模块。
os.environ['PYTHONHASHSEED'] = str(seed):- 作用: 控制 Python 解释器的哈希随机化。
- 影响: Python 的
dict、set等结构的哈希值在每次解释器启动时默认是随机的(为了安全)。固定此值可以确保字典遍历顺序等在不同运行间保持一致。 - 注意: 这行代码最好在 Python 解释器启动前设置(如在命令行 export),但在脚本中设置对当前进程生成的子进程有效,对当前进程内部已初始化的哈希表影响有限,但加上是个好习惯。
数据增广:
它指的是通过对原始训练数据进行一系列变换(如旋转、裁剪、变色等),生成新的“合成”样本,从而扩充数据集规模的技术。
深度学习模型通常是“数据饥渴”的。如果数据量太少,模型容易过拟合(Overfitting),即死记硬背了训练集的特征,导致在测试集上表现不佳。
数据增广的作用相当于一种**正则化(Regularization)**手段:
- 防止过拟合:增加数据多样性,强迫模型学习更本质的特征(如“猫”的形状),而不是背景或特定角度。
- 提高鲁棒性:模拟现实世界的变化(光照变化、物体遮挡、拍摄角度不同),让模型在复杂环境下更稳定。
- 解决数据不平衡:对少数类样本进行更多增广,平衡类别分布。
train_transform = transforms.Compose(
[
transforms.ToPILImage(), #224, 224, 3模型 :3, 224, 224
transforms.RandomResizedCrop(224),
transforms.RandomRotation(50),
transforms.ToTensor()
]
)
val_transform = transforms.Compose(
[
transforms.ToPILImage(), #224, 224, 3模型 :3, 224, 224
transforms.ToTensor()
]
)
1. 代码结构总览
代码定义了两个变换对象:
train_transform: 用于训练数据。包含数据增强(Data Augmentation),目的是增加数据多样性,防止过拟合。val_transform: 用于验证/测试数据。不包含随机增强,目的是保证评估结果的一致性和公平性。
两者都使用了 transforms.Compose,这意味着列表中的变换会按顺序依次执行。
2. 逐行详细分析
2.1 训练集变换 (train_transform)
train_transform = transforms.Compose(
[
transforms.ToPILImage(), # 步骤 1
transforms.RandomResizedCrop(224), # 步骤 2
transforms.RandomRotation(50), # 步骤 3
transforms.ToTensor() # 步骤 4
]
)
transforms.ToPILImage():- 功能: 将输入数据(通常是
numpy.ndarray或torch.Tensor)转换为 PIL Image 格式。 - 目的: 后续的
RandomResizedCrop和RandomRotation是 PIL 操作,需要输入是 PIL 格式。 - 注意: 如果你的 Dataset 类直接加载的是 PIL 图片(如
ImageFolder),这一步是冗余的。如果加载的是 numpy 数组,这一步是必需的。
- 功能: 将输入数据(通常是
transforms.RandomResizedCrop(224):- 功能: 随机裁剪图像的一部分,然后将其缩放(Resize)到 224x224 像素。
- 参数:
224表示输出尺寸为 224x224。 - 作用:
- 尺度不变性: 让模型学习不同大小的目标。
- 位置不变性: 随机裁剪让模型关注局部特征,而不是死记背景。
- 统一输入: 确保所有输入图片最终都是 224x224,满足模型(如 ResNet-18/50)的输入要求。
transforms.RandomRotation(50):- 功能: 随机旋转图像。
- 参数:
50表示旋转角度范围是 [−50,+50] 度。 - 作用: 增加模型对方向变化的鲁棒性。
- 风险: 50 度是一个比较大的角度。对于某些任务(如数字识别、人脸方向敏感任务),过大的旋转可能会破坏语义信息(例如把"6"转成"9",或把倒立的人当正立的人)。
transforms.ToTensor():- 功能: 将 PIL Image 转换为 PyTorch Tensor。
- 变化:
- 维度:
(H, W, C)→(C, H, W)。 - 数值:
[0, 255](int) →[0.0, 1.0](float)。
- 维度:
- 地位: 通常是 Transform 的最后一步,因为模型只能接收 Tensor。
2.2 验证集变换 (val_transform)
val_transform = transforms.Compose(
[
transforms.ToPILImage(), # 步骤 1
transforms.ToTensor() # 步骤 2
]
)
transforms.ToPILImage(): 同上,确保格式统一。transforms.ToTensor(): 同上,转为模型可接受的 Tensor。- 缺失的关键步骤: 这里没有 resizing 操作!
- 风险: 这意味着验证集的图片必须原本就是 224x224。如果验证集图片尺寸不一(例如 500x500 或 100x100),模型会报错(尺寸不匹配)或者性能极差(因为预训练模型通常期望固定尺寸)。
- 标准做法: 验证集通常使用
Resize+CenterCrop,以保证图片内容完整且尺寸固定。
2.3 代码中的注释分析
#224,224,3 模型:3, 224, 224
- 含义: 注释解释了数据维度的变化。
- 输入 (PIL): 高度 224, 宽度 224, 通道 3 (RGB)。格式为
(H, W, C)。 - 模型输入 (Tensor): 通道 3, 高度 224, 宽度 224。格式为
(C, H, W)。
- 输入 (PIL): 高度 224, 宽度 224, 通道 3 (RGB)。格式为
- 评价: 注释准确地描述了
ToTensor()带来的维度置换(Permute)效果。
3. 核心设计哲学:训练 vs 验证
| 特性 | Train Transform | Val Transform | 原因 |
|---|---|---|---|
| 随机性 | 高 (RandomCrop, RandomRotation) | 无 (确定性) | 训练需要“见过世面”以增强泛化;验证需要“标准考场”以公平评估。 |
| 尺寸处理 | 随机裁剪后缩放 (224) | 缺失 (隐含假设原图 224) | 训练通过裁剪增加样本;验证通常应保留完整信息(中心裁剪)。 |
| 信息量 | 损失部分信息 (裁剪) | 保留主要信息 | 训练防止过拟合;验证反映真实性能。 |
数据集类:
支持半监督学习(Semi-supervised Learning)模式。
class food_Dataset(Dataset):
def __init__(self, path, mode="train"):
self.mode = mode
if mode == "semi":
self.X = self.read_file(path)
else:
self.X, self.Y = self.read_file(path)
self.Y = torch.LongTensor(self.Y) #标签转为长整形\
if mode == "train":
self.transform = train_transform
else:
self.transform = val_transform
def read_file(self, path):
if self.mode == "semi":
file_list = os.listdir(path)
xi = np.zeros((len(file_list), HW, HW, 3), dtype=np.uint8)
# 列出文件夹下所有文件名字
for j, img_name in enumerate(file_list):
img_path = os.path.join(path, img_name)
img = Image.open(img_path)
img = img.resize((HW, HW))
xi[j, ...] = img
print("读到了%d个数据" % len(xi))
return xi
else:
for i in tqdm(range(11)):
file_dir = path + "/%02d" % i
file_list = os.listdir(file_dir)
xi = np.zeros((len(file_list), HW, HW, 3), dtype=np.uint8)
yi = np.zeros(len(file_list), dtype=np.uint8)
# 列出文件夹下所有文件名字
for j, img_name in enumerate(file_list):
img_path = os.path.join(file_dir, img_name)
img = Image.open(img_path)
img = img.resize((HW, HW))
xi[j, ...] = img
yi[j] = i
if i == 0:
X = xi
Y = yi
else:
X = np.concatenate((X, xi), axis=0)
Y = np.concatenate((Y, yi), axis=0)
print("读到了%d个数据" % len(Y))
return X, Y
def __getitem__(self, item):
if self.mode == "semi":
return self.transform(self.X[item]), self.X[item]
else:
return self.transform(self.X[item]), self.Y[item]
def __len__(self):
return len(self.X)
1. 代码结构与功能拆解
1.1 初始化 __init__
def __init__(self, path, mode="train"):
self.mode = mode
# 分支 1: 半监督模式 (只有图片,无标签)
if mode == "semi":
self.X = self.read_file(path)
# 分支 2: 有监督模式 (训练/验证,有图片有标签)
else:
self.X, self.Y = self.read_file(path)
self.Y = torch.LongTensor(self.Y) # 标签转为 LongTensor,适配 CrossEntropyLoss
# 根据模式选择变换
if mode == "train":
self.transform = train_transform
else:
self.transform = val_transform # 注意:semi 模式也会走这里,使用 val_transform
- 功能: 根据
mode决定加载数据的方式和使用的预处理变换。 - 逻辑:
semi: 只加载图像X(无标签),通常用于半监督学习中的未标记数据集。train/val: 加载图像X和标签Y。- Transform 分配: 只有
mode=="train"时用强增强 (train_transform),其他(包括semi)都用弱增强/无增强 (val_transform)。
1.2 文件读取 read_file
这是代码中问题最多的部分。
mode == "semi"分支:- 假设
path目录下全是图片文件。 - 预分配内存:
np.zeros((len(file_list), HW, HW, 3)...)。 - 强制 Resize: 读取时直接将所有图片
resize((HW, HW))。
- 假设
else分支 (有监督):- 硬编码类别数:
for i in tqdm(range(11))。假设固定有 11 个类别。 - 硬编码目录结构: 假设子文件夹名为
00,01, ...,10(path + "/%02d" % i)。 - 内存预分配: 同样将所有图片读入内存并 Resize。
- 标签生成: 根据文件夹索引
i自动生成标签yi[j] = i。
- 硬编码类别数:
1.3 数据获取 __getitem__
def __getitem__(self, item):
if self.mode == "semi":
# 返回:(增强后的 Tensor, 原始 Numpy 数组)
return self.transform(self.X[item]), self.X[item]
else:
# 返回:(增强后的 Tensor, 标签)
return self.transform(self.X[item]), self.Y[item]
- Semi 模式: 返回两个值。通常用于一致性正则化(如 Mean Teacher, FixMatch),需要同时获取增强视图和原始视图。
- 有监督模式: 标准返回
(image_tensor, label)。
1.4 长度 __len__
- 返回
self.X的长度,标准写法。
半监督学习数据集类 semiDataset 及其加载器生成函数 get_semi_loader。
自训练(Self-Training)半监督学习的典型实现。基于**伪标签(Pseudo-Labeling)**技术的半监督学习数据集类 semiDataset 及其加载器生成函数 get_semi_loader。
其核心思想是:利用当前训练好的模型,对无标签数据进行预测,筛选出置信度高于阈值(thres)的样本,赋予其预测标签,将其转化为“有标签数据”参与下一轮训练。
class semiDataset(Dataset):
def __init__(self, no_label_loder, model, device, thres=0.99):
x, y = self.get_label(no_label_loder, model, device, thres)
if x == []:
self.flag = False
else:
self.flag = True
self.X = np.array(x)
self.Y = torch.LongTensor(y)
self.transform = train_transform
def get_label(self, no_label_loder, model, device, thres):
model = model.to(device)
pred_prob = []
labels = []
x = []
y = []
soft = nn.Softmax()
with torch.no_grad():
for bat_x, _ in no_label_loder:
bat_x = bat_x.to(device)
pred = model(bat_x)
pred_soft = soft(pred)
pred_max, pred_value = pred_soft.max(1)
pred_prob.extend(pred_max.cpu().numpy().tolist())
labels.extend(pred_value.cpu().numpy().tolist())
for index, prob in enumerate(pred_prob):
if prob > thres:
x.append(no_label_loder.dataset[index][1]) #调用到原始的getitem
y.append(labels[index])
return x, y
def __getitem__(self, item):
return self.transform(self.X[item]), self.Y[item]
def __len__(self):
return len(self.X)
def get_semi_loader(no_label_loder, model, device, thres):
semiset = semiDataset(no_label_loder, model, device, thres)
if semiset.flag == False:
return None
else:
semi_loader = DataLoader(semiset, batch_size=16, shuffle=False)
return semi_loader
1. 代码功能模块拆解
1.1 类 semiDataset
这是一个自定义的 PyTorch Dataset,但它与常规 Dataset 不同,它的数据是在初始化时动态生成的。
-
__init__:- 输入:
no_label_loder(无标签数据的 DataLoader),model(当前模型),device,thres(置信度阈值)。 - 核心动作: 立即调用
self.get_label(...)。这意味着实例化这个数据集的开销非常大,因为需要遍历整个无标签数据集进行推理。 - 状态标志:
self.flag。如果筛选后没有样本(x == []),标记为False,防止后续报错。 - 数据存贮:
self.X存储原始图像(Numpy),self.Y存储伪标签(Tensor)。 - 变换: 固定使用
train_transform,意味着这些伪标签数据在训练时会接受数据增强。
- 输入:
-
get_label(核心逻辑):- 推理: 遍历
no_label_loder,使用model预测所有无标签数据。 - 置信度计算: 使用
Softmax获取概率分布,取最大值pred_max作为置信度,pred_value作为伪标签。 - 筛选: 遍历所有预测结果,只保留
prob > thres的样本。 - 数据提取:
x.append(no_label_loder.dataset[index][1])。这里从无标签数据集中提取原始图像。
- 推理: 遍历
-
__getitem__&__len__:- 标准 PyTorch Dataset 接口,返回增强后的图像和伪标签。
1.2 函数 get_semi_loader
- 功能: 封装
semiDataset的创建过程。 - 异常处理: 如果
semiDataset.flag为False(即没有高置信度样本),返回None,避免训练循环报错。 - DataLoader 配置: 硬编码了
batch_size=16和shuffle=False。
2. 核心逻辑流程分析
- 输入: 一个包含无标签图像的 DataLoader。
- 预测: 模型对所有无标签图像进行前向传播,得到类别概率。
- 过滤: 丢弃那些模型“不确定”的样本(概率 < 0.99)。
- 固化: 将剩下的样本及其预测类别保存到内存中。
- 输出: 一个新的 DataLoader,可以像普通有标签数据一样被迭代,用于计算监督损失(Supervised Loss)。
3. 使用流程
在半监督训练循环中,通常这样使用:
# 1. 准备无标签 DataLoader (必须 shuffle=False 以匹配索引,或使用 IndexedDataset)
unlabeled_loader = DataLoader(unlabeled_dataset, batch_size=64, shuffle=False)
# 2. 每隔 5 个 Epoch 更新一次伪标签
if epoch % 5 == 0:
predictions = generate_pseudo_labels(unlabeled_loader, model, device, thres=0.9)
semi_loader = get_semi_loader(unlabeled_dataset, predictions, 0.9, train_transform)
# 3. 训练循环
if semi_loader is not None:
for x_semi, y_semi in semi_loader:
# 计算半监督损失
loss_semi = criterion(model(x_semi), y_semi)
自定义卷积神经网络(CNN):
这段代码定义了一个名为 myModel 的自定义卷积神经网络(CNN),继承自 PyTorch 的 nn.Module。从结构上看,它是一个简化版的 VGG 风格网络,主要用于图像分类任务。
class myModel(nn.Module):
def __init__(self, num_class):
super(myModel, self).__init__()
#3 *224 *224 -> 512*7*7 -> 拉直 -》全连接分类
self.conv1 = nn.Conv2d(3, 64, 3, 1, 1) # 64*224*224
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU()
self.pool1 = nn.MaxPool2d(2) #64*112*112
self.layer1 = nn.Sequential(
nn.Conv2d(64, 128, 3, 1, 1), # 128*112*112
nn.BatchNorm2d(128),
nn.ReLU(),
nn.MaxPool2d(2) #128*56*56
)
self.layer2 = nn.Sequential(
nn.Conv2d(128, 256, 3, 1, 1),
nn.BatchNorm2d(256),
nn.ReLU(),
nn.MaxPool2d(2) #256*28*28
)
self.layer3 = nn.Sequential(
nn.Conv2d(256, 512, 3, 1, 1),
nn.BatchNorm2d(512),
nn.ReLU(),
nn.MaxPool2d(2) #512*14*14
)
self.pool2 = nn.MaxPool2d(2) #512*7*7
self.fc1 = nn.Linear(25088, 1000) #25088->1000
self.relu2 = nn.ReLU()
self.fc2 = nn.Linear(1000, num_class) #1000-11
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.pool1(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.pool2(x)
x = x.view(x.size()[0], -1)
x = self.fc1(x)
x = self.relu2(x)
x = self.fc2(x)
return x
1. 网络架构总览
该模型由两部分组成:
- 特征提取器(Backbone):由卷积层、批归一化、激活函数和池化层组成,负责从图像中提取特征。
- 分类器(Head):由全连接层组成,负责将提取的特征映射到具体的类别。
输入假设:3×224×224 (RGB 图像) 输出:N×num_class (Logits,未归一化的分数)
2. 逐层维度详细推导
代码中的注释基本准确,我们来验证一下数据流动的过程(假设 Batch Size 为 N ):
| 层级 | 操作 | 输入维度 | 输出维度 | 说明 |
|---|---|---|---|---|
| Input | - | N,3,224,224 | - | 原始图像 |
| conv1 | Conv2d(3, 64, 3, 1, 1) | N,3,224,224 | N,64,224,224 | padding=1 保持尺寸不变 |
| bn1 | BatchNorm2d | N,64,224,224 | N,64,224,224 | 加速收敛,防止梯度消失 |
| relu | ReLU | N,64,224,224 | N,64,224,224 | 引入非线性 |
| pool1 | MaxPool2d(2) | N,64,224,224 | N,64,112,112 | 下采样,尺寸减半 |
| layer1 | Conv+BN+ReLU+Pool | N,64,112,112 | N,128,56,56 | 通道翻倍,尺寸减半 |
| layer2 | Conv+BN+ReLU+Pool | N,128,56,56 | N,256,28,28 | 通道翻倍,尺寸减半 |
| layer3 | Conv+BN+ReLU+Pool | N,256,28,28 | N,512,14,14 | 通道翻倍,尺寸减半 |
| pool2 | MaxPool2d(2) | N,512,14,14 | N,512,7,7 | 最后一次下采样 |
| Flatten | view | N,512,7,7 | N,25088 | 512×7×7=25088 |
| fc1 | Linear(25088, 1000) | N,25088 | N,1000 | 参数量巨大 |
| relu2 | ReLU | N,1000 | N,1000 | 全连接层后的激活 |
| fc2 | Linear(1000, num_class) | N,1000 | N,num_class | 输出层 |
参数量估算:
- 卷积部分参数较少。
- fc1 层参数量:25088×1000+1000≈2500 万参数。
- fc2 层参数量:1000×11+11≈1.1 万参数。
- 结论:模型绝大部分参数集中在
fc1,这容易导致过拟合。
3. 代码逻辑分析
3.1 __init__ 初始化
- 模块化设计: 使用
nn.Sequential封装layer1到layer3,使代码更整洁。 - BatchNorm: 每个卷积层后都紧跟
BatchNorm2d,这是现代 CNN 的标准配置,有助于训练稳定。 - 硬编码:
fc1的输入维度25088是硬编码的。这意味着输入图片必须严格为 224x224。如果输入变成 256x256,程序会在fc1处报错(维度不匹配)。
3.2 forward 前向传播
- 顺序执行: 严格按照定义层的顺序调用。
- Flatten 操作:
x.view(x.size()[0], -1)将多维特征图拉平。x.size()[0]获取 Batch Size。-1自动计算剩余维度。- 注: 更现代的写法是
x = x.flatten(1)或x = x.view(x.shape[0], -1)。
- 输出: 直接返回
fc2的结果(Logits)。- 正确性: 这是正确的。PyTorch 的
nn.CrossEntropyLoss内部包含了 Softmax,因此模型输出不需要加 Softmax。
- 正确性: 这是正确的。PyTorch 的
主训练循环函数:
一个半监督学习(Semi-Supervised Learning)的主训练循环函数。它整合了有监督训练(train_loader)、伪标签半监督训练(semi_loader)、验证(val_loader)和模型保存以及结果可视化的完整流程。
def train_val(model, train_loader, val_loader, no_label_loader, device, epochs, optimizer, loss, thres, save_path):
model = model.to(device)
semi_loader = None
plt_train_loss = []
plt_val_loss = []
plt_train_acc = []
plt_val_acc = []
max_acc = 0.0
for epoch in range(epochs):
train_loss = 0.0
val_loss = 0.0
train_acc = 0.0
val_acc = 0.0
semi_loss = 0.0
semi_acc = 0.0
start_time = time.time()
model.train()
for batch_x, batch_y in train_loader:
x, target = batch_x.to(device), batch_y.to(device)
pred = model(x)
train_bat_loss = loss(pred, target)
train_bat_loss.backward()
optimizer.step() # 更新参数 之后要梯度清零否则会累积梯度
optimizer.zero_grad()
train_loss += train_bat_loss.cpu().item()
train_acc += np.sum(np.argmax(pred.detach().cpu().numpy(), axis=1) == target.cpu().numpy())
plt_train_loss.append(train_loss / train_loader.__len__())
plt_train_acc.append(train_acc/train_loader.dataset.__len__()) #记录准确率,
if semi_loader!= None:
for batch_x, batch_y in semi_loader:
x, target = batch_x.to(device), batch_y.to(device)
pred = model(x)
semi_bat_loss = loss(pred, target)
semi_bat_loss.backward()
optimizer.step() # 更新参数 之后要梯度清零否则会累积梯度
optimizer.zero_grad()
semi_loss += train_bat_loss.cpu().item()
semi_acc += np.sum(np.argmax(pred.detach().cpu().numpy(), axis=1) == target.cpu().numpy())
print("半监督数据集的训练准确率为", semi_acc/train_loader.dataset.__len__())
model.eval()
with torch.no_grad():
for batch_x, batch_y in val_loader:
x, target = batch_x.to(device), batch_y.to(device)
pred = model(x)
val_bat_loss = loss(pred, target)
val_loss += val_bat_loss.cpu().item()
val_acc += np.sum(np.argmax(pred.detach().cpu().numpy(), axis=1) == target.cpu().numpy())
plt_val_loss.append(val_loss / val_loader.dataset.__len__())
plt_val_acc.append(val_acc / val_loader.dataset.__len__())
if epoch%3 == 0 and plt_val_acc[-1] > 0.6:
semi_loader = get_semi_loader(no_label_loader, model, device, thres)
if val_acc > max_acc:
torch.save(model, save_path)
max_acc = val_loss
print('[%03d/%03d] %2.2f sec(s) TrainLoss : %.6f | valLoss: %.6f Trainacc : %.6f | valacc: %.6f' % \
(epoch, epochs, time.time() - start_time, plt_train_loss[-1], plt_val_loss[-1], plt_train_acc[-1], plt_val_acc[-1])
) # 打印训练结果。 注意python语法, %2.2f 表示小数位为2的浮点数, 后面可以对应。
plt.plot(plt_train_loss)
plt.plot(plt_val_loss)
plt.title("loss")
plt.legend(["train", "val"])
plt.show()
plt.plot(plt_train_acc)
plt.plot(plt_val_acc)
plt.title("acc")
plt.legend(["train", "val"])
plt.show()
1. 函数定义与初始化
def train_val(model, train_loader, val_loader, no_label_loader, device, epochs, optimizer, loss, thres, save_path):
- 功能: 定义训练函数。
- 参数:
model: 神经网络模型。train_loader: 有标签训练数据。val_loader: 验证数据。no_label_loader: 无标签数据(用于生成伪标签)。device: 计算设备(cuda/cpu)。loss: 损失函数(如nn.CrossEntropyLoss)。thres: 伪标签置信度阈值。
model = model.to(device)
- 功能: 将模型参数移动到指定设备(GPU 或 CPU)。
- 原理: PyTorch 要求模型和数据必须在同一设备上才能计算。
semi_loader = None
- 功能: 初始化半监督数据加载器。
- 逻辑: 初始为
None,表示训练初期不使用半监督数据,等待模型有一定能力后再开启。
plt_train_loss = []
plt_val_loss = []
plt_train_acc = []
plt_val_acc = []
- 功能: 初始化列表,用于记录每个 Epoch 的损失和准确率,以便后续绘图。
max_acc = 0.0
- 功能: 记录历史最高验证准确率,用于模型保存策略。
2. Epoch 循环与变量重置
for epoch in range(epochs):
- 功能: 开始训练循环,共
epochs轮。
train_loss = 0.0
val_loss = 0.0
train_acc = 0.0
val_acc = 0.0
semi_loss = 0.0
semi_acc = 0.0
- 功能: 每个 Epoch 开始时,重置累计变量。
- 注意: 必须重置,否则 Loss 和 Acc 会不断累加。
start_time = time.time()
- 功能: 记录当前 Epoch 开始时间,用于计算训练耗时。
3. 有监督训练阶段 (Supervised Training)
model.train()
- 功能: 将模型设置为训练模式。
- 重要性: 这会启用
Dropout和BatchNorm的训练行为(如更新运行均值/方差)。如果在训练时忘记写这行,模型性能会大幅下降。
for batch_x, batch_y in train_loader:
x, target = batch_x.to(device), batch_y.to(device)
pred = model(x)
train_bat_loss = loss(pred, target)
train_bat_loss.backward()
optimizer.step()
optimizer.zero_grad()
- 功能: 标准训练步骤。
- 潜在问题:
optimizer.zero_grad()放在了step()之后。- 标准写法:
zero_grad()->backward()->step()。 - 风险: 虽然这样写也能运行(为下一个 batch 清零),但如果
backward()报错,梯度会残留到下一个 Epoch。建议移到循环第一行。
- 标准写法:
train_loss += train_bat_loss.cpu().item()
train_acc += np.sum(np.argmax(pred.detach().cpu().numpy(), axis=1) == target.cpu().numpy())
- 功能: 累加 Loss 和 正确样本数。
- 性能问题:
cpu().numpy()会将数据从 GPU 拷贝到 CPU,频繁操作会显著降低训练速度。 - 优化: 建议使用
torch原生操作:(pred.argmax(1) == target).sum().item()。
plt_train_loss.append(train_loss / train_loader.__len__())
plt_train_acc.append(train_acc/train_loader.dataset.__len__())
- 功能: 计算平均值并记录。
- 逻辑分析:
train_loss: 累加的是batch_loss(通常是 batch 内的平均 loss)。除以len(loader)(batch 数量)。结果是 Epoch 平均 Loss。(合理)train_acc: 累加的是 正确样本数。除以len(dataset)(总样本数)。结果是 准确率。(合理)
4. 半监督训练阶段 (Semi-Supervised Training)
if semi_loader!= None:
for batch_x, batch_y in semi_loader:
x, target = batch_x.to(device), batch_y.to(device)
pred = model(x)
semi_bat_loss = loss(pred, target)
semi_bat_loss.backward()
optimizer.step()
optimizer.zero_grad()
- 功能: 如果存在伪标签数据,进行额外的训练步骤。
- 逻辑: 与有监督训练类似,使用伪标签
target计算损失。
semi_loss += train_bat_loss.cpu().item() # <--- 【严重 Bug】
- 问题: 这里累加的是
train_bat_loss(上一个有监督循环的变量),而不是当前的semi_bat_loss。 - 后果:
semi_loss统计数据完全错误,无法反映半监督训练的真实损失。 - 修正: 应改为
semi_loss += semi_bat_loss.cpu().item()。
semi_acc += np.sum(np.argmax(pred.detach().cpu().numpy(), axis=1) == target.cpu().numpy())
print("半监督数据集的训练准确率为", semi_acc/train_loader.dataset.__len__()) # <--- 【逻辑 Bug】
- 问题: 分母使用了
train_loader.dataset.__len__()(有监督数据集大小)。 - 后果: 如果半监督数据集大小与有监督不同,计算出的准确率是荒谬的(可能超过 100% 或极低)。
- 修正: 分母应为
semi_loader.dataset.__len__()。
5. 验证阶段 (Validation)
model.eval()
with torch.no_grad():
- 功能: 设置为评估模式,并关闭梯度计算。
- 重要性:
eval()关闭 Dropout;no_grad()节省显存并加速。
for batch_x, batch_y in val_loader:
x, target = batch_x.to(device), batch_y.to(device)
pred = model(x)
val_bat_loss = loss(pred, target)
val_loss += val_bat_loss.cpu().item()
val_acc += np.sum(np.argmax(pred.detach().cpu().numpy(), axis=1) == target.cpu().numpy())
- 功能: 遍历验证集,计算 Loss 和 Acc。
plt_val_loss.append(val_loss / val_loader.dataset.__len__()) # <--- 【不一致 Bug】
plt_val_acc.append(val_acc / val_loader.dataset.__len__())
- 问题:
- 训练 Loss 除以的是
len(loader)(Batch 数)。 - 验证 Loss 除以的是
len(dataset)(样本数)。 - 后果: 假设 Batch Size=16,验证 Loss 的值会比训练 Loss 小 16 倍左右。两者数量级不一致,画在同一张图上无法对比。
- 训练 Loss 除以的是
- 修正: 验证 Loss 也应除以
len(val_loader)。
6. 策略更新与模型保存
if epoch%3 == 0 and plt_val_acc[-1] > 0.6:
semi_loader = get_semi_loader(no_label_loader, model, device, thres)
- 功能: 每 3 个 Epoch 且验证准确率 > 0.6 时,重新生成伪标签。
- 逻辑: 防止模型太弱时生成错误伪标签(噪声),导致模型崩溃。策略合理。
if val_acc > max_acc:
torch.save(model, save_path)
max_acc = val_loss # <--- 【致命 Bug】
- 问题:
- 判断条件是
val_acc(准确率)。 - 更新
max_acc时却赋值为val_loss(损失值)。 - 后果: 下一轮比较时,拿
val_acc(0~1 之间) 和val_loss(可能 >1) 比较,逻辑彻底混乱,导致无法正确保存最佳模型。
- 判断条件是
- 修正:
max_acc = val_acc。 - 建议: 保存
model.state_dict()而不是整个model对象,便于移植。
7. 日志打印
print('[%03d/%03d] %2.2f sec(s) TrainLoss : %.6f | valLoss: %.6f Trainacc : %.6f | valacc: %.6f' % \
(epoch, epochs, time.time() - start_time, plt_train_loss[-1], plt_val_loss[-1], plt_train_acc[-1], plt_val_acc[-1])
)
- 功能: 打印当前 Epoch 的耗时、Loss 和 Acc。
- 注意: 由于前面提到的 Loss 计算不一致,这里打印出的
TrainLoss和valLoss数值可能相差巨大,容易误导。
8. 结果可视化 (Plotting)
plt.plot(plt_train_loss)
plt.plot(plt_val_loss)
plt.title("loss")
plt.legend(["train", "val"])
plt.show()
- 功能: 绘制 Loss 曲线。
- 风险: 由于训练集和验证集 Loss 计算分母不一致,两条曲线可能无法重合或趋势难以对比。
plt.plot(plt_train_acc)
plt.plot(plt_val_acc)
plt.title("acc")
plt.legend(["train", "val"])
plt.show()
- 功能: 绘制准确率曲线。
- 逻辑: 准确率计算逻辑基本一致,这部分图通常能正常反映模型收敛情况。
整个半监督学习项目的主入口脚本:
这段代码是整个半监督学习项目的主入口脚本(Main Script),负责配置数据路径、初始化数据集与模型、设置超参数,并启动训练流程。它串联了之前分析过的所有组件(food_Dataset, myModel/VGG, train_val, semiDataset 等)。
这段代码是项目的组装层,将数据、模型、训练逻辑串联起来。
# path = r"D:\pythonproject\classification\food_classification\food-11\training\labeled"
# train_path = r"D:\pythonproject\classification\food_classification\food-11\training\labeled"
# val_path = r"D:\pythonproject\classification\food_classification\food-11\validation"
train_path = r"D:\pythonproject\classification\food_classification\food-11_sample\training\labeled"
val_path = r"D:\pythonproject\classification\food_classification\food-11_sample\validation"
no_label_path = r"D:\pythonproject\classification\food_classification\food-11_sample\training\unlabeled\00"
train_set = food_Dataset(train_path, "train")
val_set = food_Dataset(val_path, "val")
no_label_set = food_Dataset(no_label_path, "semi")
train_loader = DataLoader(train_set, batch_size=16, shuffle=True)
val_loader = DataLoader(val_set, batch_size=16, shuffle=True)
no_label_loader = DataLoader(no_label_set, batch_size=16, shuffle=False)
# model = myModel(11)
model, _ = initialize_model("vgg", 11, use_pretrained=True)
lr = 0.001
loss = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
device = "cuda" if torch.cuda.is_available() else "cpu"
save_path = "model_save/best_model.pth"
epochs = 15
thres = 0.99
train_val(model, train_loader, val_loader, no_label_loader, device, epochs, optimizer, loss, thres, save_path)
1. 数据路径配置
train_path = r"D:\pythonproject\classification\food_classification\food-11_sample\training\labeled"
val_path = r"D:\pythonproject\classification\food_classification\food-11_sample\validation"
no_label_path = r"D:\pythonproject\classification\food_classification\food-11_sample\training\unlabeled\00"
- 功能: 定义训练集、验证集、无标签数据集的磁盘路径。
- 语法:
r"..."表示原始字符串,避免 Windows 路径中\被转义(如\n被识别为换行)。 - 潜在风险:
- 硬编码路径: 代码移植到其他电脑会直接报错。建议使用
os.path.join或相对路径。 - 无标签路径:
no_label_path指向了.../unlabeled\00。- 如果
food_Dataset的semi模式期望直接读取文件夹下的图片,这没问题。 - 但如果
unlabeled文件夹下有多个子文件夹(如00,01...),这里只读取了00,会丢失大部分无标签数据。
- 如果
- 路径存在性: 代码没有检查路径是否存在,如果路径错误,会在
food_Dataset初始化时报错。
- 硬编码路径: 代码移植到其他电脑会直接报错。建议使用
2. 数据集初始化 (关联 food_Dataset)
train_set = food_Dataset(train_path, "train")
val_set = food_Dataset(val_path, "val")
no_label_set = food_Dataset(no_label_path, "semi")
- 功能: 实例化之前定义的
food_Dataset类。 - 关联分析:
train_set("train"): 会调用read_file的else分支,期望train_path下有00~`10` 的子文件夹。如果文件夹命名不符,代码会崩溃。val_set("val"): 同样期望子文件夹结构。no_label_set("semi"): 调用read_file的semi分支,直接读取路径下的所有文件。
- 严重隐患 (内存):
- 如之前分析,
food_Dataset在__init__中将所有图片加载到内存 (np.zeros预分配)。 - 如果图片较多(如几千张 224x224),这里会瞬间占用数 GB 内存,可能导致 OOM(内存溢出)。
- 建议: 改用懒加载(只存路径,
__getitem__再读图)。
- 如之前分析,
3. DataLoader 配置
train_loader = DataLoader(train_set, batch_size=16, shuffle=True)
val_loader = DataLoader(val_set, batch_size=16, shuffle=True) # <--- 异常点
no_label_loader = DataLoader(no_label_set, batch_size=16, shuffle=False)
- 功能: 创建数据迭代器。
train_loader:shuffle=True是标准的,确保每个 Epoch 数据顺序不同。val_loader:shuffle=True是不寻常的。- 问题: 验证集通常不需要打乱,且如果后续需要分析具体哪些样本错了,打乱后索引会对不上。
- 建议: 改为
shuffle=False。
no_label_loader:shuffle=False是必须的。- 原因: 关联之前的
semiDataset代码。伪标签生成逻辑依赖索引对齐(pred_prob[index]对应dataset[index])。如果这里 shuffle 了,伪标签会和图片错乱,导致模型学习错误知识。
- 原因: 关联之前的
4. 模型初始化
# model = myModel(11)
model, _ = initialize_model("vgg", 11, use_pretrained=True)
- 功能: 初始化神经网络。
- 变化: 注释掉了自定义的
myModel,改用 VGG 预训练模型。 - 优势:
- 迁移学习:
use_pretrained=True加载 ImageNet 权重,特征提取能力远强于从头训练的myModel。 - 收敛快: 在小数据集(food-11)上效果更好。
- 迁移学习:
- 注意:
initialize_model函数未在片段中定义,假设它是封装好的工具函数(通常来自torchvision.models)。需确保其输出结构符合train_val的期望(输出 logits)。
5. 优化器与损失函数
lr = 0.001
loss = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
lr = 0.001: 学习率。对于微调预训练模型,0.001 可能略大,通常 0.0001 更稳妥,但 0.001 也可接受。nn.CrossEntropyLoss(): 分类任务标准损失函数。- 注意: 它期望模型输出是 Logits(未归一化分数),内部会自动处理 Softmax。如果模型最后加了 Softmax,这里会报错或结果错误。
AdamW: 相比Adam,它修正了权重衰减(Weight Decay)的实现方式,泛化能力通常更好。weight_decay=1e-4: 正则化项,防止过拟合。
6. 设备与超参数配置
device = "cuda" if torch.cuda.is_available() else "cpu"
save_path = "model_save/best_model.pth"
epochs = 15
thres = 0.99
device: 自动检测 GPU。save_path: 模型保存路径。- 风险: 代码没有检查
model_save文件夹是否存在。如果文件夹不存在,torch.save会报错。建议添加os.makedirs("model_save", exist_ok=True)。
- 风险: 代码没有检查
epochs = 15: 训练轮数。对于迁移学习,15 轮通常足够。thres = 0.99: 伪标签置信度阈值。- 风险: 如之前分析,0.99 非常高。训练初期模型可能无法产生 >0.99 的预测,导致
semi_loader始终为None,半监督部分失效。 - 建议: 可尝试 0.9 或 0.95,或使用动态阈值。
- 风险: 如之前分析,0.99 非常高。训练初期模型可能无法产生 >0.99 的预测,导致
7. 启动训练
train_val(model, train_loader, val_loader, no_label_loader, device, epochs, optimizer, loss, thres, save_path)
- 功能: 调用之前分析的主训练函数。
- 关联风险汇总:
- 模型保存 Bug:
train_val内部有max_acc = val_loss的致命 Bug,导致这里保存的模型可能不是最佳的。 - Loss 计算不一致:
train_val中训练和验证 Loss 计算分母不同,日志和绘图会误导。 - 内存风险:
food_Dataset的全量加载可能导致训练中途内存爆炸。 - 半监督逻辑: 如果
no_label_loader的 shuffle 设置不当,或者train_val中的索引对齐逻辑有误,半监督学习可能产生负效果。
- 模型保存 Bug:
更多推荐
所有评论(0)