【从零开始大模型开发与微调:基于PyTorch与ChatGLM】(基于PyTorch卷积层的MNIST分类实战:从卷积直觉到高效卷积设计)


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

前面使用多层感知机完成了MNIST分类实战的演示.多层感知机是一种对目标数据进行整体分类的计算方法.虽然从演示效果来看,多层感知机可以较好地完成项目目标对数据进行完整分类,但是多层感知机会在模型中使用大规模的参数,同时,由于是对数据进行总体性的处理,从而无可避免地会忽略数据局部特征的处理和掌握,因此我们需要一种新的能够对输入数据的局部特征进行抽取和计算的工具.卷积神经网络是从信号处理衍生过来的一种对数字信号进行处理的方式,发展到图像信号处理上演变成一种专门用来处理具有矩阵特征的网络结构处理方式.卷积神经网络在很多应用上都有独特的优势,甚至可以说是无可比拟的,例如音频的处理和图像处理.本篇将会介绍卷积神经网络的基本概念.首先,我们将阐述卷积实际上是一种不太复杂的数学运算,它是一种特殊的线性运算形式.接下来,将详细解释"池化"这一概念,这是卷积神经网络中必不可少的操作.最后,我们将探讨为了消除过拟合而采用的drop-out这个常用的方法.这些概念和方法都是为了让卷积神经网络运行得更加高效和稳定.

目录

1.卷积运算的基本概念

在数字图像处理中有一种基本的处理方法,即线性滤波.它将待处理的二维数字看作一个大型矩阵,图像中的每个像素可以看作矩阵中的每个元素,像素的大小就是矩阵中的元素值.

而使用的滤波工具是另一个小型矩阵,这个矩阵被称为卷积核.卷积核的大小远小于图像矩阵,而

具体的计算方式就是计算图像大矩阵中的每个像素周围的像素和卷积核对应位置的乘积,之后将结果相加,最终得到的值就是该像素的值,这样就完成了一次卷积.简单的图像卷积方式如下图所示.

卷积可以理解为"拿一个小模板在图像上滑动".这个小模板就是卷积核.每到一个位置,卷积核和对应图像区域逐元素相乘再求和,得到输出特征图上的一个值.

如果输入是一张灰度图:

text 复制代码
输入图像: 1 x 28 x 28
卷积核:   1 x 3 x 3
输出:     某种局部特征响应图

卷积最重要的价值有两个.

局部连接:每个输出点只看输入的一小块区域,因此特别适合捕捉边缘、角点、笔画方向等局部特征.

参数共享:同一个卷积核会在整张图上反复使用.也就是说,模型只需要学习"什么样的局部模式重要",而不需要为每个位置单独学习一套参数.

这就是接下来要提到的稀疏连接和参数共享.它们共同带来的结果是:卷积层比全连接层更懂图像结构,也更节省参数.

本文将详细介绍卷积的运算和定义,以及一些细节调整,这些都是使用卷积的过程中必不可少的内容.


1.1基本卷积运算示例

前面已经讲过了,卷积实际上是使用两个大小不同的矩阵进行的一种数学运算.为了便于大家理解,我们从一个例子开始介绍.

对高速公路上的跑车的位置进行追踪,这是卷积神经网络图像处理中的一个非常重要的应用.摄像头接收到的信号被计算为x(t),表示跑车在路上时刻t的位置.

但是实际上的处理往往没那么简单,因为在自然界无时无刻不面临各种影响以及摄像头传感器的滞后.为了得到跑车位置的实时数据,采用的方法就是对测量结果进行均值化处理.对于运动中的目标,采样时间越长,由于滞后性的原因,定位的准确率越低,而采样时间越短,则可以认为越接近真实值.因此,可以对不同的时间段赋予不同的权重,即通过一个权值定义来计算,可以表示为:

这种运算方式被称为卷积运算.换个符号表示为:

在卷积公式中,第一个参数x被称为"输入数据";第二个参数ω被称为"核函数";s(t)是输出,即

特征映射.

对于稀疏矩阵来说,卷积网络具有稀疏性,即卷积核的大小远小于输入数据矩阵的大小.例如,当

输入一个图片信息时,数据的大小可能为上万的结构,但是使用的卷积核却只有几十,这样能够在计算后获取更少的参数特征,极大地减少了后续的计算量,如下图所示.


稀疏矩阵

在传统的神经网络中,每个权重只对其连接的输入输出起作用,当其连接的输入输出元素结束后就不会再用到.而参数共享指的是在卷积神经网络中核的每个元素都被用在输入的每个位置上,在过程中只需学习一个参数集合,就能把这个参数应用到所有的图片元素中.

python 复制代码
import numpy as np

dateMat = np.ones((7, 7))
kernel = np.array([[2, 1, 1], [3, 0, 1], [1, 1, 0]])

def convolve(dateMat, kernel):
    m, n = dateMat.shape
    km, kn = kernel.shape

    newMat = np.ones(((m - km + 1), (n - kn + 1)))
    tempMat = np.ones(((km), (kn)))

    for row in range(m - km + 1):
        for col in range(n - kn + 1):
            for m_k in range(km):
                for n_k in range(kn):
                    tempMat[m_k, n_k] = dateMat[(row + m_k), (col + n_k)] * kernel[m_k, n_k]

            newMat[row, col] = np.sum(tempMat)

    return newMat

上面由Python基础运算包实现了卷积操作,这里卷积核从左到右、从上到下进行卷积计算,最后返回新的矩阵.


