CIFAR-10图像识别(从0开始学机器学习系列)

文章目录

CIFAR-10

玩一玩机器学习的图像识别,使用CIFAR-10数据集

github仓库地址:https://github.com/0xlally/CIFAR-10-

数据集

数据集来源于https://www.cs.toronto.edu/\~kriz/cifar.html

如果直接下载 Python 版本(最常用),你会得到一个 Python Pickle (cPickle) 格式的文件。

它的数据结构并不是直接的图片文件(如 .jpg),而是压缩后的数组。这里有三个关键点需要注意,很多初学者会在这里卡住:

数据形状 (Shape) : 网页提到 data 是一个 10000x3072 的 numpy 数组。

  • 10000 代表这一个批次有 10,000 张图。

  • 3072 代表一张图的像素点总数。怎么来的?

    32(宽)×32(高)×3(颜色通道 RGB)=3072

通道排列顺序 (Channel Order) : 这是最容易出错的地方。这 3072 个数字不是 像我们通常图片格式那样 (R, G, B), (R, G, B)... 交替排列的。 网页明确指出:前 1024 个是红 ® ,接下来 1024 个是绿 (G) ,最后 1024 个是蓝 (B)

  • 这意味着如果你直接把它 reshape 成 (32, 32, 3),图片看起来会是一团乱码。你需要先 reshape 成 (3, 32, 32),然后再转置(Transpose)。

标签 (Labels) : labels 是一个包含 0-9 数字的列表,对应 10 个类别(飞机、汽车、鸟、猫等)。你需要根据 batches.meta 文件将数字映射回文字名称。

然而后续使用pytorch框架时,发现他有自带的用于下载数据集的代码,不需要提前下载

整体步骤

要做这个项目,我建议遵循以下四个阶段:

数据加载与预处理 (Data Loading)

  • 任务:把网页上描述的那个 10000x3072 的数组,转换成你的模型能读懂的 Tensor(张量)。
  • 关键操作:归一化(Normalization),通常把像素值从 0-255 缩放到 0-1 或 -1 到 1 之间。

模型搭建 (Model Architecture)

  • 对于图像,标准的做法是使用 卷积神经网络 (CNN)
  • 结构:卷积层 (提取特征) -> 池化层 (压缩特征) -> 全连接层 (分类)。

训练 (Training)

  • 定义损失函数(CrossEntropyLoss,因为是多分类)。
  • 选择优化器(SGD 或 Adam)。
  • 循环迭代:前向传播 -> 计算误差 -> 反向传播更新权重。

测试与评估 (Evaluation)

  • 使用测试集(test batch)查看准确率。

具体操作

数据处理

归一化

其实,把 0-255 变成 0-1(或者 -1 到 1),最主要的原因是为了让模型"学"得更快、更稳

这就好比你在山里找路下山(我们在寻找损失函数的最低点):

  • 不归一化 (0-255):这座山的地形可能会变得非常畸形------像一个又细又长的峡谷。你每走一步都得非常小心(学习率必须很小),否则很容易撞到墙壁或者走回头路。
  • 归一化 (0-1) :地形会变成一个比较规则的圆形碗。你可以迈开大步直接冲向谷底,训练速度会快很多。

举个例子,200*0.01和1*0.01所产生的数值差异是很大的,归一化就是在减小这种极端变化的情况

定义数据预处理 (Transforms)

通常我们需要做两个核心操作:

  1. 把图片数据转换成 PyTorch 能处理的格式。
  2. 标准化(Normalize)。

在 PyTorch 的 torchvision.transforms 工具箱里,有一个最常用的工具叫 transforms.ToTensor()

transforms.ToTensor() 会自动把像素值除以 255,将它们缩放到 [0, 1] 的范围内。

但为了让数据分布更符合标准正态分布(加速收敛),我们通常希望把范围进一步调整到 [-1, 1] 。这时候就需要用到 transforms.Normalize

复制代码
import torch
import torchvision
import torchvision.transforms as transforms

# 定义预处理流程
transform = transforms.Compose([
    transforms.ToTensor(),  # 第一步:转为 Tensor 并归一化到 [0, 1]
    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)) # 第二步:标准化
])

# 下载并加载训练集
trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
                                        download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=4,
                                          shuffle=True, num_workers=2)

