【人工智能】【Python】卷积神经网络应用实验

1.准备自定义数据集

准备自定义数据集(PlantDOC,农作物病害分类数据集),下载后进行划分,训练、验证和测试集占比分别为60%、20%和20%。

下载PlantDOC数据集(GitHub:https://github.com/pratikkayal/PlantDoc-Dataset;Kaggle:https://www.kaggle.com/datasets/nirmalsankalana/plantdoc-dataset

解压后将各类别放置同一个文件夹,使用编写的Python脚本重新进行划分,训练、验证和测试集占比分别为60%、20%和20%。

import os
import shutil
import random
from tqdm import tqdm
​
def split_image_dataset(root_dir, save_dir, train_ratio=0.6, val_ratio=0.2, test_ratio=0.2):
    # 获取所有类别
    classes = [cls for cls in os.listdir(root_dir) if os.path.isdir(os.path.join(root_dir, cls))]
    # 创建划分后的数据文件夹结构
    split_folders = ["train", "val", "test"]
    for folder in split_folders:
        for cls in classes:
            cls_path = os.path.join(save_dir, folder, cls)
            os.makedirs(cls_path, exist_ok=True)
​
    # 按类别划分数据
    for cls in classes:
        cls_root = os.path.join(root_dir, cls)
        # 获取该类别下所有图片路径(可根据需要补充其他图片格式)
        img_paths = [os.path.join(cls_root, img) for img in os.listdir(cls_root)
                     if img.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp'))]
​
        # 打乱图片顺序(保证随机性)
        random.shuffle(img_paths)
        total = len(img_paths)
​
        # 计算各集数量
        train_num = int(total * train_ratio)
        val_num = int(total * val_ratio)
        # 测试集数量 = 总数 - 训练集 - 验证集
​
        # 分配数据到各集
        train_imgs = img_paths[:train_num]
        val_imgs = img_paths[train_num:train_num + val_num]
        test_imgs = img_paths[train_num + val_num:]
​
        # 复制图片到目标文件夹(tqdm显示进度)
        for img in tqdm(train_imgs, desc=f"处理类别 {cls} - 训练集"):
            shutil.copy(img, os.path.join(save_dir, "train", cls, os.path.basename(img)))
        for img in tqdm(val_imgs, desc=f"处理类别 {cls} - 验证集"):
            shutil.copy(img, os.path.join(save_dir, "val", cls, os.path.basename(img)))
        for img in tqdm(test_imgs, desc=f"处理类别 {cls} - 测试集"):
            shutil.copy(img, os.path.join(save_dir, "test", cls, os.path.basename(img)))
​
    print(f"数据集划分完成!保存路径:{save_dir}")
    # 输出各集统计信息
    for folder in split_folders:
        total = 0
        print(f"\n{folder}集统计:")
        for cls in classes:
            cls_count = len(os.listdir(os.path.join(save_dir, folder, cls)))
            total += cls_count
            print(f"  类别 {cls}:{cls_count} 张")
        print(f"  总计:{total} 张")
​
​
if __name__ == "__main__":
    RAW_DATA_DIR = "plant_doc_datasets"
    SAVE_DATA_DIR = "datasets"
    # 比例为 0.6, 0.2, 0.2
    split_image_dataset(
        root_dir=RAW_DATA_DIR,
        save_dir=SAVE_DATA_DIR,
        train_ratio=0.6,
        val_ratio=0.2,
        test_ratio=0.2
    )

2.CIFAR-10数据集上各模型实验

使用LeNet、AlexNet、VGG和ResNet模型在CIFAR-10数据集和自定义的农作物病害分类数据集上进行训练与测试。

自定义数据集的代码:

class CustomImageDataset(Dataset):
    def __init__(self, root_dir, split='train', transform=None):
        """
        root_dir: 'datasets'
        split: 'train', 'val', 'test'
        """
        self.data_dir = os.path.join(root_dir, split)
        self.transform = transform
        self.classes = sorted(os.listdir(self.data_dir))
        self.class_to_idx = {cls: idx for idx, cls in enumerate(self.classes)}
​
        self.samples = []
        for class_name in self.classes:
            class_dir = os.path.join(self.data_dir, class_name)
            if not os.path.isdir(class_dir):
                continue
            for img_name in os.listdir(class_dir):
                img_path = os.path.join(class_dir, img_name)
                if img_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
                    self.samples.append((img_path, self.class_to_idx[class_name]))
​
    def __len__(self):
        return len(self.samples)
​
    def __getitem__(self, idx):
        img_path, label = self.samples[idx]
        image = Image.open(img_path).convert('RGB')
        if self.transform:
            image = self.transform(image)
        return image, label
​
​
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])
​
transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])
​
trainset = CustomImageDataset(root_dir='datasets', split='train', transform=transform_train)
valset = CustomImageDataset(root_dir='datasets', split='val', transform=transform_test)
testset = CustomImageDataset(root_dir='datasets', split='test', transform=transform_test)
​
trainloader = torch.utils.data.DataLoader(trainset, batch_size=128, shuffle=True, num_workers=4)
valloader = torch.utils.data.DataLoader(valset, batch_size=128, shuffle=False, num_workers=4)
testloader = torch.utils.data.DataLoader(testset, batch_size=128, shuffle=False, num_workers=4)
​
print(f"训练集: {len(trainset)} 张图片")
print(f"验证集: {len(valset)} 张图片")
print(f"测试集: {len(testset)} 张图片")
print(f"类别数: {len(trainset.classes)}")
print(f"类别: {trainset.classes}")

