首页 / 资讯中心 / 文章详情

PyTorch实战:手撸全连接、CNN、LeNet-5、ResNet、RNN与自编码器

PyTorch实战:手撸全连接、CNN、LeNet-5、ResNet、RNN与自编码器 ★ FEATURED ARTICLE
1. 从零到一为什么我建议你用PyTorch把这五个网络全撸一遍如果你正在入门深度学习或者已经跑过几个Demo但总觉得脑子里一团浆糊那我强烈建议你花一个周末的时间把全连接网络、CNN、LeNet-5、ResNet、RNN和自编码器这六个模型在PyTorch里从头到尾手写一遍。这不是让你去调包调参而是真正理解每一层的输入输出、每一个维度的变换、每一次反向传播到底在干什么。我自己当年入门的时候就是吃了“只看不写”的亏。看别人的代码觉得都懂什么卷积、池化、残差连接概念背得滚瓜烂熟但一到自己动手连view()和reshape()的区别都要查半天更别提permute和transpose在RNN里怎么用了。后来我逼着自己把这几个经典网络全部手撸了一遍从MNIST到CIFAR10从全连接到自编码器才算真正把深度学习的任督二脉打通了。这篇文章就是把我踩过的坑、总结的经验、以及每个网络最核心的设计思路全部摊开来聊。不管你是刚学完反向传播公式但没写过几行代码的新手还是已经能跑通ResNet但说不清楚残差块为什么能解决退化问题的进阶者我相信你都能从这篇文章里找到对自己有用的东西。我会从最基础的全连接网络开始一步步过渡到CNN、LeNet-5、ResNet然后再聊RNN和自编码器。每个网络我都会讲清楚三件事为什么这么设计、代码怎么写、以及实际跑的时候会遇到什么坑。2. 全连接网络最朴素但最不该跳过的起点2.1 为什么先拿全连接网络开刀很多人觉得全连接网络太简单了不就是几层nn.Linear堆在一起吗有什么好讲的。但恰恰是这个最简单的结构藏着深度学习里最核心的几个概念前向传播、反向传播、损失函数、优化器、批次训练。你把这几个东西在全连接网络里搞明白了后面不管换什么网络结构本质上都是在换前向传播的计算方式反向传播的框架是不变的。而且全连接网络在MNIST上的表现其实不差。MNIST是28x28的灰度图拉平之后是784维的向量用一个两层或三层的全连接网络测试集准确率轻松能到97%以上。这个数字对于建立信心非常重要——你第一次跑通一个模型看到准确率从10%慢慢爬到97%那种感觉是看一百篇论文都给不了的。2.2 网络结构设计与维度变换详解我一般建议新手从这样一个结构开始输入784维第一层隐藏层256个神经元第二层隐藏层128个神经元输出层10个神经元。激活函数用ReLU输出层不加激活函数因为后面接的是CrossEntropyLoss它内部会做softmax。这里有一个非常关键的细节MNIST的图片是28x28的二维矩阵但全连接网络需要一维向量作为输入。所以在写forward函数的时候第一件事就是x x.view(x.size(0), -1)。这行代码的意思是保持batch维度不变把后面的所有维度展平。很多新手在这里会写成x.view(-1, 784)效果一样但前者更通用因为不管你的batch size是多少它都能自动适配。维度变换是深度学习里最容易出错的地方我自己的经验是每写一行涉及维度操作的代码就在旁边用注释标出当前的shape。比如def forward(self, x): # x: [batch_size, 1, 28, 28] x x.view(x.size(0), -1) # [batch_size, 784] x F.relu(self.fc1(x)) # [batch_size, 256] x F.relu(self.fc2(x)) # [batch_size, 128] x self.fc3(x) # [batch_size, 10] return x这个习惯看起来笨但能帮你省下大量debug的时间。我见过太多人因为维度对不上在RuntimeError: mat1 and mat2 shapes cannot be multiplied这个报错上卡半天。2.3 训练循环里的那些门道全连接网络的训练循环其实是最标准的模板后面所有网络的训练循环都是在这个基础上改的。核心步骤就五步梯度清零、前向传播、计算损失、反向传播、更新参数。for epoch in range(num_epochs): for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step()这五步里新手最容易忽略的是optimizer.zero_grad()。PyTorch的梯度是累加的如果你不清零上一次迭代的梯度会和这一次的叠加在一起导致参数更新完全错误。我当初就因为忘了写这一行训练了十几个epoch发现loss根本不降排查了半天才发现问题。另一个容易踩的坑是数据加载。MNIST用torchvision.datasets.MNIST加载的时候一定要记得做transforms.ToTensor()它会把PIL图像或numpy数组转成[0,1]范围的Tensor同时把维度从[H, W, C]变成[C, H, W]。如果你忘了这一步后面view的时候维度就全乱了。实操心得全连接网络在MNIST上batch size设成64或128都行学习率用0.001配合Adam优化器基本上10个epoch以内就能收敛到97%以上。如果你用SGD学习率要调到0.01左右并且加上momentum0.9收敛会稳定很多。3. CNN与LeNet-5从全连接到卷积的思维跃迁3.1 为什么全连接网络处理图像不够用全连接网络在MNIST上表现不错但你把同样的结构搬到CIFAR10上试试CIFAR10是32x32的彩色图拉平之后是3072维。如果你第一层隐藏层还是256个神经元那这一层的参数量就是3072乘以256接近80万个参数。这还只是一层。参数量爆炸带来的直接后果就是过拟合而且训练速度会慢得让你怀疑人生。更本质的问题是全连接网络丢失了图像的空间结构信息。把28x28的图片拉平成784维向量相当于把一张图的所有像素排成一条线原本相邻的像素可能被拆得很远。卷积神经网络通过局部连接和权值共享完美解决了这两个问题卷积核在图像上滑动每次只看一个小区域参数量大大减少同一个卷积核在整个图像上共享进一步压缩了参数。3.2 LeNet-5的结构拆解与CIFAR10适配LeNet-5是上世纪90年代提出的经典结构原本是用来做手写数字识别的。它的结构非常清晰两个卷积层、两个池化层、三个全连接层。具体来说第一层卷积输入通道1灰度图输出通道6卷积核5x5第二层平均池化池化窗口2x2步长2第三层卷积输入通道6输出通道16卷积核5x5第四层平均池化池化窗口2x2步长2第五层全连接输入维度16x5x5400输出120第六层全连接输入120输出84第七层全连接输入84输出10但这里有一个问题LeNet-5原本是针对28x28的灰度图设计的而CIFAR10是32x32的彩色图。所以你需要做两个调整第一把第一层卷积的输入通道从1改成3第二重新计算全连接层的输入维度。32x32的图经过5x5卷积无padding变成28x28再经过2x2池化变成14x14再经过5x5卷积变成10x10再经过2x2池化变成5x5。所以全连接层的输入是16x5x5400。这个计算过程你一定要自己动手算一遍不要直接抄别人的代码。因为一旦你改了卷积核大小或者通道数这个维度就会变你得知道怎么重新算。class LeNet5(nn.Module): def __init__(self, num_classes10): super(LeNet5, self).__init__() self.conv1 nn.Conv2d(3, 6, 5) self.pool nn.AvgPool2d(2, 2) self.conv2 nn.Conv2d(6, 16, 5) self.fc1 nn.Linear(16 * 5 * 5, 120) self.fc2 nn.Linear(120, 84) self.fc3 nn.Linear(84, num_classes) def forward(self, x): x self.pool(F.relu(self.conv1(x))) # [B, 6, 14, 14] x self.pool(F.relu(self.conv2(x))) # [B, 16, 5, 5] x x.view(x.size(0), -1) # [B, 400] x F.relu(self.fc1(x)) # [B, 120] x F.relu(self.fc2(x)) # [B, 84] x self.fc3(x) # [B, 10] return x3.3 卷积层参数计算与实操避坑卷积层的输出维度计算公式是output_size (input_size - kernel_size 2 * padding) / stride 1。这个公式你一定要背下来因为后面设计网络的时候会反复用到。以第一层卷积为例输入32x32卷积核5x5padding0stride1所以输出是(32-50)/1128。池化层如果kernel_size2stride2输出就是28/214。这些数字看起来简单但如果你在代码里写错了比如把padding设成了1那后面的维度就全对不上了。注意LeNet-5原始论文用的是平均池化但现在更多人用最大池化。两者在CIFAR10上的表现差异不大但最大池化收敛通常更快一些。你可以两种都试试感受一下区别。还有一个坑是卷积核的通道数。nn.Conv2d(in_channels, out_channels, kernel_size)第一个参数是输入通道数第二个是输出通道数。第一层卷积的输入通道必须等于图片的通道数CIFAR10是3MNIST是1。如果你搞反了PyTorch会直接报错。4. ResNet残差连接到底解决了什么问题4.1 深层网络的退化现象理论上讲网络越深表达能力越强效果应该越好。但实际训练的时候人们发现了一个奇怪的现象56层的网络在训练集上的误差反而比20层的网络更高。这不是过拟合因为过拟合是训练误差低、测试误差高而这里是训练误差本身就高。这种现象被称为退化问题。退化问题的根源不是梯度消失而是深层网络难以学习到恒等映射。换句话说如果某一层什么都不做直接把输入传下去理论上效果不应该变差。但普通的卷积层很难学到“什么都不做”这个映射因为权重初始化之后输出和输入之间总会有偏差。4.2 残差块的设计哲学与代码实现ResNet的核心思想非常简洁既然让网络直接学习目标映射H(x)很难那就让它学习残差F(x) H(x) - x然后输出F(x) x。如果最优的映射就是恒等映射那网络只需要把F(x)的权重都学成0就行了这比直接学恒等映射容易得多。残差块有两种设计BasicBlock和Bottleneck。BasicBlock用于浅层ResNet如ResNet-18、ResNet-34由两个3x3卷积组成Bottleneck用于深层ResNet如ResNet-50、ResNet-101由1x1、3x3、1x1三个卷积组成目的是减少计算量。class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1): super(BasicBlock, self).__init__() self.conv1 nn.Conv2d(in_channels, out_channels, 3, stride, 1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, 3, 1, 1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels: self.shortcut nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stride, biasFalse), nn.BatchNorm2d(out_channels) ) def forward(self, x): out F.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out self.shortcut(x) out F.relu(out) return out这里有一个关键细节当输入输出通道数不一致或者步长不为1时shortcut路径需要做一个1x1卷积来匹配维度。这个1x1卷积的作用不是提取特征纯粹是为了让张量的形状能对上。如果你忘了加这个out self.shortcut(x)就会因为维度不匹配而报错。4.3 在CIFAR10上搭建ResNet的注意事项CIFAR10的图片是32x32的比ImageNet的224x224小很多。所以你不能直接照搬ResNet-50的结构那个第一层是7x7卷积加最大池化会把32x32的图直接降到8x8信息损失太严重。针对CIFAR10通常的做法是第一层用3x3卷积不加最大池化然后直接堆叠残差块。我一般用这样的配置第一层3x3卷积输出通道64然后三个stage每个stage由两个BasicBlock组成通道数分别是64、128、256第一个block的stride设为2来做下采样最后全局平均池化加全连接层输出10类。这个结构在CIFAR10上训练200个epoch测试准确率能到93%以上。实操心得ResNet在CIFAR10上训练的时候数据增强非常关键。我通常会用RandomCrop(32, padding4)和RandomHorizontalFlip这两个增强能把准确率提升2到3个百分点。另外学习率用0.1配合CosineAnnealingLR比用StepLR效果更好。5. RNN处理序列数据的另一种思路5.1 RNN的核心机制与梯度问题前面讲的CNN和ResNet都是处理图像的输入是二维或三维的张量。但有一类数据是序列形式的比如文本、时间序列、音频它们的长度不固定而且前后元素之间有依赖关系。RNN就是专门为这类数据设计的。RNN的核心是一个循环单元它在每个时间步接收当前输入和上一个时间步的隐藏状态然后输出新的隐藏状态。用公式表示就是h_t tanh(W_ih * x_t W_hh * h_{t-1} b)。这个结构让网络有了“记忆”能够利用历史信息。但普通RNN有一个致命问题梯度消失。当序列很长的时候反向传播的梯度会指数级衰减导致网络学不到长距离的依赖关系。这就是为什么实践中几乎没人用普通RNN而是用LSTM或GRU。LSTM通过门控机制输入门、遗忘门、输出门来控制信息的流动能够有效地保留长期记忆。5.2 PyTorch中RNN的维度处理PyTorch里RNN系列的模块有一个非常容易搞混的地方batch_first参数。默认情况下nn.RNN、nn.LSTM、nn.GRU的输入维度是[seq_len, batch_size, input_size]也就是说序列长度在第一维。但我们在CNN里习惯了[batch_size, ...]的格式所以很容易搞错。我的建议是永远把batch_first设成True。这样输入就是[batch_size, seq_len, input_size]符合直觉不容易出错。class RNNClassifier(nn.Module): def __init__(self, input_size, hidden_size, num_layers, num_classes): super(RNNClassifier, self).__init__() self.hidden_size hidden_size self.num_layers num_layers self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, num_classes) def forward(self, x): h0 torch.zeros(self.num_layers, x.size(0), self.hidden_size).to(x.device) c0 torch.zeros(self.num_layers, x.size(0), self.hidden_size).to(x.device) out, _ self.lstm(x, (h0, c0)) out self.fc(out[:, -1, :]) return out这里out[:, -1, :]取的是最后一个时间步的隐藏状态因为对于分类任务来说最后一个时间步包含了整个序列的信息。如果你做的是序列标注任务那就需要每个时间步的输出不能用-1。5.3 RNN在MNIST上的另类用法你可能会问MNIST是图像数据跟RNN有什么关系其实可以把MNIST的每一行像素看作一个时间步28行就是28个时间步每个时间步的输入是28维的向量。这样就把图像分类变成了序列分类问题。这个用法虽然在实际中没什么意义CNN在图像上明显更合适但作为理解RNN的练习非常直观。你可以清楚地看到RNN是如何一步步“扫描”图像的以及隐藏状态是如何随着时间步累积信息的。注意用RNN处理MNIST的时候输入维度是28每行28个像素序列长度也是28共28行。所以input_size28seq_len28。这个数字的巧合容易让人搞混写代码的时候一定要注释清楚。6. 自编码器无监督学习的入门利器6.1 自编码器的基本原理自编码器是一种无监督学习模型它的目标不是分类而是重构。整个网络分为两部分编码器和解码器。编码器把输入压缩成一个低维的隐向量解码器再把这个隐向量还原成原始输入。训练的目标是最小化重构误差也就是让输出尽可能接近输入。自编码器的应用非常广泛数据降维、去噪、异常检测、生成模型。比如你可以用自编码器做图像去噪训练的时候输入加噪图片目标输出是干净图片训练好之后网络就学会了去除噪声。6.2 全连接自编码器与卷积自编码器最简单的自编码器是全连接的编码器和解码器都是几层nn.Linear。但全连接自编码器在处理图像时参数量太大而且丢失了空间信息。所以对于图像数据通常用卷积自编码器。卷积自编码器的编码器由卷积层和池化层组成逐步降低空间维度、增加通道数解码器由转置卷积层也叫反卷积组成逐步恢复空间维度、减少通道数。PyTorch里用nn.ConvTranspose2d来实现转置卷积。class ConvAutoencoder(nn.Module): def __init__(self): super(ConvAutoencoder, self).__init__() self.encoder nn.Sequential( nn.Conv2d(3, 16, 3, stride2, padding1), # [B, 16, 16, 16] nn.ReLU(), nn.Conv2d(16, 32, 3, stride2, padding1), # [B, 32, 8, 8] nn.ReLU(), nn.Conv2d(32, 64, 3, stride2, padding1), # [B, 64, 4, 4] nn.ReLU() ) self.decoder nn.Sequential( nn.ConvTranspose2d(64, 32, 3, stride2, padding1, output_padding1), nn.ReLU(), nn.ConvTranspose2d(32, 16, 3, stride2, padding1, output_padding1), nn.ReLU(), nn.ConvTranspose2d(16, 3, 3, stride2, padding1, output_padding1), nn.Sigmoid() ) def forward(self, x): x self.encoder(x) x self.decoder(x) return x这里有一个非常关键的细节转置卷积的output_padding参数。当你用stride2做下采样的时候如果输入尺寸是奇数上采样的时候尺寸会对不上。output_padding1就是用来解决这个问题的。具体来说如果编码器把32x32降到了16x16解码器要从16x16恢复到32x32output_padding设成1就能保证尺寸正确。6.3 自编码器的损失函数与训练技巧自编码器的损失函数通常用MSE均方误差因为目标是重构输出是连续值。如果你在输出层用了Sigmoid把值压到[0,1]范围那MSE就很合适。如果你不做归一化用MSE也可以但收敛可能会慢一些。训练自编码器的时候有一个小技巧不要用太小的瓶颈层。瓶颈层就是编码器输出的那个低维向量如果维度太小信息压缩得太厉害重构出来的图像就会很模糊。对于CIFAR10我一般把瓶颈层设成64x4x41024维再小的话重构质量就明显下降了。实操心得自编码器训练的时候loss降到0.01以下通常就差不多了。如果你发现loss降不下去先检查一下输入数据有没有归一化到[0,1]然后看看瓶颈层的维度是不是太小了。另外用Adam优化器比SGD收敛快很多学习率0.001就行。7. 常见问题与排查技巧实录7.1 维度不匹配问题速查表维度问题是深度学习里最高频的报错来源我整理了一个速查表覆盖了这几个网络里最常见的维度错误报错信息常见原因解决方法mat1 and mat2 shapes cannot be multiplied全连接层输入维度算错检查卷积/池化后的特征图尺寸重新计算in_featuresGiven groups1, weight of size...卷积层输入通道数不对确认第一层卷积的in_channels等于图片通道数Expected 4D input (got 3D input)卷积层收到了3D张量检查数据加载时是否保留了通道维度output size is too small卷积核太大或池化太多重新计算每层输出尺寸确保不会降到0size mismatch for shortcut残差块输入输出通道不一致在shortcut路径加1x1卷积调整通道数7.2 训练不收敛的排查思路训练不收敛是另一个让人头疼的问题。我的排查顺序是这样的第一检查数据。把几张图片可视化出来看看有没有标签错乱、图像全黑、归一化异常的情况。我遇到过有人把transforms.Normalize的均值和方差写反了导致输入数据分布完全不对网络根本学不动。第二检查损失函数。分类任务用CrossEntropyLoss回归任务用MSELoss这个不能搞混。另外CrossEntropyLoss的输入是logits不需要在最后一层加softmax加了反而会导致数值不稳定。第三检查学习率。学习率太大loss会震荡甚至爆炸太小loss降得极慢。我一般先用0.001试如果loss不降就调到0.01如果震荡就调到0.0001。第四检查梯度。在训练循环里打印一下梯度的范数如果梯度全是0或者全是nan那说明网络结构或者损失函数有问题。7.3 过拟合的识别与应对过拟合的典型表现是训练集准确率很高但验证集准确率明显低一截而且两者差距越来越大。应对过拟合的手段有很多我按效果排序数据增强最有效的手段没有之一。RandomCrop、RandomHorizontalFlip、ColorJitter能加的都加上。Dropout在全连接层后面加nn.Dropout(0.5)简单有效。权重衰减优化器里加weight_decay1e-4相当于L2正则化。早停验证集准确率不再提升就停止训练防止网络记住训练集的噪声。减小模型容量如果以上手段都不管用那可能是模型太大了减少层数或通道数。注意BatchNorm在训练和测试时的行为不一样训练时用当前batch的均值和方差测试时用全局的滑动平均。所以你在测试之前一定要调用model.eval()否则结果会不稳定。这个坑我踩过不止一次。8. 一些掏心窝子的经验分享把这六个网络全部手撸一遍之后我最大的感受是深度学习的门槛不在数学而在工程细节。反向传播的公式推导花一个下午总能看懂但维度怎么变换、参数怎么初始化、学习率怎么调这些东西没有捷径只能靠一行行代码堆出来。我建议你在学这几个网络的时候不要只跑一遍就完事。跑通之后试着改一改结构把LeNet-5的卷积核从5x5改成3x3看看准确率有什么变化把ResNet的残差块从两个3x3卷积改成三个3x3卷积看看训练时间增加多少把自编码器的瓶颈层维度减半看看重构图像模糊到什么程度。这种“破坏性实验”能让你对每个参数的作用有更直观的理解。另外不要迷信网上的“最佳实践”。别人的学习率、batch size、数据增强策略放到你的环境里不一定好用。我自己的习惯是每换一个数据集或网络结构先用小规模数据跑几个epoch确认loss在降、梯度正常再放大到全量数据。这样能快速排除掉大部分低级错误。最后说一个我踩过的最大的坑不要在没有model.eval()的情况下测试模型。我曾经用ResNet在CIFAR10上训练到93%的准确率测试的时候只有10%排查了两个小时才发现是忘了切换eval模式BatchNorm在测试时用了当前batch的统计量导致输出完全乱套。这个教训值两个小时希望你能记住。
阅读完成 · 觉得有帮助?
咨询建站