深度学习与计算机视觉

1 神经网络模型被是“万能的函数逼近器”

万能近似定理(Universal Approximation Theorem)是神经网络理论中的一个重要结果,它揭示了神经网络的强大表达能力,即通过增加神经网络的层数和神经元的数量,并使用适当的非线性激活函数,神经网络可以逼近任意连续函数。

2. 什么是归一化和标准化

  • 归一化:是指将数据缩放到 \([0,1]\) 的范围内。
    • 计算方法:\(x_{norm}=\frac{x−min(x)}{max(x)−min(x)}\)
  • 标准化:是指使数据符合 0 为均值,1 为标准差的分布,即将数据转换到 0 值附近。
    • 计算方法:\(x_{std}=\frac{x-\mu}{\sigma}\)

3. 什么是 Batch Normalization

批标准化(Batch Normalization,简称 BN)是一种在神经网络训练过程中对小批量数据进行标准化的方法,其目的是提高神经网络训练速度和稳定性。

  1. 批标准化步骤
    假设我们有一个神经网络的某一层的输入为 \(x=\{x_{1},x_{2},…,x_{m}\}\) ,其中 \(x_{i}\) 是一个包含 \(n\) 个特征的向量,而 \(m\) 则是批量的大小。

    • 计算小批量均值 \(\mu_B\)

      \[\mu_B = \frac{1}{m}\sum\limits^m_{i=1}{x_{i}} \]

    • 计算小批量方差 \(\sigma^2_B\)

      \[\sigma_B^2=\frac{1}{m} \sum_{i=1}^m\left(x_{i}-\mu_B\right)^2 \]

      这里 \(\sigma^2_B\) 是批量 \(B\) 中所有样本特征的方差。

    • 标准化操作
      对每一个特征训练 \(x_{i}\) 进行标准化:

      \[\hat{x}_{i}=\frac{x_{i}-\mu_B}{\sqrt{\sigma_B^2+\epsilon}} \]

      其中 \(\epsilon\) 是一个小的常数(例如 \(10^{-8}\),用于避免除以零的情况)。

    • 缩放和位移
      引入两个学习参数 \(\gamma\)(缩放因子)和 \(\beta\)(位移因子),用于调整归一化后的数据:

      \[y_{i}=\gamma \hat{x}_{i}+\beta \]

      这里的 \(y_{i}\) 是最终输出的特征向量,用于传递给下一层网络。

  2. 批标准化的作用

    • 减少内协变量偏移,加速收敛
      内部协变量偏移是指神经网络中每一层的输入分布随着前面层的参数更新而发生变化的现象。这种变化会使得每一层在每次迭代中都需要不断适应新的输入分布,从而增加训练难度和时间。批标准化通过在每个 mini-batch 中对激活值进行标准化,使得每一层的输入分布保持稳定,从而减少了内协变量偏移。
    • 减少梯度消失和梯度爆炸问题
      通过标准化,BN 确保了每一层的输入数据分布在训练过程中保持稳定,避免了激活值过大或过小,从而减少了梯度爆炸和梯度爆炸的问题。
    • 防止过拟合
      BN 通过标准化操作,使得每一层的输入都被限制在一个相对稳定的范围内,从而降低了模型对特定输入特征的依赖。这样,模型在面对不同的输入数据时,能够表现出更加稳定和一致的性能。
      此外,由于每个 mini-batch 的样本都是随机的,即均值和方差也都是随机计算的,这相当于 BN 引入了随机噪声,所以模型在训练时能够接触到更多不同的数据分布,从而提高了模型的泛化能力。
  3. 标准化训练阶段与测试阶段的区别

    • 在训练阶段,BN 通过对每个小批量的输入进行标准化,即将每个特征的均值调整为 0,方差调整为 1。
    • 在测试阶段,BN 使用的是训练阶段累积的均值和方差,而不是测试数据的小批量均值和方差。可以使用移动平均法得到:

      \[\hat{x}_\text{new} = (1 - \text{momentum}) \times \hat{x} + \text{momentum} \times x_t \]

      PyTorch 中 momentum 默认是 0.1。

  4. 标准化 \(\gamma\)\(\beta\) 参数的作用
    引入 \(\gamma\)\(\beta\) 是为了恢复批标准化后模型的非线性表达能力。BN 将激活值调整为零均值和单位方差,使得激活值可能集中在激活函数(例如 ReLU 激活函数)的线性区域,从而限制了模型的非线性特性。通过 \(\gamma\)\(\beta\) 对标准化后的激活值进行缩放和平移,可以将激活值重新分布到激活函数的非线性区域,从而增强模型的表达能力和性能。并且由于 \(\gamma\)\(\beta\) 是可训练的参数,神经网络能够在训练过程中自动调整这些参数,以找到最适合的激活值分布。

    由于 BN 自带可学习的偏置参数 \(\beta\),因此前面的层不需要再添加额外的偏置。

4 什么是 Dropout

Dropout 是一种防止神经网络过拟合的正则化技术。它通过在训练过程中随机“丢弃”一部分神经元(即将它们的输出设置为 0)来达到正则化效果。这样做的目的是为了减少神经元之间的相互依赖,从而使模型更具泛化能力。
Dropout 的工作原理的工作原理如下:

  • 训练阶段:在每次训练过程中,对于每个神经元,以一定的概率 \(p\)(通常为 0.5)决定是否将其“丢弃”。被丢弃的神经元在前向传播和反向传播中均不更新权重。剩余的神经元则按照正常方式更新权重。
  • 测试阶段:在测试阶段,所有神经元都参与计算。为了补偿训练阶段丢弃神经元的影响,需要将所有神经元的输出乘以 \(1−p\),相当于对输出进行缩放。

现在几乎不使用 Dropout 的原因:
① 数据集容易获取,很少出现数据量少导致训练过拟合的情况。
② 使用不一定有效果。

