现代卷积神经网络

一、现代卷积神经网络

1.1 AlexNet

1.1.1 理论背景解析

在2012年之前,神经网络并不受待见,几个核心原因:

  1. 传统特征工程的主导 :以前的计算机视觉专家并不直接把图片像素喂给分类器(如SVM),而是纯手工设计复杂的数学公式去提取图片的特征(比如SIFT提取关键点、HOG提取边缘方向)。当时大家认为:数据特征的好坏,比算法本身更重要。
  2. 表征学习的觉醒(Representation Learning) :以Hinton为首的一拨人认为,特征不应该是由人类手工设计的,而应该由机器自己从数据中"学"出来。 神经网络底层的卷积层学边缘、颜色;中层学纹理、局部(眼睛、轮廓);高层学整体(人脸、汽车)。
  3. 促成爆发的两个"缺失成分"
    • 数据(ImageNet):以前的数据集太小(几万张小图),神经网络参数多,极易过拟合。李飞飞团队构建的ImageNet(100多万张高清大图,1000个类别)喂饱了贪婪的深层网络。
    • 算力(GPU) :CPU的核心少且复杂,适合做各种逻辑跳转;而深度学习的核心是海量的"矩阵乘法"。GPU虽然单个核心弱,但有成千上万个核心,可以大规模并行计算。AlexNet的作者利用两块GTX 580 GPU写出了极快的CUDA卷积算子,打破了算力瓶颈。
1.2.2 AlexNet的核心创新点

从LeNet(左)到AlexNet(右)

相比于1998年的LeNet,2012年的AlexNet并没有本质上颠覆数学原理,但做出了几个极为关键的工程和网络设计创新:

  1. 更深更宽的网络 :从LeNet的5层(2卷3全)变成了8层(5卷3全),并且通道数大幅增加。
  2. 激活函数:引入 ReLU
    • 以前用 Sigmoid。Sigmoid在输入值特别大或特别小时,梯度几乎为0(这叫梯度消失),导致网络深了之后,底层参数根本无法更新。
    • ReLU (Rectified Linear Unit) :f(x)=max⁡(0,x)f(x) = \max(0, x)f(x)=max(0,x)。只要输入大于0,梯度永远是1,完美解决了正区间的梯度消失问题,而且计算极其简单(不需要算指数),极大加快了训练速度。
  3. 控制过拟合的利器:Dropout(暂退法)
    • AlexNet包含两个拥有4096个神经元的巨大全连接层,参数量占了整个网络的90%以上,极易过拟合。
    • Dropout在训练时,随机让一半的神经元"失活"(输出置0),强迫网络不依赖某几个特定的神经元,从而学到更鲁棒的特征。
  4. 数据增强(Data Augmentation):通过对原始图像进行随机裁剪、翻转、改变亮度等操作,相当于人为凭空造出了无数张新图片,进一步对抗过拟合。
  5. 重叠最大池化(Overlapping Max Pooling):LeNet用的是平均池化,AlexNet用最大池化,并且池化窗口(3x3)大于步幅(2),使得池化区域有重叠,保留了更多局部信息。
1.2.3 代码实现(Pytorch)

1. 定义网络结构

python 复制代码
from d2l import torch as d2l
import torch
from torch import nn

net = nn.Sequential(
    # --- 第1部分:特征提取(卷积层) ---
    
    # 第1层:卷积层 + ReLU + 最大池化层
    # 输入通道1(灰度图),输出通道96。因为图片放大了(224x224),所以用很大的11x11卷积核去捕捉全局特征。
    # 步幅为4,大幅度成比例缩小图片的宽和高(224 -> 54)。padding=1是为了尺寸凑整。
    nn.Conv2d(1, 96, kernel_size=11, stride=4, padding=1), 
    nn.ReLU(),  # 使用ReLU激活函数
    # 3x3的池化窗口,步幅为2,发生了重叠池化(54 -> 26)
    nn.MaxPool2d(kernel_size=3, stride=2),

    # 第2层:卷积层 + ReLU + 最大池化层
    # 输入通道96,输出增加到256。卷积核缩小为5x5。
    # padding=2保证了卷积前后特征图长宽不变(26 -> 26)。
    nn.Conv2d(96, 256, kernel_size=5, padding=2), 
    nn.ReLU(),
    # 再次重叠池化(26 -> 12)
    nn.MaxPool2d(kernel_size=3, stride=2),

    # 第3、4、5层:连续三个卷积层(中间没有池化)
    # 连续的卷积有助于提取更复杂、更抽象的深层组合特征。
    # 第3层:通道数激增到384,卷积核变为常规的3x3,padding=1保持尺寸不变(12 -> 12)
    nn.Conv2d(256, 384, kernel_size=3, padding=1), 
    nn.ReLU(),
    
    # 第4层:通道数保持384不变,尺寸不变(12 -> 12)
    nn.Conv2d(384, 384, kernel_size=3, padding=1), 
    nn.ReLU(),
    
    # 第5层:通道数降回256,尺寸不变(12 -> 12)
    nn.Conv2d(384, 256, kernel_size=3, padding=1), 
    nn.ReLU(),
    # 最后一个池化层(12 -> 5)
    nn.MaxPool2d(kernel_size=3, stride=2),

    # --- 第2部分:分类器(全连接层) ---
    
    # 将多维的特征图(Batch_Size, 256, 5, 5)拉平为一维向量(Batch_Size, 256*5*5 = 6400)
    nn.Flatten(),
    
    # 第6层:全连接层
    # 输入维度6400,输出维度4096(参数量巨大:6400*4096 = 2600万个权重!)
    nn.Linear(6400, 4096), 
    nn.ReLU(),
    nn.Dropout(p=0.5),  # 训练时随机丢弃50%的神经元,防止过拟合

    # 第7层:全连接层
    # 输入4096,输出4096
    nn.Linear(4096, 4096), 
    nn.ReLU(),
    nn.Dropout(p=0.5),

    # 第8层:输出层
    # 原版AlexNet输出是1000(ImageNet的类别数)。
    # 这里因为我们要跑Fashion-MNIST数据集,只有10个类别衣服,所以输出改为10。
    nn.Linear(4096, 10)
)

2. 观察每一层的输出形状变化

这其实是非常好的Debug习惯,通过传入一个模拟的假数据(Dummy input),来看看网络每一层到底把图片的尺寸变成了多少。

python 复制代码
# 生成一个随机张量,形状为 (批量大小=1, 通道数=1, 高=224, 宽=224)
X = torch.randn(1, 1, 224, 224)

# 遍历神经网络中的每一层
for layer in net:
    X = layer(X) # 将张量传给当前层,更新X
    # 打印当前层的类名(如Conv2d)以及输出后的形状
    print(layer.__class__.__name__, 'output shape:\t', X.shape)

输出:

复制代码
Conv2d Output shape:	 torch.Size([1, 96, 54, 54])
ReLU Output shape:	 torch.Size([1, 96, 54, 54])
MaxPool2d Output shape:	 torch.Size([1, 96, 26, 26])
Conv2d Output shape:	 torch.Size([1, 256, 26, 26])
ReLU Output shape:	 torch.Size([1, 256, 26, 26])
MaxPool2d Output shape:	 torch.Size([1, 256, 12, 12])
Conv2d Output shape:	 torch.Size([1, 384, 12, 12])
ReLU Output shape:	 torch.Size([1, 384, 12, 12])
Conv2d Output shape:	 torch.Size([1, 384, 12, 12])
ReLU Output shape:	 torch.Size([1, 384, 12, 12])
Conv2d Output shape:	 torch.Size([1, 256, 12, 12])
ReLU Output shape:	 torch.Size([1, 256, 12, 12])
MaxPool2d Output shape:	 torch.Size([1, 256, 5, 5])
Flatten Output shape:	 torch.Size([1, 6400])
Linear Output shape:	 torch.Size([1, 4096])
ReLU Output shape:	 torch.Size([1, 4096])
Dropout Output shape:	 torch.Size([1, 4096])
Linear Output shape:	 torch.Size([1, 4096])
ReLU Output shape:	 torch.Size([1, 4096])
Dropout Output shape:	 torch.Size([1, 4096])
Linear Output shape:	 torch.Size([1, 10])

3. 读取数据集与训练

python 复制代码
# 设置批量大小为128(每次喂给网络128张图片)
batch_size = 128

# 加载Fashion-MNIST数据集
# 注意核心修改:resize=224。
# Fashion-MNIST原本的图片只有28x28,这对AlexNet的11x11大卷积核和多次池化来说太小了(池化几次就没了)。
# 所以这里强行把图片放大到224x224。在实际工程中,除非用迁移学习,否则不推荐把小图强行放大,这里仅为教学演示AlexNet原版结构。
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224)

# 设置学习率为0.01(相对较小,因为网络深了容易梯度爆炸或震荡),训练10个Epoch(轮次)
lr, num_epochs = 0.01, 10

# 调用d2l封装好的训练函数。
# d2l.try_gpu()会自动检测电脑是否有Nvidia显卡或者Mac的MPS,有的话就把模型和数据搬到GPU上算,否则用CPU。
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

