随机种子设置工具函数:

它的核心目的是确保实验的可复现性(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 进行数据打乱或采样,需要此项。
  • np.random.seed(seed):
    • 作用: 设置 NumPy 库的随机种子。
    • 影响: 数据处理阶段(如使用 NumPy 进行预处理、增强)通常依赖此模块。
  • os.environ['PYTHONHASHSEED'] = str(seed):
    • 作用: 控制 Python 解释器的哈希随机化。
    • 影响: Python 的 dictset 等结构的哈希值在每次解释器启动时默认是随机的(为了安全)。固定此值可以确保字典遍历顺序等在不同运行间保持一致。
    • 注意: 这行代码最好在 Python 解释器启动前设置(如在命令行 export),但在脚本中设置对当前进程生成的子进程有效,对当前进程内部已初始化的哈希表影响有限,但加上是个好习惯。

数据增广:

它指的是通过对原始训练数据进行一系列变换(如旋转、裁剪、变色等),生成新的“合成”样本,从而扩充数据集规模的技术。

深度学习模型通常是“数据饥渴”的。如果数据量太少,模型容易过拟合(Overfitting),即死记硬背了训练集的特征,导致在测试集上表现不佳。

数据增广的作用相当于一种**正则化(Regularization)**手段:

  1. 防止过拟合:增加数据多样性,强迫模型学习更本质的特征(如“猫”的形状),而不是背景或特定角度。
  2. 提高鲁棒性:模拟现实世界的变化(光照变化、物体遮挡、拍摄角度不同),让模型在复杂环境下更稳定。
  3. 解决数据不平衡:对少数类样本进行更多增广,平衡类别分布。
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. 代码结构总览

代码定义了两个变换对象:

  1. train_transform: 用于训练数据。包含数据增强(Data Augmentation),目的是增加数据多样性,防止过拟合。
  2. 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。
    • 作用:
      1. 尺度不变性: 让模型学习不同大小的目标。
      2. 位置不变性: 随机裁剪让模型关注局部特征,而不是死记背景。
      3. 统一输入: 确保所有输入图片最终都是 224x224,满足模型(如 ResNet-18/50)的输入要求。
  • transforms.RandomRotation(50):
    • 功能: 随机旋转图像。
    • 参数50 表示旋转角度范围是 [−50,+50] 度。
    • 作用: 增加模型对方向变化的鲁棒性。
    • 风险: 50 度是一个比较大的角度。对于某些任务(如数字识别、人脸方向敏感任务),过大的旋转可能会破坏语义信息(例如把"6"转成"9",或把倒立的人当正立的人)。
  • transforms.ToTensor():
    • 功能: 将 PIL Image 转换为 PyTorch Tensor。
    • 变化:
      1. 维度(H, W, C) → (C, H, W)
      2. 数值[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)
  • 评价: 注释准确地描述了 ToTensor() 带来的维度置换(Permute)效果。

3. 核心设计哲学:训练 vs 验证

特性Train TransformVal 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 个类别。
    • 硬编码目录结构: 假设子文件夹名为 0001, ..., 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 (当前模型), devicethres (置信度阈值)。
    • 核心动作: 立即调用 self.get_label(...)。这意味着实例化这个数据集的开销非常大,因为需要遍历整个无标签数据集进行推理。
    • 状态标志self.flag。如果筛选后没有样本(x == []),标记为 False,防止后续报错。
    • 数据存贮self.X 存储原始图像(Numpy),self.Y 存储伪标签(Tensor)。
    • 变换: 固定使用 train_transform,意味着这些伪标签数据在训练时会接受数据增强。
  • get_label (核心逻辑):

    1. 推理: 遍历 no_label_loder,使用 model 预测所有无标签数据。
    2. 置信度计算: 使用 Softmax 获取概率分布,取最大值 pred_max 作为置信度,pred_value 作为伪标签。
    3. 筛选: 遍历所有预测结果,只保留 prob > thres 的样本。
    4. 数据提取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. 核心逻辑流程分析

  1. 输入: 一个包含无标签图像的 DataLoader。
  2. 预测: 模型对所有无标签图像进行前向传播,得到类别概率。
  3. 过滤: 丢弃那些模型“不确定”的样本(概率 < 0.99)。
  4. 固化: 将剩下的样本及其预测类别保存到内存中。
  5. 输出: 一个新的 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. 网络架构总览

该模型由两部分组成:

  1. 特征提取器(Backbone):由卷积层、批归一化、激活函数和池化层组成,负责从图像中提取特征。
  2. 分类器(Head):由全连接层组成,负责将提取的特征映射到具体的类别。

输入假设:3×224×224 (RGB 图像) 输出:N×num_class (Logits,未归一化的分数)


2. 逐层维度详细推导

代码中的注释基本准确,我们来验证一下数据流动的过程(假设 Batch Size 为 N ):

层级操作输入维度输出维度说明
Input-N,3,224,224-原始图像
conv1Conv2d(3, 64, 3, 1, 1)N,3,224,224N,64,224,224padding=1 保持尺寸不变
bn1BatchNorm2dN,64,224,224N,64,224,224加速收敛,防止梯度消失
reluReLUN,64,224,224N,64,224,224引入非线性
pool1MaxPool2d(2)N,64,224,224N,64,112,112下采样,尺寸减半
layer1Conv+BN+ReLU+PoolN,64,112,112N,128,56,56通道翻倍,尺寸减半
layer2Conv+BN+ReLU+PoolN,128,56,56N,256,28,28通道翻倍,尺寸减半
layer3Conv+BN+ReLU+PoolN,256,28,28N,512,14,14通道翻倍,尺寸减半
pool2MaxPool2d(2)N,512,14,14N,512,7,7最后一次下采样
FlattenviewN,512,7,7N,25088512×7×7=25088
fc1Linear(25088, 1000)N,25088N,1000参数量巨大
relu2ReLUN,1000N,1000全连接层后的激活
fc2Linear(1000, num_class)N,1000N,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。

主训练循环函数:

一个半监督学习(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 也应除以 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】
  • 问题:
    1. 判断条件是 val_acc (准确率)。
    2. 更新 max_acc 时却赋值为 val_loss (损失值)。
    3. 后果: 下一轮比较时,拿 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 被识别为换行)。
  • 潜在风险:
    1. 硬编码路径: 代码移植到其他电脑会直接报错。建议使用 os.path.join 或相对路径。
    2. 无标签路径no_label_path 指向了 .../unlabeled\00
      • 如果 food_Dataset 的 semi 模式期望直接读取文件夹下的图片,这没问题。
      • 但如果 unlabeled 文件夹下有多个子文件夹(如 0001...),这里只读取了 00,会丢失大部分无标签数据
    3. 路径存在性: 代码没有检查路径是否存在,如果路径错误,会在 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_loadershuffle=True 是标准的,确保每个 Epoch 数据顺序不同。
  • val_loadershuffle=True 是不寻常的
    • 问题: 验证集通常不需要打乱,且如果后续需要分析具体哪些样本错了,打乱后索引会对不上。
    • 建议: 改为 shuffle=False
  • no_label_loadershuffle=False 是必须的
    • 原因: 关联之前的 semiDataset 代码。伪标签生成逻辑依赖索引对齐(pred_prob[index] 对应 dataset[index])。如果这里 shuffle 了,伪标签会和图片错乱,导致模型学习错误知识。

4. 模型初始化

# model = myModel(11)
model, _ = initialize_model("vgg", 11, use_pretrained=True)
  • 功能: 初始化神经网络。
  • 变化: 注释掉了自定义的 myModel,改用 VGG 预训练模型
  • 优势:
    1. 迁移学习use_pretrained=True 加载 ImageNet 权重,特征提取能力远强于从头训练的 myModel
    2. 收敛快: 在小数据集(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,或使用动态阈值。

7. 启动训练

train_val(model, train_loader, val_loader, no_label_loader, device, epochs, optimizer, loss, thres, save_path)
  • 功能: 调用之前分析的主训练函数。
  • 关联风险汇总:
    1. 模型保存 Bugtrain_val 内部有 max_acc = val_loss 的致命 Bug,导致这里保存的模型可能不是最佳的。
    2. Loss 计算不一致train_val 中训练和验证 Loss 计算分母不同,日志和绘图会误导。
    3. 内存风险food_Dataset 的全量加载可能导致训练中途内存爆炸。
    4. 半监督逻辑: 如果 no_label_loader 的 shuffle 设置不当,或者 train_val 中的索引对齐逻辑有误,半监督学习可能产生负效果。

 

更多推荐