5 什么是残差网络

  1. 加深网络带来的问题

    • 计算资源消耗增大
      随着神经网络层数的增加,计算资源的消耗会显著增加。深层网络需要更多的计算能力和内存资源来处理更复杂的运算。这可能会导致训练时间变得非常长。我们可以使用更好的硬件来解决(如使用 GPU 集群,加大内存等)。
    • 模型容易过拟合
      随着层数增加,模型的参数数量也会增加。过多的参数可以使模型记住训练数据的噪声和细节,而不是学习到数据的总体模式。我们可以通过使用海量数据进行训练以及采样正则化方法来解决。
    • 容易产生梯度消失或梯度爆炸
      在深层网络中,梯度在反向传播过程中可能会逐渐减小到接近零(梯度消失)或增大到无穷大(梯度爆炸)。这会导致前几层的参数无法有效更新,从而影响整个网络的训练效果。我们可以通过 BN 来缓解。
    • 模型退化
      随着网络层数的增加,模型的性能反而可能会下降。这是因为信息在层与层之间传递时可能会逐渐丢失,导致模型无法有效地学习到输入数据的特征。我们就可以使用 残差网络(ResNet) 来解决这一问题。
  2. 残差网络
    残差网络(ResNet,全称Residual Network)是一种深度神经网络,其内部的残差块使用了跳跃连接(shortcut连接),将输入直接引入到非线性层的输出上。
    整个残差块的映射关系可以表示为 \(H(x)=F(x)+x\),其中 \(H(x)\) 是模型的预测值,而 \(x\) 是恒等映射(即输入值本身),\(F(x)\) 则是预测值与输入值之间的差值函数,即模型学习的残差。

    统计学中的残差是指预测值与观测值之间的差值。比如预测水位线的高度,模型预测为 10m, 你测量的是 10.4m(通常你认为 10.4m 为真实值,其实它并不是,真实值可能为 10.5m 等其他的值),这里的残差就是 0.4m。

    image

  3. 残差网络的作用

    • 缓解梯度消失问题:梯度可以通过跳跃连接有效地传播,从而大大减少了梯度消失的问题。
    • 使训练更容易:残差网络学习的是输入和期望输出之间的差异(即残差 \(F(x)\)),使得每一层只需要学习微小的变化,而不是学习整个映射,这种方式使训练更近容易。
    • 避免网络退化的问题:通过跳跃连接,可以很容易地构建恒等映射(即 \(F(x)=0\)),也就是说即使新增的层没有学到有用的特征,浅层特征也能完整的传递到深层去,信息不会丢失,从而避免网络退化的问题。
  4. 普通残差块与瓶颈残差块

    • 普通残差(ResNet Block)
      image

      普通残差块有两个卷积层,并通过跳跃连接将输入直接加到输出上,从而形成“残差”。PyTorch 代码实现如下:

      import torch
      
      
      class Residual(torch.nn.Module):
          def __init__(self, in_channels, out_channels, stride=1):
              super().__init__()
              # 一般会在第一个卷积层增加通道数的同时减少分辨率,这样可以增强特征提取能力的同时减少运算量
              self.conv1 = torch.nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False)
              self.conv2 = torch.nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias=False)
              # 输入和输出的通道数和分辨率不同时,需要添加1x1卷积层来调整通道数和分辨率
              if in_channels != out_channels or stride != 1:
                  self.conv3 = torch.nn.Conv2d(in_channels, out_channels, 1, stride, bias=False)
              else:
                  self.conv3 = None
              self.bn1 = torch.nn.BatchNorm2d(out_channels)
              self.bn2 = torch.nn.BatchNorm2d(out_channels)
              self.relu = torch.nn.ReLU()
      
          def forward(self, x):
              y = self.relu(self.bn1(self.conv1(x)))
              y = self.bn2(self.conv2(y))
              if self.conv3:
                  x = self.conv3(x)
              y += x
              return self.relu(y)
      

      此代码生成两种类型的网络:一种是当输入和输出的通道数或分辨率相同时,应用 ReLU 非线性函数之前,直接将输入添加到输出;另一种是当输入和输出的通道数和分辨率不相同时,通过添加 1×1 卷积调整输入的通道和分辨率与输出一致后再相见。
      image

    • 瓶颈残差块(Bottleneck Residual Block)
      image

      为了进一步减少计算量和参数量,ResNet引入了瓶颈残差块。瓶颈残差块通过增加 1x1 卷积层来降低和恢复维度,从而大大减少了计算量和参数量。PyTorch 代码实现如下:

      class Bottleneck(torch.nn.Module):
      
      	def __init__(self, in_channels, out_channels, stride=1, expansion=4):
      		super().__init__()
      		self.conv1 = torch.nn.Conv2d(in_channels, out_channels // expansion, 1, 1, 0, bias=False)
      		self.conv2 = torch.nn.Conv2d(out_channels // expansion, out_channels // expansion, 3, stride, 1, bias=False)
      		self.conv3 = torch.nn.Conv2d(out_channels // expansion, out_channels, 1, 1, 0, bias=False)
      		# 输入和输出的通道数和分辨率不同时,需要添加1x1卷积层来调整通道数和分辨率
      		if in_channels != out_channels or stride != 1:
      			self.conv4 = torch.nn.Conv2d(in_channels, out_channels, 1, stride, bias=False)
      		else:
      			self.conv4 = None
      		self.bn1 = torch.nn.BatchNorm2d(out_channels // expansion)
      		self.bn2 = torch.nn.BatchNorm2d(out_channels // expansion)
      		self.bn3 = torch.nn.BatchNorm2d(out_channels)
      		self.relu = torch.nn.ReLU()
      
      	def forward(self, x):
      		y = self.relu(self.bn1(self.conv1(x)))
      		y = self.relu(self.bn2(self.conv2(y)))
      		y = self.bn3(self.conv3(y))
      		if self.conv4:
      			x = self.conv4(x)
      		y += x
      		return self.relu(y)
      

      和普通残差块类似,此代码也是生成的两种类型的网络。

  5. 构造残差网络
    image

    • ResNet 的前两层分别是:
      (1)第一层是步幅(stride)为 2,填充(padding)为 3 的 7x7 卷积层,该层会使特征图的尺寸会减半。(224 → 112)
      (2)第二层时步幅(stride)为 2,填充(padding)为 1 的 3x3 最大池化层,该层同样会使特征图的尺寸会减半。(112 → 56)

    • 接下来的 ResNet 结构则是 4 个由若干残差块组成的模块。
      ResNet18、ResNet34 的每个模块是由若干普通残差块构成的,并且每个模块的组成有以下特点:
      (1)第一个模块的第一个残差块不改变通道数与分辨率;而其他模块的第一个残差块会使通道数加倍而分辨率减半。
      (2)每一个模块非第一个残差块输入输出的通道数和分辨率相同。
      而 ResNet50、ResNet101、ResNet152 的每个模块则是由若干瓶颈残差块构成的。并且每个模块的组成有以下特点:
      (1)第一个模块的第一个残差块使输入的通道数增加至 4 倍而不改变分辨率;而其他模块的第一个残差块会使输入的通道数增加倍而分辨率减半。
      (2)每一个模块非第一个残差块输入输出的通道数和分辨率相同。

    • 最后在 ResNet 中加入自适应平均池化,以及全连接层输出。

    以下是 ResNet18 的 PyTorch 代码实现,而 ResNet34 类似:

    点击查看详情
    import torch
    
    
    class Residual(torch.nn.Module):
    	def __init__(self, in_channels, out_channels, stride=1):
    		super().__init__()
    		# 一般会在第一个卷积层增加通道数的同时减少分辨率,这样可以增强特征提取能力的同时减少运算量
    		self.conv1 = torch.nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False)
    		self.conv2 = torch.nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias=False)
    		# 输入和输出的通道数和分辨率不同时,需要添加1x1卷积层来调整通道数和分辨率
    		if in_channels != out_channels or stride != 1:
    			self.conv3 = torch.nn.Conv2d(in_channels, out_channels, 1, stride, bias=False)
    		else:
    			self.conv3 = None
    		self.bn1 = torch.nn.BatchNorm2d(out_channels)
    		self.bn2 = torch.nn.BatchNorm2d(out_channels)
    		self.relu = torch.nn.ReLU()
    
    	def forward(self, x):
    		y = self.relu(self.bn1(self.conv1(x)))
    		y = self.bn2(self.conv2(y))
    		if self.conv3:
    			x = self.conv3(x)
    		y += x
    		return self.relu(y)
    
    
    def make_layer(in_channels, out_channels, num_blocks, first_layer=False):
    	blocks = []
    	for i in range(num_blocks):
    		if i == 0 and not first_layer:
    			blocks.append(Residual(in_channels, out_channels, 2))
    		else:
    			blocks.append(Residual(out_channels, out_channels))
    	return torch.nn.Sequential(*blocks)
    
    
    class ResNet18(torch.nn.Module):
    	def __init__(self, in_channels, num_classes=10):
    		super().__init__()
    		self.conv1 = torch.nn.Conv2d(in_channels, 64, 7, 2, 3, bias=False)
    		self.bn1 = torch.nn.BatchNorm2d(64)
    		self.relu = torch.nn.ReLU()
    		self.maxpool = torch.nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
    
    		self.layer1 = make_layer(64, 64, 2, first_layer=True)
    		self.layer2 = make_layer(64, 128, 2)
    		self.layer3 = make_layer(128, 256, 2)
    		self.layer4 = make_layer(256, 512, 2)
    		self.avgpool = torch.nn.AdaptiveAvgPool2d((1, 1))
    		self.fc = torch.nn.Linear(512, num_classes)
    
    	def forward(self, x):
    		x = self.relu(self.bn1(self.conv1(x)))  # 224 -> 112
    		x = self.maxpool(x)  # 112 -> 56
    		x = self.layer1(x)  # 56 -> 56
    		x = self.layer2(x)  # 56 -> 28
    		x = self.layer3(x)  # 28 -> 14
    		x = self.layer4(x)  # 14 -> 7
    		x = self.avgpool(x)  # 7 -> 1
    		x = torch.flatten(x, 1)
    		return self.fc(x)
    

    以下是 ResNet50 的 PyTorch 代码实现,而 ResNet101、ResNet152 类似:

    点击查看详情
    import torch
    
    
    class Bottleneck(torch.nn.Module):
    
    	def __init__(self, in_channels, out_channels, stride=1, expansion=4):
    		super().__init__()
    		self.conv1 = torch.nn.Conv2d(in_channels, out_channels // expansion, 1, 1, 0, bias=False)
    		self.conv2 = torch.nn.Conv2d(out_channels // expansion, out_channels // expansion, 3, stride, 1, bias=False)
    		self.conv3 = torch.nn.Conv2d(out_channels // expansion, out_channels, 1, 1, 0, bias=False)
    		# 输入和输出的通道数和分辨率不同时,需要添加1x1卷积层来调整通道数和分辨率
    		if in_channels != out_channels or stride != 1:
    			self.conv4 = torch.nn.Conv2d(in_channels, out_channels, 1, stride, bias=False)
    		else:
    			self.conv4 = None
    		self.bn1 = torch.nn.BatchNorm2d(out_channels // expansion)
    		self.bn2 = torch.nn.BatchNorm2d(out_channels // expansion)
    		self.bn3 = torch.nn.BatchNorm2d(out_channels)
    		self.relu = torch.nn.ReLU()
    
    	def forward(self, x):
    		y = self.relu(self.bn1(self.conv1(x)))
    		y = self.relu(self.bn2(self.conv2(y)))
    		y = self.bn3(self.conv3(y))
    		if self.conv4:
    			x = self.conv4(x)
    		y += x
    		return self.relu(y)
    
    
    def make_layer(in_channels, out_channels, num_blocks, first_layer=False):
    	blocks = []
    	for i in range(num_blocks):
    		if i == 0:
    			if first_layer:
    				blocks.append(Bottleneck(in_channels, out_channels))
    			else:
    				blocks.append(Bottleneck(in_channels, out_channels, stride=2))
    		else:
    			blocks.append(Bottleneck(out_channels, out_channels))
    	return torch.nn.Sequential(*blocks)
    
    
    class ResNet50(torch.nn.Module):
    	def __init__(self, in_channels, num_classes=10):
    		super().__init__()
    		self.conv1 = torch.nn.Conv2d(in_channels, 64, 7, 2, 3, bias=False)
    		self.bn1 = torch.nn.BatchNorm2d(64)
    		self.relu = torch.nn.ReLU()
    		self.maxpool = torch.nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
    
    		self.layer1 = make_layer(64, 256, 3, first_layer=True)
    		self.layer2 = make_layer(256, 512, 4)
    		self.layer3 = make_layer(512, 1024, 6)
    		self.layer4 = make_layer(1024, 2048, 3)
    		self.avgpool = torch.nn.AdaptiveAvgPool2d((1, 1))
    		self.fc = torch.nn.Linear(2048, num_classes)
    
    	def forward(self, x):
    		x = self.relu(self.bn1(self.conv1(x)))  # 224 -> 112
    		x = self.maxpool(x)  # 112 -> 56
    		x = self.layer1(x)  # 56 -> 56
    		x = self.layer2(x)  # 56 -> 28
    		x = self.layer3(x)  # 28 -> 14
    		x = self.layer4(x)  # 14 -> 7
    		x = self.avgpool(x)  # 7 -> 1
    		x = torch.flatten(x, 1)
    		return self.fc(x)
    
  6. 测试残差网络的效果
    使用 FashionMNIST 数据集测试 ResNet18 的效果,PyTorch 代码如下:

    点击查看代码
    import torch
    import torchvision
    import tqdm
    from torchvision.datasets import FashionMNIST
    from torch.utils.data import DataLoader
    from torchvision import transforms
    from torch.utils.tensorboard import SummaryWriter
    
    log_dir = 'log'
    # tensorboard --logdir=<log_dir>
    summary_writer = SummaryWriter(log_dir)
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    net = ResNet18(1, 10).to(device)
    batch_size = 256
    num_epochs = 10
    lr = 0.05
    loss_fn = torch.nn.CrossEntropyLoss().to(device)
    optimizer = torch.optim.Adam(net.parameters(), lr=lr)
    # 数据预处理
    transform = torchvision.transforms.Compose([
        transforms.Resize((224, 224)),
        transforms.ToTensor(),
        transforms.Normalize((0.5,), (0.5,))
    ])
    # 加载数据集
    train_dataset = FashionMNIST(root='data', train=True, download=True, transform=transform)
    test_dataset = FashionMNIST(root='data', train=False, download=True, transform=transform)
    train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
    test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
    for epoch in range(1, num_epochs + 1):
        train_total_loss = 0
        train_total_acc = 0
        net.train()
        for images, labels in tqdm.tqdm(train_loader, total=len(train_loader),
                                        desc=f'Training Epoch {epoch}/{num_epochs}', delay=0.1):
            images = images.to(device)
            labels = labels.to(device)
            outputs = net(images)
            loss = loss_fn(outputs, labels)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            train_total_loss += loss.item()
            train_total_acc += (outputs.argmax(dim=1) == labels).float().sum().item()
        train_avg_loss = train_total_loss / len(train_loader)
        train_avg_acc = train_total_acc / len(train_loader.dataset)
        print(f'Epoch {epoch}/{num_epochs}, Train Loss: {train_avg_loss:.4f}, Train Acc: {train_avg_acc:.4f}')
    
        test_total_loss = 0
        test_total_acc = 0
        net.eval()
        with torch.no_grad():
            for images, labels in tqdm.tqdm(test_loader, total=len(test_loader),
                                            desc=f'Testing Epoch {epoch}/{num_epochs}', delay=0.1):
                images = images.to(device)
                labels = labels.to(device)
                outputs = net(images)
                loss = loss_fn(outputs, labels)
                test_total_loss += loss.item()
                test_total_acc += (outputs.argmax(dim=1) == labels).float().sum().item()
        test_avg_loss = test_total_loss / len(test_loader)
        test_avg_acc = test_total_acc / len(test_loader.dataset)
        print(f'Epoch {epoch}/{num_epochs}, Test Loss: {test_avg_loss:.4f}, Test Acc: {test_avg_acc:.4f}')
    
        summary_writer.add_scalars('Loss', {'Train': train_avg_loss}, epoch)
        summary_writer.add_scalars('Loss', {'Test': test_avg_loss}, epoch)
        summary_writer.add_scalars('Acc', {'Train': train_avg_acc}, epoch)
        summary_writer.add_scalars('Acc', {'Test': test_avg_acc}, epoch)
    summary_writer.close()
    

    测试效果如下图所示:
    image
    image

6. PyTorch 模型打包

  1. 保存和加载模型的参数
    在 PyTorch 中,一个 torch.nn.Module 模型的可学习参数(即权重和偏置)可以通过 model.parameters() 访问。

    model.parameters() 返回的是参数张量的迭代器对象。

    state_dict 是一个 Python 字典对象,它将模型中的每一层(层的名称)映射到其对应的参数张量(权重和偏置)。需要注意的是,只有具有可学习参数的层(卷积层、线性层等)和注册的缓冲区(如 BN 的 running_mean)才会在模型的 state_dict 中有条目。并且优化器对象(torch.optim)也有一个 state_dict,其中包含有关优化器状态的信息以及使用的超参数。

    由于 state_dict 对象是 Python 字典,因此它们可以轻松地保存、更新、修改和恢复,为 PyTorch 模型和优化器增加了很大的灵活性,因此这是推荐的保存模型的方式。查看模型和优化器的 state_dict:

    from torchvision import models
    import torch
    
    model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
    print("Model's state_dict:")
    for param_tensor in model.state_dict():
    	print(param_tensor, "\t", model.state_dict()[param_tensor].size())
    
    optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
    print("Optimizer's state_dict:")
    for var_name in optimizer.state_dict():
    	print(var_name, "\t", optimizer.state_dict()[var_name])
    
    点击查看结果
    Model's state_dict:
    conv1.weight 	 torch.Size([64, 3, 7, 7])
    bn1.weight 	 torch.Size([64])
    bn1.bias 	 torch.Size([64])
    bn1.running_mean 	 torch.Size([64])
    bn1.running_var 	 torch.Size([64])
    bn1.num_batches_tracked 	 torch.Size([])
    layer1.0.conv1.weight 	 torch.Size([64, 64, 3, 3])
    layer1.0.bn1.weight 	 torch.Size([64])
    layer1.0.bn1.bias 	 torch.Size([64])
    layer1.0.bn1.running_mean 	 torch.Size([64])
    layer1.0.bn1.running_var 	 torch.Size([64])
    layer1.0.bn1.num_batches_tracked 	 torch.Size([])
    layer1.0.conv2.weight 	 torch.Size([64, 64, 3, 3])
    layer1.0.bn2.weight 	 torch.Size([64])
    layer1.0.bn2.bias 	 torch.Size([64])
    layer1.0.bn2.running_mean 	 torch.Size([64])
    layer1.0.bn2.running_var 	 torch.Size([64])
    layer1.0.bn2.num_batches_tracked 	 torch.Size([])
    layer1.1.conv1.weight 	 torch.Size([64, 64, 3, 3])
    layer1.1.bn1.weight 	 torch.Size([64])
    layer1.1.bn1.bias 	 torch.Size([64])
    layer1.1.bn1.running_mean 	 torch.Size([64])
    layer1.1.bn1.running_var 	 torch.Size([64])
    layer1.1.bn1.num_batches_tracked 	 torch.Size([])
    layer1.1.conv2.weight 	 torch.Size([64, 64, 3, 3])
    layer1.1.bn2.weight 	 torch.Size([64])
    layer1.1.bn2.bias 	 torch.Size([64])
    layer1.1.bn2.running_mean 	 torch.Size([64])
    layer1.1.bn2.running_var 	 torch.Size([64])
    layer1.1.bn2.num_batches_tracked 	 torch.Size([])
    layer2.0.conv1.weight 	 torch.Size([128, 64, 3, 3])
    layer2.0.bn1.weight 	 torch.Size([128])
    layer2.0.bn1.bias 	 torch.Size([128])
    layer2.0.bn1.running_mean 	 torch.Size([128])
    layer2.0.bn1.running_var 	 torch.Size([128])
    layer2.0.bn1.num_batches_tracked 	 torch.Size([])
    layer2.0.conv2.weight 	 torch.Size([128, 128, 3, 3])
    layer2.0.bn2.weight 	 torch.Size([128])
    layer2.0.bn2.bias 	 torch.Size([128])
    layer2.0.bn2.running_mean 	 torch.Size([128])
    layer2.0.bn2.running_var 	 torch.Size([128])
    layer2.0.bn2.num_batches_tracked 	 torch.Size([])
    layer2.0.downsample.0.weight 	 torch.Size([128, 64, 1, 1])
    layer2.0.downsample.1.weight 	 torch.Size([128])
    layer2.0.downsample.1.bias 	 torch.Size([128])
    layer2.0.downsample.1.running_mean 	 torch.Size([128])
    layer2.0.downsample.1.running_var 	 torch.Size([128])
    layer2.0.downsample.1.num_batches_tracked 	 torch.Size([])
    layer2.1.conv1.weight 	 torch.Size([128, 128, 3, 3])
    layer2.1.bn1.weight 	 torch.Size([128])
    layer2.1.bn1.bias 	 torch.Size([128])
    layer2.1.bn1.running_mean 	 torch.Size([128])
    layer2.1.bn1.running_var 	 torch.Size([128])
    layer2.1.bn1.num_batches_tracked 	 torch.Size([])
    layer2.1.conv2.weight 	 torch.Size([128, 128, 3, 3])
    layer2.1.bn2.weight 	 torch.Size([128])
    layer2.1.bn2.bias 	 torch.Size([128])
    layer2.1.bn2.running_mean 	 torch.Size([128])
    layer2.1.bn2.running_var 	 torch.Size([128])
    layer2.1.bn2.num_batches_tracked 	 torch.Size([])
    layer3.0.conv1.weight 	 torch.Size([256, 128, 3, 3])
    layer3.0.bn1.weight 	 torch.Size([256])
    layer3.0.bn1.bias 	 torch.Size([256])
    layer3.0.bn1.running_mean 	 torch.Size([256])
    layer3.0.bn1.running_var 	 torch.Size([256])
    layer3.0.bn1.num_batches_tracked 	 torch.Size([])
    layer3.0.conv2.weight 	 torch.Size([256, 256, 3, 3])
    layer3.0.bn2.weight 	 torch.Size([256])
    layer3.0.bn2.bias 	 torch.Size([256])
    layer3.0.bn2.running_mean 	 torch.Size([256])
    layer3.0.bn2.running_var 	 torch.Size([256])
    layer3.0.bn2.num_batches_tracked 	 torch.Size([])
    layer3.0.downsample.0.weight 	 torch.Size([256, 128, 1, 1])
    layer3.0.downsample.1.weight 	 torch.Size([256])
    layer3.0.downsample.1.bias 	 torch.Size([256])
    layer3.0.downsample.1.running_mean 	 torch.Size([256])
    layer3.0.downsample.1.running_var 	 torch.Size([256])
    layer3.0.downsample.1.num_batches_tracked 	 torch.Size([])
    layer3.1.conv1.weight 	 torch.Size([256, 256, 3, 3])
    layer3.1.bn1.weight 	 torch.Size([256])
    layer3.1.bn1.bias 	 torch.Size([256])
    layer3.1.bn1.running_mean 	 torch.Size([256])
    layer3.1.bn1.running_var 	 torch.Size([256])
    layer3.1.bn1.num_batches_tracked 	 torch.Size([])
    layer3.1.conv2.weight 	 torch.Size([256, 256, 3, 3])
    layer3.1.bn2.weight 	 torch.Size([256])
    layer3.1.bn2.bias 	 torch.Size([256])
    layer3.1.bn2.running_mean 	 torch.Size([256])
    layer3.1.bn2.running_var 	 torch.Size([256])
    layer3.1.bn2.num_batches_tracked 	 torch.Size([])
    layer4.0.conv1.weight 	 torch.Size([512, 256, 3, 3])
    layer4.0.bn1.weight 	 torch.Size([512])
    layer4.0.bn1.bias 	 torch.Size([512])
    layer4.0.bn1.running_mean 	 torch.Size([512])
    layer4.0.bn1.running_var 	 torch.Size([512])
    layer4.0.bn1.num_batches_tracked 	 torch.Size([])
    layer4.0.conv2.weight 	 torch.Size([512, 512, 3, 3])
    layer4.0.bn2.weight 	 torch.Size([512])
    layer4.0.bn2.bias 	 torch.Size([512])
    layer4.0.bn2.running_mean 	 torch.Size([512])
    layer4.0.bn2.running_var 	 torch.Size([512])
    layer4.0.bn2.num_batches_tracked 	 torch.Size([])
    layer4.0.downsample.0.weight 	 torch.Size([512, 256, 1, 1])
    layer4.0.downsample.1.weight 	 torch.Size([512])
    layer4.0.downsample.1.bias 	 torch.Size([512])
    layer4.0.downsample.1.running_mean 	 torch.Size([512])
    layer4.0.downsample.1.running_var 	 torch.Size([512])
    layer4.0.downsample.1.num_batches_tracked 	 torch.Size([])
    layer4.1.conv1.weight 	 torch.Size([512, 512, 3, 3])
    layer4.1.bn1.weight 	 torch.Size([512])
    layer4.1.bn1.bias 	 torch.Size([512])
    layer4.1.bn1.running_mean 	 torch.Size([512])
    layer4.1.bn1.running_var 	 torch.Size([512])
    layer4.1.bn1.num_batches_tracked 	 torch.Size([])
    layer4.1.conv2.weight 	 torch.Size([512, 512, 3, 3])
    layer4.1.bn2.weight 	 torch.Size([512])
    layer4.1.bn2.bias 	 torch.Size([512])
    layer4.1.bn2.running_mean 	 torch.Size([512])
    layer4.1.bn2.running_var 	 torch.Size([512])
    layer4.1.bn2.num_batches_tracked 	 torch.Size([])
    fc.weight 	 torch.Size([1000, 512])
    fc.bias 	 torch.Size([1000])
    Optimizer's state_dict:
    state 	 {}
    param_groups 	 [{'lr': 0.001, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'maximize': False, 'foreach': None, 'differentiable': False, 'params': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61]}]
    
    • 保存模型 state_dict 的方法如下:

      torch.save(model.state_dict(), "best.pth")
      

      一个常见的 PyTorch 约定是使用 .pt 或 .pth 文件扩展名保存权重文件。

    • 加载模型 state_dict 的方法如下:

      model = models.resnet18()
      model.load_state_dict(torch.load("best.pth"))
      

      请记住,在运行推理之前,必须调用 model.eval() 将模型的 dropout 和 batch normalization 层设置为评估模式。如果不这样做,会导致推理结果不一致。

    如果只打算保留最佳性能的模型(根据验证集的损失获得),请不要忘记 best_model_state = model.state_dict() 返回的是对状态的引用,而不是它的副本!你必须序列化 best_model_state 或使用 best_model_state = deepcopy(model.state_dict()),否则你最好的 best_model_state 将在后续的训练迭代中不断更新。

  2. 保存和加载整个模型

    • 保存整个模型的方式如下:

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

      一个常见的 PyTorch 约定是使用 .pt 或 .pth 文件扩展名保存模型文件。

    • 加载整个模型的方式如下:

      model = torch.load("model.pth")
      model.eval()
      

      在运行推理之前,必须调用 model.eval() 将模型的 dropout 和 batch normalization 层设置为评估模式。如果不这样做,会导致推理结果不一致。

    这种保存和加载模型的方法非常直观,所需的代码量很少,因此使用起来很方便。这种方法通过 Python 的 pickle 模块保存整个模型,包括模型的结构和参数。这种方法的缺点在于,序列化的数据(即保存的模型)依赖于保存时所使用的特定类和目录结构。

    pickle 模块在保存模型时,并不会保存模型类本身,而是保存一个路径,这个路径指向包含该类的文件。当你在加载模型时,pickle 会使用这个路径来找到并加载模型类。因此,如果你的项目结构发生变化(例如重构代码或在其他项目中使用该模型),可能会出现各种问题,因为路径和类可能找不到了。

  3. 以 TorchScript 格式保存和加载模型
    一种常见的使用训练模型进行推理的方法是使用 TorchScript。TorchScript 是 PyTorch 模型的一种中间表示形式,可以在 Python 中运行,也可以在 C++ 等高性能环境中运行。实际上,TorchScript 是推荐的用于大规模推理和部署的模型格式。并且使用 TorchScript 格式,您可以加载导出的模型并进行推理,而无需定义模型类。

    可以使用 torch.jit.script 或 torch.jit.trace 两种方式将模型转换为 TorchScript:

    • torch.jit.script 支持复杂的动态控制流(如条件分支和循环),可以全面覆盖模型的所有逻辑,适用于需要精确保留原始模型逻辑的情况。然而,它不支持动态特性、高级 Python 特性以及某些 Python 内置函数,限制了其在某些场景下的适用性。所以可能需要对原始模型代码进行修改和调试,以确保符合 TorchScript 的要求。

      scripted_model = torch.jit.script(model)
      # 保存
      scripted_model.save("scripted_model.pt")
      # 加载
      scripted_model = torch.jit.load("scripted_model.pt")
      scripted_model.eval()
      
    • torch.jit.trace 的转换过程简单直接,只需提供一个示例输入即可生成 TorchScript 模型,适合没有动态控制流的简单模型,且转换速度较快。然而,它受限于输入数据的形状和类型,无法处理模型中的动态控制流,所有分支和循环必须在转换时固定。此外,trace 依赖于提供的示例输入,如果模型行为依赖于输入数据的变化,跟踪结果可能不准确。

      traced_model = torch.jit.trace(model, torch.rand(1, 3, 224, 224))
      # 保存
      traced_model.save("traced_model.pt")
      # 加载
      traced_model = torch.jit.load("traced_model.pt")
      traced_model.eval()
      

    请记住,在运行推理之前,必须调用 model.eval() 将模型的 dropout 和 batch normalization 层设置为评估模式。如果不这样做,会导致推理结果不一致。

  4. 保存和加载用于推理和或恢复训练的检查点(Checkpoint)

    在保存一个通用的检查点以供推理或继续训练时,通常需要保存以下这些内容:

    • 模型的 state_dict
    • 优化器的 state_dict
    • 中断时的 epoch
    • 最新记录的训练损失 loss
    • 其他

    因此,这样的检查点通常比仅保存模型要大 2~3 倍。为了保存多个组件,可以将它们组织在一个字典中,并使用 torch.save() 序列化这个字典。一个常见的 PyTorch 约定是使用 .tar 文件扩展名保存这些检查点。具体使用方法如下:

    	import torch
    	from torchvision import models
    
    	model = models.resnet18()
    	optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
    	checkpoint = {
    		'model': model.state_dict(),
    		'optimizer': optimizer.state_dict(),
    		'epoch': 10,
    		'loss': 0.5
    	}
    
    	torch.save(checkpoint, 'checkpoint.tar')
    

    创建的字典的 key 是自己定义的,你可以根据需要和个人偏好选择合适的键名称,以便清晰地表示你保存的内容。这些键的名称应具有描述性,以便在加载检查点时能够清楚地知道每个键对应的内容。

    要加载检查点中的项,首先要初始化模型和优化器,然后使用 torch.load() 本地加载字典。接下来,可以像访问普通字典一样轻松访问保存的项目。具体使用方法如下:

    model = models.resnet18()
    optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
    
    checkpoint = torch.load('checkpoint.tar')
    model.load_state_dict(checkpoint['model'])
    optimizer.load_state_dict(checkpoint['optimizer'])
    epoch = checkpoint['epoch']
    loss = checkpoint['loss']
    
    model.eval()
    

    请记住,在运行推理之前,必须调用 model.eval() 将模型的 dropout 和 batch normalization 层设置为评估模式。如果不这样做,会导致推理结果不一致。如果你希望继续训练,则需要调用 model.train() 确保这些层处于训练模式。

  5. 将多个模型保存在一个文件中
    当保存由多个 torch.nn.Module 组成的模型时,采用与保存检查点相同的方法。也就是说,保存每个模型的 state_dict 及其对应优化器的字典。正如前面提到的,你可以通过简单地将其他有助于恢复训练的项目附加到字典中来保存它们。例如:

    checkpoint = torch.load('checkpoint.tar')
    model1 = checkpoint['model1']
    model2 = checkpoint['model2']
    model1.eval()
    model2.eval()
    

    请记住,在运行推理之前,必须调用 model.eval() 将模型的 dropout 和 batch normalization 层设置为评估模式。如果不这样做,会导致推理结果不一致。如果你希望继续训练,则需要调用 model.train() 确保这些层处于训练模式。

  6. 使用不同模型的参数热启动
    在迁移学习或训练新的复杂模型时,部分加载模型或加载部分模型是常见的情况。即使只有一部分可用的训练参数,也有助于热启动训练过程,使得比从头开始训练更快地收敛。 具体操作如下:

    modelB.load_state_dict(torch.load("modelA_weigth.pth"), strict=False)
    

    无论是从缺少一些键的部分 state_dict 加载,还是从具有比加载模型更多键的 state_dict 加载,你都可以在 load_state_dict() 函数中将 strict 参数设置为 False 以忽略不匹配的键。
    如果你希望将参数从一个层加载到另一个层,但某些键不匹配,只需在加载的 state_dict 中更改参数键的名称以匹配要加载到的模型中的键即可。

    模型预训练权重自动下载并缓存的默认目录因操作系统而异:
    (1)Linux 和 MacOS:~/.cache/torch/hub/checkpoints
    (2)Windows:C:\Users\<username>\.cache\torch\hub\checkpoints

  7. 跨设备加载模型
    当你需要在不同设备(如 CPU 和 GPU)之间加载模型时,有一些关键步骤需要注意。以下是详细说明:

    • 在 CPU 上加载使用 GPU 训练的模型
      如果你的模型是在 GPU 上训练的,但需要在 CPU 上加载和运行,可以使用 torch.load() 函数的 map_location 参数,将模型映射到 CPU 设备。具体代码如下:

      device = torch.device('cpu')
      model.load_state_dict(torch.load("gpu_weight.pt", map_location=device))
      

      在这种情况下,张量底层的存储将根据 map_location 参数动态映射到 CPU 设备上。

    • 在 GPU 上加载使用 CPU 训练的模型
      如果你的模型是在 CPU 上训练的,但需要在 GPU 上加载和运行,同样使用 torch.load() 函数的 map_location 参数,将模型映射到 GPU 设备。具体代码如下:

      device = torch.device('cuda')
      model.load_state_dict(torch.load("gpu_weight.pt", map_location=device))
      
    • 在 GPU 上加载使用 GPU 训练的模型
      如果你的模型是在GPU上训练并保存的,并且你希望在GPU上加载和运行,只需使用将 model 初始化为 CUDA 类型即可。具体代码如下:

      device = torch.device('cuda')
      model = Model().to(device)
      model.load_state_dict(torch.load("gpu_weight.pt"))
      
  8. 保存 torch.nn.DataParallel 模型
    torch.nn.DataParallel 是一个模型包装器,使模型能够并行利用多个 GPU。保存 DataParallel 模型的参数可以使用以下方式:

    torch.save(model.module.state_dict(), "weight,pt")
    

    model.module 的作用是直接访问被封装的原模型。

    由于 DataParallel 封装的缘故,model.state_dict() 获取到的 state_dict 的 key 会多一个 module. 的前缀。

7. 将非线性问题转换成线性问题的方法

在神经网络中引入激活函数的主要目的是通过引入非线性,使神经网络能够学习和表示复杂的非线性关系,从而增强模型的表达能力和适应能力。那么不添加激活函数就不能解决非线性问题了吗?
其实不然,这是因为可以通过升维的方式,将数据从低维空间映射到高维空间,使得原本线性不可分的数据在新空间中变得线性可分,如下图所示:
image

当输入数据本身具有足够高的维度时,不同类别的数据可能在高维空间中已经具备一定的线性可分性。这种情况下,即使仅使用线性变换(全连接层或卷积层),也有可能实现分类任务。例如,MNIST数据集仅使用一个卷积层,在原始 784 维特征空间中,不同数字的样本已经有一定的分离度。

MNIST 是典型的非线性问题,因为手写数字的边缘、角点等复杂特征在像素级别上展现出多样性和非线性分布。

案例代码如下:

点击查看代码
import torch
import torchvision
import tqdm
from torch.utils.data import DataLoader
from torch.utils.tensorboard import SummaryWriter
from torchvision import transforms
from torchvision.datasets import MNIST


class Net(torch.nn.Module):
    def __init__(self, in_features, num_classes):
        super().__init__()
        self.layer = torch.nn.Linear(in_features, num_classes)

    def forward(self, x):
        x = torch.flatten(x, 1)
        x = self.layer(x)
        return x


if __name__ == '__main__':
    log_dir = 'log'
    # tensorboard --logdir=<log_dir>
    summary_writer = SummaryWriter(log_dir)
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    net = Net(784, 10).to(device)
    batch_size = 256
    num_epochs = 10
    lr = 0.05
    loss_fn = torch.nn.CrossEntropyLoss().to(device)
    optimizer = torch.optim.Adam(net.parameters(), lr=lr)
    # 数据预处理
    transform = torchvision.transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.1307,), (0.3081,))
    ])
    # 加载数据集
    train_dataset = MNIST(root='data', train=True, download=True, transform=transform)
    test_dataset = MNIST(root='data', train=False, download=True, transform=transform)
    train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
    test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
    for epoch in range(1, num_epochs + 1):
        train_total_loss = 0
        train_total_acc = 0
        net.train()
        for images, labels in tqdm.tqdm(train_loader, total=len(train_loader),
                                        desc=f'Training Epoch {epoch}/{num_epochs}', delay=0.1):
            images = images.to(device)
            labels = labels.to(device)
            outputs = net(images)
            loss = loss_fn(outputs, labels)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            train_total_loss += loss.item()
            train_total_acc += (outputs.argmax(dim=1) == labels).float().sum().item()
        train_avg_loss = train_total_loss / len(train_loader)
        train_avg_acc = train_total_acc / len(train_loader.dataset)
        print(f'Epoch {epoch}/{num_epochs}, Train Loss: {train_avg_loss:.4f}, Train Acc: {train_avg_acc:.4f}')

        test_total_loss = 0
        test_total_acc = 0
        net.eval()
        with torch.no_grad():
            for images, labels in tqdm.tqdm(test_loader, total=len(test_loader),
                                            desc=f'Testing Epoch {epoch}/{num_epochs}', delay=0.1):
                images = images.to(device)
                labels = labels.to(device)
                outputs = net(images)
                loss = loss_fn(outputs, labels)
                test_total_loss += loss.item()
                test_total_acc += (outputs.argmax(dim=1) == labels).float().sum().item()
        test_avg_loss = test_total_loss / len(test_loader)
        test_avg_acc = test_total_acc / len(test_loader.dataset)
        print(f'Epoch {epoch}/{num_epochs}, Test Loss: {test_avg_loss:.4f}, Test Acc: {test_avg_acc:.4f}')

        summary_writer.add_scalars('Loss', {'Train': train_avg_loss}, epoch)
        summary_writer.add_scalars('Loss', {'Test': test_avg_loss}, epoch)
        summary_writer.add_scalars('Acc', {'Train': train_avg_acc}, epoch)
        summary_writer.add_scalars('Acc', {'Test': test_avg_acc}, epoch)
    summary_writer.close()

image
image

在这个例子中,我们可能会发现,即使没有激活函数的深层网络可以解决非线性问题,但需要更高的维度和更多的层数来达到相同的效果,这显然不是一种高效或实用的方法。

全连接层的特征空间的维度体现在每层神经元的个数上;而卷积层特征空间的维度主要体现在特征图的通道数上。

8. 常见的激活函数总结

激活函数的作用是在神经网络中引入非线性,使网络能够学习和表示复杂的模式和关系(即非线性函数)。若没有激活函数,神经网络就只是线性变换的组合,整个网络将退化为一个简单的线性模型,而无法处理复杂的数据模式和关系。

  1. Sigmoid 函数
    Sigmoid 函数是一种常用的激活函数,特别是在早期的神经网络和逻辑回归模型中。它的定义如下:

    \[f(x)=\frac{1}{1+e^{-x}} \]

    Sigmoid 函数的导函数为:

    \[f^{\prime}(x)=\frac{e^{-x}}{\left(1+e^{-x}\right)^2}=f(x)\cdot (1-f(x)) \]

    以下是 Sigmoid 函数及其导函数的代码实现,并通过绘图工具将其可视化,以便理解其形状和特点:

    点击查看代码
    import numpy as np
    
    
    def sigmoid(x):
    	return 1 / (1 + np.exp(-x))
    
    
    def d_sigmoid(x):
    	return sigmoid(x) * (1 - sigmoid(x))
    
    
    if __name__ == '__main__':
    	import matplotlib.pyplot as plt
    
    	x = np.linspace(-10, 10, 1000)
    	y = sigmoid(x)
    	dy = d_sigmoid(x)
    	plt.plot(x, y)
    	plt.plot(x, dy)
    	plt.title('Sigmoid & Derivative of Sigmoid Function')
    	plt.xlabel('x')
    	plt.ylabel('y')
    	plt.legend(['Sigmoid', 'Derivative of Sigmoid'])
    	plt.grid()
    	plt.show()
    

    image

    Sigmoid 函数的主要特点和作用有:

    • 输出范围在 0 到 1 之间:Sigmoid 函数的输出范围在 (0, 1) 之间,这使得它非常适合用于二分类问题中的概率预测,输出可以直接解释为某一类别的概率。

    Sigmoid 函数的主要缺点有:

    • 梯度消失:当输入值的绝对值较大时,Sigmoid 函数的输出会趋近于 0 或 1,此时梯度接近于零。在反向传播过程中,这会导致梯度逐层减小,使得前几层的参数更新非常缓慢,影响深层网络的训练效果。
    • 非零中心化输出:Sigmoid 函数输出的平均值约为 0.5 而不是 0,这种非零中心化的输出使得其传递给下一层的神经元的输入恒为正,这会影响反向传播过程中参数的更新,使所有参数同增或同减,从而导致模型参数的更新方向趋于一致,难以实现不同参数间的协调调整,最终导致网络收敛速度变慢,需要更多的迭代轮次才能达到最优解。

      非零中心化输出问题的原理可以参考:https://liam.page/2018/04/17/zero-centered-active-function/

  2. Tanh 函数
    Tanh(双曲正切)函数也是一种常用的激活函数。它的定义如下:

    \[f(x)=\frac{e^x-e^{-x}}{e^x+e^{-x}} \]

    Tanh 函数的导函数为:

    \[f^{\prime}(x)=1-f (x)^2 \]

    以下是 Tanh 函数及其导函数的代码实现,并通过绘图工具将其可视化:

    点击查看代码
    import numpy as np
    
    
    def tanh(x):
    	return np.tanh(x)
    
    
    def d_tanh(x):
    	return 1 - np.tanh(x) ** 2
    
    
    if __name__ == '__main__':
    	import matplotlib.pyplot as plt
    
    	x = np.linspace(-10, 10, 1000)
    	y = tanh(x)
    	dy = d_tanh(x)
    	plt.plot(x, y)
    	plt.plot(x, dy)
    	plt.title('Tanh & Derivative of Tanh Function')
    	plt.xlabel('x')
    	plt.ylabel('y')
    	plt.legend(['Tanh', 'Derivative of Tanh'])
    	plt.grid()
    	plt.show()
    

    image
    Tanh 函数的主要特点和作用:

    • 输出范围在 -1 到 1 之间:Tanh 函数的输出范围在 (-1, 1) 之间,这使得其输出更接近于零中心,减少了非零中心化输出的问题。
    • 更高的梯度值:在输入值接近于零时,Tanh 函数的梯度值较大,可以一定程度上缓解梯度消失问题。

    Tanh 函数的主要缺点:

    • 梯度消失:尽管比 Sigmoid 函数有所改进,但 Tanh 函数在输入值的绝对值较大时,同样会导致梯度消失问题。
  3. ReLU 函数
    ReLU(Rectified Linear Unit,修正线性单元)函数是目前最常用的激活函数之一,特别是在深度神经网络中。它的定义如下:

    \[f(x)=\max{(0,x)} \]

    ReLU 函数的导函数为:

    \[f^{\prime}(x)=\begin{cases}1, & x > 0 \\ 0, & x\leq0\end{cases} \]

    以下是 ReLU 函数及其导函数的代码实现,并通过绘图工具将其可视化,以便理解其形状和特点:

    点击查看代码
    import numpy as np
    
    
    def relu(x):
    	return np.maximum(x, 0)
    
    
    def d_relu(x):
    	return np.where(x > 0, 1, 0)
    
    
    if __name__ == '__main__':
    	import matplotlib.pyplot as plt
    
    	x = np.linspace(-10, 10, 1000)
    	y = relu(x)
    	dy = d_relu(x)
    	plt.plot(x, y)
    	plt.plot(x, dy)
    	plt.title('ReLU & Derivative of ReLU Function')
    	plt.xlabel('x')
    	plt.ylabel('y')
    	plt.legend(['ReLU', 'Derivative of ReLU'])
    	plt.grid()
    	plt.show()
    

    image

    ReLU 函数的主要特点和作用:

    • 避免梯度消失:ReLU 函数在输入值大于零时,梯度恒为 1,这有效地避免了梯度消失问题,使得深层网络的训练变得更加高效。
    • 计算简单:ReLU 函数的计算非常简单,只需要判断输入值是否大于零,选择较大的值输出即可。因此,它的计算效率较高,适合用于深度神经网络中。
    • 稀疏激活:由于 ReLU 函数会将小于零的输入值全部置为零,因此在训练过程中,部分神经元会被“关闭”,这有助于模型的稀疏表示,提高模型的训练效率和泛化能力。

    ReLU 函数的主要缺点:

    • 死亡 ReLU:在训练过程中,如果神经元的输入值总是小于零,那么这些神经元的输出也将一直为零,从而导致它们的梯度也一直为零,这些神经元将无法更新参数,最终变得“死掉”,这会影响模型的表现。
    • 未对输出归一化:ReLU 函数的输出范围是 (0, +∞),这可能导致某些神经元的输出值过大,从而影响模型的训练稳定性。
  4. Leaky ReLU 函数
    Leaky ReLU(Leaky Rectified Linear Unit,泄露修正线性单元)函数是一种对 ReLU 改进了的激活函数。它的定义如下:

    \[f(x)=\max{(\alpha x,x)} \]

    ReLU 函数的导函数为:

    \[f^{\prime}(x)=\begin{cases}1, & x > 0 \\ \alpha, & x\leq0\end{cases} \]

    以下是 Leaky ReLU 函数及其导函数的代码实现,并通过绘图工具将其可视化,以便理解其形状和特点:

    Leaky ReLU 函数的斜率参数 \(\alpha\) 一般取值为 0.01,此处为了可视化明显,取为了 0.1。

    点击查看代码
    import numpy as np
    
    alpha = 0.1
    
    
    def leaky_relu(x):
    	return np.where(x > 0, x, alpha * x)
    
    
    def d_leaky_relu(x):
    	return np.where(x > 0, 1, alpha)
    
    
    if __name__ == '__main__':
    	import matplotlib.pyplot as plt
    
    	x = np.linspace(-10, 10, 1000)
    	y = leaky_relu(x)
    	dy = d_leaky_relu(x)
    	plt.plot(x, y)
    	plt.plot(x, dy)
    	plt.title('Leaky ReLU & Derivative of Leaky ReLU Function')
    	plt.xlabel('x')
    	plt.ylabel('y')
    	plt.legend(['Leaky ReLU', 'Derivative of Leaky ReLU'])
    	plt.grid()
    	plt.show()
    

    image

    Leaky ReLU 函数的主要特点和作用:

    • 避免梯度消失:与标准 ReLU 函数一样,在输入值大于零时,Leaky ReLU 函数的梯度恒为 1,有也能效地避免了梯度消失问题,使得深层网络的训练更加高效。
    • 计算简单:与标准 ReLU 函数一样,Leaky ReLU 的计算也非常简单,稍微复杂一点的是需要在输入值小于零时乘以一个小的斜率参数 \(\alpha\) 即可。
    • 解决死亡 ReLU:标准 ReLU 函数在输入值小于零时输出为零,可能导致某些神经元永远不激活,出现死亡 ReLU 问题。Leaky ReLU 函数通过在输入值小于零时保留一个很小的负斜率 \(\alpha\),使得这些神经元仍有机会更新参数,从而缓解了这一问题。

    Leaky ReLU 函数的主要缺点:

    • 参数选择问题:Leaky ReLU 函数中的负斜率 \(\alpha\) 需要手动选择,可能需要进行超参数调优,不同的任务和数据集可能需要不同的 \(\alpha\) 值。
    • 未对输出归一化:Leaky ReLU 函数的输出范围是 (-∞, +∞),这可能导致某些神经元的输出值过小或过大,从而影响模型的训练稳定性。
  5. PReLU 函数
    Parametric ReLU(PReLU,参数化整流线性单元)也是一种对 ReLU 改进了的激活函数。其定义如下:

    \[f(x)=\max{(\alpha x,x)} \]

    PReLU 函数的导函数为:

    \[f^{\prime}(x)=\begin{cases}1, & x > 0 \\ \alpha, & x\leq0\end{cases} \]

    PReLU 函数 和 Leaky ReLU 函数的定义完全相同,只不过斜率参数 \(\alpha\) 是一个可学习的参数向量,对于每个神经元可以学习不同的负斜率。通常情况下,\(\alpha\) 是一个小于 1 的值,可以通过反向传播算法进行优化和更新。

    以下是 PReLU 的代码实现:

    PReLU 函数的斜率参数 \(\alpha\) 一般取值为 0.01。

    点击查看代码
    import torch
    from torch.nn import functional as F
    from torch import nn
    
    
    class PReLU(nn.Module):
    	def __init__(self, num_parameters=1, init=0.01):
    		super().__init__()
    		self.alpha = nn.Parameter(torch.Tensor(num_parameters).fill_(init))
    
    	def forward(self, x):
    		pos = F.relu(x)
    		neg = self.alpha * (x - torch.abs(x)) / 2
    		return pos + neg
    

    PReLU 函数相较于 Leaky ReLU 函数而言,避免了手动调整 \(\alpha\),可以根据任务和数据自动选择合适的 \(\alpha\)。但缺点是引入了更多的可学习参数,这导致模型训练时间更长,以及在训练数据有限或噪声较大的情况下,增加了模型过拟合可能性。

  6. ELU 函数
    ELU(Exponential Linear Unit,指数线性单元)函数是一种对 ReLU 改进了的激活函数。其定义如下:

    \[f(x)=\begin{cases}x, & x > 0 \\ \alpha(e^x-1), & x\leq0\end{cases} \]

    ELU 函数的导函数为:

    \[f^{\prime}(x)=\begin{cases}1, & x > 0 \\ \alpha e^x, & x\leq0\end{cases} \]

    以下是 ELU 函数及其导函数的代码实现,并通过绘图工具将其可视化,以便理解其形状和特点:

    ELU 函数的斜率参数 \(\alpha\) 一般取值为 1。

    点击查看代码
    import numpy as np
    
    alpha = 1
    
    
    def elu(x):
    	return np.where(x > 0, x, alpha * (np.exp(x) - 1))
    
    
    def d_elu(x):
    	return np.where(x > 0, 1, alpha * np.exp(x))
    
    
    if __name__ == '__main__':
    	import matplotlib.pyplot as plt
    
    	x = np.linspace(-10, 10, 1000)
    	y = elu(x)
    	dy = d_elu(x)
    	plt.plot(x, y)
    	plt.plot(x, dy)
    	plt.title('ELU & Derivative of ELU Function')
    	plt.xlabel('x')
    	plt.ylabel('y')
    	plt.legend(['ELU', 'Derivative of ELU'])
    	plt.grid()
    	plt.show()
    

    image

    ELU 函数相较于 Leaky ReLU 函数而言,具有更平滑的负数输出,这使得网络具有更强的非线性表达能力。但缺点是 ELU 函数在负数范围内需要计算指数函数,这相对于 Leaky ReLU 的线性计算来说,计算开销更大。

  7. Softmax 函数
    Softmax 函数是一种常用的激活函数,其定义如下:

    \[f(x_i)=\frac{e^{x_i}}{\sum_{j=1}^n e^{x_j}} \]

    其中 \(x_i\) 是输入向量的第 \(i\) 个元素,\(n\) 是输入向量 \(\mathbf{x}=\left[x_1, x_2, \ldots, x_n\right]\) 的长度。
    以下是 Softmax 函数的代码实现,并通过绘图工具将其可视化,以便理解其形状和特点::

    点击查看代码
    import numpy as np
    
    
    def softmax(x):
    	exp_x = np.exp(x)
    	return exp_x / np.sum(exp_x)
    
    
    if __name__ == '__main__':
    	import matplotlib.pyplot as plt
    
    	x = np.linspace(-10, 10, 1000)
    	y = softmax(x)
    	plt.plot(x, y)
    	plt.title('Softmax')
    	plt.xlabel('x')
    	plt.ylabel('y')
    	plt.grid()
    	plt.show()
    

    image

    Softmax 函数的主要特点和作用:

    • 将向量转化为概率分布:Softmax 函数将输入向量转化为概率分布,使得每个类别的概率之和为 1。这在多分类问题中非常有用,因为输出可以直接解释为每个类别的概率。
    • 增强区分度:Softmax 函数可以放大输入向量中的差异,使得概率最大的类别更加突出,从而提高模型的分类准确性。

    Softmax 函数的主要缺点:

    • 数值稳定性问题:Softmax 函数的实现中涉及指数运算,当输入值较大时,可能会导致数值溢出或数值不稳定的问题。
    • 独立假设:Softmax 函数假设每个类别的输出是相互独立的,这在某些实际场景中可能并不成立。
  8. Swish 函数
    Swish 函数是由 Google Brain 提出的一种激活函数,旨在提高神经网络的表达能力和训练速度。它的定义如下:

    \[f(x)=x \cdot \operatorname{sigmoid}(x)=\frac{x}{1+e^{-x}} \]

    Swish 函数的导函数为:

    \[f^{\prime}(x) =\operatorname{swish}(x)+\operatorname{sigmoid}(x)\cdot (1-\operatorname{swish}(x)) \]

    以下是 Swish 函数及其导函数的代码实现,并通过绘图工具将其可视化,以便理解其形状和特点:

    点击查看代码
    import numpy as np
    
    
    def sigmoid(x):
    	return 1 / (1 + np.exp(-x))
    
    
    def swish(x):
    	return x * sigmoid(x)
    
    
    def d_swish(x):
    	return sigmoid(x) + x * sigmoid(x) * (1 - sigmoid(x))
    
    
    if __name__ == '__main__':
    	import matplotlib.pyplot as plt
    
    	x = np.linspace(-10, 10, 100)
    	y = swish(x)
    	y_d = d_swish(x)
    	plt.plot(x, y)
    	plt.plot(x, y_d)
    	plt.title('Swish & Derivative of Swish Function')
    	plt.xlabel('x')
    	plt.ylabel('y')
    	plt.legend(['Swish', 'Derivative of Swish'])
    	plt.grid()
    	plt.show()
    

    image

    Swish 函数的主要特点和作用:

    • Swish 函数结合了线性性质和非线性性质,可以被视为是 ReLU 函数的平滑版本,一定程度上提升了模型的性能。

    Swish 函数的主要缺点:

    • Swish 函数相对复杂,计算代价较高,可能增加模型的训练时间和资源消耗。
  9. Softplus 函数
    Softplus 函数是一种平滑的激活函数,通常用于神经网络的隐藏层。它的定义形式为:

    \[f(x)=\log \left(1+e^x\right) \]

    Softplus 函数的导函数为:

    \[f^{\prime}(x)=\frac{1}{1+e^{-x}} \]

    以下是 Softplus 函数及其导函数的代码实现,并通过绘图工具将其可视化,以便理解其形状和特点:

    点击查看代码
    import numpy as np
    
    
    def softplus(x):
    	return np.log(1 + np.exp(x))
    
    
    def d_softplus(x):
    	return 1 / (1 + np.exp(-x))
    
    
    if __name__ == '__main__':
    	import matplotlib.pyplot as plt
    
    	x = np.linspace(-10, 10, 100)
    	y = softplus(x)
    	y_d = d_softplus(x)
    	plt.plot(x, y)
    	plt.plot(x, y_d)
    	plt.title('Softplus & Derivative of Softplus Function')
    	plt.xlabel('x')
    	plt.ylabel('y')
    	plt.legend(['Softplus', 'Derivative of Softplus'])
    	plt.grid()
    	plt.show()
    

    image

    Softplus 函数的主要特点和作用:

    • Softplus 函数是 ReLU 函数的平滑版本,避免了 ReLU 在零点处不可导的问题,同时保留了 ReLU 函数的非线性特性。

    Softplus 函数的主要缺点:

    • Softplus 函数相对复杂,计算代价较高,可能增加模型的训练时间和资源消耗。
  10. Mish 函数
    Mish 函数是一种激活函数,它的定义形式如下:

    \[f(x)=x \cdot \tanh (\operatorname{softplus}(x)) \]

    以下是 Mish 函数的代码实现,并通过绘图工具将其可视化,以便理解其形状和特点:

    点击查看代码
    import numpy as np
    
    
    def mish(x):
    	return x * np.tanh(np.log(1 + np.exp(x)))
    
    
    if __name__ == '__main__':
    	import matplotlib.pyplot as plt
    
    	x = np.linspace(-10, 10, 100)
    	y = mish(x)
    	plt.plot(x, y)
    	plt.title('Mish')
    	plt.xlabel('x')
    	plt.ylabel('y')
    	plt.grid()
    	plt.show()
    

    image

    Mish 函数的主要特点和作用:

    • 函数结合了线性性质和非线性性质,可以被视为是 ReLU 函数的平滑版本,一定程度上提升了模型的性能。
      Mish 函数的主要缺点:
    • Mish 函数的计算涉及到指数和双曲函数的运算,特别是在处理大规模数据时,可能会增加计算成本和训练时间。

9. 常见的正则化技术

  1. L1 正则化(Lasso 正则化)
    L1 正则化通过向损失函数添加权重向量的 L1 范数作为惩罚项,即 \(\lambda \sum_i\left|w_i\right|\) ,其中 \(\lambda\) 是正则化系数, \(w_i\) 是权重向量的元素。它促使模型学习稀疏的权重向量,有助于特征选择和模型简化。

  2. L2 正则化(Ridge 正则化)
    L2 正则化通过向损失函数添加权重向量的 L2 范数的平方作为惩罚项,即 \(\lambda \sum_i w_i^2\) ,其中 \(\lambda\) 是正则化系数, \(w_i\) 是权重向量的元素。它限制每个权重的大小,有助于防止参数过度膨胀,提升模型的泛化能力。

  3. Elastic Net 正则化
    Elastic Net 正则化综合了 L1 和 L2 正则化的优点,同时使用 L1 范数和 L2 范数的组合作为惩罚项,即 \(\lambda_1 \sum_i\left|w_i\right|+\lambda_2 \sum_i w_i^2\) ,其中 \(\lambda_1\)\(\lambda_2\) 是正则化系数。它能够综合考虑特征选择和参数收缩的效果。

  4. Dropout
    Dropout 是一种随机失活技术,通过在训练过程中随机将神经网络中的部分神经元置零,来防止神经元之间的共适应性,减少过拟合现象。

  5. Batch Normalization
    Batch Normalization 是通过规范化每一层的输入分布,使其均值为 0,方差为 1,来加速神经网络的训练过程,同时具有一定的正则化效果。

10. 将 PyTorch 模型打包成 ONNX 格式

ONNX(Open Neural Network Exchange)是一种开放的标准格式,用于在不同的深度学习框架之间共享和转换模型。它提高了跨框架互操作性和模型部署的灵活性,通过标准化的模型表示减少转换错误。

以下是导出 ONNX 模型的完整的代码示例,包括模型加载、导出、推理和验证:

点击查看代码
import numpy as np
import onnxruntime
import torch
from torchvision import models

import onnx

# 加载模型并开启模型评估模式
model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
model.eval()

# 准备输入数据
dummy_input = torch.randn(1, 3, 224, 224)

# 导出ONNX模型
torch.onnx.export(
    model,  # 被导出的模型
    dummy_input,  # 示例输入
    "model.onnx",  # 导出的文件路径
    export_params=True,  # 是否导出训练参数
    opset_version=11,  # ONNX 的操作集版本
    do_constant_folding=True,  # 是否执行常量折叠优化
    input_names=['input'],  # 输入名,可以有多个
    output_names=['output'],  # 输出名,可以有多个
    dynamic_axes={
        'input': {0: 'batch_size', 1: "channel", 2: "height", 3: "width"},
        'output': {0: 'batch_size'}
    }  # 动态轴,键是input_names、output_names中的值,值是动态轴的名称,一般是描述性名称即可
)

# 验证导出的ONNX模型结构是否正确
onnx_model = onnx.load("model.onnx")  # 加载模型结构(不能用来推理)
onnx.checker.check_model(onnx_model)  # 检查导出模型结构的有效性

# 验证导出的ONNX模型精度是否达标
ort_session = onnxruntime.InferenceSession(
    "model.onnx",  # 模型文件路径
    providers=["CPUExecutionProvider"]  # 一般使用使用 CPU 进行推理验证精度
)


def to_numpy(tensor):  #
    return tensor.detach().cpu().numpy() if tensor.requires_grad else tensor.cpu().numpy()


x = torch.randn(1, 3, 224, 224)  # PyTorch模型输入
ort_inputs = {ort_session.get_inputs()[0].name: to_numpy(x)}  # ONNX模型输入
ort_out_np = ort_session.run(None, ort_inputs)[0]  # 使用ONNX模型进行推理
torch_out_np = to_numpy(model(x))  # 使用PyTorch模型进行推理
np.testing.assert_allclose(ort_out_np, torch_out_np, rtol=1e-03, atol=1e-05)  # 比较精度

可以将以上 PyTorch 模型导出为 ONNX 格式的完整代码分解为下几个步骤:

  • 准备环境
    确保已安装 ONNX 和 ONNX 运行环境的库。可以使用以下命令安装:

    pip install onnx onnxruntime
    
  • 定义或加载模型
    加载已经训练好的模型或定义新的模型,并确保模型处于评估模式:

    model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
    model.eval()
    
  • 准备输入数据
    创建一个与模型输入匹配的示例输入张量。这对于 ONNX 导出器了解输入的形状和类型是必需的。

    dummy_input = torch.randn(1, 3, 224, 224)
    
  • 导出模型
    使用 torch.onnx.export 函数将模型导出为 ONNX 格式。你需要指定模型、示例输入、导出文件的路径以及一些可选参数。

    # 导出ONNX模型
    torch.onnx.export(
    	model,  # 被导出的模型
    	dummy_input,  # 示例输入
    	"model.onnx",  # 导出的文件路径
    	export_params=True,  # 是否导出训练参数
    	opset_version=11,  # ONNX 的操作集版本
    	do_constant_folding=True,  # 是否执行常量折叠优化
    	input_names=['input'],  # 输入名,可以有多个
    	output_names=['output'],  # 输出名,可以有多个
    	dynamic_axes={
    		'input': {0: 'batch_size', 1: "channel", 2: "height", 3: "width"},
    		'output': {0: 'batch_size'}
    	}  # 动态轴,键是input_names、output_names中的值,值是动态轴的名称,一般是描述性名称即可
    )
    

    ONNX 格式导出文件的后缀习惯设置成为 .ONNX。

  • 验证导出的模型
    可以使用 ONNX 库来加载和验证导出的模型结构的正确性:

    # 验证导出的ONNX模型结构是否正确
    onnx_model = onnx.load("model.onnx")  # 加载模型结构(不能用来推理)
    onnx.checker.check_model(onnx_model)  # 检查导出模型结构的有效性
    

    还可以使用 ONNX Runtime 库来加载和验证导出模型的精度:

    # 验证导出的ONNX模型精度是否达标
    ort_session = onnxruntime.InferenceSession(
    	"model.onnx",  # 模型文件路径
    	providers=["CPUExecutionProvider"]  # 一般使用使用 CPU 进行推理验证精度
    )
    
    
    def to_numpy(tensor):  
    	return tensor.detach().cpu().numpy() if tensor.requires_grad else tensor.cpu().numpy()
    
    
    x = torch.randn(1, 3, 224, 224)  # PyTorch模型输入
    ort_inputs = {ort_session.get_inputs()[0].name: to_numpy(x)}  # ONNX模型输入
    ort_out_np = ort_session.run(None, ort_inputs)[0]  # 使用ONNX模型进行推理
    torch_out_np = to_numpy(model(x))  # 使用PyTorch模型进行推理
    np.testing.assert_allclose(ort_out_np, torch_out_np, rtol=1e-03, atol=1e-05)  # 比较精度
    

    np.testing.assert_allclose() 函数通过以下公式比较两个数组中的每个元素:abs(a - b) <= (atol + rtol * abs(b)),其中 a、b 分别是函数的第一个和第二个参数。

此外,将导出的 ONNX 模型文件可以直接拖入 https://netron.app/ 网站中查看模型结构。

image

posted @ 2024-06-27 10:22  gokamisama  阅读(125)  评论(0)    收藏  举报