除了实验描述提供的三个模型外,我另外使用的是ResNet50:

import torch.nn as nn
import torch
​
​
class BasicBlock(nn.Module):
    # ResNet-18/34 中使用的基本残差块
    expansion = 1  # 输出通道扩张倍数(BasicBlock 不扩张)
​
    def __init__(self, in_channel, out_channel, stride=1, downsample=None, **kwargs):
        super(BasicBlock, self).__init__()
        # 第一个 3x3 卷积(stride 可为 1 或 2,用于下采样)
        self.conv1 = nn.Conv2d(in_channels=in_channel, out_channels=out_channel,
                               kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channel)  # 批归一化
        self.relu = nn.ReLU()
        # 第二个 3x3 卷积(stride=1)
        self.conv2 = nn.Conv2d(in_channels=out_channel, out_channels=out_channel,
                               kernel_size=3, stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channel)
        # 如果输入输出维度不一致,需要下采样(即论文中的虚线连接)
        self.downsample = downsample
​
    def forward(self, x):
        identity = x  # 残差连接的输入
        if self.downsample is not None:
            identity = self.downsample(x)
​
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)
​
        out = self.conv2(out)
        out = self.bn2(out)
​
        # 残差连接:F(x) + x
        out += identity
        out = self.relu(out)
​
        return out
​
​
class Bottleneck(nn.Module):
    # ResNet-50/101/152 中使用的瓶颈残差块
    # 采用 1x1 -> 3x3 -> 1x1 的结构来减少计算量
    expansion = 4  # 输出通道数是中间 out_channel 的 4 倍
​
    def __init__(self, in_channel, out_channel, stride=1, downsample=None,
                 groups=1, width_per_group=64):
        super(Bottleneck, self).__init__()
​
        # 计算宽度(支持 ResNeXt 的 group conv)
        width = int(out_channel * (width_per_group / 64.)) * groups
​
        # 第一个 1x1 卷积:压缩通道
        self.conv1 = nn.Conv2d(in_channels=in_channel, out_channels=width,
                               kernel_size=1, stride=1, bias=False)
        self.bn1 = nn.BatchNorm2d(width)
​
        # 第二个 3x3 卷积:空间卷积,可带 stride 控制下采样
        self.conv2 = nn.Conv2d(in_channels=width, out_channels=width, groups=groups,
                               kernel_size=3, stride=stride, bias=False, padding=1)
        self.bn2 = nn.BatchNorm2d(width)
​
        # 第三个 1x1 卷积:恢复通道(扩展到 out_channel*expansion)
        self.conv3 = nn.Conv2d(in_channels=width, out_channels=out_channel*self.expansion,
                               kernel_size=1, stride=1, bias=False)
        self.bn3 = nn.BatchNorm2d(out_channel*self.expansion)
​
        self.relu = nn.ReLU(inplace=True)
        self.downsample = downsample  # 虚线连接时的下采样
​
    def forward(self, x):
        identity = x
        if self.downsample is not None:
            identity = self.downsample(x)
​
        # 主分支:1x1 -> 3x3 -> 1x1
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)
​
        out = self.conv2(out)
        out = self.bn2(out)
        out = self.relu(out)
​
        out = self.conv3(out)
        out = self.bn3(out)
​
        # 残差连接
        out += identity
        out = self.relu(out)
