【从零开始大模型开发与微调:基于PyTorch与ChatGLM】(从退化问题到信息高速公路:ResNet残差网络实战拆解)


🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》 《MySQL数据库学习》 《Python知识内容》
✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介:

随着卷积网络模型的成功,更深、更宽、更复杂的网络似乎成为卷积神经网络搭建的主流.卷积神经网络能够用来提取所侦测对象的低、中、高特征,网络的层数越多,意味着能够提取到不同等级的特征越丰富.通过还原镜像发现,越深的网络提取的特征越抽象,越具有语义信息.这也产生了一个非常大的疑问,是否可以单纯地通过增加神经网络模型的深度和宽度(增加更多的隐藏层和每个层中的神经元)来获得更好的结果?答案是不可能.因为根据实验发现,随着卷积神经网络层数的加深,出现了另一个问题,即在训练集上,准确率难以达到100%正确,甚至产生了下降.这似乎不能简单地解释为卷积神经网络的性能下降,因为卷积神经网络加深的基础理论就是越深越好.如果强行解释为产生了"过拟合",似乎也不能够解释准确率下降的原因,因为如果产生了过拟合,那么在训练集上卷积神经网络应该表现得更好才对.这个问题被称为"神经网络退化".神经网络退化问题的产生说明了卷积神经网络不能够被简单地使用堆积层数的方法进行优化.2015年,152层深的ResNet(残差网络)横空出世,取得当年ImageNet竞赛冠军,相关论文在CVPR 2016斩获最佳论文奖.ResNet成为视觉乃至整个AI界的一个经典.ResNet使得训练深度达到数百甚至数千层的网络成为可能,而且性能仍然优越.本文主要介绍ResNet及其变种.以及后面介绍的Attention模块也是基于ResNet模型的扩展,因此本文内容非常重要.让我们站在巨人的肩膀上,从冠军开始!

目录

1.ResNet基础原理与程序设计基础

为了获取更好的准确率和辨识度,科研人员不断使用更深、更宽、更大的网络来挖掘对象的数据特征,但是随之而来的研究发现,过多的参数和层数并不能带来性能上的提升,反而由于网络层数的增加,训练过程会带来训练的不稳定性增加.因此,无论是科学界还是工业界都在探索和寻找一种新的神经网络结构模型.

ResNet的出现彻底改变了传统靠堆积卷积层所带来的固定思维,破天荒地提出了采用模块化的集合模式来替代整体的卷积层,通过一个个模块的堆叠来替代不断增加的卷积层.

对ResNet的研究和不断改进成为过去几年中计算机视觉和深度学习领域最具突破性的工作.由于其表征能力强,ResNet在图像分类任务以外的许多计算机视觉应用上都取得了巨大的性能提升,例如对象检测和人脸识别.


1.1ResNet诞生的背景

卷积神经网络的实质就是无限拟合一个符合对应目标的函数.而根据泛逼近定理(Universal

Approximation Theorem),如果给定足够的容量,一个单层的前馈网络就足以表示任何函数.但是,这个层可能非常大,而且网络数据容易过拟合.因此,学术界有一个共识,就是网络架构需要更深.

但是,研究发现只是简单地将层堆叠在一起,增加网络的深度并不会起太大的作用.这是由于难搞

的梯度消失(Vanishing Gradient)问题,深层的网络很难训练.因为梯度反向传播到前一层,重复相乘可能使梯度无穷小.结果就是,随着网络层数的加深,其性能趋于饱和,甚至开始迅速下降,如下图所示.


随着网络层数的加深,其性能趋于饱和,甚至开始迅速下降

在ResNet之前,已经出现好几种处理梯度消失问题的方法,但是没有一种方法能够真正解决这个问题.何恺明等人于2015年发表的论文《用于图像识别的深度残差学习》(Deep Residual Learning forImage Recognition)中,认为堆叠的层不应该降低网络的性能,可以简单地在当前网络上堆叠映射层(不处理任何事情的层),并且所得到的架构性能不变.

即当f(x)为0时,f'(x)等于x,而当f(x)不为0,所获得的f'(x)性能要优于单纯地输入x.公式表明,较深的

模型所产生的训练误差不应比较浅的模型的误差更高.假设让堆叠的层拟合一个残差映射(ResidualMapping)要比让它们直接拟合所需的底层映射更容易.

从下图可以看到,残差映射与传统的直接相连的卷积网络相比,最大的变化是加入了一个恒等映射层y=x层.其主要作用是使得网络随着深度的增加而不会产生权重衰减、梯度衰减或者消失这些问题.


残差框架模块