1.2PyTorch中的卷积函数实现详解

前面通过Python实现了卷积的计算,PyTorch为了框架计算的迅捷,同样使用了专门的高级API函数Conv2d(Conv)作为卷积计算函数,如下图所示.

python 复制代码
conv2d = torch.nn.Conv2d(3, 10, kernel_size=3)

这个函数是搭建卷积神经网络的核心函数之一,其说明如下:

python 复制代码
class Conv2d(_ConvNd):
    ...
    def __init__(
        self,
        in_channels: int,
        out_channels: int,
        kernel_size: _size_2_t,
        stride: _size_2_t = 1,
        padding: Union[str, _size_2_t] = 0,
        dilation: _size_2_t = 1,
        groups: int = 1,
        bias: bool = True,
        padding_mode: str = 'zeros',  # TODO: refine this type
        device=None,
        dtype=None
    ) -> None:

Conv2d是PyTorch的卷积层自带的函数,其最重要的5个参数如下.

· in_channels:输入的卷积核数目.

· out_channels:输出的卷积核数目.

· kernel_size:卷积核大小,它要求是一个输入向量,具有filter_height, filter_width这样的维度,具体含义是卷积核的高度,卷积核的宽度,要求类型与参数input相同.

· strides:步进大小,卷积时在图形计算时移动的步长,默认为1;如果参数是stride=(2, 1),2代表着高(h)进行步长为2,1代表着宽(w)进行步长为1.

· padding:补全方式,int类型的量,只能是1和0其中之一,这个值决定了不同的卷积方式.

使用卷积计算的示例代码如下:

python 复制代码
import torch

image = torch.randn(size=(5, 3, 128, 128))

# 下面是定义的卷积层示例
"""
输入维度:3
输出维度:10
卷积核大小:基本写法是[3,3],这里简略写法3代表卷积核的长和宽大小一致
步长:2
补偿方式:维度不变补偿
"""
conv2d = torch.nn.Conv2d(3, 10, kernel_size=3, stride=1, padding=1)
image_new = conv2d(image)
print(image_new.shape)

上面的代码段展示了一个使用TensorFlow高级API进行卷积计算的例子,在这里随机生成了5个3,128,128大小的矩阵,之后使用1个大小为3,3的卷积核对其进行计算,打印结果如下:

可以看到,这是计算后生成的新图形,其大小根据设置没有变化,这是由于我们所使用的padding补偿方式将其按原有大小进行补偿.具体来说,这是由于卷积在工作时边缘被处理消失,因此生成的结果小于原有的图像.

但是,若需要生成的卷积结果和原输入矩阵的大小一致,则需要将参数padding的值设为1,此时表示图像边缘将由一圈0补齐,使得卷积后的图像大小和输入大小一致,示意如下:

bash 复制代码
  00000000000
  0 xxxxxxxxx 0
  0 xxxxxxxxx 0
  0 xxxxxxxxx 0
  00000000000

其中可以看到,这里x是图片的矩阵信息,而外面一圈是补齐的0,0在卷积处理时对最终结果没有任何影响.这里略微对其进行修改,更多的参数调整请大家自行调试查看.

下面我们修改一下卷积核stride,也就是步进的大小,代码如下:

python 复制代码
import torch

image = torch.randn(size=(5, 3, 128, 128))

conv2d = torch.nn.Conv2d(3, 10, kernel_size=3, stride=2, padding=1)
image_new = conv2d(image)
print(image_new.shape)

我们使用同样大小的输入数据修正了卷积层的步进距离,最终结果如下:

下面我们对这个情况进行总结,经过卷积计算后,图像的大小变化可由如下公式进行确定:

· 输入图片大小为W×W.

· Filter大小为F×F.

· 步长为S.

· padding的像素数为P,一般情况下P=1或者0(参考PyTorch).

此时,把上述数据代入公式可得(注意取模计算):

需要注意的是,在这里是取模计算,因此127//2 = 63.

Conv2d 的关键参数:不只是输入输出通道

PyTorch 中最常用的二维卷积层是:

python 复制代码
import torch.nn as nn

conv = nn.Conv2d(
    in_channels=1,
    out_channels=16,
    kernel_size=3,
    stride=1,
    padding=1,
)

几个参数要牢牢记住:

参数 含义 常见影响
in_channels 输入通道数 MNIST 灰度图是 1,RGB 图像是 3
out_channels 输出特征图数量 可以理解为学习多少种局部模式
kernel_size 卷积核大小 常见为 3 或 5
stride 滑动步长 越大,输出空间尺寸越小
padding 边缘补零 常用来保持尺寸不变
groups 通道分组 深度可分离卷积的关键
dilation 膨胀率 扩大感受野的关键

输出尺寸的通用公式是:

text 复制代码
H_out = floor((H_in + 2P - D x (K - 1) - 1) / S + 1)
W_out = floor((W_in + 2P - D x (K - 1) - 1) / S + 1)

其中 K 是卷积核大小,S 是 stride,P 是 padding,D 是 dilation.普通卷积里 D=1.

例如 28 x 28 的 MNIST 图像,使用 kernel_size=3, stride=1, padding=1 后,输出仍然是 28 x 28.这也是很多入门 CNN 喜欢用 3x3 + padding=1 的原因:它能提取特征,同时不立刻改变空间尺寸.


1.3池化运算

在通过卷积获得了特征(Feature)之后,下一步希望利用这些特征进行分类.理论上讲,人们可以