​
        return out
​
​
class ResNet(nn.Module):
    # 通用 ResNet 网络实现,可配置 block 类型和层数
    def __init__(self,
                 block,
                 blocks_num,
                 num_classes=1000,
                 include_top=True,
                 groups=1,
                 width_per_group=64):
        super(ResNet, self).__init__()
        self.include_top = include_top
        self.in_channel = 64
        self.groups = groups
        self.width_per_group = width_per_group
​
        # stem:7x7 卷积 + BN + ReLU + 3x3 最大池化(论文中固定结构)
        self.conv1 = nn.Conv2d(3, self.in_channel, kernel_size=7, stride=2,
                               padding=3, bias=False)
        self.bn1 = nn.BatchNorm2d(self.in_channel)
        self.relu = nn.ReLU(inplace=True)
        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
​
        # 四个 stage,每个 stage 下采样一次,通道数加倍
        self.layer1 = self._make_layer(block, 64, blocks_num[0])
        self.layer2 = self._make_layer(block, 128, blocks_num[1], stride=2)
        self.layer3 = self._make_layer(block, 256, blocks_num[2], stride=2)
        self.layer4 = self._make_layer(block, 512, blocks_num[3], stride=2)
​
        # 分类头:全局平均池化 + 全连接
        if self.include_top:
            self.avgpool = nn.AdaptiveAvgPool2d((1, 1))  # 输出固定为 1x1
            self.fc = nn.Linear(512 * block.expansion, num_classes)
​
        # Kaiming 初始化(论文中使用)
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
​
    def _make_layer(self, block, channel, block_num, stride=1):
        # 构建一个 stage(多个残差块堆叠)
        downsample = None
        # 若维度不匹配或需要下采样,则构造 1x1 卷积调整
        if stride != 1 or self.in_channel != channel * block.expansion:
            downsample = nn.Sequential(
                nn.Conv2d(self.in_channel, channel * block.expansion,
                          kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(channel * block.expansion))
​
        layers = []
        # 第一个 block 可能需要 downsample
        layers.append(block(self.in_channel,
                            channel,
                            downsample=downsample,
                            stride=stride,
                            groups=self.groups,
                            width_per_group=self.width_per_group))
        self.in_channel = channel * block.expansion
        # 后续 block 不再改变输入输出维度
        for _ in range(1, block_num):
            layers.append(block(self.in_channel,
                                channel,
                                groups=self.groups,
                                width_per_group=self.width_per_group))
        return nn.Sequential(*layers)
​
    def forward(self, x):
        # stem
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.maxpool(x)
​
        # 进入四个 stage
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)
​
        # 分类头
        if self.include_top:
            x = self.avgpool(x)
            x = torch.flatten(x, 1)
            x = self.fc(x)
​
        return x
​
​
# 不同深度的 ResNet 配置
def resnet34(num_classes=1000, include_top=True):
    return ResNet(BasicBlock, [3, 4, 6, 3], num_classes=num_classes, include_top=include_top)
​
​
def resnet50(num_classes=1000, include_top=True):
    return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes, include_top=include_top)
​
​
def resnet101(num_classes=1000, include_top=True):
    return ResNet(Bottleneck, [3, 4, 23, 3], num_classes=num_classes, include_top=include_top)
​
if __name__ == "__main__":
    from thop import profile
​
    model = resnet50()
    input = torch.randn(1, 3, 256, 256)
    macs, params = profile(model, inputs=(input,))
    print(f"FLOPs: {macs / 1e9:.2f}G")
    print(f"Params: {params / 1e6:.2f}M")

2.1模型结构分析

图片[1] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本
图片[2] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本

2.2LeNet、AlexNet、VGG和ResNet模型在CIFAR10数据集上的训练&测试日志

此时Epoch=50;lr=0.001;batch_size=128

图片[3] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本
图片[4] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本

在CIFAR-10数据集上,VGG-11参数量最大,测试准确率最高(Adam/SGD下均近0.9);ResNet-50、AlexNet精度次之,LeNet因结构简单精度最低(≈0.65~0.7)。优化器方面,SGD(带动量)在AlexNet、VGG-11上的表现与Adam相当甚至更优(如VGG-11的SGD测试准确率略超Adam),LeNet两者差距小,ResNet-50中Adam稍占优。训练曲线显示,VGG-11和ResNet-50收敛更稳定,深层模型的特征提取能力更适配CIFAR-10的复杂场景。

