%%HTML

'''0_0'''
import matplotlib.pyplot as plt
import numpy as np
import time
import torch
from IPython import display
from torch import nn, optim
from torch.nn import functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
%config InlineBackend.figure_format = 'svg'
gpu = torch.device("cuda:0")

gpu = torch.device("mps:0")

'''0_1'''
print(f"GPU个数:{torch.cuda.device_count()},PyTorch版本:{torch.__version__}。")
print(f"GPU型号:{torch.cuda.get_device_name(0)},显存{torch.cuda.get_device_properties(0).total_memory / 1e9:.2f}GB。")

Colab中文显示问题,文泉驿微米黑开源中文字体。

!sudo apt-get install -y fonts-wqy-microhei
!rm -rf ~/.cache/matplotlib
from matplotlib import font_manager
font_manager.fontManager.addfont('/usr/share/fonts/truetype/wqy/wqy-microhei.ttc')
Colab不方便使用自己写的.py,将LIMU.py全放下面了,不过,做了些裁剪。
'''0_2'''

'''------------------------------辅助类------------------------------'''

class ZigzagChartAnimator:
"""折线图动画可视化类。"""
def __init__(self, title='', legend=(), info_xy_=('','',(),(),'linear','linear'),
rows=1, cols=1, figsize=(5, 2.5)):
"""
title: 图表标题
legend: 图例列表
info_xy_: 包含x轴和y轴共6个参数
xlabel: x轴标签 -字符串
ylabel: y轴标签 -字符串
xlimit: x轴范围 -元组含2个数 空元组自动匹配数据范围
ylimit: y轴范围 -元组含2个数 空元组自动匹配数据范围
xscale: x轴缩放 -可选('linear', 'log')
yscale: y轴缩放 -可选('linear', 'log')
rows: 子图行数
cols: 子图列数
figsize: 图表尺寸
"""

正常显示中文字符,和负号。