transforms.Normalize((M1, M2, M3), (S1, S2, S3))
  • 第一组 (0.5, 0.5, 0.5):均值 (Mean)
    • M1 = 0.5 对应 R (红) 通道的均值
    • M2 = 0.5 对应 G (绿) 通道的均值
    • M3 = 0.5 对应 B (蓝) 通道的均值
  • 第二组 (0.5, 0.5, 0.5):标准差 (Std)
    • S1 = 0.5 对应 R (红) 通道的标准差
    • 同理对应 G 和 B。

搭建模型

搞定了数据加载,现在的核心任务是搭建卷积神经网络 (CNN)

CIFAR-10 的图片尺寸比较小 (32×32)。一个经典的 CNN 结构通常像是"夹心饼干":一层卷积(提取特征),一层池化(压缩尺寸),再一层卷积......最后拉平(Flatten)连接到全连接层进行分类。

先做一些背景知识的了解

什么是池化

你可以把池化想象成是在做**"浓缩"或者"马赛克化"**。

2×2 的最大池化(Max Pooling)就是把图片切成无数个 2×2 的小方块(每块有4个像素)。在每个小方块里,它只保留最大的那个数值,扔掉其他三个像素。

  • 目的:减少数据量(让图片变小,计算更快),同时保留最显著的特征(比如保留最亮的边缘)。

什么是卷积

想象你在一间漆黑的屋子里,墙上挂着一幅巨大的画(这就是我们的输入图片 )。 你手里拿了一个手电筒 (这就是卷积核 或者叫过滤器 Filter)。

这个手电筒有两个特点:

  1. 光圈很小:它一次只能照亮画上的一小块区域(比如 3×3 的像素区域)。
  2. 有特殊的"镜片" :这个镜片不是透明的,而是上面刻着某种特定的图案(比如一条竖线,或者一个直角)。

卷积的过程 ,就是你拿着手电筒,从画的左上角 开始,一格一格地往右下角移动(扫描)。

  • 当你手电筒照到的地方,如果画上的内容和镜片上的图案很像 ,手电筒就会亮红灯(输出一个很大的数值)。
  • 如果照到的地方和图案完全不像,手电筒就不亮(输出 0 或者很小的数值)。
2. 为什么要这么做?(找特征)

这就是卷积的核心目的:寻找特征

  • 如果是普通的神经网络(全连接),它是把整张图一次性"吞"进去,太乱了。
  • 卷积层是派了一堆"侦探"去局部扫描。

回到代码中的 nn.Conv2d(3, 6, 5)

  • 3 (Input Channels): 因为图片是彩色的(RGB),所以我们的手电筒比较厚,要同时透过红、绿、蓝三层来看。
  • 5 (Kernel Size): 手电筒的光圈大小是 5×5 个像素。
  • 6 (Output Channels) : 这是最关键的! 为什么输出变成了 6?

这意味着我们派出了 6 个拿不同手电筒的侦探

  • 侦探 A 的手电筒专门找"横线"。
  • 侦探 B 的手电筒专门找"竖线"。
  • 侦探 C 的手电筒专门找"圆弧"。
  • ...
  • 侦探 F 的手电筒专门找"颜色突变"。

理解了卷积(提取特征)和池化(压缩),我们就可以搭建一个经典的 CNN 网络了。

在 PyTorch 里,我们需要继承 nn.Module 并填写两个部分:

  1. __init__定义你要用的层(比如卷积层、池化层、全连接层)。

  2. forward连接这些层,规定数据流动的顺序。

    import torch.nn as nn
    import torch.nn.functional as F

    class Net(nn.Module):
    def init(self):
    super(Net, self).init()
    # 1. 卷积层 1: 输入3个通道(RGB), 输出6个通道, 卷积核大小 5x5
    self.conv1 = nn.Conv2d(3, 6, 5)
    # 2. 池化层: 2x2 最大池化
    self.pool = nn.MaxPool2d(2, 2)
    # 3. 卷积层 2: 输入6个通道, 输出16个通道, 卷积核大小 5x5
    self.conv2 = nn.Conv2d(6, 16, 5)

    复制代码
         # 4. 全连接层 (Fully Connected Layers)
         # 这里的 16 * 5 * 5 是怎么算出来的?这是新手最容易卡住的地方
         self.fc1 = nn.Linear(16 * 5 * 5, 120)
         self.fc2 = nn.Linear(120, 84)
         self.fc3 = nn.Linear(84, 10) # 最后输出10个分类
    
     def forward(self, x):
         # 数据流向:卷积 -> 激活函数(ReLU) -> 池化
         x = self.pool(F.relu(self.conv1(x)))
         x = self.pool(F.relu(self.conv2(x)))
         
         # 展平 (Flatten): 把立体的特征图拉成一条直线,才能喂给全连接层
         x = torch.flatten(x, 1) 
         
         x = F.relu(self.fc1(x))
         x = F.relu(self.fc2(x))
         x = self.fc3(x)
         return x

    net = Net()