一些思考题

  1. 显存和计算资源主要消耗在哪?
    • 显存(参数量 Memory) :主要消耗在全连接层(Dense/Linear)。刚才我们算过,第一个全连接层就有约2600万个参数,占了巨大显存空间。
    • 计算量(FLOPs) :主要消耗在卷积层(Conv2D)。因为全连接层只做一次大规模矩阵乘法,而卷积层需要在图片的每一个像素位置滑动进行点乘和相加,尤其是通道数很大时,计算量极为惊人。
  2. 如何设计一个能直接在28x28上工作的AlexNet?
    • 不需要强行放大图片。
    • 去掉第1层的11x11大卷积核,改为3x3或5x5,去掉stride=4(改为stride=1)。
    • 减少池化层的数量,因为28x28池化3次就只剩3x3了,不够后续处理。
  3. 如果把Dropout和ReLU加到老版的LeNet里会怎样?
    • 效果会提升!ReLU会加快LeNet的收敛速度,Dropout能让LeNet在测试集上表现更好(泛化能力更强)。
    • 另:在卷积神经网络博客里面已经做了这个尝试,效果相当好

1.2 VGG

从AlexNet到VGG,它们本质上都是块设计。

如果说AlexNet证明了"深度学习是有效的",那么VGG(Visual Geometry Group)就证明了"深度学习是可以被模块化、标准化设计的"

1.2.1 核心思想

在VGG之前,AlexNet虽然赢了比赛,但它的网络结构很像"手工拼凑"出来的。第一层用11×1111 \times 1111×11的卷积核,第二层用5×55 \times 55×5,后面又切成3×33 \times 33×3......这让后来的研究者很困惑:如果我要设计更深的网络,我该怎么选卷积核?有没有规律可循?

VGG的作者(牛津大学的研究团队)提出了两个极其重要的概念,深刻影响了直至今日的AI模型设计(包括ResNet、Transformer):

1. "块(Block)"的概念

像写代码时把重复逻辑封装成"函数"一样,VGG把**"几个相同的卷积层 + 一个池化层"封装成了一个 "VGG块"。整个网络不再是一个个神经层堆叠,而是一个个"块"的堆积**。这种高度的模块化,让设计上百层的网络变得非常容易。

2. 深而窄 > 浅而宽(小卷积核的胜利)

VGG彻底抛弃了AlexNet里的11×1111 \times 1111×11和5×55 \times 55×5大卷积核,全部采用最小的 3×33 \times 33×3 卷积核

  • 数学原理 :两个 3×33 \times 33×3 卷积层的感受野(能看到的图片范围)等于一个 5×55 \times 55×5 卷积层;三个 3×33 \times 33×3 等于一个 7×77 \times 77×7。
  • 优势 :使用多个 3×33 \times 33×3 卷积,不仅参数量更少,而且多出了好几次ReLU激活函数,让网络的非线性表达能力大大增强。
1.2.2 代码实现

1. 定义 VGG 块 (VGG Block)

一个VGG块包含:指定数量的 3×33 \times 33×3 卷积层(带着ReLU) + 1个最大池化层。

python 复制代码
from d2l import torch as d2l
import torch
from torch import nn

def vgg_block(num_convs, in_channels, out_channels):
    layers = [] # 用一个列表来临时装载我们要搭建的层
    
    # 循环添加卷积层
    for _ in range(num_convs):
        # 核心:全部使用 3x3 卷积,且 padding=1。
        # padding=1 的精妙之处在于:输入和输出的特征图(长宽)尺寸完全不变!
        layers.append(nn.Conv2d(in_channels, out_channels,
                                kernel_size=3, padding=1))
        layers.append(nn.ReLU())
        # 第一个卷积层把 in_channels 变成了 out_channels
        # 块内后续的卷积层,输入输出都是 out_channels 了
        in_channels = out_channels 
        
    # 在经过了几个尺寸不变的卷积层提取特征后,最后加一个池化层
    # kernel_size=2, stride=2 的作用是:将特征图的长和宽精确减半!
    layers.append(nn.MaxPool2d(kernel_size=2, stride=2))
    
    # *layers 是 Python 的解包(Unpacking)语法
    # 相当于把列表里的元素一个个拿出来喂给 nn.Sequential
    return nn.Sequential(*layers) 

2. 定义 VGG-11 网络

VGG-11 包含 8个卷积层 + 3个全连接层 = 11层(带有权重的层才算作网络深度)。

python 复制代码
# 定义网络架构的超参数:(该块中卷积层的个数, 该块的输出通道数)
conv_arch = ((1, 64), (1, 128), (2, 256), (2, 512), (2, 512))

def vgg(conv_arch):
    conv_blks = []
    in_channels = 1 # 输入是单通道灰度图 (Fashion-MNIST)
    
    # 1. 卷积层部分(特征提取器)
    for (num_convs, out_channels) in conv_arch:
        # 调用刚才写好的函数,构建块并塞进列表
        conv_blks.append(vgg_block(num_convs, in_channels, out_channels))
        in_channels = out_channels # 为下一个块做准备

    # 2. 拼装整个网络
    return nn.Sequential(
        *conv_blks, # 解包5个VGG块
        nn.Flatten(), # 将多维特征图拉平为一维向量,准备喂给全连接层
        
        # 3. 全连接层部分(分类器)
        # 为什么输入维度是 out_channels * 7 * 7 ?
        # 因为原始图片是 224x224。经过5个池化层,每次减半:224 -> 112 -> 56 -> 28 -> 14 -> 7。
        # 最后的通道数 out_channels 是 512,所以扁平化后长度为 512 * 7 * 7 = 25088。
        nn.Linear(out_channels * 7 * 7, 4096), nn.ReLU(), nn.Dropout(0.5),
        nn.Linear(4096, 4096), nn.ReLU(), nn.Dropout(0.5),
        nn.Linear(4096, 10)) # 输出10类

net = vgg(conv_arch)

3. 训练网络(缩水版 VGG)

正宗的VGG参数量达到了1.3亿,如果直接里跑,哪怕用GPU也要等很久。所以我们搞了个**"缩水版"**。