plt.rcParams['font.sans-serif'] = ['WenQuanYi Micro Hei', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False

# 创建子图网格。
    self.fig, self.axes = plt.subplots(rows, cols, figsize=figsize)
    # self.fig.canvas.manager.set_window_title(title)
    if rows * cols == 1:
        # 如果只有1个子图,返回的是1个Axes对象,转为列表。
        self.axes = [self.axes]
    # 通过lambda函数的闭包机制捕获所有必要的上下文信息。
    self.config_axes = lambda: self._set_axes(self.axes[0], title, legend, info_xy_)
    # m: 要绘多少条线。
    m = len(legend)
    self.X_2dli = [[] for _ in range(m)]
    self.Y_2dli = [[] for _ in range(m)]

def _set_axes(self, ax, title, legend, info_xy_):
    """
    ax: 子图Axes对象
    title: 图表标题
    legend: 图例列表
    xlabel: x轴标签
    ylabel: y轴标签
    xlimit: x轴范围
    ylimit: y轴范围
    xscale: x轴缩放
    yscale: y轴缩放
    """
    xlabel, ylabel, xlimit, ylimit, xscale, yscale = info_xy_
    ax.set_xlabel(xlabel)
    ax.set_ylabel(ylabel)
    if xlimit:
        ax.set_xlim(xlimit)
    if ylimit:
        ax.set_ylim(ylimit)
    ax.set_xscale(xscale)
    ax.set_yscale(yscale)
    # 图例锚点(1.0, 0.7),距离画布右下角(100%, 70%)位置处,图例框的'lower left'钉在锚点。
    ax.legend(legend, loc='lower left', bbox_to_anchor=(1.0, 0.7))
    ax.set_title(title)
    ax.grid(True)

def insert_point(self, x_li, y_li, ipy=True):
    """
    向折线图中添加数据点

    x_li: x坐标值 可以是数或列表
    y_li: y坐标值 可以是数或列表

    普通Python环境在所有insert_point()之后一定要加一行plt.show(),显示最终图表!
    """
    if not hasattr(y_li, "__len__"):
        # 数,转列表,方便后续遍历。
        y_li = [y_li]
    m = len(y_li)
    if not hasattr(x_li, "__len__"):
        # 数,转列表,方便后续遍历。
        x_li = [x_li] * m
    # 比如x_li=1, y_li=[0.2,0.3,0.4],转换成[1,1,1], [0.2,0.3,0.4]。
    # 即1个横坐标对应3个纵坐标,3条线。

    # 添加数据点。
    for i, (a, b) in enumerate(zip(x_li, y_li)):
        if a and b:
            # a, b 都是数。
            self.X_2dli[i].append(a)
            self.Y_2dli[i].append(b)
    # 清除当前图表并重新绘制。
    self.axes[0].clear()
    # 定义线条样式,依次顺序使用下面元素。
    fmts = ('-','m--','g-.','r:','y-o','k-v','w-x')
    for x_li, y_li, t in zip(self.X_2dli, self.Y_2dli, fmts):
        self.axes[0].plot(x_li, y_li, t)
    # 重新配置坐标轴。
    self.config_axes()
    self._show_plot(ipy)

def _show_plot(self, ipy=True):
    """显示图表。"""
    # iPython、Jupyter场景如下方式。
    if ipy:
        # 显示图形并清空之前的输出。
        display.display(self.fig)
        display.clear_output(wait=True)
    # 普通Python场景如下方式。
    else:
        # 强制重绘图表并短暂暂停程序执行,以便用户查看。
        plt.draw()
        plt.pause(0.5)
        # 在所有add_point()之后,再加一行plt.show(),显示最终图表!

class StopWatchMonitor:
"""记录代码多次运行的相关时间。"""
def __init__(self):

秒数记录列表。

self.secos_list = []
self.start()

def __enter__(self):
    """支持上下文管理器。"""
    self.start()
    return self

def __exit__(self, *args):
    """退出上下文时自动停止计时。"""
    self.stop()

def start(self):
    """启动秒表监视器。"""
    self.tik = time.time()

def stop(self):
    """停止计时器并将时间记录在列表中。"""
    self.secos_list.append(time.time() - self.tik)
    return self.secos_list[-1]

def sum(self):
    """返回时间总和。"""
    return sum(self.secos_list)

def avg(self):
    """返回平均耗时。"""
    return sum(self.secos_list) / len(self.secos_list)

def cml(self):
    """返回时间叠加和,CumulativeSum,就是PrefixSum前缀和。"""
    return np.array(self.secos_list).cumsum().tolist()

'''------------------------------章节类------------------------------'''

class Chapter00:

@staticmethod
def load_data_fashion_mnist(batch_size, reset_size=None, fashion=True):
    """
    默认加载Fashion-MNIST数据集到内存里。
    参数:
        batch_size: 批量大小,通常取256。
        reset_size: 可选,图像重置尺寸,数或元组,例如32、(16, 9)。
        fashion: True,Fashion-MNIST;False,Original-MNIST。
    返回:
        iterate_devset: @torch.utils.data.DataLoader,开发数据集迭代器,60000个样本。
        iterate_testset: @torch.utils.data.DataLoader,测试数据集迭代器,10000个样本。
    """
    if reset_size:
        megatron = transforms.Compose([
            transforms.Resize(reset_size), transforms.ToTensor()])
    else:
        megatron = transforms.Compose([transforms.ToTensor()])
    # megatron来自《变形金刚transformers》里的“威震天”。
    # optimus_prime“擎天柱”,bumblebee“大黄蜂”。
    root_dir = "/content/drive/MyDrive/ColabNotebooks"
    if fashion:
        fashion_mnist_dev = datasets.FashionMNIST(
            root=root_dir, train=True, transform=megatron, download=True)
        fashion_mnist_test = datasets.FashionMNIST(
            root=root_dir, train=False, transform=megatron, download=True)
    else:
        fashion_mnist_dev = datasets.MNIST(
            root=root_dir, train=True, transform=megatron, download=True)
        fashion_mnist_test = datasets.MNIST(
            root=root_dir, train=False, transform=megatron, download=True)
    # MacBookARM64平台,使用MPS后端,多进程在共享内存方面存在兼容性问题,num_workers=0是当前最稳妥的选择,创建0个子进程。
    return (DataLoader(fashion_mnist_dev, batch_size, shuffle=True, num_workers=0),
        DataLoader(fashion_mnist_test, batch_size, shuffle=False, num_workers=0))

@staticmethod
def count_accurate(o, y):
    """
    计算预测正确的数量。
    参数:
        o: 模型预测输出,形状为(batch_size, num_classes)。
        y: 真实标签,形状为(batch_size,)。
    返回:
        预测正确的数量,转浮点数。
    """
    if len(o.shape) > 1 and o.shape[1] > 1:
        # 若o是多维的,且第2维大于1,则对每个样本取最大概率的类别索引,即下标。
        o = o.argmax(axis=1)
    # 创建bool张量,标记哪些预测正确。
    comp = o.type(y.dtype) == y
    # True→1,False→0,求和。
    return float(comp.type(y.dtype).sum())

class Chapter01:

@staticmethod
def init_weights(mo):
    """初始化权重。"""
    if isinstance(mo, nn.Linear) or isinstance(mo, nn.Conv2d):
        nn.init.xavier_uniform_(mo.weight)

@staticmethod
def evaluate_accuracy(votre_modele, iterate_data, device=None):
    """"评估准确率函数,使用GPU计算。"""
    # 确保isinstance(votre_modele, nn.Module)==True!
    votre_modele.eval()
    if device is None:
        # 取出模型第一个参数所在的计算设备(cpu|cuda:0|mps:0),输入和权重必须在同一个设备上才能进行运算。
        device = next(iter(votre_modele.parameters())).device
    # 2个数,分别是预测正确数、样本总数。
    metric = [0.0, 0.0]
    for XX, y in iterate_data:
        if isinstance(XX, list):
            # 支持多输入,例如BERT。
            X = [X.to(device) for X in XX]
        else:
            X = XX.to(device)
        y = y.to(device)
        metric[0] += Chapter00.count_accurate(votre_modele(X), y)
        # 分类任务中,y.numel()==X.shape[0]都是样本总数。
        metric[1] += y.numel()
    return metric[0] / metric[1]

@staticmethod
def training_classifier_1on_fashion_mnist(votre_modele:nn.Module, 
            device:torch.device, train_epochs:int, learning_rate:float,
            iterate_devset, iterate_testset, evaluate_accuracy=evaluate_accuracy):
    """
    训练分类器。
    参数:
        votre_modele: 你的模型,使用torch.nn.Module内置的。
        train_epochs: 训练轮数、超参数。
        learning_rate: 学习率、超参数。
        iterate_devset: @torch.utils.data.DataLoader,开发数据集迭代器。
        iterate_testset: @torch.utils.data.DataLoader,测试数据集迭代器。
        evaluate_accuracy: 评估准确率的函数。
        loss_function, train_optimizer就使用torch框架提供的啦。
    返回:
        无,绘制折线图使训练过程可视化,包含损失值、开发数据集准确率、测试数据集准确率。
    """
    votre_modele.apply(Chapter01.init_weights)
    votre_modele.to(device)
    train_optimizer = optim.SGD(votre_modele.parameters(), lr=learning_rate)
    # 使用默认的reduction='mean',返回每个批次样本的平均损失,梯度也是平均梯度。
    loss_function = nn.CrossEntropyLoss()
    # 批次总数量,开发数据集60000个样本,批次大小取256,ceil(60000/256)=235个批次,最后一次批次只有96个样本。
    numof_batches = len(iterate_devset)
    info_xy_ = ("训练轮数","",(1,train_epochs),None,'linear','linear')
    animator = ZigzagChartAnimator('训练过程可视化', ['损失值', '开发数据集准确率', '测试数据集准确率'], info_xy_)
    monitor = StopWatchMonitor()
    for epoch in range(train_epochs):
        # 3个数,分别是损失总和、预测正确数、样本总数。
        metric = [0.0, 0.0, 0.0]
        votre_modele.train()
        for i, (X, y) in enumerate(iterate_devset):
            monitor.start()
            train_optimizer.zero_grad()
            X, y = X.to(device), y.to(device)
            # o是logits,生数据,没有经过softmax归一化。
            o = votre_modele(X)
            CE = loss_function(o, y)
            CE.backward()
            train_optimizer.step()
            # 防御性编程Safety。
            with torch.no_grad():
                metric[0] += CE.item() * y.numel()
                metric[1] += Chapter00.count_accurate(o, y)
                # 分类任务中,y.numel()==X.shape[0]都是样本总数。
                # 若是语义分割、独热编码,y.numel()是类别总数、像素总数,则!=X.shape[0]。
                metric[2] += y.numel()
            monitor.stop()
            train_loss, train_accu = metric[0] / metric[2], metric[1] / metric[2]
            # 把一个epoch分成5份,当前迭代刚好走完了1份(进度条20%、40%、60%、80%、100%)时,描点。
            if (i + 1) % (numof_batches // 5) == 0 or (i + 1) == numof_batches:
                animator.insert_point(epoch + (i + 1) / numof_batches,
                            [train_loss, train_accu, None])
        # 看一下评估模式在测试数据集上的准确率。
        infer_accu = evaluate_accuracy(votre_modele, iterate_testset)
        animator.insert_point(epoch + 1, [None, None, infer_accu])
    print(f"完成,损失值:{train_loss:.3f},开发数据集准确率:{train_accu:.3f},测试数据集准确率:{infer_accu:.3f}。")
    print(f"在{device}上计时{monitor.sum():.2f}秒,每秒{metric[2] * train_epochs / monitor.sum():.1f}个样本。")

'''1_2'''

要将Fashion-MNIST的28❌28图像,放大到224❌224,批次大小128。

iterate_devset, iterate_testset = Chapter00.load_data_fashion_mnist(128, 224)

现代卷积神经网络

CNN Convolutional Neural Network

AlexNet

2012年,在 ImageNet Large Scale Visual Recognition Challenge 上以压倒性的优势夺冠,标志着深度学习在计算机视觉领域的统治地位正式确立。由Alex Krizhevsky、Ilya Sutskever、Geoffrey Hinton合作设计。

'''1_1'''

我们将使用Fashion-MNIST来训练我们的AlexNet,而非ImageNet,所以这里

第一层输入通道是1,不是论文中的3,对应Fashion-MNIST的灰度图。

最后输出是10,不是论文中的1000,对应Fashion-MNIST的10类别。

alex_krizh_net = nn.Sequential(
nn.Conv2d(1, 96, kernel_size=11, stride=4, padding=1), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Conv2d(96, 256, kernel_size=5, padding=2), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Conv2d(256, 384, kernel_size=3, padding=1), nn.ReLU(),
nn.Conv2d(384, 384, kernel_size=3, padding=1), nn.ReLU(),
nn.Conv2d(384, 256, kernel_size=3, padding=1), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Flatten(),
nn.Linear(6400, 4096), nn.ReLU(), nn.Dropout(p=0.5),
nn.Linear(4096, 4096), nn.ReLU(), nn.Dropout(p=0.5),
nn.Linear(4096, 10))

输入必须是4维,BCHW,BatchSize, Channel, Height, Width。

X_4D = torch.rand(size=(1, 1, 224, 224), dtype=torch.float32)
for layer in alex_krizh_net:
X_4D = layer(X_4D)
print(f"{layer.__class__.__name__:>20} output shape:\t{X_4D.shape}")
'''1_2'''
Chapter01.training_classifier_1on_fashion_mnist(
alex_krizh_net, gpu, 10, 0.1, iterate_devset, iterate_testset)

VGG块

VGG块是由牛津大学视觉几何组 Visual Geometry Group 提出的核心构建单元。论文《Very Deep Convolutional Networks for Large-Scale Image Recognition》,作者Karen Simonyan、Andrew Zisserman。在2014年的ImageNet竞赛中获亚军。

简单来说,VGG块的设计思想就是:用多个小的$3×3$卷积层堆叠(步长1,填充1,),来替代一个大的卷积层,2个堆叠的$3×3$卷积层的感受野相当于1个$5×5$的卷积层,3个堆叠的$3×3$卷积层的感受野相当于1个$7×7$的卷积层,使用多个小卷积核代替一个大卷积核,可以在保持相同感受野 "Receptive Field" 的同时,减少参数量,并引入更多的非线性激活函数(ReLU),从而增强网络的判别能力。通过堆叠这些卷积块,VGG成功地将网络深度推到了16层(VGG16)、19层(VGG19)。

后面跟池化层, 使用$2×2$的最大池化,步长为2,用于减小特征图的尺寸。

2个堆叠的$3×3$卷积层,看宽度一维方向,输入层经过第1层卷积得到中间层1,中间层1经过第2层卷积得到输出层,假设输出点在位置$i$:

  • 输出层 $i$ 依赖于 中间层1 的 $[i-1,i,i+1]$。
  • 中间层1 $i-1$ 依赖于 输入层 的 $[(i-1)-1,(i-1),(i-1)+1] = [i-2,i-1,i]$。
  • 中间层1 $i+1$ 依赖于 输入层 的 $[(i+1)-1,(i+1),(i+1)+1] = [i,i+1,i+2]$。

取并集,输出层 的1个像素点取决于 输入层 的 $[i-2,...,i+2]$ ,5个点,感受野就是5。

而参数量是 $2×3×3×C_{in}×C_{out}=18C^2$ ,换作1个$5×5$的卷积层的话参数量是 $5×5×C_{in}×C_{out}=25C^2$ 。

非线性增强:

1个$5×5$卷积层后面通常只接1个非线性激活函数,如ReLU。2个堆叠的$3×3$卷积层,中间可以插入2个ReLU。更多的非线性层使得网络能学习更复杂的特征函数。
'''2_1'''
def vgg_blk(num_convs, chann_in, chann_out):
"""VGG块,包含num_convs个卷积层,输入通道数chann_in,输出通道数chann_out。"""
the_layers = []
for _ in range(num_convs):
the_layers.append(nn.Conv2d(chann_in, chann_out,
kernel_size=3, padding=1))
the_layers.append(nn.ReLU())
chann_in = chann_out
the_layers.append(nn.MaxPool2d(kernel_size=2, stride=2))
return nn.Sequential(*the_layers)

def vgg_net(conv_arch):
"""VGG网络,vgg块的架构由conv_arch指定。"""
blk_list = []
chann_in = 1
for num_convs, chann_out in conv_arch:
blk_list.append(vgg_blk(num_convs, chann_in, chann_out))
chann_in = chann_out
blk_list.extend([nn.Flatten(), nn.Linear(512 7 7, 4096), nn.ReLU(), nn.Dropout(0.5),
nn.Linear(4096, 4096), nn.ReLU(), nn.Dropout(0.5), nn.Linear(4096, 10)])
return nn.Sequential(*blk_list)
'''2_2'''

包含5个VGG块,卷积层个数依次是1、1、2、2、2,输出通道数依次是64、128、256、512、512。

conv_arch = ((1, 64), (1, 128), (2, 256), (2, 512), (2, 512))

卷积层1+1+2+2+2=8个,全连接层3个,总共11个。

vgg_blk_net_11 = vgg_net(conv_arch)

输入必须是4维,BCHW,BatchSize, Channel, Height, Width。

X_4D = torch.rand(size=(1, 1, 224, 224), dtype=torch.float32)
for block in vgg_blk_net_11:
X_4D = block(X_4D)
print(f"{block.__class__.__name__:>20} output shape:\t{X_4D.shape}")
'''2_3'''
Chapter01.training_classifier_1on_fashion_mnist(
vgg_blk_net_11, gpu, 10, 0.1, iterate_devset, iterate_testset)
'''2_4'''
conv_arch = ((2, 64), (2, 128), (3, 256), (3, 512), (3, 512))

卷积层13个,全连接层3个。

vgg_blk_net_16 = vgg_net(conv_arch)
X_4D = torch.rand(size=(1, 1, 224, 224), dtype=torch.float32)
for block in vgg_blk_net_16:
X_4D = block(X_4D)
print(f"{block.__class__.__name__:>20} output shape:\t{X_4D.shape}")
'''2_5'''

看下VGG-16效果怎么样。

Chapter01.training_classifier_1on_fashion_mnist(
vgg_blk_net_16, gpu, 10, 0.1, iterate_devset, iterate_testset)

耗时巨长,Colab T4 耗时154分,准确率还低。

NiN块

NiN是由新加坡国立大学的【林敏、陈强、颜水成】在2013年的论文《Network In Network》中提出的一种深度学习网络架构。它虽然没有像AlexNet或VGG那样因赢得ImageNet竞赛而一举成名,但它提出的两个核心思想 —— MLP Convolution Layer 和 Global Average Pooling——对后来的卷积神经网络设计产生了极其深远的影响,例如GoogLeNet、ResNet都借鉴了其思想。可以把NiN看作是连接传统CNN和现代CNN的一座重要桥梁。

  1. MLP Convolution Layer

传统CNN的问题:在传统的卷积层中,滤波器是线性的。它对感受野内的数据进行加权求和,然后通过一个非线性激活函数。这种线性模型对于提取高度非线性的抽象特征能力有限。作者认为,要提取更抽象的特征,需要更强大的函数逼近器。

NiN的解决方案:NiN提出用一个微型网络来替代传统的线性滤波器。这个微型网络本身就是一个小型的多层感知机(MLP)。具体来说,它由多个$1×1$的卷积层堆叠而成。

  • 一个$1×1$的卷积可以看作是对输入特征图 "feature map" 的每个像素位置上的所有通道进行一次全连接操作。
  • 将多个$1×1$卷积层和激活函数堆叠起来,就构成了一个小型的MLP,它能够对感受野内的特征进行更复杂的非线性变换和抽象。
  1. Global Average Pooling

传统CNN的问题:在NiN之前,CNN的末端通常会接上几个全连接层来进行分类。这些全连接层存在几个严重问题:1️⃣参数量巨大,例如AlexNet中90%的参数都集中在全连接层;2️⃣可解释性差,全连接层像一个“黑盒”,它破坏了卷积层输出特征图的空间信息,使得我们很难将最终的分类结果与原始图像的特定区域联系起来。

NiN的解决方案:NiN提出用全局平均池化(GAP)层来取代全连接层。

  • 在网络的最后一个Mlpconv层,假设我们得到了$C$个特征图,GAP层对每一个特征图计算其所有像素值的平均值。这样,一个$H×W$大小的特征图就被池化成一个单一的数值。
  • $C$个特征图就得到了一个长度为$C$的向量,这个向量直接被送入Softmax层进行分类。

'''3_1'''
def nin_blk(chann_in, chann_out, kernel_size, stride, padding):
"""NiN块,包含3个卷积层,输入通道数chann_in,输出通道数chann_out。"""
the_layers = [nn.Conv2d(chann_in, chann_out, kernel_size, stride, padding), nn.ReLU(),
nn.Conv2d(chann_out, chann_out, kernel_size=1), nn.ReLU(),
nn.Conv2d(chann_out, chann_out, kernel_size=1), nn.ReLU()]
return nn.Sequential(*the_layers)

def nin_net():
"""NiN网络,nin块的架构固定。"""
return nn.Sequential(

第1个NiN块,输入通道数1,输出通道数96,卷积核大小11,步幅4,填充0。

nin_blk(1, 96, kernel_size=11, stride=4, padding=0),
nn.MaxPool2d(kernel_size=3, stride=2),

第2个NiN块,输入通道数96,输出通道数256,卷积核大小5,步幅1,填充2。

nin_blk(96, 256, kernel_size=5, stride=1, padding=2),
nn.MaxPool2d(kernel_size=3, stride=2),

第3个NiN块,输入通道数256,输出通道数384,卷积核大小3,步幅1,填充1。

nin_blk(256, 384, kernel_size=3, stride=1, padding=1),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Dropout(0.5),

第4个NiN块,输入通道数384,输出通道数10,卷积核大小3,步幅1,填充1。

nin_blk(384, 10, kernel_size=3, stride=1, padding=1),
nn.AdaptiveAvgPool2d((1, 1)),
nn.Flatten())

'''3_2'''
nin_blk_net_4 = nin_net()

输入必须是4维,BCHW,BatchSize, Channel, Height, Width。

X_4D = torch.rand(size=(1, 1, 224, 224), dtype=torch.float32)
for block in nin_blk_net_4:
X_4D = block(X_4D)
print(f"{block.__class__.__name__:>20} output shape:\t{X_4D.shape}")
'''3_3'''
Chapter01.training_classifier_1on_fashion_mnist(
nin_blk_net_4, gpu, 10, 0.1, iterate_devset, iterate_testset)

GoogLeNet

也称为 Inception v1,在2014年的ImageNet竞赛中获得了冠军。主要贡献者是Google的研究团队的 Christian Szegedy 等人,GoogLe也是为了致敬LeNet。

  1. 核心创新:Inception模块

在GoogLeNet之前,设计卷积神经网络面临一个两难的选择:卷积核选多大?$1×1$?$3×3$?还是$5×5$?

小的卷积核能捕捉局部细节。大的卷积核能捕捉更宏观的特征。Inception模块的解决方案是:我全都要。

它在一个层级内并行使用多种尺寸的卷积核,然后将结果拼接 "Concatenate" 在一起。

关键技术:$1×1$卷积 (Bottleneck Layer “瓶颈层”)
如果直接并行堆叠$3×3$和$5×5$的卷积,计算量会爆炸。GoogLeNet引入了$1×1$卷积来进行降维 "Dimensionality Reduction"。

在进入昂贵的$3×3$或$5×5$卷积之前,先通过$1×1$卷积减少通道数。这不仅减少了参数,还增加了网络的非线性(因为每个卷积后都有ReLU)。

  1. 网络架构特点

深度:网络共有22层(带参数的层),比当时的VGG网络更深。

参数量少:GoogLeNet的参数量约为400~500万个,而同期的VGG-16参数量高达1.38亿个。GoogLeNet更加轻量级。

去除了全连接层 "No FC Layers":在网络的末端,GoogLeNet使用全局平均池化层来替代传统的全连接层。这极大地减少了参数量,并防止了过拟合。

辅助分类器 "Auxiliary Classifiers":为了解决深层网络中的梯度消失 "Vanishing Gradient" 问题,GoogLeNet在网络的中间部分引出了两个额外的分支作为辅助分类器。

  1. 后续演进

Inception v2/v3 :引入Batch Normalization,并将$5×5$卷积分解为2个$3×3$卷积,甚至分解为$1×7$和$7×1$的非对称卷积,进一步降低计算量。

Inception v4 :结合了ResNet的残差连接思想,使得网络训练更加稳定,收敛更快。

'''4_1'''
class Inception(nn.Module):
"""下面定义的是Inception v1。"""
def __init__(self, chann_in, chann_out1, chann_out2, chann_out3, chann_out4, version=1):
super().__init__()
self.version = version

线路route1,1×1卷积层。

self.rt1 = nn.Sequential(nn.Conv2d(chann_in, chann_out1, kernel_size=1), nn.ReLU())

线路route2,1×1卷积层、3×3卷积层。

self.rt2 = nn.Sequential(nn.Conv2d(chann_in, chann_out2[0], kernel_size=1), nn.ReLU(),
nn.Conv2d(chann_out2[0], chann_out2[1], kernel_size=3, padding=1), nn.ReLU())

线路route3,1×1卷积层、5×5卷积层。

self.rt3 = nn.Sequential(nn.Conv2d(chann_in, chann_out3[0], kernel_size=1), nn.ReLU(),
nn.Conv2d(chann_out3[0], chann_out3[1], kernel_size=5, padding=2), nn.ReLU())

线路route4,3×3最大池化层、1×1卷积层。

self.rt4 = nn.Sequential(nn.MaxPool2d(kernel_size=3, stride=1, padding=1),
nn.Conv2d(chann_in, chann_out4, kernel_size=1), nn.ReLU())

def forward(self, X_4D):
    # 线路route1,1×1卷积层。
    rt1 = self.rt1(X_4D)
    # 线路route2,1×1卷积层、3×3卷积层。
    rt2 = self.rt2(X_4D)
    # 线路route3,1×1卷积层、5×5卷积层。
    rt3 = self.rt3(X_4D)
    # 线路route4,3×3最大池化层、1×1卷积层。
    rt4 = self.rt4(X_4D)
    # BCHW,在通道维度上连结输出,总通道数=chann_out1+chann_out2[1]+chann_out3[1]+chann_out4。
    return torch.cat((rt1, rt2, rt3, rt4), dim=1)

'''4_2'''
class GoogLeNet(nn.Module):
def __init__(self):
super().__init__()

第1阶段,64×7×7卷积层、3×3最大池化层,主要目的是快速降低分辨率。

self.stage1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

第2阶段,64×1×1卷积层、192×3×3卷积层、3×3最大池化层,提取低级特征。

self.stage2 = 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))