关键点解析:那个 16 * 5 * 5 是怎么来的?

这是搭建网络时的数学题:

  1. 原始图片: 32×32
  2. 经过 Conv1 (kernel=5): 像素减少 4 个 (5−1) →28×28
  3. 经过 Pool (/2): →14×14
  4. 经过 Conv2 (kernel=5): 像素再减少 4 个 →10×10
  5. 经过 Pool (/2): →5×5

因为 Conv2 输出是 16 个通道,所以最后的数据体积是 16×5×5。

在代码流程里:

  1. 卷积层提取出了 16×5×5=400 个特征值。

  2. fc1 (大脑皮层第一层):把这 400 个特征拿来进行综合分析,转化成 120 个更高级的特征。

  3. fc2 (大脑皮层第二层):把 120 个特征再浓缩成 84 个。

  4. fc3 (输出层) :把 84 个特征最终映射到 10 个分类上(飞机、汽车、鸟...)。

    x = F.relu(self.fc1(x)) # 第一步:思考 + 激活
    x = F.relu(self.fc2(x)) # 第二步:思考 + 激活
    x = self.fc3(x) # 第三步:最终输出分数

这里还有一个细节: 前两层 fc 后面都加了 F.relu(激活函数),这是为了给网络加入非线性 ,让它能处理复杂的逻辑。 但在最后一层 fc3 后面,通常不加 ReLU。因为最后一层输出的是每一类的"得分",这个得分也就是可以是负数(代表可能性极低),如果加了 ReLU 变成 0,就丢失信息了。

损失函数和优化器

optimizer.zero_grad() (梯度清零)

  • 为什么? PyTorch 默认会把梯度"累加"。这就像你在黑板上做题,做下一道题之前,必须先把上一道题的演算过程擦干净,否则字叠在一起就乱套了。

outputs = net(inputs) (前向传播)

  • 考试:把图片(试卷)喂给网络,网络经过层层计算,吐出预测结果(outputs)。

loss = criterion(outputs, labels) (计算损失)

  • 打分:判卷老师对比"模型的预测"和"真实标签",算出一个分数(Loss)。Loss 越小,说明答得越好。

loss.backward() (反向传播)

  • 找原因:这是深度学习的魔法时刻。PyTorch 会自动计算"梯度"。
  • 通俗地说,它会分析:为了让 Loss 变小,每一个神经元的权重应该"变大一点"还是"变小一点"?这个方向信息就叫梯度。

optimizer.step() (更新参数)

  • 改正:补习老师登场。它根据刚才算出来的梯度,真正地去修改模型里的权重参数。

  • 执行完这一步,模型就比上一秒钟聪明了一点点。

    import torch.optim as optim

    1. 定义损失函数

    criterion = nn.CrossEntropyLoss()

    2. 定义优化器

    net.parameters() 告诉优化器我们要更新哪些参数

    lr=0.001 是学习率 (步子大小)

    momentum=0.9 是动量 (惯性)

    optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9)

    for epoch in range(2): # loop over the dataset multiple times (训练 2 轮)

    复制代码
      running_loss = 0.0
      for i, data in enumerate(trainloader, 0):
          # data 中包含图片 (inputs) 和标签 (labels)
          inputs, labels = data
    
          # --- 核心动作开始 ---
    
          # 1. 梯度清零
          optimizer.zero_grad()
    
          # 2. 前向传播 (考试)
          outputs = net(inputs)
          
          # 3. 计算误差 (对答案)
          loss = criterion(outputs, labels)
          
          # 4. 反向传播 (找原因) + 更新权重 (改正)
          loss.backward()
          optimizer.step()
    
          # --- 核心动作结束 ---
    
          # 打印日志
          running_loss += loss.item()
          if i % 2000 == 1999:    # print every 2000 mini-batches
              print(f'[{epoch + 1}, {i + 1:5d}] loss: {running_loss / 2000:.3f}')
              running_loss = 0.0

    print('Finished Training')