2.3LeNet、AlexNet、VGG和ResNet模型在PlantDOC数据集上的训练&测试日志

此时Epoch=50;lr=0.001;batch_size=128

这个数据集难度较大,所以经典模型效果普遍较差。

图片[5] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本
图片[6] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本

在PlantDOC数据集上,ResNet-50(Adam优化器)表现最优,测试准确率达0.3414,深层残差结构更擅长提取植物病害的复杂特征;VGG-11和AlexNet更适配SGD(测试准确率分别为0.2873、0.2713),Adam因超参数或数据分布问题收敛不稳定;LeNet因模型简单,两类优化器下准确率均低于0.25,难以捕捉病害细节。整体来看,深层模型(ResNet-50、VGG-11)比浅层模型(LeNet、AlexNet)更适配PlantDOC的复杂场景,且优化器需结合模型结构灵活选择。

3.模型对比分析

针对所实验的经典CNN模型从参数量、准备率、优缺点、训练时间等角度进行对比分析如下

从上面的模型结构分析和代码运行结束后输出的Matplotlib图来看,

LeNet的参数量大约只有0.06M,在四类模型里是最小的。它结构简单,由2个卷积层以及3个全连接层组成,训练和推理速度极快。不过,其特征提取能力较弱,只在诸如手写数字识别这类简单任务中表现不错。当面对复杂的自然图像分类时,它在验证集和测试集的准确率都是最低的,验证集最终大概0.7,在Adam优化器下测试集约0.65。

AlexNet的参数量约为23.27M,它首次引入了ReLU激活、Dropout正则以及GPU加速,突破了深层网络训练的瓶颈。其5个卷积层加上3个全连接层的结构,让它的特征提取能力远远超过LeNet,适合中等规模的数据集。其验证集最终约0.8,在Adam优化器下测试集约0.75。虽然它的训练时间相比LeNet显著增加,但由于优化手段,其收敛速度比同参数量级的传统网络要快。

VGG-11的参数量高达约128.81M,是四类模型中最大的。它采用3×3小卷积核堆叠来替代大卷积核,在维持相同感受野的同时,增强了非线性表达,使得特征提取更加精细。在复杂场景下,它在验证集和测试集的准确率是最高的,验证集最终约0.9,在Adam优化器下测试集约0.85。然而,参数量大使得它在小数据集上容易过拟合,训练和推理成本高,实时性差。要部署到边缘设备,需要进行剪枝、量化等优化。而且它的训练时间也是最长的,从训练Loss图可以看出其Loss下降缓慢,并且后期仍有波动。

ResNet-50的参数量约23.53M,与AlexNet接近。它引入了残差块,解决了深层网络梯度消失的问题,能够支持50层深度的网络。在参数量仅为VGG-11五分之一的情况下,达到了相近的准确率,效率更高,其验证集和测试集准确率接近VGG-11。虽然残差结构增加了模型复杂度,导致实时性差,但因为残差连接优化了梯度传播,它的收敛速度比VGG-11快,训练时间比VGG-11短,但是比LeNet和AlexNet长。

4.优化器对比

根据实验训练情况对比分析SGD,Adam优化器如下。

从速度来讲,Adam往往比带动量的SGD收敛更快。在训练前期,Adam能让验证准确率更快提升,LeNet和AlexNet前期的变化曲线就是很好的演示。 最终性能的话,对于LeNet、AlexNet以及ResNet-50这些模型,Adam的测试与验证准确率表现更佳。但在VGG-11模型中,带动量的SGD最终准确率却略高于Adam,这可能源于模型结构与优化器适配性的不同。 从训练稳定性角度,Adam在训练时损失下降更为平滑,而SGD的曲线波动则更为突出,比如ResNet-50采用SGD时损失的变化状况。 所以,Adam在快速收敛上优势突出,多数场景下最终性能也更占优。虽然带动量的SGD在部分模型的中后期具备一定潜力,但具体分析时需结合模型结构的适配性。

5.适当调整学习率、批次大小、运用Dropout、BN等策略,分析其性能影响

5.1将学习率调整至0.01,LeNet、AlexNet、VGG和ResNet模型在CIFAR10数据集上的训练&测试日志

图片[7] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本
图片[8] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本