第3阶段,2个Inception块、3×3最大池化层。

self.stage3 = nn.Sequential(Inception(192, 64, (96, 128), (16, 32), 32),
Inception(256, 128, (128, 192), (32, 96), 64),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

第4阶段,躯干,5个Inception块、3×3最大池化层。

self.stage4 = 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))

第5阶段,2个Inception块、全局平均池化层。

self.stage5 = nn.Sequential(Inception(832, 256, (160, 320), (32, 128), 128),
Inception(832, 384, (192, 384), (48, 128), 128),
nn.AdaptiveAvgPool2d((1,1)), nn.Flatten())

全连接层,输出10个分类的logits,再传给Softmax。

self.fc = nn.Linear(1024, 10)

def forward(self, X_4D):
    X_4D = self.stage1(X_4D)
    X_4D = self.stage2(X_4D)
    X_4D = self.stage3(X_4D)
    X_4D = self.stage4(X_4D)
    X_4D = self.stage5(X_4D)
    return self.fc(X_4D)

'''4_3'''
chris_szeg_net = GoogLeNet()

输入必须是4维,BCHW,BatchSize, Channel, Height, Width。

X_4D = torch.rand(size=(1, 1, 224, 224), dtype=torch.float32)
for block in chris_szeg_net.children():
X_4D = block(X_4D)
print(f"{block.__class__.__name__:>20} output shape:\t{X_4D.shape}")
'''4_4'''
Chapter01.training_classifier_1on_fashion_mnist(
chris_szeg_net, gpu, 10, 0.1, iterate_devset, iterate_testset)