测试与评估

复制代码
outputs = net(images)

输入:一批测试图片(比如 4 张)。

输出 :一个形状为 (4, 10) 的矩阵。每一行对应一张图,包含 10 个数字(代表 10 个类别的得分)。

复制代码
_, predicted = torch.max(outputs.data, 1)

outputs.data:取出得分数据。

参数 1 :指定在维度 1(列的方向,即横向)上寻找最大值。因为每一行代表一张图的 10 个分类得分,我们要看在这 10 个分里哪个最高。

返回值torch.max 会返回两个张量:

  1. 最大值本身 (比如 5.1 分)。我们不关心分数具体是多少,所以用下划线 _ 丢弃它。

  2. 最大值的索引 (比如第 3 个位置)。这个索引(0-9)就代表模型预测的类别 ID 。我们将它赋值给 predicted

    torch.no_grad() 非常重要!

    因为测试时不需要算梯度,这能省下大量内存和计算时间

    with torch.no_grad():
    for data in testloader:
    images, labels = data
    outputs = net(images)

    复制代码
         # 核心操作:找出得分最高的那个类别的编号
         # outputs.data 是那10个分数
         # 1 代表我们在"行"方向上找最大值
         # max函数会返回两个值:(最大分数, 最大分数的索引)
         # 我们只关心索引(predicted),所以第一个用 _ 忽略掉
         _, predicted = torch.max(outputs.data, 1)
         
         total += labels.size(0)
         correct += (predicted == labels).sum().item()

    print(f'Accuracy of the network on the 10000 test images: {100 * correct / total} %')

labels.size(0) :获取当前批次的样本数量(例如 batch_size 是 4,这里就是 4),加到 total 总数里。

predicted == labels :将模型预测的类别 ID 与真实标签 ID 进行逐一对比。结果是一个布尔张量,如 [True, False, True, True](即 [1, 0, 1, 1])。

.sum():将上面的 1 加起来(例如结果是 3),表示这一批猜对了 3 个。

.item() :把张量格式的数值 3 转换成标准的 Python 数字 3,以便累加到 correct 变量中。

加速训练

将模型放到gpu上,这一段加到main函数的最开头

复制代码
# 检测是否有可用的 GPU
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print(f'当前使用的设备: {device}')

找到 net = Net() 这一行,在后面加一行:

复制代码
net = Net()
net.to(device)  # <--- 关键代码:把模型搬到 GPU

模型在 GPU 上了,但数据加载器(DataLoader)读出来的数据默认还在内存(CPU)里。CPU 的数据不能直接喂给 GPU 的模型,否则会报错。

你需要进入训练循环测试循环,把每一批读进来的图片和标签也都搬过去。

复制代码
for i, data in enumerate(trainloader, 0):
        # 原始代码:inputs, labels = data
        
        # 修改后的代码:把数据拆开并搬运
        inputs, labels = data[0].to(device), data[1].to(device) 

        optimizer.zero_grad()
        outputs = net(inputs) # 此时 inputs 已经在 GPU 上了
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

保存模型

只保存模型网络中的参数

复制代码
# 定义保存路径 (后缀名通常用 .pth 或 .pt)
PATH = './cifar_net.pth'

# 核心代码:只保存模型的"参数字典" (state_dict)
# 它本质上就是把每一层的权重矩阵存成了一个字典文件
torch.save(net.state_dict(), PATH)

print(f"模型已保存至 {PATH}")

怎么加载?

你需要做的是:

  1. 重新定义网络结构 (必须让 PyTorch 知道 Net 是个什么东西)。

  2. 实例化网络

  3. 加载参数

    1. 自动检测当前机器的最佳设备

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    2. 加载模型

    map_location=device 这一招非常灵

    它会自动判断:如果当前只有CPU,就强制把原本在GPU上的模型拉回到CPU

    如果当前有GPU,它也会正确处理

    net.load_state_dict(torch.load(path, map_location=device))

    3. 确保模型实体也在该设备上 (双重保险)

    net.to(device)