分析以上数据和图像,和默认配置下实验对比可以发现,将学习率调至0.1后,不同模型与优化器性能差异明显。从训练日志及曲线可见,Adam优化器受影响最大。LeNet使用Adam时,训练Loss震荡剧烈,验证准确率0.5084,测试准确率0.5282,远逊于默认学习率时;AlexNet的Adam甚至Loss“爆炸”,训练Loss飙升至140,验证准确率仅0.2636,难以有效收敛,原因是Adam自适应学习率机制与高初始学习率冲突,致梯度更新激进,破坏收敛稳定性。

而SGD+Momentum对大学习率耐受性稍好。LeNet的SGD验证准确率达0.6872,测试准确率0.7092,虽不及默认学习率但能基本收敛;VGG-11的SGD表现突出,验证准确率0.8972,测试准确率0.8978,接近之前良好状态,这或因VGG小卷积核结构与BatchNorm层稳定梯度,动量又平滑了梯度震荡。

模型复杂度也影响对大学习率的适应。AlexNet和ResNet等中深层模型对学习率更敏感。AlexNet的Adam完全崩溃,SGD勉强收敛;ResNet-50的Adam和SGD均出现Loss波动,验证准确率分别为0.7536和0.7590,远低于之前。深层网络梯度传播易受学习率影响,高学习率放大了梯度消失或爆炸风险。简单模型LeNet虽SGD能勉强收敛,但Adam因学习率适配问题性能暴跌,表明简单模型使用Adam也需精细调优学习率。

总体而言,0.1的学习率远超多数模型与优化器适配范围。Adam因自适应机制“惯性”在高学习率下易失控;SGD+Momentum虽靠动量维持,但仅在VGG-11等带BatchNorm的模型上表现较好,其余模型普遍收敛困难、准确率下滑。这表明学习率调整需结合模型复杂度与优化器特性,盲目增大学习率会严重破坏训练稳定性。

5.2将批量大小调整至256,LeNet、AlexNet、VGG和ResNet模型在CIFAR10数据集上的训练&测试日志

图片[9] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本
图片[10] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本

分析以上数据和图像,和默认配置下实验对比可以发现,将batchsize从128调整到256时,训练稳定性最先改变。大batch下,单步梯度经更多样本统计平均,梯度噪声降低,各模型训练Loss曲线更平滑,batch为128时Loss震荡更剧烈。

收敛速度方面,前期大batch使模型更快脱离初始随机状态,后期深层模型如VGG-11、ResNet-50收敛差距缩小,简单模型如LeNet收敛优势仍存。

准确率变化上,简单模型LeNet提升明显,中等复杂度的AlexNet收益最大,深层模型VGG-11和ResNet-50提升微小。

优化器适配性差异显著,Adam在大batch下表现稳定,各模型准确率均提升;SGD+Momentum对batch更敏感,部分模型在大batch下准确率下降。

大batch提升训练稳定性与前期收敛速度,对简单和中等复杂度模型准确率增益大,深层模型收益有限。Adam适配大batch,SGD需谨慎调优。工程实践中,大batch虽减少训练步数,但要结合模型复杂度与优化器特性,并非越大越好。

5.3将AlexNet、VGG的Dropout从0.5调整至0.1,在CIFAR10数据集上的训练&测试日志:(dl1)

图片[11] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本
图片[12] - AI科研 编程 读书笔记 - 【人工智能】【Python】卷积神经网络应用实验 - AI科研 编程 读书笔记 - 小竹の笔记本

分析以上数据和图像,和默认配置下实验对比可以发现,在CIFAR-10数据集上,把AlexNet和VGG的Dropout从0.5降低到0.1后,二者的验证准确率与测试准确率都有所提高。在Adam优化器下,AlexNet的验证准确率从默认配置时的相近水准提升至0.7874,测试准确率达到0.7929;VGG-11通过Adam优化,验证准确率为0.8970,测试准确率为0.8996,并且训练曲线变得更加平滑,Loss下降也更为顺利。

出现这种情况是因为Dropout的降低削弱了正则化约束,使得模型能够更全面地学习数据特征,减少了因“过度抑制”而产生的拟合不充分问题。与此同时,优化器(SGD和Adam)之间的性能差异有所减小。这是由于Dropout正则化作用降低后,模型自身的拟合能力变强,对优化器的依赖程度相对降低。

与默认配置相比,此次调整更契合CIFAR-10数据集的复杂程度,有效避免了高Dropout情况下的“欠拟合”趋势,从而让AlexNet和VGG在训练稳定性和最终精度方面都得到了优化。

© 版权声明
THE END
点赞8 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容