批量规一化

Batch Normalization,Google团队 Ioffe、Szegedy 在2015年提出。

问题提出:神经网络中,损失出现在靠近输出的后面层,后面层梯度大学习较快,由梯度的反向传播,靠近输入的前面层梯度小学习较慢。而前面层是提取低级特征的,前面层一变后面层要重新学习,导致神经网络收敛很慢。如何在前面层学习的同时避免后面层的重新学习?

核心思想:针对每批次的样本,在进入激活函数之前,强行将其拉回到标准正态分布,然后再通过2个可学习的参数进行缩放和平移,以保留模型的表达能力。

数学步骤:

  1. 当前批次样本$x$,形状是$BCHW$。
  2. 按通道计算样本的均值$𝜇$和方差$𝜎^2$。
  3. 归一化,$\hat{x}=\frac{x-𝜇}{\sqrt{𝜎^2+𝜀}}$,加𝜀是为了防止分母为0。
  4. 缩放和平移,$y=𝛾\hat{x}+𝛽$。

举个例子:

假设我们的$BCHW=(2,2,2,2)$,即2张图片(A、B),每张图片2个通道,图片极小宽2px高2px。

输入$x$

图片A: 通道0 $[[0,0],[0,0]]$       通道1 $[[2,2],[2,2]]$