图中,F(x)表示的是残差,F(x) +x是最终的映射输出,因此可以得到网络的最终输出为H(x)=F(x)

+x.由于网络框架中有2个卷积层和2个ReLU函数,因此最终的输出结果可以表示为:

其中H1是第一层的输出,而H2是第二层的输出.这样在输入与输出有相同维度时,可以使用直接输入的形式将数据传递到框架的输出层.

ResNet整体结构图及与VGGNet的比较如下图所示.

下图展示了VGGNet19、一个34层的普通结构神经网络以及与一个34层的ResNet网络的对比图.通过验证可以知道,在使用了ResNet的结构后,可以发现层数不断加深导致的训练集上误差增大的现象被消除了,ResNet网络的训练误差会随着层数的增加而逐渐减小,并且在测试集上的表现也会变好.


ResNet模型结构及比较

但是,除了用以讲解的二层残差学习单元外,实际上更多的是使用1,1结构的三层残差学习单元,如下图所示.


二层(左)和三层(右)残差单元的比较

这是借鉴了NIN模型的思想,在二层残差单元中包含一个3,3卷积层的基础上,还包含了两个1,1大小的卷积层,放在3,3卷积层的前后,执行先降维再升维的操作.

无论采用哪种连接方式,ResNet的核心是引入一个"身份捷径连接"(Identity Shortcut Connection),直接跳过一层或多层将输入层与输出层进行连接.实际上,ResNet并不是第一个利用身份捷径连接的方法,较早期有相关研究人员就在卷积神经网络中引入了"门控短路电路",即参数化的门控系统允许特定信息通过网络通道,如下图所示.

但是并不是所有加入了Shortcut的卷积神经网络都会提高传输效果.在后续的研究中,有不少研究人员对残差块进行了改进,但是很遗憾并不能获得性能上的提高.


门控短路电路


1.2PyTorch2.0中的模块工具

在正式讲解ResNet之前,我们先熟悉一下ResNet构建过程中所使用的PyTorch 2.0模块.

在构建自己的残差网络之前,需要准备好相关的程序设计工具.这里的工具是指那些已经设计好结构,可以直接使用的代码.最重要的是卷积核的创建方法.从模型上看,需要更改的内容很少,即卷积核的大小、输出通道数以及所定义的卷积层的名称,代码如下:

python 复制代码
torch.nn.Conv2d

Conv2d这个PyTorch卷积模型在前面的学习中已经介绍过,后期我们还会学习其1d模式.此外,还有一个非常重要的方法是获取数据的BatchNormalization,这是使用批量正则化对数据进行

处理,代码如下:

python 复制代码
torch.nn.BatchNorm2d

在这里,BatchNorm2d类生成时需要定义输出的最后一个维度,从而在初始化时生成一个特定的数据维度.还有最大池化层,代码如下:

python 复制代码
torch.nn.MaxPool2d

平均池化层,代码如下:

python 复制代码
torch.nn.AvgPool2d

这些是在模型单元中需要使用的基本工具,这些工具的用法我们在后续的模型实现中会进行讲解,有了这些工具,就可以直接构建ResNet模型单元.


1.3ResNet残差模块的实现

ResNet网络结构已经在前文做了介绍,它突破性地使用模块化思维来对网络进行叠加,从而实现了数据在模块内部特征的传递不会产生丢失.

如下图所示,模块内部实际上是3个卷积通道相互叠加,形成了一种瓶颈设计.对于每个残差模块使用3层卷积.这3层分别是1×1、3×3和1×1的卷积层,其中1×1层卷积对输入数据起到"整形"的作用,通过修改通道数使得3×3卷积层具有较小的输入/输出数据结构.


模块内部

实现的瓶颈3层卷积结构的代码如下:

python 复制代码
torch.nn.Conv2d(input_dim,input_dim//4,kernel_size=1,padding=1)
     torch.nn.ReLU(input_dim//4)
     
torch.nn.Conv2d(input_dim//4,input_dim//4,kernel_size=3,padding=1)
     torch.nn.ReLU(input_dim//4)
     torch.nn.BatchNorm2d(input_dim//4)
     
torch.nn.Conv2d(input_dim,input_dim,kernel_size=1,padding=1)
     torch.nn.ReLU(input_dim)

代码中输入的数据首先经过Conv2d卷积层计算,输出的维度为1/4的输出维度,这是为了降低输入数据的整个数据量,为进行下一层的3,3计算打下基础.同时,因为PyTorch2.0的关系,需要显式地加入ReLU和BatchNorm2d作为激活层和批处理层.

在数据传递的过程中,ResNet模块使用了名为shortcut的"信息高速公路",Shortcut连接相当于简

单执行了同等映射,不会产生额外的参数,也不会增加计算复杂度.而且,整个网络依旧可以通过端到端的反向传播训练.

正是因为有了Shortcut的出现,才使得信息可以在每个块BLOCK中进行传播,据此构成的ResNetBasicBlock代码如下:

python 复制代码
import torch
import torch.nn as nn
     
     class BasicBlock(nn.Module):
        expansion = 1
        def __init__(self, in_channels, out_channels, stride=1):
          super().__init__()
     
          #residual function
          self.residual_function = nn.Sequential(
              nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1,
bias=False),
              nn.BatchNorm2d(out_channels),
              nn.ReLU(inplace=True),
              nn.Conv2d(out_channels, out_channels * BasicBlock.expansion, kernel_size=3,
padding=1, bias=False),
              nn.BatchNorm2d(out_channels * BasicBlock.expansion)
          )
     
          #shortcut
          self.shortcut = nn.Sequential()
          #判定输出的维度是否和输入相一致
          if stride != 1 or in_channels != BasicBlock.expansion * out_channels:
              self.shortcut = nn.Sequential(
                 nn.Conv2d(in_channels, out_channels * BasicBlock.expansion, kernel_size=1,
stride=stride, bias=False),
                 nn.BatchNorm2d(out_channels * BasicBlock.expansion)
              )
     
       def forward(self, x):
          return nn.ReLU(inplace=True)(self.residual_function(x) + self.shortcut(x))

在这里实现的是经典的ResNetBlock模型,除此之外,还有很多ResNet模块化的方式,如下图所示.


其他ResNet模块化的方式


1.4残差连接到底解决了什么

残差连接也常被叫做 shortcut、skip connection 或身份捷径连接.它的做法是让输入绕过若干卷积层,直接与卷积路径的输出相加.

从前向传播看,残差块输出是:

text 复制代码
y = F(x, W) + x

其中 F(x, W) 是卷积、归一化、激活等操作形成的残差分支,x 是直连分支.

从反向传播看,残差连接更关键.对输入 x 求梯度时,会出现一条直接通路:

text 复制代码
dL/dx = dL/dy * (dF/dx + 1)

这里的 +1 可以理解为梯度的"直通车道".即使残差分支在深层网络中出现梯度衰减,shortcut 仍然能把梯度传回更早的层.这也是 ResNet 能训练到几十层、上百层甚至更深的重要原因.

换句话说,ResNet 不是让深层网络"不会犯错",而是给优化过程加了一条更稳定的路径:当新增层暂时学不好时,信息仍然能通过直连分支保留下来;当新增层学到了有用特征时,它再作为残差修正量叠加到主表示上.


1.5BasicBlock与Bottleneck:两种残差模块

先从基础残差框架讲起,然后过渡到更常用的三层瓶颈结构.理解这两种模块,是读懂 ResNet-18、ResNet-34、ResNet-50、ResNet-101、ResNet-152 的关键.


1.5.1BasicBlock:适合较浅ResNet

BasicBlock 通常由两个 3x3 卷积组成:

text 复制代码
x -> Conv3x3 -> BN -> ReLU -> Conv3x3 -> BN -> Add(x) -> ReLU

它的优点是结构直观,适合 ResNet-18 和 ResNet-34 这类相对较浅的网络.每个块内部做两次局部特征提取,然后把输入加回去.

在 PyTorch 中,一个简化版 BasicBlock 可以写成:

python 复制代码
import torch
from torch import nn


class BasicBlock(nn.Module):
    expansion = 1

    def __init__(self, in_channels: int, out_channels: int, stride: int = 1):
        super().__init__()
        self.residual = nn.Sequential(
            nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True),
            nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1, bias=False),
            nn.BatchNorm2d(out_channels),
        )

        self.shortcut = nn.Identity()
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(out_channels),
            )

        self.activation = nn.ReLU(inplace=True)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.activation(self.residual(x) + self.shortcut(x))

这里有一个非常重要的细节:如果输入输出通道数不同,或者 stride 改变了特征图尺寸,就不能直接把 x 加到残差分支上.此时需要用 1x1 卷积投影 shortcut,让两条分支的 shape 对齐.


1.5.2Bottleneck:先压缩,再提取,再恢复

更深的 ResNet 通常使用 Bottleneck 结构.它由三层卷积组成:

text 复制代码
1x1 Conv -> 3x3 Conv -> 1x1 Conv

这三个卷积层的职责并不相同:

  • 第一个 1x1 卷积负责降维,减少通道数,降低后续 3x3 卷积的计算量.
  • 中间的 3x3 卷积负责在空间维度上提取局部特征.
  • 最后的 1x1 卷积负责升维,把通道数恢复到残差块输出需要的宽度.

为什么这叫瓶颈?因为中间计算被压缩到了较窄的通道空间.它像一个"先收窄、再加工、再展开"的结构:计算成本下降了,但表达能力仍然可以通过更深的层数和残差连接维持.

在大模型视角下,这种思想并不陌生.很多现代网络都会在昂贵操作前后做投影或通道变换:先把表示压到更适合计算的空间,再做核心变换,最后映射回主干维度.ResNet 的 Bottleneck 是这种工程思想在卷积网络中的经典版本.


1.6ResNet网络的实现

在介绍完ResNet模块的实现后,下面使用完成的ResNetBlock模型实现完整的ResNet.ResNet的结构如下图所示.


ResNet的结构

图中一共给出了5种深度的ResNet,分别是18、34、50、101和152,其中所有的网络都分成5部分,分别是conv1、conv2_x、conv3_x、conv4_x和conv5_x.

说明:ResNet完整的实现需要较高性能的显卡,因此我们对其做了修改,去掉了Pooling层,并降低了每次filter的数目和每层的层数,这一点请大家注意.

完整实现ResNet模型的结构如下:

python 复制代码
import torch
import torch.nn as nn


class BasicBlock(nn.Module):
    expansion = 1

    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()

        # residual function
        self.residual_function = nn.Sequential(
            nn.Conv2d(
                in_channels,
                out_channels,
                kernel_size=3,
                stride=stride,
                padding=1,
                bias=False
            ),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True),
            nn.Conv2d(
                out_channels,
                out_channels * BasicBlock.expansion,
                kernel_size=3,
                padding=1,
                bias=False
            ),
            nn.BatchNorm2d(out_channels * BasicBlock.expansion)
        )

        # shortcut
        self.shortcut = nn.Sequential()

        # 判断输出的维度是否和输入相一致
        if stride != 1 or in_channels != BasicBlock.expansion * out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(
                    in_channels,
                    out_channels * BasicBlock.expansion,
                    kernel_size=1,
                    stride=stride,
                    bias=False
                ),
                nn.BatchNorm2d(
                    out_channels * BasicBlock.expansion
                )
            )

    def forward(self, x):
        return nn.ReLU(inplace=True)(
            self.residual_function(x) + self.shortcut(x)
        )