用所有提取得到的特征来训练分类器,例如Softmax分类器推导,但这样做会面临计算量的挑战.因此,为了降低计算量,我们尝试利用神经网络的"参数共享"这一特性.

这意味着在一个图像区域有用的特征极有可能在另一个区域同样适用.因此,为了描述大的图像,一个很自然的想法就是对不同位置的特征进行聚合统计.

例如,特征提取可以计算图像在一个区域上的某个特定特征的平均值(或最大值),如下图所示.

这些概要统计特征不仅具有低得多的维度(相比使用所有提取到的特征),同时还会改善结果(不容易过拟合).这种聚合的操作就叫作池化(Pooling),有时也称为平均池化或者最大池化(取决于计算池化的方法).


max-pooling后的图片
如果选择图像中的连续范围作为池化区域,并且只是池化相同(重复)的隐藏单元产生的特征,那 么这些池化单元就具有平移不变性(Translation Invariant).这就意味着即使图像经历了一个小的平移,依然会产生相同的(池化的)特征.在很多任务(例如物体检测、声音识别)中,我们都更希望得到具有平移不变性的特征,因为即使图像经过了平移,样例(图像)的标记仍然保持不变.

PyTorch 2.0中池化运算的函数如下:

python 复制代码
class AvgPool2d(_AvgPoolNd):
    ...
    def __init__(
        self,
        kernel_size: _size_2_t,
        stride: Optional[_size_2_t] = None,
        padding: _size_2_t = 0,
        ceil_mode: bool = False,
        count_include_pad: bool = True,
        divisor_override: Optional[int] = None
    ) -> None:

重要的参数如下.

· kernel_size:池化窗口的大小,默认大小一般是2, 2.

· strides:和卷积类似,窗口在每个维度上滑动的步长,默认大小一般也是2,2.

· padding:和卷积类似,可以取1或者0,返回一个Tensor,类型不变,shape仍然是batch,channel,height, width这种形式.

池化的一个非常重要的作用就是能够帮助输入的数据表示近似不变性.对于平移不变性,指的是对输入的数据进行少量平移时,经过池化后的输出结果并不会发生改变.局部平移不变性是一个很有用的性质,尤其是当关心某个特征是否出现而不关心它出现的具体位置时.

例如,当判定一幅图像中是否包含人脸时,并不需要判定眼睛的位置,而是需要知道有一只眼睛出

现在脸部的左侧,另一只出现在右侧就可以了.使用池化层的代码如下:

python 复制代码
import torch

image = torch.randn(size=(5, 3, 28, 28))

pool = torch.nn.AvgPool2d(kernel_size=3, stride=2, padding=0)
image_pooled = pool(image)
print(image_pooled.shape)

除此之外,PyTorch 2.0中还提供了一种新的池化层------全局池化层,使用方法如下:

python 复制代码
import torch

image = torch.randn(size=(5, 3, 28, 28))
image_pooled = torch.nn.AdaptiveAvgPool2d(1)(image)
print(image_pooled.shape)

AdaptiveAvgPool2d函数的作用是对输入的图形进行全局池化,也就是在每个channel上对图形整体进行归一化的池化计算.

卷积会得到特征图,但如果每一层都保留完整尺寸,计算量会越来越大.池化层的作用是把局部区域压缩成一个代表值.

最常见的是最大池化:

python 复制代码
pool = nn.MaxPool2d(kernel_size=2, stride=2)

它会把 2 x 2 区域压缩成 1 个值.空间尺寸通常从 28 x 28 变成 14 x 14.

池化的意义不是"丢信息"这么简单,而是让模型更关心"某个特征是否出现",而不是过度纠结它精确出现在哪个像素点.对于手写数字来说,同样是数字 7,笔画略微偏左或偏右,类别都不应该改变.池化提供了一定的局部平移不变性.

更现代的模型里,也常用自适应池化来减少手动计算:

python 复制代码
self.gap = nn.AdaptiveAvgPool2d((1, 1))

它会把每个通道压缩成一个值,非常适合作为卷积骨干网络和分类头之间的过渡.


1.4Softmax激活函数

Softmax函数在前面已经做过介绍,并且使用NumPy自定义实现了Softmax的功能和函数.Softmax是一个对概率进行计算的模型,因为在真实的计算模型系统中,对一个实物的判定并不是100%,而是只有一定的概率,并且在所有的结果标签上都可以求出一个概率.

其中第一个公式是人为定义的训练模型,这里采用的是输入数据与权重的乘积和,并加上一个偏置b的方式.偏置b存在的意义是为了加上一定的噪声.

对于求出的

f ( x ) = ∑ i j w i j x j + b f(x) = \sum_{i}^{j} w_{ij}x_j + b f(x)=i∑jwijxj+b

用更为正式的语言表述,那就是Softmax是模型函数定义的一种形式:把输入值当成幂指数求值,再正则化这些结果值.而这个幂运算表示,更大的概率计算结果对应更大的假设模型中的乘数权重值.反之,拥有更少的概率计算结果意味着在假设模型中拥有更小的乘数权重值.

假设模型中的权值不可以是0或者负值.Softmax会正则化这些权重值,使它们的总和等于1,以此构造一个有效的概率分布.

对于最终的公式

y = softmax ⁡ ( f ( x ) ) = softmax ⁡ ( w i j x j + b ) y = \operatorname{softmax}(f(x)) = \operatorname{softmax}(w_{ij}x_j + b) y=softmax(f(x))=softmax(wijxj+b)