图片B: 通道0 $[[10,10],[10,10]]$   通道1 $[[4,4],[4,4]]$

通道0,数据池$[0,0,0,0,10,10,10,10]$。

  1. 计算均值和方差
    $𝜇_0=\frac{0×4+10×4}{8}=5,𝜎^2_0=\frac{25×4+25×4}{8}=25,𝜎=5.$。
  2. 归一化

    图片A,$(0-5)/5=-1$,4个像素值变成$[[-1,-1],[-1,-1]]$。

    图片B,$(10-5)/5=1$,4个像素值变成$[[1,1],[1,1]]$。

通道1,数据池$[2,2,2,2,4,4,4,4]$。

  1. 计算均值和方差
    $𝜇_1=\frac{2×4+4×4}{8}=3,𝜎^2_1=\frac{1×4+1×4}{8}=1,𝜎=1.$。
  2. 归一化

    图片A,$(2-3)/1=-1$,4个像素值变成$[[-1,-1],[-1,-1]]$。

    图片B,$(4-3)/1=1$,4个像素值变成$[[1,1],[1,1]]$。

暂时忽略了归一化时,分母上的极小数𝜀。

中间值$\hat{x}$

图片A: 通道0 $[[-1,-1],[-1,-1]]$   通道1 $[[-1,-1],[-1,-1]]$