class ResNet(nn.Module):

    def __init__(self, block, num_block, num_classes=100):
        super().__init__()

        self.in_channels = 64

        self.conv1 = nn.Sequential(
            nn.Conv2d(
                3,
                64,
                kernel_size=3,
                padding=1,
                bias=False
            ),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True)
        )

        # 根据传入的模型结构构建各阶段网络
        self.conv2_x = self._make_layer(
            block, 64, num_block[0], 1
        )
        self.conv3_x = self._make_layer(
            block, 128, num_block[1], 2
        )
        self.conv4_x = self._make_layer(
            block, 256, num_block[2], 2
        )
        self.conv5_x = self._make_layer(
            block, 512, num_block[3], 2
        )

        self.avg_pool = nn.AdaptiveAvgPool2d((1, 1))
        self.fc = nn.Linear(
            512 * block.expansion,
            num_classes
        )

    def _make_layer(
        self,
        block,
        out_channels,
        num_blocks,
        stride
    ):
        strides = [stride] + [1] * (num_blocks - 1)
        layers = []

        for stride in strides:
            layers.append(
                block(
                    self.in_channels,
                    out_channels,
                    stride
                )
            )
            self.in_channels = (
                out_channels * block.expansion
            )

        return nn.Sequential(*layers)

    def forward(self, x):
        output = self.conv1(x)
        output = self.conv2_x(output)
        output = self.conv3_x(output)
        output = self.conv4_x(output)
        output = self.conv5_x(output)
        output = self.avg_pool(output)

        # 展平特征后输入全连接分类层
        output = output.view(output.size(0), -1)
        output = self.fc(output)

        return output


# 18层的ResNet
def resnet18():
    return ResNet(
        BasicBlock,
        [2, 2, 2, 2]
    )


# 34层的ResNet
def resnet34():
    return ResNet(
        BasicBlock,
        [3, 4, 6, 3]
    )


if __name__ == "__main__":
    image = torch.randn(
        size=(5, 3, 224, 224)
    )

    resnet = ResNet(
        BasicBlock,
        [2, 2, 2, 2]
    )

    img_out = resnet(image)
    print(img_out.shape)