python 复制代码
ratio = 4
# 把每个块的输出通道数全部除以4(64变成16,512变成128...)
# 计算量和参数量呈平方级下降,极大加快了训练速度,用于教学演示
small_conv_arch = [(pair[0], pair[1] // ratio) for pair in conv_arch]
net = vgg(small_conv_arch)

# 像AlexNet一样加载数据并训练
lr, num_epochs, batch_size = 0.05, 10, 128
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

输出:

一些练习题

1. 打印层的尺寸时,我们只看到8个结果,而不是11个结果。剩余的3层信息去哪了?

python 复制代码
X = torch.randn(size=(1, 1, 224, 224))
for blk in net:
    X = blk(X)
    print(blk.__class__.__name__, 'output shape:\t', X.shape)
复制代码
Sequential output shape:	 torch.Size([1, 64, 112, 112])
Sequential output shape:	 torch.Size([1, 128, 56, 56])
Sequential output shape:	 torch.Size([1, 256, 28, 28])
Sequential output shape:	 torch.Size([1, 512, 14, 14])
Sequential output shape:	 torch.Size([1, 512, 7, 7])
Flatten output shape:	 torch.Size([1, 25088])
Linear output shape:	 torch.Size([1, 4096])
ReLU output shape:	 torch.Size([1, 4096])
Dropout output shape:	 torch.Size([1, 4096])
Linear output shape:	 torch.Size([1, 4096])
ReLU output shape:	 torch.Size([1, 4096])
Dropout output shape:	 torch.Size([1, 4096])
Linear output shape:	 torch.Size([1, 10])

解答:

因为在深度学习中,"网络层数"(如VGG-11, VGG-16)只计算带有学习参数的层 (即卷积层 Conv2D 和全连接层 Linear)。

  • VGG-11 的组成:conv_arch 中定义了 1+1+2+2+2 = 8个卷积层 ,加上最后的 3个全连接层 ,共计 11层
  • 为什么代码遍历打印出来的结果没有11个?因为代码中 for blk in net: 遍历的是 nn.Sequential 里面的最外层元素。前5个元素是打包好的VGG块(里面藏了8个卷积层),后几个元素是展平层、全连接层。打印的是模块结构,而不是严格按权重层来数的。

2. 与AlexNet相比,VGG的计算要慢得多,而且它还需要更多的显存。分析出现这种情况的原因。

解答:

  • 计算更慢(FLOPs剧增) :VGG在较浅的层就使用了大量的通道数(比如第一个块输出64通道,第二个就128通道)。大量的通道数配合频繁的 3×33 \times 33×3 卷积滑动,导致矩阵乘法计算量远超AlexNet。
  • 显存需求更大(参数量爆炸) :最大的罪魁祸首在于第一个全连接层。AlexNet池化后的特征图是 256×5×5256 \times 5 \times 5256×5×5,而VGG最后池化后的特征图是 512×7×7512 \times 7 \times 7512×7×7。
    导致第一个全连接层的权重矩阵为:(512×7×7)×4096=25088×4096≈1亿个参数(512 \times 7 \times 7) \times 4096 = 25088 \times 4096 \approx \mathbf{1亿个参数}(512×7×7)×4096=25088×4096≈1亿个参数!光这一层就占用了约400MB的显存,这是极为恐怖的。

3. 尝试将Fashion-MNIST数据集图像的高度和宽度从224改为96。这对实验有什么影响?

解答:

如果把输入直接改成 96,代码会直接报错崩溃(Crash)!

  • 推导: 96 经过 5 个 VGG块(5次除以2),尺寸变化为:96→48→24→12→6→396 \rightarrow 48 \rightarrow 24 \rightarrow 12 \rightarrow 6 \rightarrow 396→48→24→12→6→3。
  • 原因: 到了全连接层前,特征图尺寸变成了 3×33 \times 33×3。展平后的长度应该是 out_channels * 3 * 3。但是,在代码的 nn.Linear(out_channels * 7 * 7, 4096) 中,7×77 \times 77×7 被硬编码(写死了)。维度不匹配,PyTorch 就会报错。
  • 引申:这也是后来全卷积网络(FCN)和全局平均池化(Global Average Pooling, 在ResNet中应用)诞生的原因------为了摆脱全连接层对输入图片尺寸的死板要求。

4. 请参考VGG论文中的表1构建其他常见模型,如VGG-16或VGG-19。

解答:

有了 VGG块的模板,构建更深的网络只需要改一行代码(修改超参数 conv_arch):

  • VGG-16 (13个卷积层 + 3个全连接):

    前面2个块各有2个卷积层,后面3个块各有3个卷积层。

    python 复制代码
    vgg16_arch = ((2, 64), (2, 128), (3, 256), (3, 512), (3, 512))
    net_vgg16 = vgg(vgg16_arch)
  • VGG-19 (16个卷积层 + 3个全连接):

    前面2个块各有2个卷积层,后面3个块各有4个卷积层。

    python 复制代码
    vgg19_arch = ((2, 64), (2, 128), (4, 256), (4, 512), (4, 512))
    net_vgg19 = vgg(vgg19_arch)

    这也正是讲义开头所说的:"通过使用循环和子程序,可以很容易地实现重复架构。" VGG让网络设计从"拼砖头"变成了"搭积木"。

1.3 NIN

如果说 VGG 解决了"网络如何搭得更深"的问题,那么 NiN 就彻底解决了"全连接层参数爆炸"的问题 。NiN 提出的两大核心创新------1×11 \times 11×1 卷积全局平均池化(GAP),成为了后来几乎所有现代卷积神经网络(如 ResNet, Inception, MobileNet 等)的标准配置。

对比 VGG 和 NiN 及它们的块之间主要架构差异。

1.3.1 NiN 的两大核心创新

在 NiN 之前(包括 AlexNet 和 VGG),网络架构都是"两段式":

  1. 前半部分:卷层 + 池化层(提取空间特征)。
  2. 后半部分:把特征图拉平(Flatten),接上几个巨大的**全连接层(MLP)**来输出分类结果。

痛点在于全连接层(FC层):

以 VGG 为例,第一个全连接层的参数量高达 1 亿,这不仅极度消耗显存,而且由于参数太多,极其容易过拟合(Overfitting)。此外,把二维的图片拉平成一维,也破坏了空间结构。

NiN 团队心想:能不能彻底干掉全连接层? 他们做出了两点创新:

创新 1:NiN 块(本质是引入 1×11 \times 11×1 卷积)

普通的卷积层只用一个线性滤波器扫过图片,提取特征的能力有限。

NiN 提出:我们在每一次普通卷积之后,加上两个 1×11 \times 11×1 卷积层

  • 为什么是 1×11 \times 11×1 卷积? 想象你站在特征图的某一个像素点上,垂直往下看所有的通道(Channels)。1×11 \times 11×1 卷积不对空间(高和宽)做任何改变,它只对通道维度进行跨通道的线性组合。
  • 等价性: 一个 1×11 \times 11×1 卷积层 + ReLU 激活,等价于在图片每一个像素位置上单独作用的一个全连接层(多层感知机 MLP)。这大大增强了网络在局部区域的非线性表达能力。

创新 2:全局平均池化(Global Average Pooling, GAP)

如何不用全连接层输出分类结果?

假设我们要对 10 种衣服分类(Fashion-MNIST):

  1. NiN 把最后一个卷积层的输出通道数,强行设定为 10(也就是类别数)
  2. 此时我们得到 10 张特征图,比如每张大小是 5×55 \times 55×5。
  3. 全局平均池化 :对这 10 张特征图,每张图求一个平均值 。这样 10×5×510 \times 5 \times 510×5×5 的张量,瞬间变成了 10×1×110 \times 1 \times 110×1×1 的向量(10个数字)。
  4. 这 10 个数字直接作为 10 个类别的预测分数(Logits)送去算损失。
  • 优势:GAP 没有任何权重参数,彻底消除了过拟合的重灾区,参数量锐减。
1.3.2 代码实现

1. 定义 NiN 块 (NiN Block)

python 复制代码
import torch
from torch import nn
from d2l import torch as d2l

def nin_block(in_channels, out_channels, kernel_size, strides, padding):
    return nn.Sequential(
        # 第一层:普通的卷积层,负责提取感受野内的空间特征
        # 由用户决定 kernel_size, strides, padding
        nn.Conv2d(in_channels, out_channels, kernel_size, strides, padding),
        nn.ReLU(),
        # 第二层:1x1 卷积层。
        # 注意:输入和输出通道都是 out_channels,尺寸不变。相当于在像素点上做全连接。
        nn.Conv2d(out_channels, out_channels, kernel_size=1), 
        nn.ReLU(),
        # 第三层:又一个 1x1 卷积层。
        # 为什么要两个 1x1?因为两层加上非线性激活,才构成一个真正的"多层感知机",拟合能力更强。
        nn.Conv2d(out_channels, out_channels, kernel_size=1), 
        nn.ReLU())

2. 定义完整的 NiN 网络

NiN 的整体架构借鉴了 AlexNet(使用了 11×1111\times1111×11, 5×55\times55×5, 3×33\times33×3 等不同的感受野)。

python 复制代码
net = nn.Sequential(
    # 第 1 个 NiN 块:模拟 AlexNet 的第一层
    # 输入通道1(灰度图),输出通道96,大卷积核 11x11,步幅为4(大幅缩小尺寸)
    nin_block(1, 96, kernel_size=11, strides=4, padding=0),
    nn.MaxPool2d(3, stride=2), # 池化层进一步降采样
    
    # 第 2 个 NiN 块
    nin_block(96, 256, kernel_size=5, strides=1, padding=2),
    nn.MaxPool2d(3, stride=2),
    
    # 第 3 个 NiN 块
    nin_block(256, 384, kernel_size=3, strides=1, padding=1),
    nn.MaxPool2d(3, stride=2),
    
    nn.Dropout(0.5), # 在预测分类前加入 Dropout,防止特征共适应,进一步正则化
    
    # 第 4 个 NiN 块(分类块)
    # 将上一层的 384 个通道,直接降维映射到 10 个通道(因为我们要分10类)
    nin_block(384, 10, kernel_size=3, strides=1, padding=1),
    
    # 全局平均池化层 GAP
    # nn.AdaptiveAvgPool2d((1, 1)) 是 PyTorch 的自适应平均池化。
    # 无论前面传过来的特征图长宽是多少 (比如 5x5),它都会把每个通道强制平均成 1x1 的大小。
    # 结果形状:(Batch_Size, 10, 1, 1)
    nn.AdaptiveAvgPool2d((1, 1)),
    
    # 最后用 Flatten 把多余的维度去掉
    # (Batch_Size, 10, 1, 1) -> (Batch_Size, 10)
    # 这 10 个数字就直接作为最终的分类预测得分,不需要 Linear
    nn.Flatten()
)

3. 查看输出形状

python 复制代码
X = torch.rand(size=(1, 1, 224, 224))

for layer in net:
    X = layer(X)
    print(layer.__class__.__name__, 'output shape:\t', X.shape)

输出:

复制代码
Sequential output shape:	 torch.Size([1, 96, 54, 54])
MaxPool2d output shape:	 torch.Size([1, 96, 26, 26])
Sequential output shape:	 torch.Size([1, 256, 26, 26])
MaxPool2d output shape:	 torch.Size([1, 256, 12, 12])
Sequential output shape:	 torch.Size([1, 384, 12, 12])
MaxPool2d output shape:	 torch.Size([1, 384, 5, 5])
Dropout output shape:	 torch.Size([1, 384, 5, 5])
Sequential output shape:	 torch.Size([1, 10, 5, 5])
AdaptiveAvgPool2d output shape:	 torch.Size([1, 10, 1, 1])
Flatten output shape:	 torch.Size([1, 10])

训练

python 复制代码
lr, num_epochs, batch_size = 0.1, 10, 128
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

输出:

复制代码
loss 0.325, train acc 0.880, test acc 0.880
994.4 examples/sec on cuda:0

一些练习题

1. 调整NiN的超参数,以提高分类准确性。

解答:可以尝试:1. 增加每个 block 的通道数(如 96->128, 256->512);2. 修改学习率和 Batch Size;3. 增加数据增强(如随机裁剪、翻转)。NiN 比较难收敛,有时需要较小的初始学习率配合学习率衰减。

2. 为什么NiN块中有两个 1×11\times 11×1 卷积层?删除其中一个,然后观察和分析实验现象。

解答

  • 为什么是两个 :在神经网络理论中,单层 感知机(等于只有一层 1×11\times 11×1 卷积)只能解决线性可分问题;至少需要两层 加上非线性激活函数(ReLU),才能逼近任意复杂的非线性函数。所以两个 1×11\times 11×1 卷积构成了一个微型的多层感知机(MLP),能在不改变空间结构的前提下,充分融合各通道的特征。
  • 删除一个的后果 :如果只保留一个 1×11\times 11×1 卷积,网络的非线性拟合能力会下降。你会观察到模型在训练集上的收敛速度变慢,最终的准确率(Accuracy)可能会降低 1%~3% 左右。

3. 计算NiN的资源使用情况(对比 AlexNet/VGG)。

解答

  1. 参数数量极少 !因为干掉了全连接层,参数几乎全在第一层的 11×1111\times1111×11 卷积和后续的特征提取卷积中。相比 AlexNet 动辄 6000 万参数,NiN 通常只有几百万参数,锐减 90% 以上。
  2. 计算量(FLOPs)偏高 。虽然参数少了,但 1×11\times11×1 卷积相当于在图片的每一个像素点 上都做了一次矩阵乘法。如果特征图很大(比如 54×5454\times5454×54),这个计算量是相当恐怖的,导致 NiN 训练速度偏慢。
  3. 训练显存:由于卷积层多,前向传播时需要保存大量中间特征图(Activations)用于反向传播算梯度,显存占用不低。
  4. 预测显存:较低。预测时不需要保存中间梯度,只需保留当前层和下一层的数据即可。

4. 一次性直接将 384×5×5384 \times 5 \times 5384×5×5 的表示缩减为 10×5×510 \times 5 \times 510×5×5 的表示,会存在哪些问题?

解答

这是著名的信息瓶颈(Information Bottleneck)问题

如果用一个普通的 1×11\times 11×1 或 3×33\times 33×3 卷积,直接把 384 个通道压缩到 10 个通道,通道数骤降了 38 倍!

这就像一条八车道的高速公路突然收缩成一条乡间小道,大量的高维抽象特征(比如各种纹理、边缘组合)会被不可逆转地丢失/挤压掉
这会导致网络损失大量的表达能力,准确率崩塌。后续的 ResNet(残差网络)中的 Bottleneck 设计,也是通过极其精巧的方式(先降维再升维)来规避这个问题的。

1.4 GoogLeNet

**GoogLeNet(也叫 Inception v1)**是2014年ImageNet大赛的冠军模型。

如果说VGG的贡献是"网络可以堆得很深",NiN的贡献是"去掉了全连接层",那么GoogLeNet的贡献就是:"网络不仅可以很深,还可以很宽,并且计算效率极高!"

1.4.1 为什么要有Inception块?

在GoogLeNet之前,设计神经网络有一个让人头疼的问题:我到底该用多大的卷积核?

  • 图片里的物体如果很小(比如远处的狗),用 1×11 \times 11×1 或 3×33 \times 33×3 的小卷积核更好。
  • 如果物体很大(比如占据全图的人脸),用 5×55 \times 55×5 或 11×1111 \times 1111×11 的大卷积核更好。

GoogLeNet的作者(谷歌团队)给出了一个简单粗暴但极其聪明的答案:"小孩子才做选择,我全都要!"

1.4.2 Inception块的"多管齐下"

Inception块的架构

在同一个模块(Inception块)里,作者设计了四条并行的路线

  1. 路线1 :1×11 \times 11×1 卷积(看单个像素的通道融合)
  2. 路线2 :3×33 \times 33×3 卷积(看局部细节)
  3. 路线3 :5×55 \times 55×5 卷积(看更大范围的轮廓)
  4. 路线4 :3×33 \times 33×3 最大池化(提取最显著的特征)

把这四条路线提取到的特征,在通道维度上拼接到一起(Concatenate)。这样,网络自己就能在训练中学习到,针对当前的图片,到底哪条路线的特征最有用。

1.4.3 1×11 \times 11×1 卷积的"降维打击"(瓶颈设计 Bottleneck)

如果你同时做这么多复杂的卷积,计算量和通道数会瞬间爆炸!

为了解决这个问题,作者大量借鉴了 NiN 的思想,在 3×33 \times 33×3 和 5×55 \times 55×5 卷积的前面,以及池化层的后面,强行插入了 1×11 \times 11×1 卷积

  • 作用 :1×11 \times 11×1 卷积能在不改变图片长宽的前提下,大幅压缩通道数(降维)
  • 效果 :这使得 GoogLeNet 虽然看起来极其复杂、网络很深,但它的参数量实际上只有 AlexNet 的十二分之一
1.4.4 代码实现

GoogLeNet架构

GoogLeNet一共使用9个Inception块和全局平均汇聚层的堆叠来生成其估计值。Inception块之间的最大汇聚层可降低维度。 第一个模块类似于AlexNet和LeNet,Inception块的组合从VGG继承,全局平均汇聚层避免了在最后使用全连接层。

1. 定义 Inception 块

python 复制代码
from d2l import torch as d2l
import torch
from torch import nn
from torch.nn import functional as F

class Inception(nn.Module):
    # c1--c4 是我们为四条路径指定的输出通道数
    def __init__(self, in_channels, c1, c2, c3, c4, **kwargs):
        super(Inception, self).__init__(**kwargs)
        
        # 路线 1:最简单的单 1x1 卷积
        self.p1_1 = nn.Conv2d(in_channels, c1, kernel_size=1)
        
        # 路线 2:1x1 卷积 (降维) -> 接 3x3 卷积
        # 【关键点】padding=1 保证了 3x3 卷积后,图片的长宽不变
        self.p2_1 = nn.Conv2d(in_channels, c2[0], kernel_size=1)
        self.p2_2 = nn.Conv2d(c2[0], c2[1], kernel_size=3, padding=1)
        
        # 路线 3:1x1 卷积 (降维) -> 接 5x5 卷积
        # 【关键点】padding=2 保证了 5x5 卷积后,图片的长宽不变
        self.p3_1 = nn.Conv2d(in_channels, c3[0], kernel_size=1)
        self.p3_2 = nn.Conv2d(c3[0], c3[1], kernel_size=5, padding=2)
        
        # 路线 4:3x3 最大池化 -> 接 1x1 卷积 (降维)
        # stride=1 和 padding=1 保证了池化后长宽不变
        self.p4_1 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
        self.p4_2 = nn.Conv2d(in_channels, c4, kernel_size=1)

    def forward(self, x):
        # 分别将输入 x 送入四条路线,并经过 ReLU 激活
        p1 = F.relu(self.p1_1(x))
        p2 = F.relu(self.p2_2(F.relu(self.p2_1(x))))
        p3 = F.relu(self.p3_2(F.relu(self.p3_1(x))))
        p4 = F.relu(self.p4_2(self.p4_1(x)))
        
        # 【核心操作】在通道维度(dim=1)上将四个输出拼接起来
        # 因为前面精心设置了 padding,所以 p1~p4 的空间长宽完全一致,可以直接无缝拼接
        return torch.cat((p1, p2, p3, p4), dim=1)

2. 搭建 GoogLeNet 网络(五大模块)

GoogLeNet 把网络分成了 b1b5 五个大模块(Block)。那些奇怪的通道数(如 (96, 208))是谷歌科学家用大量算力搜索出来的"最佳超参数",我们直接照抄即可。

python 复制代码
# 模块 b1:前期特征提取(模仿 AlexNet 的开头)
# 使用 7x7 大卷积核快速降低图片分辨率
b1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
                   nn.ReLU(),
                   nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

# 模块 b2:进一步提取特征
# 1x1 卷积 + 3x3 卷积,通道数升到 192
b2 = nn.Sequential(nn.Conv2d(64, 64, kernel_size=1),
                   nn.ReLU(),
                   nn.Conv2d(64, 192, kernel_size=3, padding=1),
                   nn.ReLU(),
                   nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

# 模块 b3:真正开始使用 Inception 块
# 串联 2 个 Inception 块,最后跟一个最大池化层降维
b3 = nn.Sequential(
    # Inception(输入通道数, c1, (c2_0, c2_1), (c3_0, c3_1), c4)
    Inception(192, 64, (96, 128), (16, 32), 32),   # 输出通道:64+128+32+32 = 256
    Inception(256, 128, (128, 192), (32, 96), 64), # 输出通道:128+192+96+64 = 480
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
)

# 模块 b4:网络的中坚力量
# 疯狂串联 5 个 Inception 块,通道数一路飙升到 832
b4 = nn.Sequential(
    Inception(480, 192, (96, 208), (16, 48), 64),
    Inception(512, 160, (112, 224), (24, 64), 64),
    Inception(512, 128, (128, 256), (24, 64), 64),
    Inception(512, 112, (144, 288), (32, 64), 64),
    Inception(528, 256, (160, 320), (32, 128), 128),
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
)

# 模块 b5:收尾工作
# 2 个 Inception 块 + 全局平均池化(GAP)
b5 = nn.Sequential(
    Inception(832, 256, (160, 320), (32, 128), 128),
    Inception(832, 384, (192, 384), (48, 128), 128), # 最终输出通道:384+384+128+128 = 1024
    nn.AdaptiveAvgPool2d((1,1)), # 从 NiN 学来的:把每个通道的特征图平均成 1x1,消灭全连接层的过拟合隐患
    nn.Flatten() # 拉平为 1024 长度的向量
)

# 组装最终网络
# 最后接一个简单的全连接层,输出 10 个类别(Fashion-MNIST)
net = nn.Sequential(b1, b2, b3, b4, b5, nn.Linear(1024, 10))

训练

python 复制代码
lr, num_epochs, batch_size = 0.1, 10, 128
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

输出:

复制代码
loss 0.239, train acc 0.907, test acc 0.862
1066.5 examples/sec on cuda:0

一些练习题

1. GoogLeNet的后续版本演进(V2, V3, V4)是什么?

  • Inception V2 :引入了批量归一化(Batch Normalization)。大大加快了训练速度,让模型不再那么挑剔初始化的参数。
  • Inception V3 :提出了非对称卷积分解 。把一个 5×55 \times 55×5 的卷积拆成两个 3×33 \times 33×3 的卷积串联;甚至把一个 3×33 \times 33×3 拆成一个 1×31 \times 31×3 加上一个 3×13 \times 13×1。这不仅进一步减少了参数,还增加了非线性层数。(目前工程中最常用的是V3)。同时引入了**标签平滑(Label Smoothing)**防止过拟合。
  • Inception V4 (Inception-ResNet):打不过就加入。看到了何恺明发明的 ResNet(残差网络)太猛了,于是把"残差连接"和 Inception 结合在一起,训练速度和准确率进一步起飞。

2. 使用GoogLeNet的最小图像大小是多少?

解答:32×3232 \times 3232×32(理论极限)

  • 我们来数一下网络中有多少次空间降采样(使得长宽减半的操作)。
  • b1 中有一个 stride=2 的卷积和一个 stride=2 的池化(除以 4)。
  • b2, b3, b4 中各有一个 stride=2 的最大池化(各除以 2)。
  • 总计缩小的倍数是:2×2×2×2×2=322 \times 2 \times 2 \times 2 \times 2 = \mathbf{32}2×2×2×2×2=32 倍。
  • 如果输入图像小于 32×3232 \times 3232×32(比如 28×2828 \times 2828×28),经过五次减半后,特征图尺寸会变成 0(或者小于1),卷积操作就会报错崩溃。所以输入尺寸必须至少是 32×3232 \times 3232×32。

3. 将AlexNet、VGG和NiN的模型参数大小与GoogLeNet进行比较。后两个网络架构是如何显著减少模型参数大小的?

解答:

  • 参数量对比:VGG(约 1.3 亿) >>> AlexNet(约 6000 万) > GoogLeNet(约 500 万) > NiN(约 200 万)。
  • 减少参数的核心秘诀
    1. 全局平均池化(GAP):NiN 和 GoogLeNet 都彻底抛弃了巨大的第一层全连接层。你要知道,VGG 的第一个全连接层就占了 1 亿个参数!换成 GAP 后,这里参数量直接变成了 0。
    2. 1×11 \times 11×1 卷积降维(瓶颈层) :GoogLeNet 在执行昂贵的 3×33 \times 33×3 和 5×55 \times 55×5 卷积之前,先用 1×11 \times 11×1 卷积把厚厚的通道数"压缩"变薄,算完之后再拼接。这让 GoogLeNet 可以在保持极高深度的同时,控制住了参数爆炸。

1.5 批量规范化

批量规范化(Batch Normalization, 简称 BatchNorm 或 BN)

在2015年BN被提出之前,训练深层神经网络(比如超过20层)简直是一场噩梦,网络极容易因为梯度消失或爆炸而崩溃,研究人员只能小心翼翼地调节学习率和权重初始化。BN 的出现彻底改变了游戏规则,不仅能训练上百层的网络(如ResNet),还能使用极大的学习率,极大地缩短了训练时间。

1.5.1 核心理论

1. 为什么要用 BN?(痛点是什么)

  • 输入标准化有效,那中间层呢? 我们知道在把数据喂给神经网络前,通常会把像素值减去均值除以标准差(变成均值为0,方差为1),这会让模型学得更快。但是,随着网络越来越深,数据经过一层层的矩阵乘法和激活函数,每一层输出的数据分布都在剧烈变化(这在原论文中被称为"内部协变量偏移 Internal Covariate Shift")。
  • 后一层的痛苦:前一层权重的微小变化,会导致后一层接收到的数据分布大变。后一层总是被迫不断适应新的数据分布,导致整个网络学习极度缓慢。
1.5.2 BN 的数学原理

不管你前面的层把数据分布搞得多乱,我都在每一层强制把你拉回到"均值为0,方差为1"的标准正态分布。

具体步骤如下(针对某一个批次 Batch 的数据):

  1. 求均值 :μ=1m∑xi\mu = \frac{1}{m} \sum x_iμ=m1∑xi
  2. 求方差 :σ2=1m∑(xi−μ)2\sigma^2 = \frac{1}{m} \sum (x_i - \mu)^2σ2=m1∑(xi−μ)2
  3. 标准化 :x^i=xi−μσ2+ϵ\hat{x}_i = \frac{x_i - \mu}{\sqrt{\sigma^2 + \epsilon}}x^i=σ2+ϵ xi−μ (加一个极小的 ϵ\epsilonϵ 是为了防止分母为0)
  4. ★ 缩放和偏移(拉伸参数 γ\gammaγ 和 偏移参数 β\betaβ) :yi=γx^i+βy_i = \gamma \hat{x}_i + \betayi=γx^i+β

为什么要进行第4步?

如果只做到第3步,把所有数据都强制变成均值为0、方差为1,会严重破坏网络原本学到的特征表达能力 。比如某些特征本来就需要是非负的,你非要把它拉到0附近。

因此,BN 引入了两个可学习的参数 γ\gammaγ 和 β\betaβ。网络可以通过反向传播自己学习这两个参数。如果网络觉得不需要标准化,它可以学到 γ=σ\gamma = \sigmaγ=σ 且 β=μ\beta = \muβ=μ,从而完美还原原本的数据。这给了网络"选择的自由"。

1.5.3 训练模式 vs 预测模式
  • 训练时:我们每次传入一个 Batch(比如 128 张图片),我们就计算这 128 张图片的均值和方差来进行标准化。
  • 预测/推理时:用户可能一次只传 1 张图片进来测试,1张图片是没法算均值和方差的,怎么办?
  • 解决办法 :在训练的过程中,BN 层会偷偷记录一个全局移动平均均值(moving_mean)移动平均方差(moving_var)。到了预测阶段,就直接用这两个全局统计量来进行标准化。
1.5.4. 全连接层与卷积层的区别
  • 全连接层 :作用在特征维度(也就是神经元个数)上。(即几个神经元几对参数
  • 卷积层 :作用在**通道(Channel)**维度上。如果输出有 64 个通道,就有 64 对 γ\gammaγ 和 β\betaβ。同一个通道内,不管是哪一张图片、哪个像素位置,全都在一起算均值和方差。
1.5.5 代码实现

1. 核心数学逻辑实现 (batch_norm 函数)

python 复制代码
from d2l import torch as d2l
import torch
from torch import nn

# 这个函数实现了 BN 的纯数学计算逻辑
def batch_norm(X, gamma, beta, moving_mean, moving_var, eps, momentum):
    # 【1. 判断模式】
    # torch.is_grad_enabled() 用来判断当前是否在算梯度(即是否在训练模式)
    if not torch.is_grad_enabled():
        # 如果是预测模式:直接拿平时攒下来的全局变量 moving_mean 和 moving_var 来用
        X_hat = (X - moving_mean) / torch.sqrt(moving_var + eps)
    else:
        # 如果是训练模式:
        assert len(X.shape) in (2, 4) # 2代表全连接层(Batch, Features),4代表卷积层(Batch, Channels, H, W)
        
        # 【2. 计算均值和方差】
        if len(X.shape) == 2:
            # 对于全连接层,沿着 Batch 维度(dim=0)求均值和方差
            mean = X.mean(dim=0)
            var = ((X - mean) ** 2).mean(dim=0)
        else:
            # 对于卷积层,沿着 Batch(dim=0), 高(dim=2), 宽(dim=3) 求均值和方差
            # 唯独保留了通道维度(dim=1),因为每个通道要单独算一组统计量!
            # keepdim=True 是为了让求完均值后的形状保持为 (1, Channels, 1, 1),方便后面广播机制做减法
            mean = X.mean(dim=(0, 2, 3), keepdim=True)
            var = ((X - mean) ** 2).mean(dim=(0, 2, 3), keepdim=True)
            
        # 【3. 执行标准化】使用当前 Batch 算出来的均值和方差
        X_hat = (X - mean) / torch.sqrt(var + eps)
        
        # 【4. 更新全局统计量】(指数加权移动平均)
        # momentum 通常设为 0.9。意味着旧均值占90%权重,当前batch的均值占10%权重。
        moving_mean = momentum * moving_mean + (1.0 - momentum) * mean
        moving_var = momentum * moving_var + (1.0 - momentum) * var
        
    # 【5. 缩放和偏移】乘以学习参数 gamma,加上学习参数 beta
    Y = gamma * X_hat + beta  
    
    # 返回标准化后的结果,以及更新后的全局变量 (使用 .data 防止把它们加入计算图求梯度)
    return Y, moving_mean.data, moving_var.data

2. 将逻辑封装成 PyTorch 的 Layer (BatchNorm 类)

python 复制代码
class BatchNorm(nn.Module):
    # num_features: 全连接层的节点数,或卷积层的通道数
    # num_dims: 2 表示全连接,4 表示卷积
    def __init__(self, num_features, num_dims):
        super().__init__()
        if num_dims == 2:
            shape = (1, num_features)
        else:
            shape = (1, num_features, 1, 1)
            
        # 【非常关键】:gamma 和 beta 是网络的权重参数,需要通过反向传播更新!
        # 所以必须用 nn.Parameter 包装起来。gamma 初始化为 1,beta 初始化为 0。
        self.gamma = nn.Parameter(torch.ones(shape))
        self.beta = nn.Parameter(torch.zeros(shape))
        
        # moving_mean 和 moving_var 不是通过梯度更新的,而是自己手动算出来的。
        # 所以它们只是普通的 Tensor,不需要求导。
        self.moving_mean = torch.zeros(shape)
        self.moving_var = torch.ones(shape)

    def forward(self, X):
        # 确保全局统计量和输入 X 都在同一个设备上(比如都在同一块 GPU 上)
        if self.moving_mean.device != X.device:
            self.moving_mean = self.moving_mean.to(X.device)
            self.moving_var = self.moving_var.to(X.device)
            
        # 调用前面写的数学逻辑函数
        Y, self.moving_mean, self.moving_var = batch_norm(
            X, self.gamma, self.beta, self.moving_mean,
            self.moving_var, eps=1e-5, momentum=0.9)
        return Y

3. 将 BN 应用于 LeNet (简明实现/调包方式)

python 复制代码
net = nn.Sequential(
    nn.Conv2d(1, 6, kernel_size=5), 
    nn.BatchNorm2d(6), # 紧跟在卷积层后面,参数是上一层的输出通道数 6
    nn.Sigmoid(),      # 放在激活函数前面
    nn.AvgPool2d(kernel_size=2, stride=2),
    
    nn.Conv2d(6, 16, kernel_size=5), 
    nn.BatchNorm2d(16), 
    nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2), 
    nn.Flatten(),
    
    nn.Linear(256, 120), 
    nn.BatchNorm1d(120), # 全连接层使用 1d,参数是神经元个数 120
    nn.Sigmoid(),
    
    nn.Linear(120, 84), 
    nn.BatchNorm1d(84), 
    nn.Sigmoid(),
    
    nn.Linear(84, 10) # 最后一层分类输出,不需要加 BN
)

输出:

复制代码
loss 0.263, train acc 0.903, test acc 0.841
31039.2 examples/sec on cuda:0

查看gamma 和 beta参数:

python 复制代码
net[1].gamma.reshape((-1,)), net[1].beta.reshape((-1,))

输出:

复制代码
(tensor([0.3582, 2.4964, 2.7782, 2.7305, 3.0509, 3.8073], device='cuda:0',
        grad_fn=<ViewBackward0>),
 tensor([ 0.5304, -2.9976,  1.7180, -2.3712,  3.2242,  1.7507], device='cuda:0',
        grad_fn=<ViewBackward0>))

利用pytorch 中的BatchNorm再来一次

python 复制代码
net = nn.Sequential(
    nn.Conv2d(1, 6, kernel_size=5), nn.BatchNorm2d(6), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Conv2d(6, 16, kernel_size=5), nn.BatchNorm2d(16), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(),
    nn.Linear(256, 120), nn.BatchNorm1d(120), nn.Sigmoid(),
    nn.Linear(120, 84), nn.BatchNorm1d(84), nn.Sigmoid(),
    nn.Linear(84, 10))

d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

输出:

复制代码
loss 0.264, train acc 0.904, test acc 0.878
48306.6 examples/sec on cuda:0

一些练习题:

  1. 在使用 BN 之前,我们是否可以从全连接层或卷积层中删除偏置参数(Bias)?为什么?

解答:是的,强烈建议删除(设置 bias=False)!

  • 数学原因 :偏置相当于对特征图加上一个常数 bbb。但在紧随其后的 BN 层中,第一步操作就是计算均值 μ\muμ 并用数据减去均值。你加上去的这个常数 bbb 会导致均值 μ\muμ 也增加了 bbb。所以在执行 X−μX - \muX−μ 时,这个偏置 bbb 会被完完全全地抵消掉
  • 既然加了也是白加,不如直接删掉,还能节省一点点显存和计算量。BN 层自己的 β\betaβ 参数起到了原先偏置的作用。
  1. 比较LeNet在使用和不使用BN情况下的学习率。

解答:

  • 不使用 BN :学习率通常只能设为 0.010.1 左右,否则容易发散。收敛需要很多 Epoch。
  • 使用 BN :如前面代码所示,学习率可以飙升到 1.0 甚至更大,且在头几个 Epoch 就能迅速达到很高的准确率。
  1. 我们是否需要在每个层中进行批量规范化?

解答:

目前主流的经验是:对于所有的中间层(卷积层/全连接层),都建议加 BN。 但是,网络的输出层(最后一层分类层)绝对不能加 BN。因为输出层输出的是原始预测分数(Logits),你需要保持这些分数的绝对大小以进行 Softmax 损失计算,标准化会彻底破坏这些预测结果。

  1. 可以通过批量规范化来替换暂退法(Dropout)吗?行为会如何改变?

解答:
在很多现代卷积网络(如 ResNet)中,BN 确实完全替代了 Dropout。

  • 为什么 BN 也有正则化(防过拟合)效果? 因为在训练时,网络是使用当前 Batch 的均值和方差来进行标准化的。这相当于给每一次数据的传递加入了一些随机的"噪声"(因为每次抽样的 batch 不同,均值和方差都会波动)。这种随机噪声起到了和 Dropout 随机丢弃神经元类似的正则化效果。
  • 通常不建议把 BN 和 Dropout 放在一起用(存在"方差偏移"冲突),如果非要用,一般放在全连接层部分。

​ 5. 其他"规范化"转换?

由于 BN 严重依赖 Batch Size(如果 Batch Size 太小,算出来的均值和方差误差极大,网络会崩溃),后来研究者又提出了几种变体:

  • Layer Normalization (LN) :在单个样本的特征维度上求均值方差,彻底摆脱对 Batch 的依赖。现在是 Transformer / 大语言模型(LLM) 的绝对标准配置。
  • Instance Normalization (IN) :在单样本的单通道上求均值方差,多用于风格迁移 / 图像生成(GAN)
  • Group Normalization (GN):介于 LN 和 IN 之间,当显存不够、Batch Size只能设为 1 或 2 时用来替代 BN。

1.6 ResNet

2015年,何恺明(Kaiming He)团队提出的 ResNet 彻底解决了"深层网络无法训练"的千古难题,一举拿下了当时所有计算机视觉大赛的冠军。毫不夸张地说,现今几乎所有主流模型(包括 Transformer)的内部,都深深烙印着 ResNet 的思想。

1.6.1 核心思想

1.退化问题(Degradation Problem)与嵌套函数

在 ResNet 出现之前,人们发现了一个极其反直觉的现象:当网络层数加深时,模型不仅在测试集上表现变差(过拟合),甚至在训练集上的错误率也变高了.

  • 直觉冲突 :假设一个 20 层的网络已经训练得很好了。我现在强行在它后面加 80 层,变成 100 层网络。最差的情况下,这新增的 80 层什么都不做(这叫恒等映射 Identity Mapping ,即 f(x)=xf(x) = xf(x)=x),100 层网络的表现至少应该和 20 层一模一样才对!为什么反而变差了?
  • 真相 :神经网络极其不擅长 学习恒等映射 f(x)=xf(x) = xf(x)=x。让一堆经过非线性激活函数的权重层输出和输入一模一样的值,比登天还难。

2. 破局之道:残差块(Residual Block)

既然网络很难学出 H(x)=xH(x) = xH(x)=x,何恺明认为我们可以改变一下网络的目标:

  • 原来的网络要直接拟合出一个完美的映射 H(x)H(x)H(x)。
  • 现在,我们让网络去拟合输入和输出之间的差值(残差) ,记作 F(x)=H(x)−xF(x) = H(x) - xF(x)=H(x)−x。
  • 那么最终的输出就变成了:H(x)=F(x)+xH(x) = F(x) + xH(x)=F(x)+x。

这一个"+ x"简直是神来之笔:

  1. 如果新增的层真的不需要做什么,网络只需要把权重参数 F(x)F(x)F(x) 轻松地更新为 0 即可(因为有权重衰减正则化,变 0 很容易)。此时 H(x)=0+x=xH(x) = 0 + x = xH(x)=0+x=x,完美实现了恒等映射!
  2. 在反向传播求梯度时,+++ 号会将梯度无损地、原封不动地传给前一层。彻底解决了深层网络"梯度消失"的问题。

ResNet-18 架构

1.6.2 代码实现

1. 定义残差块 (Residual 类)

它包含两条路:一条是复杂的卷积路,另一条是直接连过去的"捷径(Shortcut/Skip Connection)"。

python 复制代码
from d2l import torch as d2l
import torch
from torch import nn
from torch.nn import functional as F

class Residual(nn.Module):
    # use_1x1conv: 是否需要使用 1x1 卷积来调整捷径的形状
    # strides: 步幅。如果是2,不仅降低分辨率,通道数通常也会翻倍
    def __init__(self, input_channels, num_channels, use_1x1conv=False, strides=1):
        super().__init__()
        # 第一层卷积:可能包含下采样(stride=2)
        self.conv1 = nn.Conv2d(input_channels, num_channels,
                               kernel_size=3, padding=1, stride=strides)
        # 第二层卷积:stride永远是1,保持尺寸不变
        self.conv2 = nn.Conv2d(num_channels, num_channels,
                               kernel_size=3, padding=1)
        
        # 捷径(Shortcut)分支
        if use_1x1conv:
            # 如果输入和输出的通道数或尺寸不一致,没法直接相加!
            # 必须用一个 1x1 卷积,配合相同的 stride,把 X 的形状变换成和 Y 一样
            self.conv3 = nn.Conv2d(input_channels, num_channels,
                                   kernel_size=1, stride=strides)
        else:
            self.conv3 = None
            
        # 每一个卷积层后都紧跟 BatchNorm
        self.bn1 = nn.BatchNorm2d(num_channels)
        self.bn2 = nn.BatchNorm2d(num_channels)

    def forward(self, X):
        # 路线 1:走两层卷积
        Y = F.relu(self.bn1(self.conv1(X)))
        Y = self.bn2(self.conv2(Y))
        
        # 路线 2:走捷径
        if self.conv3:
            X = self.conv3(X)
            
        # ★ 核心灵魂:在进入第二次 ReLU 之前,把捷径加进来! ★
        Y += X 
        
        # 最后再激活
        return F.relu(Y)

2. 生成一个残差阶段 (resnet_block 函数)

ResNet 的整体架构是由几个大的"阶段(Stage)"组成的。每个阶段包含好几个残差块。

python 复制代码
def resnet_block(input_channels, num_channels, num_residuals, first_block=False):
    blk = []
    for i in range(num_residuals): # 循环创建多个残差块
        if i == 0 and not first_block:
            # 每个 Stage 的【第一个块】,负责把特征图长宽减半 (stride=2),通道数翻倍
            # 但是,整个网络的第一个 Stage (first_block=True) 除外,因为前面已经有过下采样了
            blk.append(Residual(input_channels, num_channels,
                                use_1x1conv=True, strides=2))
        else:
            # 该 Stage 后续的块,全都是保持尺寸和通道不变的纯粹特征提取
            blk.append(Residual(num_channels, num_channels))
    return blk

3. 组装完整的 ResNet-18

ResNet 的宏观架构极其优美、整齐,分为三个大部分:Stem(主干)、Body(躯干:4个Stage)和 Head(头部)。

python 复制代码
# 1. Stem(主干):快速降低分辨率
# 输入 224x224 单通道图片,经过 7x7 大卷积和最大池化,迅速变成 56x56,64通道
b1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
                   nn.BatchNorm2d(64), nn.ReLU(),
                   nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

# 2. Body(躯干):4个残差阶段
# b2 的 first_block=True,尺寸不减半,仍为 56x56,通道 64
b2 = nn.Sequential(*resnet_block(64, 64, 2, first_block=True))

# 后面的阶段,每一次都在第一个块执行长宽减半、通道翻倍
b3 = nn.Sequential(*resnet_block(64, 128, 2))  # 输出: 28x28, 128通道
b4 = nn.Sequential(*resnet_block(128, 256, 2)) # 输出: 14x14, 256通道
b5 = nn.Sequential(*resnet_block(256, 512, 2)) # 输出: 7x7, 512通道

# 3. Head(头部):输出分类结果
# 利用 NiN 提出的全局平均池化 GAP,干掉巨大的全连接层矩阵
net = nn.Sequential(b1, b2, b3, b4, b5,
                    nn.AdaptiveAvgPool2d((1,1)), # 输出: 1x1, 512通道
                    nn.Flatten(),                # 拉平为 512 维向量
                    nn.Linear(512, 10))          # 映射到 10 个类别

注:为什么叫 ResNet-18?因为 b2∼b5b2 \sim b5b2∼b5 共有 4×2=84 \times 2 = 84×2=8 个残差块,每个块 2 层卷积,共 16 层。加上 b1b1b1 里的 1 层卷积和最后头部的 1 层全连接,共 18 层。

查看每层输出形状:

复制代码
Sequential output shape:	 torch.Size([1, 64, 56, 56])
Sequential output shape:	 torch.Size([1, 64, 56, 56])
Sequential output shape:	 torch.Size([1, 128, 28, 28])
Sequential output shape:	 torch.Size([1, 256, 14, 14])
Sequential output shape:	 torch.Size([1, 512, 7, 7])
AdaptiveAvgPool2d output shape:	 torch.Size([1, 512, 1, 1])
Flatten output shape:	 torch.Size([1, 512])
Linear output shape:	 torch.Size([1, 10])

训练:

python 复制代码
Sequential output shape:	 torch.Size([1, 64, 56, 56])
Sequential output shape:	 torch.Size([1, 64, 56, 56])
Sequential output shape:	 torch.Size([1, 128, 28, 28])
Sequential output shape:	 torch.Size([1, 256, 14, 14])
Sequential output shape:	 torch.Size([1, 512, 7, 7])
AdaptiveAvgPool2d output shape:	 torch.Size([1, 512, 1, 1])
Flatten output shape:	 torch.Size([1, 512])
Linear output shape:	 torch.Size([1, 10])

输出:

复制代码
loss 0.016, train acc 0.996, test acc 0.859
1735.7 examples/sec on cuda:0

一些练习题

1. Inception块与残差块之间的主要区别是什么?

  • 拓扑结构差异 :Inception 是"变宽",通过不同大小的卷积核(1x1, 3x3, 5x5)并行 提取特征,然后在通道维度上拼接(Concat) ;而残差块是"加深",通过一条直达的捷径和卷积层做元素级相加(Add)
  • 联系:如果把 Inception 块里只留下一条 3x3 的卷积路和一条 1x1 的卷积路,并且最后把拼接(Concat)改成相加(Add),那么 Inception 块就退化成了一个残差块。

2. 参考 ResNet 论文实现不同的变体(如 ResNet-34)。

只需要改变 num_residuals(每个模块里的残差块数量)。

  • ResNet-18 的块数是:[2, 2, 2, 2]

  • ResNet-34 的块数是:[3, 4, 6, 3]。你只需要在代码中把组装模块的地方改成:

    python 复制代码
    b2 = nn.Sequential(*resnet_block(64, 64, 3, first_block=True))
    b3 = nn.Sequential(*resnet_block(64, 128, 4))
    b4 = nn.Sequential(*resnet_block(128, 256, 6))
    b5 = nn.Sequential(*resnet_block(256, 512, 3))

3. 什么是"bottleneck(瓶颈)"架构?

在 ResNet-50、101 和 152 这样极深的网络中,如果一直用 3x3 卷积,计算量会爆炸。何恺明引入了 Bottleneck 结构:

将原本的 [3x3卷积 -> 3x3卷积] 替换为 [1x1卷积(降维) -> 3x3卷积 -> 1x1卷积(升维)]

这样 3x3 卷积的通道数大大减少,就像一个"沙漏"或"瓶颈",在保持网络深度的同时极大地节约了参数和计算量。

4. 作者后来的"预激活(Pre-activation)"改进是什么?

  • 原始版本(ResNet-v1)Conv -> BN -> ReLU -> Conv -> BN -> ADD -> ReLU。注意,相加之后还有一个 ReLU,这意味着捷径传过来的梯度必须经过 ReLU 的筛选(小于0直接截断)。
  • 改进版本(ResNet-v2)BN -> ReLU -> Conv -> BN -> ReLU -> Conv -> ADD
    何恺明发现,把 BN 和 ReLU 移到卷积之前,让加法(ADD)作为当前残差块的绝对最后一步。这样捷径上没有任何非线性阻挡,梯度可以真正意义上"畅通无阻"地回传,这在训练 1000 层以上的网络时极为有效!

5. 为什么即使函数类是嵌套的,我们仍然要限制增加函数的复杂性?

因为数学上的"拟合能力强"不等于"泛化能力好"。

虽然 ResNet 理论上层数越多,训练误差可以降得越低(嵌套的保证),但如果盲目加深到 10000 层:

  1. 过拟合(Overfitting):模型会死记硬背训练集的噪声,导致测试集表现一塌糊涂。
  2. 计算资源限制:显存会爆炸,推理速度会慢到无法投入实际生产。
  3. 数值稳定性:尽管有捷径,极深网络依然会面临数值精度溢出或下溢的工程问题。
1.6.3 ResNet的梯度计算

ResNet 为何能够解决梯度消失?

何恺明团队在 2016 年发表的第二篇 ResNet 论文(Identity Mappings in Deep Residual Networks,即所谓的 ResNet-V2)中,给出了极其极其优雅的数学证明。

普通网络为什么会"梯度消失"?

假设我们有一个传统的"平原网络"(Plain Network,如 VGG)。

设 xlx_lxl 为第 lll 层的输入,WlW_lWl 为第 lll 层的权重,F\mathcal{F}F 为该层的非线性变换(卷积+激活)。

前向传播公式:
xl+1=F(xl,Wl) x_{l+1} = \mathcal{F}(x_l, W_l) xl+1=F(xl,Wl)

假设我们要计算损失函数 L\mathcal{L}L 对浅层 lll 的梯度(即反向传播传到底部的信号)。根据链式法则 ,从深层 LLL 传到浅层 lll,梯度为:
∂L∂xl=∂L∂xL⋅∂xL∂xl \frac{\partial \mathcal{L}}{\partial x_l} = \frac{\partial \mathcal{L}}{\partial x_L} \cdot \frac{\partial x_L}{\partial x_l} ∂xl∂L=∂xL∂L⋅∂xl∂xL

展开中间的偏导数 ∂xL∂xl\frac{\partial x_L}{\partial x_l}∂xl∂xL:
∂L∂xl=∂L∂xL⋅∏i=lL−1∂xi+1∂xi=∂L∂xL⋅∏i=lL−1∂F(xi,Wi)∂xi \frac{\partial \mathcal{L}}{\partial x_l} = \frac{\partial \mathcal{L}}{\partial x_L} \cdot \prod_{i=l}^{L-1} \frac{\partial x_{i+1}}{\partial x_i} = \frac{\partial \mathcal{L}}{\partial x_L} \cdot \prod_{i=l}^{L-1} \frac{\partial \mathcal{F}(x_i, W_i)}{\partial x_i} ∂xl∂L=∂xL∂L⋅i=l∏L−1∂xi∂xi+1=∂xL∂L⋅i=l∏L−1∂xi∂F(xi,Wi)

连乘效应

看最后那个连乘符号 ∏\prod∏

假设网络有一百层,哪怕每一层的导数 ∂F∂xi\frac{\partial \mathcal{F}}{\partial x_i}∂xi∂F 只有 0.9(小于1)。

那么 0.9100≈0.0000260.9^{100} \approx 0.0000260.9100≈0.000026。

传到浅层时,梯度无限趋近于 0,这就叫梯度消失。浅层网络的权重根本得不到更新,彻底废掉。

ResNet 的前向传播

现在来看 ResNet。一个纯粹的残差块(ResNet-V2 预激活版本)的数学定义是:

xl+1=xl+F(xl,Wl) x_{l+1} = x_l + \mathcal{F}(x_l, W_l) xl+1=xl+F(xl,Wl)

这里,左边的 xlx_lxl 是走捷径(Shortcut)过来的,右边的 F\mathcal{F}F 是走卷积层计算出来的残差。

何恺明做了一个极为巧妙的数学操作:展开递归(Unrolling)

既然 xl+1=xl+F(xl)x_{l+1} = x_l + \mathcal{F}(x_l)xl+1=xl+F(xl),那么:
xl+2=xl+1+F(xl+1,Wl+1) x_{l+2} = x_{l+1} + \mathcal{F}(x_{l+1}, W_{l+1}) xl+2=xl+1+F(xl+1,Wl+1)

代入 xl+1x_{l+1}xl+1:
xl+2=[xl+F(xl,Wl)]+F(xl+1,Wl+1) x_{l+2} = \left[ x_l + \mathcal{F}(x_l, W_l) \right] + \mathcal{F}(x_{l+1}, W_{l+1}) xl+2=[xl+F(xl,Wl)]+F(xl+1,Wl+1)

如果我们一直这样代入下去,从任意浅层 lll 到任意深层 LLL,前向传播的公式可以写成:

xL=xl+∑i=lL−1F(xi,Wi) x_L = x_l + \sum_{i=l}^{L-1} \mathcal{F}(x_i, W_i) xL=xl+i=l∑L−1F(xi,Wi)

核心洞见 1:

普通网络是 xL=F(F(F(...)))x_L = \mathcal{F}(\mathcal{F}(\mathcal{F}(...)))xL=F(F(F(...))) (乘法/嵌套模型 )。

而 ResNet 变成了 xL=xl+∑Fx_L = x_l + \sum \mathcal{F}xL=xl+∑F (加法模型 )!

深层的特征 xLx_LxL 等于浅层特征 xlx_lxl 加上这中间所有残差块输出的总和

ResNet 的反向传播

现在,我们要对上面那个加法公式求梯度。

依然是求损失函数 L\mathcal{L}L 对浅层 xlx_lxl 的梯度:

∂L∂xl=∂L∂xL⋅∂xL∂xl \frac{\partial \mathcal{L}}{\partial x_l} = \frac{\partial \mathcal{L}}{\partial x_L} \cdot \frac{\partial x_L}{\partial x_l} ∂xl∂L=∂xL∂L⋅∂xl∂xL

把刚刚推导出的 xLx_LxL 表达式代入进去求偏导:
∂xL∂xl=∂∂xl(xl+∑i=lL−1F(xi,Wi)) \frac{\partial x_L}{\partial x_l} = \frac{\partial}{\partial x_l} \left( x_l + \sum_{i=l}^{L-1} \mathcal{F}(x_i, W_i) \right) ∂xl∂xL=∂xl∂(xl+i=l∑L−1F(xi,Wi))

由于 xlx_lxl 对 xlx_lxl 求导等于 111,我们得到:

∂xL∂xl=1+∂∂xl∑i=lL−1F(xi,Wi) \frac{\partial x_L}{\partial x_l} = 1 + \frac{\partial}{\partial x_l} \sum_{i=l}^{L-1} \mathcal{F}(x_i, W_i) ∂xl∂xL=1+∂xl∂i=l∑L−1F(xi,Wi)

把这个结果代回总的梯度公式中,得到最终的 ResNet 梯度公式:

∂L∂xl=∂L∂xL⋅(1+∂∂xl∑i=lL−1F(xi,Wi)) \frac{\partial \mathcal{L}}{\partial x_l} = \frac{\partial \mathcal{L}}{\partial x_L} \cdot \left( 1 + \frac{\partial}{\partial x_l} \sum_{i=l}^{L-1} \mathcal{F}(x_i, W_i) \right) ∂xl∂L=∂xL∂L⋅(1+∂xl∂i=l∑L−1F(xi,Wi))

把它拆开写:

∂L∂xl=∂L∂xL⋅1⏟无损直达梯度+∂L∂xL⋅∂∂xl∑F⏟穿过各层的衰减梯度 \frac{\partial \mathcal{L}}{\partial x_l} = \underbrace{ \frac{\partial \mathcal{L}}{\partial x_L} \cdot 1 }{\text{无损直达梯度}} + \underbrace{ \frac{\partial \mathcal{L}}{\partial x_L} \cdot \frac{\partial}{\partial x_l} \sum \mathcal{F} }{\text{穿过各层的衰减梯度}} ∂xl∂L=无损直达梯度 ∂xL∂L⋅1+穿过各层的衰减梯度 ∂xL∂L⋅∂xl∂∑F

看着最后这个公式,我们来分析为什么它完美解决了梯度消失:

1. 救命的" + 1 "

在普通网络中,梯度是 ∏\prod∏ (连乘)。而在 ResNet 中,括号里多出了一个加号:1 + ...

这表示,无论右边的这坨 ∑F\sum \mathcal{F}∑F 衰减得有多么厉害(哪怕里面的权重烂到极点,梯度变成了 0.0000001),

整个括号里的值也至少是 1 + 0.0000001
梯度永远不会因为连乘而变成 0!

2. "无损直达"的shortcut

把公式拆开看,梯度由两部分相加组成:

第一部分是 ∂L∂xL⋅1\frac{\partial \mathcal{L}}{\partial x_L} \cdot 1∂xL∂L⋅1。

这意味着,不管两层之间隔了多远(比如 LLL 是第1000层,lll 是第1层),深层的梯度信号都可以原封不动、不经过任何缩放或衰减(乘 1),直接通过捷径传回第1层。

3. 极低概率的完全抵消

有人可能会问:如果 ∂∂xl∑F\frac{\partial}{\partial x_l} \sum \mathcal{F}∂xl∂∑F 刚好等于 -1 呢?那 1 - 1 = 0,梯度不还是消失了吗?

何恺明在论文中指出,在一个包含成千上万样本的小批量(Mini-batch)训练中,对于所有的样本,这个偏导数恒等于 -1 的概率在统计学上几乎为零

从数学推导上我们可以清晰地看到:

  • 普通网络 的前向和反向传播都是连乘机制,必定导致指数级的梯度消失或爆炸。
  • ResNet 巧妙地利用了捷径连接,把前向传播变成了累加 ,从而使得反向传播的偏导数中多出了一个常数 111
  • 这个 111 就像是一条无视任何阻碍的信息高速公路,保证了无论网络有多深,深层的误差都能不受阻碍地传到底层神经元手中。
相关推荐
RuiBo_Qiu1 小时前
【LLM基础】3.大模型前沿注意力机制优化笔记 (以 Qwen3.5-MoE 为例)
人工智能·ai·transformer
seven97_top1 小时前
第一批被龙虾气到的人出现了
人工智能
AC赳赳老秦1 小时前
国产化AI运维新趋势:DeepSeek赋能国产算力部署的高效故障排查
大数据·人工智能·python·django·去中心化·ai-native·deepseek
1941s1 小时前
01-LLM 基础与提示词工程:从 API 调用到 Prompt 优化技巧
人工智能·python·prompt
愚公搬代码1 小时前
【粉丝福利社】AI时代硬核竞争力:这个数学书单传疯了
人工智能
超级学长1 小时前
光学神经网络:进展与挑战(Optical Neural Networks: Progress and Challenges)
人工智能·深度学习·光学神经网络
咚咚王者1 小时前
人工智能之语言领域 自然语言处理 第九章 文本相似度计算
人工智能·自然语言处理
研究点啥好呢1 小时前
每日GitHub热门项目推荐 | 2026年3月9日
人工智能·ai·自动化·github·openclaw
itwangyang5201 小时前
GitHub Push Protection 报错解决指南(检测到 Token / Secret)
人工智能·python·github