进一步优化,提高模型准确率

在当前的简单两层网络结构下,epoch为50,batch为64,通道数为6和16的背景下,我们只能达到

复制代码
类别 plane : 73.50 %
类别   car : 76.10 %
类别  bird : 41.80 %
类别   cat : 43.40 %
类别  deer : 60.80 %
类别   dog : 53.40 %
类别  frog : 72.10 %
类别 horse : 79.50 %
类别  ship : 70.10 %
类别 truck : 68.80 %

核心问题在于:你的模型"脑容量"太小了(Underfitting)。 目前的网络只有 2 层卷积,通道数也只有 6 和 16。它就像让一个刚学会认字母的小学生去读《哈姆雷特》,它看不懂那么复杂的纹理细节(比如猫毛和狗毛的区别)。

第一:加宽加深模型 (升级大脑) ------ 最关键

原来的模型太瘦了(通道少)。现代 CNN 通常起步就是 32 或 64 个通道。 我们需要:

  1. 增加卷积层:从 2 层加到 3 层。

  2. 增加通道数3 -> 6 -> 16 改为 3 -> 32 -> 64 -> 128

  3. 引入 Batch Normalization (BN):这是神器,能让训练快几倍且更稳定。

    class BetterNet(nn.Module):
    def init(self):
    super(BetterNet, self).init()

    复制代码
         # 第一层块: 3 -> 32
         # 加了 BatchNorm2d,这是提升准确率的神器
         self.conv1 = nn.Conv2d(3, 32, 3, padding=1)
         self.bn1 = nn.BatchNorm2d(32)
    
         # 第二层块: 32 -> 64
         self.conv2 = nn.Conv2d(32, 64, 3, padding=1)
         self.bn2 = nn.BatchNorm2d(64)
    
         # 第三层块: 64 -> 128
         self.conv3 = nn.Conv2d(64, 128, 3, padding=1)
         self.bn3 = nn.BatchNorm2d(128)
    
         self.pool = nn.MaxPool2d(2, 2)
    
         # 全连接层
         # 经过3次池化(32->16->8->4),且最后一层通道是128
         # 所以输入是 128 * 4 * 4
         self.fc1 = nn.Linear(128 * 4 * 4, 512)
         self.fc2 = nn.Linear(512, 10)
    
         # Dropout: 防止过拟合,扔掉一些神经元
         self.dropout = nn.Dropout(0.5)

第二:数据增强 (Data Augmentation)

让模型多见见世面。现在的模型看一张图是死记硬背。我们可以让图片在训练时随机翻转随机裁剪。 这样模型就会明白:"头朝左的猫是猫,头朝右的猫也是猫"。

复制代码
# 训练集:加了随机翻转和随机裁剪,增加难度,防止死记硬背
    transform_train = transforms.Compose([
        transforms.RandomHorizontalFlip(),  # 50%概率水平翻转
        transforms.RandomCrop(32, padding=4), # 随机裁剪
        transforms.ToTensor(),
        transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
    ])

第三:换个更聪明的优化器

SGD 对学习率很敏感。我们换成 Adam,它自带自适应学习率,通常收敛更快。

复制代码
optimizer = optim.Adam(net.parameters(), lr=0.001)

背景知识补充

1. Batch Normalization (BN):"流水线质检员"

它是什么? 在深度神经网络中,数据经过每一层计算后,数值的分布会发生变化(有的变大,有的变小,有的偏左,有的偏右)。这会让下一层的神经元非常"头大",因为它们每次都要重新适应新的数据分布,导致训练很慢,甚至练不动。

BN 做什么? BN 层就像一个极其严格的质检员 ,被安插在每一个卷积层和激活函数之间。 它的工作是:强行把流过来的数据重新整理,让它们的平均值变成 0,方差变成 1(标准正态分布)。

生活类比: 想象你在教一个班级(神经网络),学生是上一层的输出。

  • 没有 BN:第一天来的学生全是考 10 分的差生,你得用教差生的方法;第二天来的全是考 100 分的学霸,你得换教法。你每天都在适应学生,教学进度非常慢。
  • 有 BN :BN 强制把学生按能力分班,或者标准化他们的成绩。不管上一层送来什么学生,到了你这一层,看起来都差不多。你只需要专注于你的逻辑教学,不需要一直调整适应。