图片B: 通道0 $[[1,1],[1,1]]$         通道1 $[[1,1],[1,1]]$

注意观察:两张图片虽然通道0的输入值差距很大 (0、10),通道1的输入值差距很小 (2、4),但经过BN后,它们都变成了$-1,1$。

缩放和平移,也是按通道处理,假设神经网络已经学习好缩放和平移的参数。

通道0,缩放$𝛾_0=2$,平移$𝛽_0=10$,意图把数据拉大并整体抬高。

通道1,缩放$𝛾_1=0.5$,平移$𝛽_1=1$,意图把数据缩小并微调位置。

输出$y$

图片A: 通道0 $[[8.0,8.0],[8.0,8.0]]$       通道1 $[[0.5,0.5],[0.5,0.5]]$

图片B: 通道0 $[[12.0,12.0],[12.0,12.0]]$   通道1 $[[1.5,1.5],[1.5,1.5]]$

虽然都是从$-1,1$变过来的,但不同通道的𝛾、𝛽不同,最终的数值范围完全不同了,这就是BN层赋予网络的灵活性。
'''5_1'''
def batch_norm(X, gamma, beta, epsilon, moving_mean, moving_var, momentum):
"""批量归一化计算的手动实现。"""
if not torch.is_grad_enabled():

评估模式,直接使用移动平均所得的均值和方差。

X_hat = (X - moving_mean) / torch.sqrt(moving_var + epsilon)
else:

训练模式,使用当前批次的均值和方差,并更新移动平均的均值和方差。

assert len(X.shape) in (2, 4)
if len(X.shape) == 2:

全连接层的批量归一化,计算特征维上的均值和方差。