Softmax计算形式

上图演示了Softmax的计算公式,这实际上就是输入的数据通过与权重乘积之后,对其进行Softmax计算得到的结果.将其用数学方法表示如下图所示.


Softmax矩阵表示

将这个计算过程用矩阵的形式表示出来,即矩阵乘法和向量加法,这样有利于使用TensorFlow内置的数学公式进行计算,极大地提高了程序效率.

Softmax 的作用是把一组 logits 转换成概率分布:

text 复制代码
logits:  [1.2, -0.4, 3.1]
softmax: [0.13, 0.03, 0.84]

它让每个类别都有一个概率,且所有概率相加等于 1.

但在 PyTorch 训练分类模型时,要注意一个很常见的坑:如果使用 nn.CrossEntropyLoss(),模型最后一层应该直接输出 logits,不要在模型内部先加 Softmax.因为 CrossEntropyLoss 内部已经包含了 log_softmax + nll_loss 的稳定实现.

正确写法通常是:

python 复制代码
logits = model(images)
loss = nn.CrossEntropyLoss()(logits, labels)

推理或展示概率时再做:

python 复制代码
probs = logits.softmax(dim=1)

1.5卷积神经网络的原理

前面介绍了卷积运算的基本原理和概念,从本质上来说,卷积神经网络就是将图像处理中的二维离散卷积运算和神经网络相结合.这种卷积运算可以用于自动提取特征,而卷积神经网络主要应用于二维图像的识别.下面将采用一个图示更加直观地介绍卷积神经网络的工作原理.

一个卷积神经网络如果包含一个输入层、一个卷积层和一个输出层,那么在真正使用的时候一般会使用多层卷积神经网络不断地提取特征,特征越抽象,越有利于识别(分类).而且通常卷积神经网络包含池化层、全连接层,最后接输出层.

下图展示了一幅图片进行卷积神经网络处理的过程,主要包含以下4个步骤.

· 图像输入:获取输入的数据图像.

· 卷积:对图像特征进行提取.

· Pooling层:用于缩小在卷积时获取的图像特征.

· 全连接层:用于对图像进行分类.


卷积神经网络处理图像的步骤

这几个步骤依次进行,分别具有不同的作用.经过卷积层的图像被卷积核心提取后,获得分块的、

同样大小的图片,如下图所示.


卷积处理的分解图像

可以看到,经过卷积处理后的图像被分为若干个大小相同的、只具有局部特征的图片.下图表示对分解后的图片使用一个小型神经网络进行进一步的处理,即将二维矩阵转化成一维数组.


分解后图像的处理

需要说明的是,在这个步骤,也就是对图片进行卷积化处理时,卷积算法对所有分解后的局部特征

进行同样的计算,这个步骤称为"权值共享'.这样做的依据如下:

  • 对图像等数组数据来说,局部数组的值经常是高度相关的,可以形成容易被探测到的独特的局部特征.
  • 图像和其他信号的局部统计特征与其位置是不太相关的,如果特征图能在图片的一个部分出现,那么也能出现在其他任何地方.所以不同位置的单元共享同样的权重,并在数组的不同部分探测相同的模式.

在数学上,这种由一个特征图执行的过滤操作是一个离散的卷积,卷积神经网络由此得名.

池化层的作用是对获取的图像特征进行缩减.从前面的例子中可以看到,使用2,2大小的矩阵来处理特征矩阵,使得原有的特征矩阵可以缩减到1/4大小,特征提取的池化效应如下图所示.


池化处理后的图像

经过池化处理后的矩阵作为下一层神经网络的输入,使用一个全连接层对输入的数据进行分类计算(见下图),从而计算出这个图像对应位置最大的概率类别.

采用较为通俗的语言概括,卷积神经网络是一个层级递增的结构,也可以将其认为是一个人在读报纸,首先一字一句地读取,之后整段地理解,最后获得全文的表述.卷积神经网络也是从边缘、结构和位置等一起感知物体的形状.


2.实战:基于卷积的MNIST手写体分类

前面我们实现了基于多层感知机的MNIST手写体,下面将实现以卷积神经网络完成的MNIST手写体识别.

一个典型的 MNIST 卷积分类器可以写成下面这样:

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


class MnistCNN(nn.Module):
    def __init__(self) -> None:
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 16, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2),          # 1 x 28 x 28 -> 16 x 14 x 14

            nn.Conv2d(16, 32, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2),          # 32 x 14 x 14 -> 32 x 7 x 7

            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(64 * 7 * 7, 128),
            nn.ReLU(inplace=True),
            nn.Dropout(p=0.2),
            nn.Linear(128, 10),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = self.features(x)
        return self.classifier(x)

前面用多层卷积提取局部特征,后面用全连接层完成分类.区别在于这里显式标注了尺寸流动,便于排查 Linear 输入维度不匹配的问题.

如果想让模型更不依赖手算尺寸,可以改成:

python 复制代码
self.classifier = nn.Sequential(
    nn.AdaptiveAvgPool2d((1, 1)),
    nn.Flatten(),
    nn.Linear(64, 10),
)

这种写法在工程里更稳,因为前面卷积层的空间尺寸调整后,分类头不一定需要跟着大改.


2.1数据的准备

在本例中,我们依旧使用MNIST数据集,对这个数据集的数据和标签介绍在前面的文章中已有较好的说明,相对于前面章节直接对数据进行"折叠"处理,这里需要显式地标注出数据的通道,代码如下:

python 复制代码
import numpy as np
import einops.layers.torch as elt

# 载入数据
x_train = np.load("../dataset/mnist/x_train.npy")
y_train_label = np.load("../dataset/mnist/y_train_label.npy")

x_train = np.expand_dims(x_train, axis=1)  # 在指定维度上进行扩充
print(x_train.shape)

这里是对数据的修正,np.expand_dims的作用是在指定维度上进行扩充,在这里我们在第二维(也就是PyTorch的通道维度)进行扩充,结果如下:

(60000, 1, 28, 28)

数据输入:MNIST 为什么需要通道维

卷积层接收的数据格式一般是:

text 复制代码
batch_size x channels x height x width

MNIST 是灰度图,所以通道数是 1.单张图像形状应为:

text 复制代码
1 x 28 x 28

如果数据原本是 28 x 28,就需要补出通道维.使用 torchvision 时通常由 ToTensor() 自动完成:

python 复制代码
from torchvision import datasets, transforms

transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,)),
])

train_set = datasets.MNIST(
    root="data",
    train=True,
    download=True,
    transform=transform,
)

前面提到的 np.expand_dims 本质上也是为了做同一件事:把图像从二维矩阵补成卷积层需要的四维批数据.


2.2模型的设计

本小节使用PyTorch2.0框架对模型进行设计.在本例中,我们将使用卷积层对数据进行处理,完整的模型如下:

python 复制代码
import torch
import torch.nn as nn
import numpy as np
import einops.layers.torch as elt


class MnistNetword(nn.Module):
    def __init__(self):
        super(MnistNetword, self).__init__()

        # 前置的特征提取模块
        self.convs_stack = nn.Sequential(
            nn.Conv2d(1, 12, kernel_size=7),      # 第一个卷积层
            nn.ReLU(),
            nn.Conv2d(12, 24, kernel_size=5),     # 第二个卷积层
            nn.ReLU(),
            nn.Conv2d(24, 6, kernel_size=3)       # 第三个卷积层
        )

        # 最终分类器层
        self.logits_layer = nn.Linear(in_features=1536, out_features=10)

    def forward(self, inputs):
        image = inputs
        x = self.convs_stack(image)

        # elt.Rearrange 的作用是对输入数据维度进行调整,读者可以使用 torch.nn.Flatten 函数完成此工作
        x = elt.Rearrange("b c h w -> b (c h w)")(x)

        logits = self.logits_layer(x)
        return logits


model = MnistNetword()
torch.save(model, "model.pth")

在这里,我们首先设定了3个卷积层作为前置的特征提取层,最后一个全连接层作为分类器层.在这里需要注意的是,对于分类器的全连接层,输入维度需要手动计算,当然读者可以一步一步尝试打印特征提取层的结果,使用shape函数打印维度后计算.

最后对模型进行保存,这里可以调用前面文章中介绍的Netro软件对维度进行展示,结果如图所示.

在这里可以可视化地看到整体模型的结构与显示,这里对每个维度都进行了展示,感兴趣的可以自行查阅.


2.3基于卷积的MNIST分类模型

下面进入示例部分,也就是MNIST手写体的分类.完整的训练代码如下:

python 复制代码
import torch
import torch.nn as nn
import numpy as np
import einops.layers.torch as elt


# 载入数据
x_train = np.load("../dataset/mnist/x_train.npy")
y_train_label = np.load("../dataset/mnist/y_train_label.npy")

x_train = np.expand_dims(x_train, axis=1)
print(x_train.shape)


class MnistNetword(nn.Module):
    def __init__(self):
        super(MnistNetword, self).__init__()

        self.convs_stack = nn.Sequential(
            nn.Conv2d(1, 12, kernel_size=7),
            nn.ReLU(),
            nn.Conv2d(12, 24, kernel_size=5),
            nn.ReLU(),
            nn.Conv2d(24, 6, kernel_size=3)
        )
self.logits_layer = nn.Linear(in_features=1536, out_features=10)

def forward(self, inputs):
    image = inputs
    x = self.convs_stack(image)
    x = elt.Rearrange("b c h w -> b (c h w)")(x)
    logits = self.logits_layer(x)
    return logits


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

# 注意需要将model发送到GPU计算
model = MnistNetword().to(device)
model = torch.compile(model)
loss_fn = nn.CrossEntropyLoss()

optimizer = torch.optim.SGD(model.parameters(), lr=1e-4)

batch_size = 128
for epoch in range(42):
    train_num = len(x_train) // 128
    train_loss = 0.

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

        x_batch = torch.tensor(x_train[start:end]).to(device)
        y_batch = torch.tensor(y_train_label[start:end]).to(device)

        pred = model(x_batch)
        loss = loss_fn(pred, y_batch)
        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
    print(
        "epoch:", epoch,
        "train_loss:", round(train_loss, 2),
        "accuracy:", round(accuracy, 2)
    )

请大家自行尝试学习.


3.PyTorch的深度可分离膨胀卷积详解

接下来开始就说明了,相对于多层感知机来说,卷积神经网络能够对输入特征局部进行计算,同时

能够节省大量的待训练参数.基于此,下面将介绍更为深入的内容,即进阶部分------深度可分离

膨胀卷积.

需要说明的是,本例中深度可分离膨胀卷积可以按功能分为"深度" "可分离""膨胀""卷积".

在讲解下面的内容之前,首先回顾PyTorch 2.0中的卷积定义类:

python 复制代码
class Conv2d(_ConvNd):
    ...
    def __init__(
        self,
        in_channels: int,
        out_channels: int,
        kernel_size: _size_2_t,
        stride: _size_2_t = 1,
        padding: Union[str, _size_2_t] = 0,
        dilation: _size_2_t = 1,
        groups: int = 1,
        bias: bool = True,
        padding_mode: str = 'zeros',  # TODO: refine this type
        device=None,
        dtype=None
    ) -> None:

前面讲解了卷积类中常用的输入输出维度(in_channels,out_channels)的定义,卷积核(kernel_size)以及步长(stride)大小的设置,而对于其他部分的参数定义却没有详细说明,下面将通过对深度可分离膨胀卷积的讲解更为细致地说明卷积类的定义与使用.


3.1深度可分离卷积的定义

在普通的卷积中,可以将其分为两个步骤来计算:

(1)跨通道计算.

(2)平面内计算.

这是由于卷积的局部跨通道计算的性质所形成的,一个非常简单的思想是,能否使用另一种方法将这部分计算过程分开计算,从而获得参数上的数据量减少.

答案是可以的.深度可分离卷积总体如下图所示.


深度可分离卷积

在进行深度卷积的时候,每个卷积核只关注单个通道的信息,而在分离卷积中,每个卷积核可以联

合多个通道的信息.这在PyTorch 2.0中的具体实现如下:

python 复制代码
 #group=3是依据通道数设置的分离卷积数
     Conv2d(in_channels=3, out_channels=3, kernel_size=3, groups=3) #这是第一步完成跨通道计算
     Conv2d(in_channels=4, out_channels=4, kernel_size=1)        #完成平面内计算

可以看到,此时我们在传统的卷积层定义上额外增加了groups=4的定义,这是根据通道数对卷积类的定义进行划分.下面通过一个具体的例子说明常规卷积与深度可分离卷积的区别.

常规卷积操作如下图所示.


常规卷积操作

假设输入层为一个大小为28×28像素、三通道的彩色图片.经过一个包含4个卷积核的卷积层,卷积核尺寸为3×3×3.最终会输出具有4个通道数据的特征向量,而尺寸大小由卷积的Padding方式决定.在深度可分离卷积操作中,深度卷积操作有以下两个步骤。

(1)分离卷积的独立计算,如下图所示.


分离卷积的独立计算

图中深度卷积使用的是3个尺寸为3×3的卷积核,经过该操作之后,输出的特征图尺寸为28×28

×3(padding=1).

(2)堆积多个可分离卷积计算,如下图所示(注意下图中输入的是上图第一步的输出).

可以看到,图中使用了4个独立的通道完成,经过此步骤后,由第一个步骤输入的特征图在4个独

立的通道计算下,输出维度变为28×28×3.

深度可分离卷积:把"空间计算"和"通道融合"拆开

它的核心思想是:普通卷积同时做两件事,一边在空间上看局部邻域,一边跨通道混合信息;深度可分离卷积把这两件事拆成两步.

第一步是 depthwise convolution:每个输入通道单独使用自己的卷积核,只负责空间特征提取.

第二步是 pointwise convolution:使用 1 x 1 卷积融合通道信息.

PyTorch 中可以这样写:

python 复制代码
class DepthwiseSeparableConv(nn.Module):
    def __init__(self, in_channels: int, out_channels: int, dilation: int = 1) -> None:
        super().__init__()
        padding = dilation
        self.block = nn.Sequential(
            nn.Conv2d(
                in_channels,
                in_channels,
                kernel_size=3,
                padding=padding,
                dilation=dilation,
                groups=in_channels,
                bias=False,
            ),
            nn.BatchNorm2d(in_channels),
            nn.ReLU(inplace=True),
            nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.block(x)

这里最关键的是 groups=in_channels.它表示每个通道独立卷积,是 depthwise convolution 的标准写法.

参数量对比非常直观.忽略 bias,普通卷积参数量是:

text 复制代码
K x K x C_in x C_out

深度可分离卷积参数量是:

text 复制代码
K x K x C_in + C_in x C_out

K=3, C_in=64, C_out=128 时:

text 复制代码
普通卷积: 3 x 3 x 64 x 128 = 73728
深度可分离卷积: 3 x 3 x 64 + 64 x 128 = 8768

参数量减少约 88%.这也是 MobileNet 等轻量视觉模型喜欢使用这类结构的原因.


3.2深度的定义以及不同计算层待训练参数的比较

前面介绍了深度可分离卷积,并在一开始的时候就提到了深度可分离卷积可以减少待训练参数,那么事实是否如此呢?我们通过代码打印待训练参数数量进行比较,代码如下:

python 复制代码
import torch
from torch.nn import Conv2d, Linear

linear = Linear(in_features=3*28*28, out_features=3*28*28)
linear_params = sum(p.numel() for p in linear.parameters() if p.requires_grad)

conv = Conv2d(in_channels=3, out_channels=3, kernel_size=3)
params = sum(p.numel() for p in conv.parameters() if p.requires_grad)

depth_conv = Conv2d(in_channels=3, out_channels=3, kernel_size=3, groups=3)
point_conv = Conv2d(in_channels=3, out_channels=3, kernel_size=1)

# 需要注意的是,这里是先进行depth,然后进行逐点卷积,从而两者结合,就得到了深度、可分离、卷积
depthwise_separable_conv = torch.nn.Sequential(depth_conv, point_conv)
params_depthwise = sum(
    p.numel() for p in depthwise_separable_conv.parameters() if p.requires_grad
)

print(f"多层感知机使用的参数为 {linear_params} parameters.")
print("---------")
print(f"普通卷积层使用的参数为 {params} parameters.")
print("---------")
print(f"深度可分离卷积使用的参数为 {params_depthwise} parameters.")

在上面的代码段中,依次准备了多层感知机、普通卷积层以及深度可分离卷积,对其输出待训

练参数,结果如下图所示.

可以很明显地看到,图中对参数的输出随着采用不同的计算层,待训练参数也会随之变化,即使

一个普通的深度可分离卷积层也能减少一半的参数使用量.


3.3膨胀卷积详解

我们先回到PyTorch 2.0中对卷积的说明,此时应该了解了group参数的含义,这里还有一个不常

用的参数dilation,这是决定卷积层在计算时的膨胀系数.dilation有点类似于stride,实际含义为:每个点之间有空隙的过滤器,即为dilation,如下图所示.

简单地说,膨胀卷积通过在卷积核中增加空洞,可以增加单位面积中计算的大小,从而扩大模型的

计算视野.卷积核的膨胀系数(空洞的大小)每一层是不同的,一般可以取(1, 2, 4, 8,...),即前一层的两倍.注意膨胀卷积的上下文大小和层数是指数相关的,可以通过比较少的卷积层得到更大的计算面积.使用膨胀卷积的方法如下:

python 复制代码
# 注意这里dilation被设置为2
depth_conv = Conv2d(in_channels=3, out_channels=3, kernel_size=3, groups=3, dilation=2)
point_conv = Conv2d(in_channels=3, out_channels=3, kernel_size=1)

# 深度、可分离、膨胀卷积的定义
depthwise_separable_conv = torch.nn.Sequential(depth_conv, point_conv)

需要注意的是,在卷积层的定义中,只有dilation被设置成大于或等于2的整数时,才能实现膨胀卷积.对于其参数大小的计算,大家可以自行完成.

膨胀卷积:不增加参数,也能看得更远

膨胀卷积对应 Conv2d 中的 dilation 参数.它的直觉是:卷积核仍然只有 3 x 3 个采样点,但采样点之间插入空洞,让它覆盖更大的输入区域.

有效卷积核大小可以这样理解:

text 复制代码
K_eff = K + (K - 1) x (dilation - 1)

K=3

dilation 有效感受野
1 3 x 3
2 5 x 5
4 9 x 9

PyTorch 写法如下:

python 复制代码
conv = nn.Conv2d(
    in_channels=32,
    out_channels=64,
    kernel_size=3,
    padding=2,
    dilation=2,
)

如果想保持输入输出空间尺寸一致,kernel_size=3 时通常让 padding=dilation.

膨胀卷积适合需要扩大上下文但又不想立刻降采样的场景,例如语义分割、密集预测、时序建模等.对于 MNIST来说它不是必须的,但作为学习 dilation 参数的入口非常合适.


3.4实战:基于深度可分离膨胀卷积的MNIST手写体识别

下面进入实战部分,基于前期介绍的深度可分离膨胀卷积完成实战的MNIST手写体的识别.

首先是模型的定义,在这里我们预期使用自定义的卷积替代部分原生卷积完成模型的设计,代码如下:

python 复制代码
import torch
import torch.nn as nn
import numpy as np
import einops.layers.torch as elt

# 下面是自定义的深度、可分离、膨胀卷积的定义
depth_conv = nn.Conv2d(
    in_channels=12,
    out_channels=12,
    kernel_size=3,
    groups=6,
    dilation=2
)
point_conv = nn.Conv2d(in_channels=12, out_channels=24, kernel_size=1)
depthwise_separable_conv = torch.nn.Sequential(depth_conv, point_conv)


class MnistNetwork(nn.Module):
    def __init__(self):
        super(MnistNetwork, self).__init__()

        self.convs_stack = nn.Sequential(
            nn.Conv2d(1, 12, kernel_size=7),
            nn.ReLU(),
            depthwise_separable_conv,  # 使用自定义卷积替代了原生卷积层
            nn.ReLU(),
            nn.Conv2d(24, 6, kernel_size=3)
        )

        self.logits_layer = nn.Linear(in_features=1536, out_features=10)

    def forward(self, inputs):
        image = inputs
        x = self.convs_stack(image)
        x = elt.Rearrange("b c h w -> b (c h w)")(x)
        logits = self.logits_layer(x)
        return logits

可以看到,我们在中层部分使用自定义的卷积层替代了部分原生卷积层.完整的训练代码如下:

python 复制代码
import torch
import torch.nn as nn
import numpy as np
import einops.layers.torch as elt

# 载入数据
x_train = np.load("../dataset/mnist/x_train.npy")
y_train_label = np.load("../dataset/mnist/y_train_label.npy")

x_train = np.expand_dims(x_train, axis=1)
print(x_train.shape)

depth_conv = nn.Conv2d(
    in_channels=12,
    out_channels=12,
    kernel_size=3,
    groups=6,
    dilation=2
)
point_conv = nn.Conv2d(in_channels=12, out_channels=24, kernel_size=1)

# 深度、可分离、膨胀卷积的定义
depthwise_separable_conv = torch.nn.Sequential(depth_conv, point_conv)

import torch
import torch.nn as nn
import numpy as np
import einops.layers.torch as elt

# 载入数据
x_train = np.load("../dataset/mnist/x_train.npy")
y_train_label = np.load("../dataset/mnist/y_train_label.npy")

x_train = np.expand_dims(x_train, axis=1)
print(x_train.shape)

depth_conv = nn.Conv2d(
    in_channels=12,
    out_channels=12,
    kernel_size=3,
    groups=6,
    dilation=2
)
point_conv = nn.Conv2d(in_channels=12, out_channels=24, kernel_size=1)

# 深度、可分离、膨胀卷积的定义
depthwise_separable_conv = torch.nn.Sequential(depth_conv, point_conv)

class MnistNetwork(nn.Module):
    def __init__(self):
        super(MnistNetwork, self).__init__()
        self.convs_stack = nn.Sequential(
            nn.Conv2d(1, 12, kernel_size=7),
            nn.ReLU(),
            depthwise_separable_conv,
            nn.ReLU(),
            nn.Conv2d(24, 6, kernel_size=3)
        )

        self.logits_layer = nn.Linear(in_features=1536, out_features=10)

    def forward(self, inputs):
        image = inputs
        x = self.convs_stack(image)
        x = elt.Rearrange("b c h w -> b (c h w)")(x)
        logits = self.logits_layer(x)
        return logits


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

# 注意需要将model发送到GPU计算
model = MnistNetwork().to(device)
model = torch.compile(model)
loss_fn = nn.CrossEntropyLoss()

optimizer = torch.optim.SGD(model.parameters(), lr=1e-4)

batch_size = 128
for epoch in range(63):
    train_num = len(x_train) // 128
    train_loss = 0

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

        x_batch = torch.tensor(x_train[start:end]).to(device)
        y_batch = torch.tensor(y_train_label[start:end]).to(device)

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

        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
print("epoch ", epoch, "train_loss:", round(train_loss, 2), "accuracy:", round(accuracy, 2))

最终计算结果请大家自行完成.


4.实战排错清单

写 CNN 分类器时,最常见的问题基本都能归到下面几类:

问题 典型原因 排查方式
Expected 4D input 输入缺少 batch 或 channel 维 打印 images.shape
mat1 and mat2 shapes cannot be multiplied Linear 输入维度算错 Flatten 前打印特征图形状
loss 不下降 学习率、归一化、标签格式异常 检查 Normalizelabels.dtype、优化器
准确率训练高测试低 过拟合 Dropout、数据增强、减小模型
使用 Softmax 后训练不稳 CrossEntropyLoss 重复 模型输出 logits 即可
CPU 训练慢 batch 太大或 DataLoader 设置不合适 调整 batch、num_workers、使用 GPU

一个好习惯是先用一小批数据过拟合测试:取 128 张训练图,让模型训练到接近 100% 准确率.如果做不到,通常说明数据、损失函数或模型连接有问题.


5.总结

本章的主线可以概括为:

text 复制代码
卷积提取局部特征
-> 池化压缩空间信息
-> 全连接/Softmax 完成分类
-> 深度可分离卷积降低参数量
-> 膨胀卷积扩大感受野
-> block 化组合让模型更可维护

如果只记住一句话:卷积神经网络不是简单把图像送进模型,而是用"局部连接 + 参数共享 + 层级特征"把图像结构利用起来.理解这一点,再去看 ResNet、MobileNet、多模态视觉编码器,甚至大模型里的高效微调方法,都会更顺.

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


敬请期待下一篇文章内容


每日心灵鸡汤: 真正成熟的沟通,是不再依赖回应完成情绪闭环!

为什么你在有些沟通中,总想把问题说开、讲明白,甚至非要得到对方一个回应?很多人以为这是因为自己重视沟通,其实更多是在寻求心理上的确定性.当关系出现沉默、回避或模糊回应时,大脑会本能地把它当作一个"未完成的任务",不断驱使你去追问、解释、求证,希望通过对方的回应完成情绪闭环.同时,人也会不自觉地通过他人的反馈确认自己的价值和存在感,因此对方越沉默,越容易引发焦虑、自我怀疑,甚至把沟通变成索取安全感.然而,人与人终究是独立的个体,不是所有误解都能靠沟通消除,也不是所有需求都能被满足.真正成熟的沟通,不是一定要把每件事都说到彻底明白,而是能够接受有些问题没有答案,有些关系存在边界,把情绪的出口从依赖他人的回应,逐渐转向建立自己的内在稳定.真正的平静,不是终于等到了一个答案,而是不再需要别人替你完成情绪闭环.

相关推荐
火山引擎开发者社区1 小时前
火山引擎 Supabase 正式融入 Agent Plan,CLI + Skills 保姆级上手教程来了
人工智能
量化交易曾小健(金融号)1 小时前
ICML 2026 量化论文合集
人工智能
卷福同学7 小时前
不用服务器,不用配环境,我10分钟上线了一个AI Agent
人工智能·后端·算法
码兄科技9 小时前
Java AI智能体开发实战:从零构建智能对话系统指南
java·开发语言·人工智能
Zik----9 小时前
CCswitch-code
人工智能
AI科技星10 小时前
全域谱分析:无穷维超复数信息场分形统一场论 ——自然、量子、金融多重分形第一性原理完整体系(中英双语终稿)
人工智能·机器学习·金融·乖乖数学·全域数学
stormzhangV10 小时前
为什么你的 AI 像智障
人工智能
ai产品老杨11 小时前
H264 H265视频分析常见问题和排查清单
人工智能·算法·音视频
项目经理老王11 小时前
OpenClaw无捆绑安装包,安全纯净版AI助手部署
人工智能·安全