作用:

  1. 极大加快训练速度(你可以开更大的学习率)。
  2. 让模型更稳定,不容易梯度消失或爆炸。

SGD (Stochastic Gradient Descent):手动挡车

  • 原理:看准下山的方向,迈出固定的一步。
  • 缺点
    1. 死板:不管地形是陡峭还是平缓,它的步子大小(学习率)通常是固定的。
    2. 怕坑:容易困在局部最低点(Local Minima)爬不出来。
    3. 难调:非常依赖你手动设置的学习率,设大了震荡,设小了走不动。

Adam (Adaptive Moment Estimation):自动挡越野车 Adam 结合了两个强大的机制:

  1. 动量 (Momentum)惯性。像一个重铁球滚下山,即使遇到小坑,借着惯性也能冲过去。
  2. 自适应学习率 (Adaptive LR)智能步幅
    • 路平坦时(梯度小),它会自动迈大步子加速。
    • 路陡峭时(梯度大),它会自动迈小步子防摔倒。

为什么新手首选 Adam?

  • 省心 :Adam 对学习率不敏感。通常设个 0.001 就能跑得很好。而 SGD 可能需要你反复尝试 0.1, 0.01, 0.005...
  • 收敛快:在训练初期,Adam 的下降速度通常吊打 SGD。

3. Dropout:"特种兵抗压训练"

它是什么? Dropout 是一种防止过拟合(Overfitting)的手段。过拟合就是模型"死记硬背"了训练集的图片,导致考试(测试集)时遇到稍微不一样的图就不会了。

Dropout 做什么? 在训练过程中,它会随机关掉(扔掉) 一部分神经元(比如 50%)。 这意味着:每一次训练,网络都有一半的神经元在"罢工"。

生活类比: 想象在一个项目组里:

  • 没有 Dropout:所有人都依赖组里的大神(某个强特征)。如果有问题,都问大神。一旦大神生病了(测试集里没有这个特征),整个项目组就垮了。
  • 有 Dropout轮流强制让组员休假。大神今天强制休假,其他人被迫要学会独立解决问题;明天另一波人休假...
  • 结果 :经过这种"残缺"的训练,组里每个人(每个神经元)都变得很强,不再依赖某一个特定的线索。整个团队的鲁棒性极高。

注意: Dropout 只在训练(Train)时开启 。 在测试(Test/Eval)时,所有人都要回来上班(所有神经元全连接),以发挥最强实力。 这也是为什么我在代码里强调 net.train()net.eval() 的原因。

完整代码

完成了这一步,在大多数类的准确率就可以达到百分之九十了

复制代码
>> 总体准确率: 84.87 %

正在分析每一类的表现...
类别 plane : 89.70 %
类别   car : 93.70 %
类别  bird : 77.00 %
类别   cat : 67.20 %
类别  deer : 81.80 %
类别   dog : 77.10 %
类别  frog : 90.30 %
类别 horse : 90.20 %
类别  ship : 90.00 %
类别 truck : 91.70 %

train.py

复制代码
import torch
import torchvision
import torchvision.transforms as transforms
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