在这里需要提醒的是,根据输入层数的不同,作者采用PyTorch 2.0中特有的构造方法对传入的块形式进行构建,使用view层作为全局池化层,之后的fc层对结果进行最终的分类.请大家注意,这里为了配合CIFAR-10数据集分类,分类结果被设置成10种.为了演示,在这里实现了18层和34层的ResNet模型的构建,更多的模型请大家自行完成.


1.7PyTorch实现ResNet的四个关键组件

在进入完整模型前,先回顾了几个 PyTorch 组件.它们看似基础,但决定了 ResNet 是否能稳定训练.


1.7.1Conv2d:特征提取的核心算子

nn.Conv2d 负责局部感受野上的特征提取.ResNet 中最常见的是 3x31x1

  • 3x3:提取局部空间模式.
  • 1x1:改变通道数,实现升维、降维或 shortcut 投影.

1.7.2BatchNorm2d:稳定分布,帮助深层训练

nn.BatchNorm2d 会对 batch 内特征做归一化,缓解深层训练中分布变化带来的不稳定.ResNet 的卷积层后通常接 BN,再接ReLU.

text 复制代码
Conv -> BatchNorm -> ReLU

BN与residual connection是 ResNet 成功训练的重要组合.shortcut 负责信息与梯度通路,BN 负责让每层输入分布更可控.


1.7.3ReLU:引入非线性

如果只有卷积和加法,整个网络仍然接近线性变换.ReLU 让模型能表示复杂非线性模式.需要注意的是,不同 ResNet 变体会调整 ReLU 放置位置,例如 pre-activation ResNet 会把 BN/ReLU 放在卷积之前.


1.7.4Pooling与全局汇聚

分类网络最后需要把空间特征转成类别预测.常见做法是全局平均池化,再接全连接层:

text 复制代码
feature map -> global average pooling -> linear classifier

对于CIFAR-10这样的小图像任务,也可以使用 viewflatten 连接到分类层,但更通用的写法是 nn.AdaptiveAvgPool2d((1, 1)),这样模型能适配不同输入尺寸.


2.ResNet实战:CIFAR-10数据集分类

2.1CIFAR-10数据集简介

CIFAR-10数据集共有60000幅彩色图像,这些图像是32×32像素的,分为10个类,每类6000幅图.这里面有50000幅用于训练,构成了5个训练批,每一批10000幅图;另外10000幅图用于测试,单独构成一批.测试批的数据取自100类中的每一类,每一类随机取1000幅图.抽剩下的就随机排列组成训练批.注意,一个训练批中的各类图像的数量并不一定相同,总的来看,训练批每一类都有5000幅图,如下图所示.


CIFAR-10数据集

大家自行搜索CIFAR-10数据集下载地址,进入下载页面后,选择下载方式,如下图所示.

由于PyTorch 2.0采用Python语言编程,因此选择pythonversion版本下载.下载之后解压缩,得到如下图所示的几个文件.

data_batch_1 ~ data_batch_5是划分好的训练数据,每个文件中包含10000幅图片,test_batch是测试集数据,也包含10000幅图片.

读取数据的代码如下:

python 复制代码
import pickle
     def load_file(filename):
        with open(filename, 'rb') as fo:
           data = pickle.load(fo, encoding='latin1')
        return data

首先定义读取数据的函数,这几个文件都是通过pickle产生的,所以在读取的时候也要用到这个包.返回的data是一个字典,先来看这个字典里面有哪些键.

输出结果如下:

python 复制代码
dict_keys([ 'batch_label', 'labels', 'data', 'filenames' ])

具体说明如下:

· batch_label:对应的值是一个字符串,用来表明当前文件的一些基本信息.

·labels:对应的值是一个长度为10000的列表,每个数字取值范围为0~9,代表当前图片所属的类别.

· data:10000×3072的二维数组,每一行代表一幅图片的像素值.

· filenames:长度为10000的列表,里面每一项是代表图片文件名的字符串.

完整的数据读取函数如下

python 复制代码
import pickle
import numpy as np
import os


def get_cifar10_train_data_and_label(root=""):
    def load_file(filename):
        with open(filename, "rb") as fo:
            data = pickle.load(fo, encoding="latin1")
        return data

    data_batch_1 = load_file(os.path.join(root, "data_batch_1"))
    data_batch_2 = load_file(os.path.join(root, "data_batch_2"))
    data_batch_3 = load_file(os.path.join(root, "data_batch_3"))
    data_batch_4 = load_file(os.path.join(root, "data_batch_4"))
    data_batch_5 = load_file(os.path.join(root, "data_batch_5"))

    dataset = []
    labelset = []

    for data in [
        data_batch_1,
        data_batch_2,
        data_batch_3,
        data_batch_4,
        data_batch_5,
    ]:
        img_data = data["data"]
        img_label = data["labels"]

        dataset.append(img_data)
        labelset.append(img_label)

    dataset = np.concatenate(dataset)
    labelset = np.concatenate(labelset)

    return dataset, labelset