进入全连接层前的数据通常被Flatten成二维的(N,F)批次大小、特征。

可以不写keepdim=True,形状为(F,),pytorch会从右到左广播,沿着N维度复制。

still_mean = X.mean(dim=0)
still_var = ((X - still_mean) ** 2).mean(dim=0)
else:

卷积层的批量归一化,计算通道维上的均值和方差,保持维度以便后续广播。

如果keepdim=False,形状为(C,),无法与形状为(N,C,H,W)的X做减法。

keepdim=True,形状为(1,C,1,1),N,H,W维度上可以广播。

still_mean = X.mean(dim=(0, 2, 3), keepdim=True)
still_var = ((X - still_mean) ** 2).mean(dim=(0, 2, 3), keepdim=True)
X_hat = (X - still_mean) / torch.sqrt(still_var + epsilon)

更新移动平均的均值和方差,指数移动平均,历史数据的动量因子会越乘越小。

moving_mean = momentum moving_mean + (1.0 - momentum) still_mean
moving_var = momentum moving_var + (1.0 - momentum) still_var

缩放和移位,gamma和beta是可学习参数,形状与X的特征维或通道维相同,保持维度以便后续广播。

Y = gamma * X_hat + beta
return Y, moving_mean.data, moving_var.data

'''5_2'''
class Consecutive2d(nn.Module):
"""包装卷积层->批量归一化层->激活函数,连续进行的基本模块,可以避免在Inception块中写重复代码。"""
def __init__(self, chann_in, chann_out, **kwargs):
super().__init__()

BN层已有beta,卷积层就不需要偏置了,bias=False。

self.conv = nn.Conv2d(chann_in, chann_out, bias=False, **kwargs)
self.bn = nn.BatchNorm2d(chann_out)
self.relu = nn.ReLU(inplace=True)

def forward(self, X_4D):
    X_4D = self.conv(X_4D)
    X_4D = self.bn(X_4D)
    X_4D = self.relu(X_4D)
    return X_4D

'''5_3'''
class Inception(nn.Module):
"""Inception v1基础上添加了批量归一化。"""
def __init__(self, chann_in, chann_out1, chann_out2, chann_out3, chann_out4):
super().__init__()

线路route1,1×1卷积层。

self.rt1 = Consecutive2d(chann_in, chann_out1, kernel_size=1)

线路route2,1×1卷积层、3×3卷积层。

self.rt2 = nn.Sequential(Consecutive2d(chann_in, chann_out2[0], kernel_size=1),
Consecutive2d(chann_out2[0], chann_out2[1], kernel_size=3, padding=1))

线路route3,1×1卷积层、5×5卷积层。

self.rt3 = nn.Sequential(Consecutive2d(chann_in, chann_out3[0], kernel_size=1),
Consecutive2d(chann_out3[0], chann_out3[1], kernel_size=5, padding=2))

线路route4,3×3最大池化层、1×1卷积层。

self.rt4 = nn.Sequential(nn.MaxPool2d(kernel_size=3, stride=1, padding=1),
Consecutive2d(chann_in, chann_out4, kernel_size=1))

def forward(self, X_4D):
    rt1 = self.rt1(X_4D)
    rt2 = self.rt2(X_4D)
    rt3 = self.rt3(X_4D)
    rt4 = self.rt4(X_4D)
    return torch.cat((rt1, rt2, rt3, rt4), dim=1)

'''5_4'''
class GoogSeNet(nn.Module):
"""将BatchNorm层集成到GoogLeNet中。"""
def __init__(self):
super().__init__()

第1阶段,64×7×7卷积层、3×3最大池化层,主要目的是快速降低分辨率。