def main():
    device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    print(f'Training on: {device}')

    # ==========================================
    # 策略 1: 数据增强 (Data Augmentation)
    # ==========================================
    # 训练集:加了随机翻转和随机裁剪,增加难度,防止死记硬背
    transform_train = transforms.Compose([
        transforms.RandomHorizontalFlip(),  # 50%概率水平翻转
        transforms.RandomCrop(32, padding=4), # 随机裁剪
        transforms.ToTensor(),
        transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
    ])

    # 测试集:保持原样,只做标准化
    transform_test = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
    ])

    trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
                                            download=True, transform=transform_train)
    # Batch Size 建议 64 或 128
    trainloader = torch.utils.data.DataLoader(trainset, batch_size=64,
                                              shuffle=True, num_workers=2)

    testset = torchvision.datasets.CIFAR10(root='./data', train=False,
                                           download=True, transform=transform_test)
    testloader = torch.utils.data.DataLoader(testset, batch_size=64,
                                             shuffle=False, num_workers=2)

    # ==========================================
    # 策略 2: 定义更深更宽的网络 (Better Architecture)
    # ==========================================
    class BetterNet(nn.Module):
        def __init__(self):
            super(BetterNet, self).__init__()
            
            # 第一层块: 3 -> 32
            # 加了 BatchNorm2d,这是提升准确率的神器
            self.conv1 = nn.Conv2d(3, 32, 3, padding=1)
            self.bn1 = nn.BatchNorm2d(32)
            
            # 第二层块: 32 -> 64
            self.conv2 = nn.Conv2d(32, 64, 3, padding=1)
            self.bn2 = nn.BatchNorm2d(64)
            
            # 第三层块: 64 -> 128
            self.conv3 = nn.Conv2d(64, 128, 3, padding=1)
            self.bn3 = nn.BatchNorm2d(128)
            
            self.pool = nn.MaxPool2d(2, 2)
            
            # 全连接层
            # 经过3次池化(32->16->8->4),且最后一层通道是128
            # 所以输入是 128 * 4 * 4
            self.fc1 = nn.Linear(128 * 4 * 4, 512)
            self.fc2 = nn.Linear(512, 10)
            
            # Dropout: 防止过拟合,扔掉一些神经元
            self.dropout = nn.Dropout(0.5)

        def forward(self, x):
            # Conv -> BN -> ReLU -> Pool
            x = self.pool(F.relu(self.bn1(self.conv1(x))))
            x = self.pool(F.relu(self.bn2(self.conv2(x))))
            x = self.pool(F.relu(self.bn3(self.conv3(x))))
            
            x = torch.flatten(x, 1)
            
            x = F.relu(self.fc1(x))
            x = self.dropout(x) # 在全连接层之间加 Dropout
            x = self.fc2(x)
            return x

    net = BetterNet()
    net.to(device)

    # ==========================================
    # 策略 3: 使用 Adam 优化器
    # ==========================================
    criterion = nn.CrossEntropyLoss()
    # Adam 通常比 SGD 收敛更快,且对学习率不那么敏感
    optimizer = optim.Adam(net.parameters(), lr=0.001)

    # ==========================================
    # 训练循环
    # ==========================================
    # 建议跑 20-30 个 Epoch 看看效果
    for epoch in range(50): 
        net.train() # 确保开启训练模式(启用 Dropout/BN)
        running_loss = 0.0
        
        for i, data in enumerate(trainloader, 0):
            inputs, labels = data[0].to(device), data[1].to(device)

            optimizer.zero_grad()
            outputs = net(inputs)
            loss = criterion(outputs, labels)
            loss.backward()
            optimizer.step()

            running_loss += loss.item()
            # 这里的 200 是打印频率,你可以根据自己喜好改
            if i % 200 == 199:
                print(f'[Epoch: {epoch + 1}, Batch: {i + 1:5d}] loss: {running_loss / 200:.3f}')
                running_loss = 0.0

    print('Finished Training')
    
    # 保存新模型
    torch.save(net.state_dict(), './cifar_betternet.pth')
    print("BetterNet 模型已保存")

if __name__ == '__main__':
    main()

test.py

复制代码
import torch
import torchvision
import torchvision.transforms as transforms
import torch.nn as nn
import torch.nn.functional as F

# ==========================================
# 1. 网络结构定义 (必须与训练代码完全一致)
# ==========================================
class BetterNet(nn.Module):
    def __init__(self):
        super(BetterNet, self).__init__()
        
        # 第一层块: 3 -> 32
        self.conv1 = nn.Conv2d(3, 32, 3, padding=1)
        self.bn1 = nn.BatchNorm2d(32)
        
        # 第二层块: 32 -> 64
        self.conv2 = nn.Conv2d(32, 64, 3, padding=1)
        self.bn2 = nn.BatchNorm2d(64)
        
        # 第三层块: 64 -> 128
        self.conv3 = nn.Conv2d(64, 128, 3, padding=1)
        self.bn3 = nn.BatchNorm2d(128)
        
        self.pool = nn.MaxPool2d(2, 2)
        
        # 全连接层 (输入是 128 * 4 * 4)
        self.fc1 = nn.Linear(128 * 4 * 4, 512)
        self.fc2 = nn.Linear(512, 10)
        
        # Dropout
        self.dropout = nn.Dropout(0.5)

    def forward(self, x):
        # Conv -> BN -> ReLU -> Pool
        x = self.pool(F.relu(self.bn1(self.conv1(x))))
        x = self.pool(F.relu(self.bn2(self.conv2(x))))
        x = self.pool(F.relu(self.bn3(self.conv3(x))))
        
        x = torch.flatten(x, 1)
        
        x = F.relu(self.fc1(x))
        # 注意:虽然这里写了 dropout,但在 net.eval() 模式下它会自动失效
        x = self.dropout(x) 
        x = self.fc2(x)
        return x