def get_cifar10_test_data_and_label(root=""):
    def load_file(filename):
        with open(filename, "rb") as fo:
            data = pickle.load(fo, encoding="latin1")
        return data

    data_batch_1 = load_file(os.path.join(root, "test_batch"))

    dataset = []
    labelset = []

    for data in [data_batch_1]:
        img_data = data["data"]
        img_label = data["labels"]

        dataset.append(img_data)
        labelset.append(img_label)

    dataset = np.concatenate(dataset)
    labelset = np.concatenate(labelset)

    return dataset, labelset


def get_CIFAR10_dataset(root=""):
    train_dataset, label_dataset = get_cifar10_train_data_and_label(
        root=root
    )

    test_dataset, test_label_dataset = get_cifar10_test_data_and_label(
        root=root
    )

    return (
        train_dataset,
        label_dataset,
        test_dataset,
        test_label_dataset,
    )


if __name__ == "__main__":
    train_dataset, label_dataset, test_dataset, test_label_dataset = (
        get_CIFAR10_dataset(
            root="../dataset/cifar-10-batches-py/"
        )
    )

    train_dataset = np.reshape(
        train_dataset,
        [len(train_dataset), 3, 32, 32],
    ).astype(np.float32) / 255.0

    test_dataset = np.reshape(
        test_dataset,
        [len(test_dataset), 3, 32, 32],
    ).astype(np.float32) / 255.0

    label_dataset = np.array(label_dataset)
    test_label_dataset = np.array(test_label_dataset)

其中的root参数是下载数据解压后的目录,os.join函数将其组合成数据文件的位置.最终返回训练文件、测试文件以及它们对应的label.由于我们提取出的文件数据格式为-1,3072,因此需要重新对数据维度进行调整,使之适用模型的输入.


2.2基于ResNet的CIFAR-10数据集分类

前面我们对ResNet模型以及CIFAR-10数据集做了介绍,本小节将使用前面定义的ResNet模型进行分类任务.已经介绍了CIFAR-10数据集的基本构成,并讲解了ResNet的基本模型结构,接下来直接导入对应的数据和模型即可.完整的模型训练如下:

python 复制代码
import torch
import resnet
import get_data
import numpy as np


train_dataset, label_dataset, test_dataset, test_label_dataset = (
    get_data.get_CIFAR10_dataset(
        root="../dataset/cifar-10-batches-py/"
    )
)

train_dataset = np.reshape(
    train_dataset,
    [len(train_dataset), 3, 32, 32]
).astype(np.float32) / 255.0

test_dataset = np.reshape(
    test_dataset,
    [len(test_dataset), 3, 32, 32]
).astype(np.float32) / 255.0

label_dataset = np.array(label_dataset)
test_label_dataset = np.array(test_label_dataset)


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

model = resnet.resnet18()       # 导入ResNet模型
model = model.to(device)        # 将模型传入GPU或CPU
model = torch.compile(model)    # PyTorch 2.0特性,加速计算速度

optimizer = torch.optim.Adam(
    model.parameters(),
    lr=2e-5
)

loss_fn = torch.nn.CrossEntropyLoss()


batch_size = 128
train_num = len(label_dataset) // batch_size

for epoch in range(63):
    train_loss = 0.0

    for i in range(train_num):
        start = i * batch_size
        end = (i + 1) * batch_size

        x_batch = torch.from_numpy(
            train_dataset[start:end]
        ).to(device)

        y_batch = torch.from_numpy(
            label_dataset[start:end]
        ).to(device)

        pred = model(x_batch)
        loss = loss_fn(pred, y_batch.long())

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        # 记录每个批次的损失值
        train_loss += loss.item()

    # 计算并打印损失值
    train_loss /= train_num

    accuracy = (
        (pred.argmax(1) == y_batch)
        .type(torch.float32)
        .sum()
        .item()
        / batch_size
    )

    # 2048可根据GPU显存大小调整
    test_num = 2048

    x_test = torch.from_numpy(
        test_dataset[:test_num]
    ).to(device)

    y_test = torch.from_numpy(
        test_label_dataset[:test_num]
    ).to(device)

    pred = model(x_test)

    test_accuracy = (
        (pred.argmax(1) == y_test)
        .type(torch.float32)
        .sum()
        .item()
        / test_num
    )

    print(
        "epoch:", epoch,
        "train_loss:", round(train_loss, 2),
        "accuracy:", round(accuracy, 2),
        ";test_accuracy:", round(test_accuracy, 2)
    )

