16 | PyTorch中的模型优化,更深、更宽的模型、正则化方法

2022-07-11 15:48:33 浏览数 (1)

上一节,我们已经成功训练了我们的深度神经网络,甚至尝试了在GPU上训练。不过我们的网络仍然处于一种初级状态,只能说大概了解了炼丹炉的工作流程,炼丹的时候还有很多改进技巧可以提升炼丹的效率或者效果。

增加模型宽度

增加模型宽度是一个很容易的事情,虽然我前面说过窄而深的网络效果往往比宽而浅的网络效果要好,不过有些时候增加模型宽度也是有帮助的。其实从代码中我们就能明白,要增加宽度我们只需要把每一层的输出设大一点就好了,比如说把卷积的输出通道数设多一点。

代码语言:javascript复制
class NetWidth(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1) #这里之前输出是16,现在改成32了        self.conv2 = nn.Conv2d(32, 16, kernel_size=3, padding=1)
        self.fc1 = nn.Linear(16 * 8 * 8, 32)
        self.fc2 = nn.Linear(32, 2)
        
    def forward(self, x):
        out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
        out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
        out = out.view(-1, 16 * 8 * 8)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

通常我们自己搭建网络的话,肯定一开始不知道到底设多少比较好,要做各种尝试,因此可以把这些尺寸都写成变量的形式,在使用的时候再传具体的值进去。比如说这样