def main():
    # ==========================================
    # 2. 准备工作
    # ==========================================
    device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    print(f'当前测试运行设备: {device}')

    # 测试集的预处理(不需要翻转和裁剪,只要归一化)
    transform_test = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
    ])

    testset = torchvision.datasets.CIFAR10(root='./data', train=False,
                                           download=True, transform=transform_test)
    testloader = torch.utils.data.DataLoader(testset, batch_size=100,
                                             shuffle=False, num_workers=2)

    classes = ('plane', 'car', 'bird', 'cat',
               'deer', 'dog', 'frog', 'horse', 'ship', 'truck')

    # ==========================================
    # 3. 加载模型
    # ==========================================
    # 注意文件名要和你训练保存的名字一致
    PATH = './cifar_betternet.pth' 
    
    print(f"正在加载模型: {PATH} ...")
    
    net = BetterNet()
    
    try:
        net.load_state_dict(torch.load(PATH, map_location=device))
    except FileNotFoundError:
        print(f"错误:找不到文件 {PATH}。请先运行训练脚本生成模型。")
        return
    except RuntimeError as e:
        print(f"加载失败,可能是网络结构定义不匹配。\n详细错误: {e}")
        return

    net.to(device)
    
    # 开启评估模式!这会冻结 BN 层和关闭 Dropout,至关重要
    net.eval() 

    # ==========================================
    # 4. 评估总体准确率
    # ==========================================
    print("开始评估...")
    correct = 0
    total = 0
    
    with torch.no_grad():
        for data in testloader:
            images, labels = data[0].to(device), data[1].to(device)
            outputs = net(images)
            _, predicted = torch.max(outputs.data, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

    print(f'>> 总体准确率: {100 * correct / total:.2f} %')

    # ==========================================
    # 5. 评估每一类的准确率
    # ==========================================
    print("\n正在分析每一类的表现...")
    class_correct = list(0. for i in range(10))
    class_total = list(0. for i in range(10))

    with torch.no_grad():
        for data in testloader:
            images, labels = data[0].to(device), data[1].to(device)
            outputs = net(images)
            _, predicted = torch.max(outputs, 1)
            c = (predicted == labels).squeeze()
            
            for i in range(len(labels)):
                label = labels[i]
                class_correct[label] += c[i].item()
                class_total[label] += 1

    for i in range(10):
        acc = 100 * class_correct[i] / class_total[i]
        print(f'类别 {classes[i]:>5s} : {acc:.2f} %')

if __name__ == '__main__':
    main()

后续想要进一步提升准确率,可以利用Res-Net18,更改亮度,对比度使模型强行记住形状等策略

相关推荐
乾元37 分钟前
多厂商配置对齐器:AI 如何在 Cisco / Huawei / Juniper 间做语义映射
运维·开发语言·网络·人工智能·网络协议·华为·智能路由器
高洁0140 分钟前
卷积神经网络(CNN)
人工智能·python·深度学习·神经网络·transformer
墨染星辰云水间41 分钟前
机器学习(二)
人工智能·机器学习
专注数据的痴汉42 分钟前
「数据获取」清远统计年鉴(2008-2023)
人工智能
byte轻骑兵42 分钟前
openEuler在AI图像分类场景下的性能深度评测与优化实践
人工智能·分类·数据挖掘
ViiTor_AI42 分钟前
怎么又快又好的给视频加字幕?介绍三个工具
人工智能·音视频·双语字幕·给视频加字幕
qyresearch_44 分钟前
石油燃烧器市场:现状洞察与未来展望
人工智能
_oP_i1 小时前
知识图谱与检索增强RAG的关系
人工智能·知识图谱
Dfreedom.1 小时前
深度学习中的偏差-方差权衡:规模效应下的新范式
人工智能·深度学习·偏差误差权衡