在这里使用训练集数据对模型进行训练,之后使用测试集数据对其输出进行测试,训练结果如下图所示.

可以看到,经过5轮后,模型在训练集的准确率达到0.99,在测试集的准确率也达到0.98,这是一个较好的成绩,可以看到模型的性能达到较高水平.其他层次的模型请大家自行尝试,根据自己不同的硬件设备,模型的参数和训练集的batch_size都需要作出调整,具体数值请根据需要对它们进行设置.


2.3CIFAR-10 实战:ResNet如何完成10类分类

使用 CIFAR-10 做 ResNet 分类实战.CIFAR-10 是计算机视觉入门中非常经典的数据集:

  • 共 60000 张彩色图像.
  • 每张图像大小为 32x32.
  • 共 10 个类别.
  • 50000 张训练图像,10000 张测试图像.

它的难点不在于图像尺寸,而在于小图像的信息密度有限.模型需要在很小的空间范围内识别物体类别,因此数据增强、网络下采样策略和训练稳定性都会影响结果.

一个更工程化的训练流程通常包括:

text 复制代码
读取数据 -> 数据增强 -> 构建 DataLoader -> 初始化 ResNet -> 定义损失函数与优化器 -> 训练 -> 验证 -> 保存模型

下面是一段更贴近现代 PyTorch 风格的训练骨架:

python 复制代码
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms


def build_cifar10_loaders(root: str, batch_size: int = 128):
    train_transform = transforms.Compose([
        transforms.RandomCrop(32, padding=4),
        transforms.RandomHorizontalFlip(),
        transforms.ToTensor(),
        transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)),
    ])
    test_transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)),
    ])

    train_set = datasets.CIFAR10(root=root, train=True, download=True, transform=train_transform)
    test_set = datasets.CIFAR10(root=root, train=False, download=True, transform=test_transform)

    train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=2)
    test_loader = DataLoader(test_set, batch_size=batch_size, shuffle=False, num_workers=2)
    return train_loader, test_loader


def train_one_epoch(model, loader, optimizer, criterion, device):
    model.train()
    total_loss = 0.0
    correct = 0
    total = 0

    for images, labels in loader:
        images = images.to(device)
        labels = labels.to(device)

        optimizer.zero_grad(set_to_none=True)
        logits = model(images)
        loss = criterion(logits, labels)
        loss.backward()
        optimizer.step()

        total_loss += loss.item() * labels.size(0)
        correct += (logits.argmax(dim=1) == labels).sum().item()
        total += labels.size(0)

    return total_loss / total, correct / total


@torch.no_grad()
def evaluate(model, loader, criterion, device):
    model.eval()
    total_loss = 0.0
    correct = 0
    total = 0

    for images, labels in loader:
        images = images.to(device)
        labels = labels.to(device)

        logits = model(images)
        loss = criterion(logits, labels)

        total_loss += loss.item() * labels.size(0)
        correct += (logits.argmax(dim=1) == labels).sum().item()
        total += labels.size(0)

    return total_loss / total, correct / total

这个骨架没有绑定某个具体 ResNet 实现,但训练逻辑是通用的.对于 CIFAR-10,推荐重点关注四个参数:

  • batch_size:显存不够时先调小它.
  • learning_rate:过大容易震荡,过小收敛慢.
  • weight_decay:帮助控制过拟合.
  • augmentation:小图像任务中,随机裁剪和水平翻转非常重要.

实验展示了一个很理想的结果:训练若干轮后,训练集和测试集准确率都达到较高水平.实际复现实验时,结果会受到硬件、随机种子、数据增强、学习率策略、模型简化程度影响,不应只盯着单次准确率,而应关注训练曲线是否稳定、训练集和测试集差距是否合理.


3.实战中最容易踩的五个坑

3.1shortcut两边shape不一致

残差相加要求两个张量形状一致.常见错误是主分支 stride 改了,但 shortcut 仍然用 Identity.解决方法是使用 1x1 卷积加相同 stride 做投影.


3.2小图像上过度下采样

CIFAR-10 只有 32x32.如果直接照搬 ImageNet 版本开头的 7x7 conv + maxpool,空间信息会很快被压缩.小图像任务通常使用更温和的初始卷积.


3.3忽略BatchNorm的 train/eval模式

训练时 BN 使用 batch 统计量,推理时使用滑动统计量.因此验证和测试前必须调用:

python 复制代码
model.eval()

训练前则要切回:

python 复制代码
model.train()

3.4只看训练准确率

ResNet 表达能力强,如果训练准确率很高但测试准确率明显落后,可能是数据增强不足、正则化不够或训练轮次过多.更健康的做法是同时记录 train loss、train acc、val loss、val acc.