self.stage1 = nn.Sequential(Consecutive2d(1, 64, kernel_size=7, stride=2, padding=3),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

第2阶段,64×1×1卷积层、192×3×3卷积层、3×3最大池化层,提取低级特征。

self.stage2 = nn.Sequential(Consecutive2d(64, 64, kernel_size=1),
Consecutive2d(64, 192, kernel_size=3, padding=1),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

第3阶段,2个Inception块、3×3最大池化层。

self.stage3 = nn.Sequential(Inception(192, 64, (96, 128), (16, 32), 32),
Inception(256, 128, (128, 192), (32, 96), 64),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

第4阶段,躯干,5个Inception块、3×3最大池化层。

self.stage4 = 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))

第5阶段,2个Inception块、全局平均池化层。

self.stage5 = nn.Sequential(Inception(832, 256, (160, 320), (32, 128), 128),
Inception(832, 384, (192, 384), (48, 128), 128),
nn.AdaptiveAvgPool2d((1,1)), nn.Flatten())

全连接层,输出10个分类的logits,再传给Softmax。

self.fc = nn.Linear(1024, 10)

def forward(self, X_4D):
    X_4D = self.stage1(X_4D)
    X_4D = self.stage2(X_4D)
    X_4D = self.stage3(X_4D)
    X_4D = self.stage4(X_4D)
    X_4D = self.stage5(X_4D)
    return self.fc(X_4D)

'''5_5'''
chris_szeg_net = GoogLeNet()

输入必须是4维,BCHW,BatchSize, Channel, Height, Width。

X_4D = torch.rand(size=(1, 1, 224, 224), dtype=torch.float32)
for block in chris_szeg_net.children():
X_4D = block(X_4D)
print(f"{block.__class__.__name__:>20} output shape:\t{X_4D.shape}")
'''5_6'''
Chapter01.training_classifier_1on_fashion_mnist(
chris_szeg_net, gpu, 10, 0.1, iterate_devset, iterate_testset)

ResNet

Residual Network,残差神经网络。计算机视觉领域的一个里程碑式模型,由何恺明(Kaiming He)等人在2015年提出,在当年的ILSVRC中以绝对优势获得了冠军。

ResNet的核心贡献在于解决了深度神经网络的“退化问题”"Degradation Problem",使得训练数百层甚至上千层的神经网络成为可能。

  1. 想象一下我们在玩“传话游戏”。

输入输出:你看到了一张“猫”的照片,你要描述给下一个人。

深层网络:如果中间隔了100个人(100层的神经网络)。

结果:传到最后一个人时,信息可能已经严重失真了,变成了“虎”或者完全不知道是什么的东西。

这就是退化问题,层数太多,信息在传递过程中丢失了。

  1. ResNet的绝招:随身带个“小抄”。

ResNet的发明者想了一招:跳跃连接"Shortcut Connection"。

还是那个传话游戏,但这次规则变了:第1个人传给第2个人的时候,不仅口头描述,还塞了一张小抄给第2个人。第2个人处理完信息后,把处理结果和这张纸条加在一起,再传给第3个人,同时也把纸条复印一份传下去。

这样做的好处是:哪怕中间某个人(某一层网络),脑子短路了完全没听懂前一个人在说什么(输出为0),他手里至少还有那张“小抄”(原始信息$x$)。他可以直接把小抄递给下一个人。

那么,最差的情况下,这一层网络什么都不做,也不会把原本的信息搞丢。这就是所谓的恒等映射"Identity Mapping"。

  1. 什么是“残差”?

“残差”这个词听起来很高深,其实就是差值

以前的神经网络:试图直接学会“最终答案”。这很难,就像让你直接画出一幅蒙娜丽莎。

ResNet:试图学会“还需要修改多少”。

手里已经有了上一层的答案(小抄$x$)。这一层只需要去学“当前答案和正确答案之间的差值”,即残差$F(x)$。最终输出 = 小抄 + 差值, $H(x) = x + F(x)$。
'''6_1'''
class ResidBlock(nn.Module):
"""残差块,卷积层->批量归一化->激活函数->卷积层->批量归一化->跳跃连接->激活函数。"""
def __init__(self, chann_in, chann_out, use_11_conv=False, stride=1):
super().__init__()
self.cv1 = nn.Conv2d(chann_in, chann_out, kernel_size=3, padding=1, stride=stride)
self.cv2 = nn.Conv2d(chann_out, chann_out, kernel_size=3, padding=1)
if use_11_conv:

使用1×1卷积层,调整通道数和分辨率。

self.cv3 = nn.Conv2d(chann_in, chann_out, kernel_size=1, stride=stride)
else:
self.cv3 = None
self.bn1 = nn.BatchNorm2d(chann_out)
self.bn2 = nn.BatchNorm2d(chann_out)

def forward(self, X_4D):
    Y_4D = F.relu(self.bn1(self.cv1(X_4D)))
    Y_4D = self.bn2(self.cv2(Y_4D))
    if self.cv3:
        X_4D = self.cv3(X_4D)
    return F.relu(Y_4D + X_4D)

上面的ResidBlock将生成2种类型的残差网络,

1️⃣use_11_conv=False如左图所示;

2️⃣use_11_conv=True如右图所示;


'''6_2'''
X_4D = torch.rand(size=(4, 3, 6, 6))

输入和输出形状一致的情况。

rsd_blk = ResidBlock(3, 3)
Y_4D = rsd_blk(X_4D)
print(Y_4D.shape)

输出形状,增加通道数,减少高和宽。

rsd_blk = ResidBlock(3, 6, use_11_conv=True, stride=2)
Y_4D = rsd_blk(X_4D)
print(Y_4D.shape)
'''6_3'''
class ResidualNet(nn.Module):
"""残差神经网络。"""
def __init__(self, ):
super().__init__()
self.stage1 = 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))
self.stage2 = self._make_stage(64, 64, 2, True)
self.stage3 = self._make_stage(64, 128, 2)
self.stage4 = self._make_stage(128, 256, 2)
self.stage5 = self._make_stage(256, 512, 2)
self.glolayer = nn.Sequential(nn.AdaptiveAvgPool2d((1,1)),
nn.Flatten(), nn.Linear(512, 10))

def _make_stage(self, chann_in, chann_out, num_residuals, first_block=False):
    """生成一个stage,包含num_residuals个ResidBlock。"""
    if first_block:
        # 第1个残差块,即stage2,不使用1×1卷积层。
        blk_list = [ResidBlock(chann_in, chann_out)]
    else:
        # 后续stage3,4,5,使用1个1×1卷积层。
        blk_list = [ResidBlock(chann_in, chann_out, True, 2)]
    for _ in range(num_residuals-1):
        blk_list.append(ResidBlock(chann_out, chann_out))
    return nn.Sequential(*blk_list)

def forward(self, X_4D):
    X_4D = self.stage1(X_4D)
    X_4D = self.stage2(X_4D)
    X_4D = self.stage3(X_4D)
    X_4D = self.stage4(X_4D)
    X_4D = self.stage5(X_4D)
    return self.glolayer(X_4D)

上面代码神经网络架构
'''6_4'''
def check_shape(block, X_4D):
if isinstance(block, nn.Sequential):

若是Sequential容器,则递归解包,深入内部。

for subblk in block.children():
X_4D = check_shape(subblk, X_4D)
else:

若是基本层(如Conv2d)或自定义块(如ResidBlock),直接前向传播并打印输出形状。

X_4D = block(X_4D)
print(f"{block.__class__.__name__:>20} output shape:\t{X_4D.shape}")
return X_4D
'''6_5'''
kmh_net = ResidualNet()

输入必须是4维,BCHW,BatchSize, Channel, Height, Width

X_4D = torch.rand(size=(1, 1, 224, 224), dtype=torch.float32)
for block in kmh_net.children():
X_4D = check_shape(block, X_4D)
'''6_6'''
Chapter01.training_classifier_1on_fashion_mnist(
kmh_net, gpu, 10, 0.05, iterate_devset, iterate_testset)

35分钟。

最后修改于:2026年03月20日
如果觉得我的文章对你有用请狠狠地打赏我