代码语言:javascript复制
class NetWidth(nn.Module):
    def __init__(self, n_chans1=32):
        super().__init__()
        self.n_chans1 = n_chans1# 用变量代替具体数值        self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
                               padding=1)
        self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32)
        self.fc2 = nn.Linear(32, 2)
        
    def forward(self, x):
        out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
        out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
        out = out.view(-1, 8 * 8 * self.n_chans1 // 2)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

这个时候的模型参数比之前多了差不多一倍。 我们前面说,卷积的每一个通道可以学习到一类特征,如果通道数设置的比较合理,那么恰好能学习到我们所需要的特征从而使得模型效果比较好,但是随着通道数变多,它可能会学到更多吹毛求疵的特征,以至于模型变得过拟合。

正则化

关于正则化这个词,听起来就比较难理解,什么正则化,我们返回去看看它的英文。正则化的英文是Regularization,查一下它的意思,除了正则化,它还有正规化,合法化,规范化的意思,这看起来就好理解多了。所以正则化就是在我们训练中加上一些规则,加入一些限制,让模型不要出现过拟合的情况。

第一个正则化方法是权重惩罚。 在权重惩罚方法中,通过给损失加上一个正则化项来实现平滑损失的效果。这里有L1正则和L2正则,L1正则指的是加入所有权重的绝对值之和,(当然这里还要乘以一个系数),而L2正则是所有权重的平方和。我们不妨来看看代码。当然,很多时候我们不需要手动加入这个权重惩罚了,因为这是一个非常常见的功能,在PyTorch提供的优化器中,往往已经加入了正则化项。

代码语言:javascript复制
def training_loop_l2reg(n_epochs, optimizer, model, loss_fn,
                        train_loader):
    for epoch in range(1, n_epochs   1):
        loss_train = 0.0
        for imgs, labels in train_loader:
            imgs = imgs.to(device=device)
            labels = labels.to(device=device)
            outputs = model(imgs)
            loss = loss_fn(outputs, labels)

            l2_lambda = 0.001 #这个lambda也是一个超参数
            l2_norm = sum(p.pow(2.0).sum()
                          for p in model.parameters())  # 在这个位置计算所有权重的平方和
            loss = loss   l2_lambda * l2_norm # 然后用平方和乘以系数lambda

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            
            loss_train  = loss.item()
        if epoch == 1 or epoch % 10 == 0:
            print('{} Epoch {}, Training loss {}'.format(
                datetime.datetime.now(), epoch,
                loss_train / len(train_loader)))

第二个正则化方法是dropout。从字面意思来看dropout就是退出,实际的策略也是如此,原论文(Dropout: A Simple Way to Prevent neural Network from Overfitting),dropout方法的思路就是每一个epoch中,随机的把一部分神经元清零。也就是说你的模型要努力学会在一个脑回路不完整的情况下仍然能够认出你给它的图片信息。当然,在预测环节我们就可以去掉dropout,就像给你打通了任督二脉,效果更好了。

代码语言:javascript复制
class NetDropout(nn.Module):
    def __init__(self, n_chans1=32):
        super().__init__()
        self.n_chans1 = n_chans1        self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
        self.conv1_dropout = nn.Dropout2d(p=0.4) #这里设定清零神经元的概率为0.4
        self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
                               padding=1)
        self.conv2_dropout = nn.Dropout2d(p=0.4)
        self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32)
        self.fc2 = nn.Linear(32, 2)
        
    def forward(self, x):
        out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
        out = self.conv1_dropout(out)
        out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
        out = self.conv2_dropout(out)
        out = out.view(-1, 8 * 8 * self.n_chans1 // 2)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

第三个正则化方法是批量归一化。(Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift(https://arxiv.org/abs/1502.03167))这篇论文提出的批量归一化策略,号称有很多好处,比如可以提高学习率,这样我们的训练迭代的次数就可以减少了;然后是减少了对初始化的依赖,并且可以作为一种正则化方法取代dropout。

批量归一化方法是加在线性变换和激活函数之间,在线性变换完成之后使用批量归一化对数据进行处理,使得在小批量数据上的分布更加均衡,然后再把这些数据传入激活函数。回想我们的激活函数,通常是在某个区间内对数据比较敏感,而在超过某个区域之后趋于稳定。批量归一化就是让数据更多的能落在敏感范围内,这样可以有效减少梯度消失的问题。

代码语言:javascript复制
class NetBatchNorm(nn.Module):
    def __init__(self, n_chans1=32):
        super().__init__()
        self.n_chans1 = n_chans1        self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
        self.conv1_batchnorm = nn.BatchNorm2d(num_features=n_chans1)  
        self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3, 
                               padding=1)
        self.conv2_batchnorm = nn.BatchNorm2d(num_features=n_chans1 // 2)
        self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32)
        self.fc2 = nn.Linear(32, 2)
        
    def forward(self, x):
        out = self.conv1_batchnorm(self.conv1(x))
        out = F.max_pool2d(torch.tanh(out), 2)
        out = self.conv2_batchnorm(self.conv2(out))
        out = F.max_pool2d(torch.tanh(out), 2)
        out = out.view(-1, 8 * 8 * self.n_chans1 // 2)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

增加模型深度

关于增加模型深度,似乎也是很简单的事情,就像我们前面做的那样不断的追加隐藏层的数量就可以了,但是真的是这样吗?其实在2015年之前深度神经网络也就徘徊在20层以内的深度。我们前面大概讲过,在计算梯度的时候,我们使用的是求导链式法则,当其中的一些数值都很大的时候可能就会引发梯度爆炸,数字大到无法计算,或者那些较小的数值所在的层的贡献被抹灭了,反过来有很多特别小的数的时候,又会有梯度消失,所以当层数变多,这种不稳定因素也越来越多,导致层数的增加对模型效果没有什么帮助。

就在2015年12月,ResNet横空出世,解开了模型深度的封印,让深度学习真的深不见底。这里使用的技巧就是跳跃连接。简单来说,跳跃连接就是把某一层的输出传给若干层之后层作为输入,这样使得处于较深层级的神经层也能够维持对早些层中特征的学习,如下图所示

image.png

接下来是代码实现,这里面有两个变动,一个是把激活函数换成了ReLU,一个就是在第三个池化操作的输入加入了第一层输出

代码语言:javascript复制
class NetRes(nn.Module):
    def __init__(self, n_chans1=32):
        super().__init__()
        self.n_chans1 = n_chans1        self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
                               padding=1)
        self.conv3 = nn.Conv2d(n_chans1 // 2, n_chans1 // 2,
                               kernel_size=3, padding=1)
        self.fc1 = nn.Linear(4 * 4 * n_chans1 // 2, 32)
        self.fc2 = nn.Linear(32, 2)
        
    def forward(self, x):
        out = F.max_pool2d(torch.relu(self.conv1(x)), 2)
        out = F.max_pool2d(torch.relu(self.conv2(out)), 2)
        out1 = out
        out = F.max_pool2d(torch.relu(self.conv3(out))   out1, 2) #变动在这里
        out = out.view(-1, 4 * 4 * self.n_chans1 // 2)
        out = torch.relu(self.fc1(out))
        out = self.fc2(out)
        return out
outs:2022-06-06 17:38:04.677526 Epoch 1, Training loss 0.66021598903996172022-06-06 17:38:12.162822 Epoch 10, Training loss 0.33114931499882112022-06-06 17:38:20.615475 Epoch 20, Training loss 0.28254203612257722022-06-06 17:38:28.997158 Epoch 30, Training loss 0.24748281664719252022-06-06 17:38:37.533645 Epoch 40, Training loss 0.218804421033828882022-06-06 17:38:46.145436 Epoch 50, Training loss 0.19305456766657012022-06-06 17:38:54.485719 Epoch 60, Training loss 0.168704600803032042022-06-06 17:39:02.958393 Epoch 70, Training loss 0.14476190198948432022-06-06 17:39:11.380145 Epoch 80, Training loss 0.120031091343065742022-06-06 17:39:20.359881 Epoch 90, Training loss 0.095959922678436452022-06-06 17:39:29.205687 Epoch 100, Training loss 0.07665619137845221Accuracy train: 0.94Accuracy val: 0.89

这里我们跑了一下这个模型,在训练集上的准确率为94%,在验证集上的准确率为89%,训练集上的效果比我们之前好了一丢丢,验证集略微下降。 在这里通过跳跃连接,创建了一条直达较深层网络的路径,使得较深层对梯度的贡献更直接,跳跃连接有利于模型的收敛。

这里我们仿照ResNet来构建一个100层的网络。其中我们把核心的残差网络层进行单独的构建,我们称为一个残差块。在这个残差块中,输出是这块的输入加上数据流经这块的输出作为整块的输出传给下个残差块,是不是看起来很简单,但就是这么简单的操作使得网络能够保持一个稳定的状态。

代码语言:javascript复制
class ResBlock(nn.Module):
    def __init__(self, n_chans):
        super(ResBlock, self).__init__()
        self.conv = nn.Conv2d(n_chans, n_chans, kernel_size=3,
                              padding=1, bias=False)  
        self.batch_norm = nn.BatchNorm2d(num_features=n_chans) #批量归一化
        torch.nn.init.kaiming_normal_(self.conv.weight,
                                      nonlinearity='relu')  
        torch.nn.init.constant_(self.batch_norm.weight, 0.5)
        torch.nn.init.zeros_(self.batch_norm.bias)

    def forward(self, x):
        out = self.conv(x)
        out = self.batch_norm(out)
        out = torch.relu(out)
        return out   x

然后我们加上整个网络的输入层和输出层,

代码语言:javascript复制
class NetResDeep(nn.Module):
    def __init__(self, n_chans1=32, n_blocks=10):
        super().__init__()
        self.n_chans1 = n_chans1        self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
        self.resblocks = nn.Sequential(
            *(n_blocks * [ResBlock(n_chans=n_chans1)]))
        self.fc1 = nn.Linear(8 * 8 * n_chans1, 32)
        self.fc2 = nn.Linear(32, 2)
        
    def forward(self, x):
        out = F.max_pool2d(torch.relu(self.conv1(x)), 2)
        out = self.resblocks(out)
        out = F.max_pool2d(out, 2)
        out = out.view(-1, 8 * 8 * self.n_chans1)
        out = torch.relu(self.fc1(out))
        out = self.fc2(out)
        return out

下面是我们这个ResNet模型的流程图

image.png

关于调优,还有一个重点就是初始化,在模型训练最开始使用什么样的权重初始化也会影响模型的效果,但是这部分在PyTorch中应该还没有很好的实现,有机会我们再单独讨论关于权重初始化的问题。

如果你有兴趣,可以把这里每一个调优方案的训练结果都跑一下验证集,然后对比一下效果,这里作者给出了一张对比图,有很多调优之后的效果看起来还没有基线好,这并不是说调优的效果不好,只能说这个调优的方式不适合当前的数据,因为这里提到的调优方案都是经过了验证的,可以放心尝试。不过在实际工作中就是这样,有很多时候你要去尝试到底什么样的方案对你的业务需求才是最佳解决方案。

image.png

总结

接下来我们就该做个总结了。我们花了很长的时间来学习PyTorch的基本功能,这节课结束,我们算是学完了整个模型构建的流程,但是这只是深度学习的起步。我们前面提到的很多内容都可以单独拿出来讲一章甚至是一本书,我对这些内容的了解程度也不多,后面还得好好学习。就目前学习的这本书来说,我们已经知道怎么用PyTorch去构建一个深度学习模型,里面的每一个环节是怎么一步步演变过来的,以及在构建模型的时候有什么优化方法。从下一节开始,书上就开始了第二大部分,那就是用PyTorch构建一个真正的项目,沿着数据挖掘的路径,首先是理解业务,然后是处理数据,接着是模型训练和模型评估,最后是进行线上部署,我觉得学完之后这个项目都可以写在简历上了。

今天写的内容比较粗略,字数貌似也比较少,不过高兴的是终于把第一部分学完了。

0 人点赞