3.5把残差连接理解成"简单复制"

shortcut 不是为了偷懒复制输入,而是为了让网络更容易学习"增量修正".残差分支负责学习新特征,直连分支负责保留基础表示和稳定梯度.


4.从ResNet到大模型:残差思想为什么仍然重要

虽然讨论的是卷积视觉模型,但残差思想并没有停留在 CNN 时代.今天的大模型主干网络中,残差路径同样是核心结构之一.Transformer block 中常见的写法就是:

text 复制代码
x = x + Attention(LayerNorm(x))
x = x + MLP(LayerNorm(x))

这和 ResNet 的精神高度一致:复杂模块负责学习变换,残差路径负责保留原始表示并提供稳定的梯度通道.没有残差连接,超深层 Transformer 的训练会困难得多.

因此,学习 ResNet 不只是学习一个图像分类模型,更是在学习现代深度网络的一条基础原则:

深层模型要想可训练,必须给信息和梯度设计稳定的通路.

这条原则贯穿 CNN、Transformer、扩散模型和多模态模型.网络可以越来越深,模块可以越来越复杂,但 residual path 这种"让信息有路可走"的思想一直没有过时.


5.总结

本文的核心可以压缩成三句话:

  1. 普通深层 CNN 会遇到退化问题,简单堆层并不等于更强模型.
  2. ResNet 用 H(x)=F(x)+x 把目标映射改写为残差学习,让深层网络更容易优化.
  3. 在 CIFAR-10 这类小图像分类任务中,ResNet 的模块化结构、shortcut 对齐、BatchNorm、数据增强和训练参数共同决定最终效果.

如果用一个比喻来理解 ResNet:普通网络像一条必须穿过所有收费站的长路,层数越多越容易堵;ResNet则修了一条信息高速公路,重要信息可以绕过拥堵路段,残差分支只负责补充必要的修正.这个设计让"更深"第一次真正变成了可训练、可扩展、可工程化的优势.

🚀真正的勇者不是流泪的人,而是含泪奔跑的人!


敬请期待下一篇文章内容


每日心灵鸡汤: 在不完美的秩序中找到自己的位置!

对当下世界的理解,往往从一个简单但不断被验证的前提出发:秩序并不是一个稳定、统一的存在.我们需要先尽可能完整地观察秩序是如何被建立、如何运作,以及在现实中如何被不同力量不断拉扯、变形甚至异化的;在这个基础上,会形成一个更直接的判断:现实秩序并不稳定,也并非始终指向公平与一致,在许多领域中,它会被激励结构、利益关系与执行偏差不断侵蚀,甚至在某些情况下出现明显的失真与失衡;但这种批判并不是停留在情绪性的否定,而是一种清醒的认知方式,在承认问题存在的同时,保持自身的判断独立性,并最终回到个人层面,通过持续成长与行动,在这种不完美且高度复杂的秩序结构中找到自己的位置.

相关推荐
大鱼>1 小时前
宠物行为识别AI算法详解:从数据采集到模型部署的完整方案
人工智能·深度学习·算法·宠物
Lhappy嘻嘻1 小时前
网络初识|基础入门:局域网广域网、IP 端口、TCP/IP 五层模型与封装解封装全过程
java·开发语言·网络·笔记·网络协议·tcp/ip
doiito(Do It Together)1 小时前
【开源项目】Rust 重构 TTS 引擎:kokoroi-rs 的高性能流水线架构揭秘
人工智能
王莎莎-MinerU1 小时前
解析回归集:RAG 和 Agent 上线前,先把表格、公式、版面测清楚
人工智能·算法·数据挖掘·回归·pdf·ocr
AI服务老曹1 小时前
Docker部署AI视频分析平台项目实战记录
人工智能·docker·音视频
KaMeidebaby1 小时前
卡梅德生物技术快报|兔单克隆抗体:噬菌体 Fab 免疫文库搭建实操:兔单克隆抗体重组构建完整参数
前端·网络·数据库·人工智能·算法
AndrewHZ1 小时前
【LLM技术全景】混合精度与分布式训练:训练大模型的工程奥秘
人工智能·分布式·深度学习·算法·ai·语言模型·llm
万岳科技程序员小金1 小时前
真人数字人小程序如何开发?AI数字人平台搭建流程全面解析
人工智能·小程序·ai数字人系统源码·ai数字人平台搭建·ai数字人小程序开发
吴佳浩1 小时前
为什么 MCP、Skill、RAG 能工作?从 Conversation Loop 看现代 Agent 的底层架构
人工智能·llm·agent