1. 从一张“老图”说起:为什么现在还要回头看CNN经典模型
如果你翻过任何一本深度学习入门教材,大概率会在前几章看到一张密密麻麻的“网络结构演进图”——从最早的卷积雏形,到后来动辄十几层、几十层的庞然大物,一条时间线串起了整个卷积神经网络的发展史。我做模型复现和教学演示这几年,被问得最多的一个问题就是:“现在都有各种注意力机制和视觉大模型了,为什么还要花时间去看这些老框架?”
这个问题问得好,但答案也很直接:经典模型是理解一切现代视觉架构的“语法基础”。你去看现在任何一个新出的视觉骨干网络,剥开那些花哨的模块名字,底层依然是卷积、池化、归一化、残差连接这些基本单元的组合。如果连LeNet、AlexNet、VGG、GoogLeNet、ResNet这几个里程碑式的结构都没亲手搭过一遍,那看论文时基本就是“每个字都认识,连起来不知道在说什么”的状态。
所以这篇博文,我想从一个一线从业者的角度,把CNN经典模型框架的展示与介绍这件事讲透。不是照本宣科地念论文摘要,而是把每个模型为什么这么设计、关键参数怎么算、代码怎么落地、实际跑起来会遇到什么坑,全部摊开来说。适合正在学深度学习基础的学生、需要做模型选型对比的工程师,以及想给团队做技术分享但不知道怎么组织内容的开发者。读完你至少能做到两件事:一是能对着白板把五个经典模型的骨架画出来并讲清楚设计动机,二是能用代码把它们跑起来做一次完整的对比实验。
2. 经典CNN框架的整体设计脉络与选型逻辑
2.1 五个绕不开的里程碑:各自解决了什么问题
在动手写代码之前,先把这几个模型放在一条逻辑线上看,你会理解得更深。它们不是孤立出现的,每一个都是为了解决前一个暴露出来的问题。
LeNet-5是卷积神经网络的“祖师爷”级别结构,诞生于上世纪九十年代,用来做手写数字识别。它的贡献在于第一次完整定义了“卷积层提取特征 + 池化层降维 + 全连接层分类”这个基本范式。结构非常小,总共大约六万个参数,放到今天连一个迷你模型都算不上,但它是理解卷积操作最干净的样本。
AlexNet的关键词是“深度”和“工程突破”。它把网络加深到八层,参数量拉到六千万级别,并且引入了ReLU激活函数和Dropout来对抗过拟合。更重要的是,它证明了卷积网络在大规模图像分类任务上真的能打,直接点燃了后续几年的研究热潮。从工程角度看,AlexNet还用了数据增强和多GPU训练的思路,这些做法至今仍是标配。
VGG的核心思想就一个字:规整。它用连续的3×3小卷积核堆叠来替代大卷积核,通过反复的“卷积-卷积-池化”模块把网络做到十六到十九层。VGG的设计哲学是“用深度换表征能力”,结构极其统一,所以特别适合用来做特征提取的骨干网络。但它的问题也很明显——全连接层参数量巨大,模型文件动辄五百多兆,部署起来很吃力。
GoogLeNet走的是另一条路:不盲目加深,而是加宽。它提出了Inception模块,在同一层里并行使用不同尺寸的卷积核,然后把结果拼接起来。这样做的好处是网络可以同时捕捉不同尺度的特征,而且通过1×1卷积做通道降维,参数量反而比VGG小了很多,只有大约五百万参数。GoogLeNet还引入了辅助分类器的设计来缓解深层网络的梯度消失问题。
ResNet则是为了解决“网络越深越难训练”这个根本性难题。它提出的残差连接让梯度可以绕过某些层直接回传,使得训练上百层甚至上千层的网络成为可能。残差块的设计极其简洁——一个恒等映射加上两层卷积,但效果拔群。ResNet之后,深度网络的训练问题基本被解决了,后续的很多架构都在这个基础上做文章。
2.2 为什么选择这五个模型做展示对比
你可能会问,经典模型那么多,为什么偏偏选这五个?我的选型逻辑是这样的:
- 覆盖完整演进路径:从最初的卷积范式到深度化、模块化、残差化,五个模型刚好串成一条完整的技术演进线。
- 结构差异足够大:每个模型都有独特的设计决策,对比起来信息量大,不会让人觉得“都差不多”。
- 代码实现难度适中:都能用主流框架在几百行以内实现,适合做教学演示和快速实验。
- 参数量跨度合理:从六万到六千万,能直观感受模型复杂度对训练和推理的影响。
提示:如果你时间有限,建议优先吃透LeNet和ResNet这两个。前者帮你建立最基本的卷积直觉,后者帮你理解现代深度网络的核心设计思想。中间三个可以作为扩展阅读。
2.3 展示与介绍类项目的整体架构思路
做这类“经典模型框架展示与介绍”的项目,我的经验是不要一上来就堆代码。合理的组织方式应该分三层:
第一层是结构可视化,用文字、表格或手绘示意图把每个模型的层次结构和数据流向讲清楚。这一层不需要任何代码,但要求你对每个模块的输入输出尺寸了如指掌。
第二层是代码实现,用统一的风格把每个模型写出来,保持接口一致,方便后续对比。这里要注意的是,不要直接复制粘贴网上的代码,最好自己从头写一遍,哪怕照着论文伪代码翻译也行,写的过程中你会被迫理解每一个细节。
第三层是实验对比,在同一个数据集上跑一遍,记录参数量、训练时间、准确率等指标,用数据说话。这一层是区分“看过”和“做过”的关键。
3. 逐个拆解:核心结构细节与参数计算实操
3.1 LeNet-5:最小可用的卷积范式
LeNet-5的结构非常清晰,输入是一张32×32的单通道图像,然后依次经过:
- 卷积层C1:6个5×5卷积核,步长1,输出6×28×28
- 池化层S2:2×2平均池化,步长2,输出6×14×14
- 卷积层C3:16个5×5卷积核,输出16×10×10
- 池化层S4:2×2平均池化,输出16×5×5
- 卷积层C5:120个5×5卷积核,输出120×1×1
- 全连接层F6:84个神经元
- 输出层:10个神经元对应数字分类
参数量的计算过程值得亲手算一遍。以C1层为例,参数量是6×(5×5×1+1)=156,其中加1是偏置项。C3层的参数量是16×(5×5×6+1)=2416。整个模型加起来大约六万个参数,非常轻量。
用PyTorch实现的话,核心代码如下:
import torch.nn as nn class LeNet5(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 6, kernel_size=5), nn.Tanh(), nn.AvgPool2d(kernel_size=2, stride=2), nn.Conv2d(6, 16, kernel_size=5), nn.Tanh(), nn.AvgPool2d(kernel_size=2, stride=2), nn.Conv2d(16, 120, kernel_size=5), nn.Tanh(), ) self.classifier = nn.Sequential( nn.Linear(120, 84), nn.Tanh(), nn.Linear(84, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)注意:原始LeNet用的是Tanh激活函数,不是现在常用的ReLU。做展示的时候建议保留原版设计,这样更能体现历史原貌。如果你要拿它做实际任务,换成ReLU和最大池化会明显提升效果。
3.2 AlexNet:深度与工程技巧的结合
AlexNet把输入尺寸扩大到224×224的三通道图像,结构上分为五个卷积层和三个全连接层。关键设计点包括:
- 第一层用11×11的大卷积核,步长4,快速降低空间尺寸
- 使用ReLU替代Tanh,训练速度提升明显
- 引入Dropout(丢弃率0.5)放在全连接层后面
- 使用局部响应归一化(LRN),不过这个后来被证明作用有限
参数量的大头在全连接层。以第六层为例,输入是256×6×6的特征图,展平后是9216维,输出4096维,参数量就是9216×4096+4096,大约三千七百万。整个模型的参数量约六千万,其中绝大部分集中在全连接层。
class AlexNet(nn.Module): def __init__(self, num_classes=1000): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), nn.Conv2d(64, 192, kernel_size=5, padding=2), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), nn.Conv2d(192, 384, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(384, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), ) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(256 * 6 * 6, 4096), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Linear(4096, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)3.3 VGG:用规整换深度
VGG的设计哲学是“极致的规整”。它全部使用3×3卷积核和2×2最大池化,通过反复堆叠来增加深度。VGG-16的结构可以分成五个卷积块,每个块后面接一个池化层,最后是三个全连接层。
为什么用两个3×3卷积代替一个5×5卷积?这里有一个经典的计算:两个3×3卷积的感受野和一個5×5卷积相同,但参数量更少(2×3×3=18 vs 5×5=25),而且中间多了一次非线性激活,表达能力更强。这个思路后来被广泛采用。
VGG的参数量约一亿三千八百万,其中全连接层占了绝大部分。第一层全连接输入是512×7×7=25088维,输出4096维,参数量就超过一亿。这也是VGG模型文件特别大的原因。
def make_vgg_block(in_channels, out_channels, num_convs): layers = [] for i in range(num_convs): layers.append(nn.Conv2d(in_channels if i == 0 else out_channels, out_channels, kernel_size=3, padding=1)) layers.append(nn.ReLU(inplace=True)) layers.append(nn.MaxPool2d(kernel_size=2, stride=2)) return layers class VGG16(nn.Module): def __init__(self, num_classes=1000): super().__init__() config = [(64, 2), (128, 2), (256, 3), (512, 3), (512, 3)] layers = [] in_ch = 3 for out_ch, num_convs in config: layers.extend(make_vgg_block(in_ch, out_ch, num_convs)) in_ch = out_ch self.features = nn.Sequential(*layers) self.classifier = nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(4096, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)3.4 GoogLeNet:Inception模块的精巧设计
GoogLeNet的核心创新是Inception模块。一个基础的Inception模块包含四条并行分支:
- 1×1卷积分支
- 1×1卷积后接3×3卷积分支
- 1×1卷积后接5×5卷积分支
- 3×3最大池化后接1×1卷积分支
四条分支的输出在通道维度上拼接。这里的关键技巧是:在3×3和5×5卷积之前先用1×1卷积做通道降维,否则参数量和计算量会爆炸。比如输入256通道,直接做5×5卷积到64通道,参数量是256×5×5×64=409600;如果先用1×1卷积降到32通道再做5×5卷积到64通道,参数量变成256×1×1×32 + 32×5×5×64=8192+51200=59392,只有原来的七分之一左右。
class InceptionBlock(nn.Module): def __init__(self, in_ch, ch1x1, ch3x3_reduce, ch3x3, ch5x5_reduce, ch5x5, pool_proj): super().__init__() self.branch1 = nn.Sequential( nn.Conv2d(in_ch, ch1x1, kernel_size=1), nn.ReLU(inplace=True), ) self.branch2 = nn.Sequential( nn.Conv2d(in_ch, ch3x3_reduce, kernel_size=1), nn.ReLU(inplace=True), nn.Conv2d(ch3x3_reduce, ch3x3, kernel_size=3, padding=1), nn.ReLU(inplace=True), ) self.branch3 = nn.Sequential( nn.Conv2d(in_ch, ch5x5_reduce, kernel_size=1), nn.ReLU(inplace=True), nn.Conv2d(ch5x5_reduce, ch5x5, kernel_size=5, padding=2), nn.ReLU(inplace=True), ) self.branch4 = nn.Sequential( nn.MaxPool2d(kernel_size=3, stride=1, padding=1), nn.Conv2d(in_ch, pool_proj, kernel_size=1), nn.ReLU(inplace=True), ) def forward(self, x): return torch.cat([self.branch1(x), self.branch2(x), self.branch3(x), self.branch4(x)], dim=1)GoogLeNet还有一个值得注意的设计是全局平均池化。它在最后一个Inception模块后直接对每个通道取平均值,然后接一个全连接层输出分类结果。这样做大幅减少了参数量,这个思路后来被很多网络借鉴。
3.5 ResNet:残差连接改变游戏规则
ResNet的核心是残差块。一个基础残差块包含两层3×3卷积,然后把输入直接加到输出上。如果输入输出通道数不一致,就需要用一个1×1卷积做投影来匹配维度。
class BasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_ch) self.relu = nn.ReLU(inplace=True) self.shortcut = nn.Sequential() if stride != 1 or in_ch != out_ch: self.shortcut = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_ch), ) def forward(self, x): out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(x) return self.relu(out)残差连接为什么有效?直观理解是:如果某些层学不到有用特征,网络可以让它们输出接近零,此时恒等映射保证信息仍然能传过去。从梯度角度看,反向传播时梯度可以通过shortcut路径直接回传,避免了逐层相乘导致的梯度消失。这个设计让训练上百层的网络变得可行,是深度学习发展史上的关键转折点。
4. 完整实操:从零搭建对比实验环境
4.1 环境准备与依赖安装
做这类对比实验,环境不需要太复杂。我的建议是用Python 3.9以上版本,PyTorch 2.0以上,再加一些辅助库。
pip install torch torchvision matplotlib pandas tqdm如果你有GPU,确认一下CUDA是否可用:
import torch print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU only")数据集方面,建议用CIFAR-10做对比实验。它比MNIST更有挑战性,又比ImageNet轻量得多,单卡就能跑。torchvision自带下载接口,几行代码就能搞定。
from torchvision import datasets, transforms transform_train = transforms.Compose([ transforms.Resize(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), ]) train_set = datasets.CIFAR10(root="./data", train=True, download=True, transform=transform_train)注意:LeNet原始输入是32×32单通道,用在CIFAR-10上需要把输入改成三通道,尺寸保持32×32即可。其他模型统一用224×224输入,方便对比。
4.2 统一训练脚本的设计
为了让对比公平,所有模型共用同一套训练循环。关键参数保持一致:优化器用SGD加动量,学习率0.01,权重衰减5e-4,批次大小64,训练30个epoch。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += images.size(0) return total_loss / total, correct / total训练过程中建议记录每个epoch的损失和准确率,最后统一画图对比。用tqdm加个进度条,看着舒服也方便判断是否卡住。
4.3 参数量与计算量统计
参数量可以直接数:
def count_parameters(model): return sum(p.numel() for p in model.parameters() if p.requires_grad)计算量(FLOPs)需要额外工具,推荐用thop或者ptflops:
pip install thopfrom thop import profile input_tensor = torch.randn(1, 3, 224, 224) flops, params = profile(model, inputs=(input_tensor,)) print(f"FLOPs: {flops / 1e9:.2f}G, Params: {params / 1e6:.2f}M")把五个模型的数据整理成表格,对比效果一目了然:
| 模型 | 参数量 | FLOPs | CIFAR-10准确率 | 单epoch训练时间 |
|---|---|---|---|---|
| LeNet-5 | 约6万 | 约0.04G | 约62% | 约8秒 |
| AlexNet | 约6000万 | 约0.7G | 约78% | 约25秒 |
| VGG-16 | 约1.38亿 | 约15.5G | 约85% | 约60秒 |
| GoogLeNet | 约500万 | 约1.5G | 约83% | 约30秒 |
| ResNet-18 | 约1100万 | 约1.8G | 约88% | 约35秒 |
提示:上表中的准确率和时间是参考值,实际结果会因硬件、超参数、数据增强策略不同而有波动。重点看相对关系,不要纠结绝对数值。
4.4 训练过程的可视化与结果分析
训练完成后,把损失曲线和准确率曲线画出来对比。我习惯用matplotlib画在一张图上,不同模型用不同颜色区分。
import matplotlib.pyplot as plt plt.figure(figsize=(12, 5)) plt.subplot(1, 2, 1) for name, history in all_histories.items(): plt.plot(history["train_loss"], label=name) plt.xlabel("Epoch") plt.ylabel("Loss") plt.legend() plt.title("Training Loss Comparison") plt.subplot(1, 2, 2) for name, history in all_histories.items(): plt.plot(history["val_acc"], label=name) plt.xlabel("Epoch") plt.ylabel("Accuracy") plt.legend() plt.title("Validation Accuracy Comparison") plt.tight_layout() plt.savefig("comparison.png", dpi=150)从曲线上能看出几个有意思的现象。LeNet收敛最快但最终准确率最低,说明容量不够。VGG准确率不错但训练损失下降很慢,因为参数量大需要更多迭代。ResNet收敛稳定且最终效果最好,残差连接的作用非常明显。GoogLeNet在参数量远小于VGG的情况下达到了接近的准确率,说明Inception模块的参数效率很高。
5. 实操中踩过的坑与常见问题排查
5.1 尺寸不匹配:最常见的报错来源
做这类模型展示,十次报错有八次是尺寸问题。卷积层输出尺寸的计算公式是:
输出尺寸 = (输入尺寸 + 2×填充 - 卷积核尺寸) / 步长 + 1
这个公式必须记牢。比如输入224,卷积核7,步长2,填充3,输出就是(224+6-7)/2+1=112。如果算错了,下一层就会报维度不匹配。
排查技巧:在forward函数里加print语句,把每一层的输出尺寸打出来,一眼就能定位问题在哪一层。
def forward(self, x): for i, layer in enumerate(self.features): x = layer(x) print(f"Layer {i}: {x.shape}") return x5.2 显存不够:批次大小与梯度累积
VGG-16在224×224输入下,批次大小开到32就可能爆显存。解决办法有两个:一是减小批次大小,二是用梯度累积模拟大批次。
accumulation_steps = 4 for i, (images, labels) in enumerate(loader): outputs = model(images) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()梯度累积的原理是多次前向传播的梯度累加后再更新参数,效果上等价于更大的批次,但显存占用不变。
5.3 训练不收敛:学习率与初始化的影响
经典模型对初始化比较敏感。如果你发现损失一直不降或者变成NaN,先检查三件事:
- 学习率是不是太大了,试试降到0.001
- 权重初始化是否合理,PyTorch默认初始化通常够用,但VGG这种深层网络可能需要手动初始化
- 数据归一化做了没有,输入没归一化很容易导致训练不稳定
注意:ResNet的残差块最后一层BN的权重初始化为零,这样初始状态下残差块相当于恒等映射,训练更稳定。这个细节在原始论文里有提到,但很多复现代码会忽略。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 维度不匹配报错 | 卷积输出尺寸算错 | 用公式重新计算,加print调试 |
| 损失变成NaN | 学习率过大或梯度爆炸 | 降低学习率,加梯度裁剪 |
| 显存溢出 | 批次太大或模型太深 | 减小批次,用梯度累积 |
| 准确率不上升 | 学习率太小或数据有问题 | 调大学习率,检查数据标签 |
| 训练慢 | 数据加载是瓶颈 | 增加num_workers,用pin_memory |
| 过拟合严重 | 模型容量过大 | 加Dropout,加数据增强,加权重衰减 |
5.5 几个容易被忽略的实操心得
第一个心得:不要一上来就训练完整数据集。先用一小部分数据跑通流程,确认没有维度错误、显存够用、损失能下降,再上全量数据。这样能省很多等待时间。
第二个心得:保存检查点比保存最终模型重要。训练过程中每隔几个epoch存一次,万一中途出问题不用从头再来。而且你可以对比不同阶段的模型效果,有时候中间epoch的泛化能力反而更好。
第三个心得:对比实验要控制变量。如果你想比较模型结构的影响,那数据增强、优化器、学习率这些都要保持一致。我见过有人对比ResNet和VGG,结果一个用了数据增强一个没用,得出的结论完全没有参考价值。
第四个心得:可视化中间特征图。把卷积层的输出画出来看看,你会发现浅层学的是边缘和纹理,深层学的是语义部件。这个直观感受比看多少论文都管用。
def visualize_feature_maps(model, image, layer_index=0): x = image.unsqueeze(0) for i, layer in enumerate(model.features): x = layer(x) if i == layer_index: break feature_maps = x.squeeze(0).detach().cpu() fig, axes = plt.subplots(4, 8, figsize=(16, 8)) for idx, ax in enumerate(axes.flat): if idx < feature_maps.shape[0]: ax.imshow(feature_maps[idx], cmap="viridis") ax.axis("off") plt.savefig("feature_maps.png", dpi=150)6. 模型选型建议与后续扩展方向
6.1 不同场景下该选哪个模型
做完这一轮对比实验,选型思路其实就清晰了。如果你要做的是教学演示或快速原型验证,LeNet足够,改起来方便,跑起来快。如果是中等规模的图像分类任务,数据量在几万到几十万之间,ResNet-18或GoogLeNet是性价比最高的选择,参数量适中,效果稳定。如果任务对精度要求极高且算力充足,可以考虑更深的ResNet变体或者在此基础上做迁移学习。VGG和AlexNet现在更多是作为教学样本存在,实际项目中直接用它们的情况已经很少了,但理解它们的设计思路对用好其他模型很有帮助。
6.2 从经典模型到现代架构的延伸
把这五个模型吃透之后,你再去看后续的架构会轻松很多。比如DenseNet的密集连接可以看作残差连接的极端版本,每一层都和前面所有层相连。EfficientNet的复合缩放策略是在深度、宽度、分辨率三个维度上做平衡,这个思路的起点就是VGG和ResNet的对比实验。甚至Transformer在视觉领域的应用,本质上也是在回答“如何更好地建模全局依赖”这个问题,而卷积网络通过堆叠感受野来逼近全局建模的做法,正是这个问题的早期答案。
6.3 给想深入的朋友几个练手方向
如果你想在这个基础上继续深入,我建议从三个方向入手。一是手动实现反向传播,不依赖框架的自动求导,用numpy把LeNet的训练过程写一遍,写完你对卷积的理解会上一个台阶。二是做消融实验,比如把ResNet的残差连接去掉看看效果差多少,把VGG的3×3卷积换成5×5看看参数量和准确率怎么变,这种对比能让你对每个设计决策的价值有切身体会。三是迁移学习实践,拿预训练的ResNet在你自己领域的小数据集上微调,感受一下预训练特征到底有多强。
最后分享一个我自己的习惯:每学完一个经典模型,我都会用白板把它画一遍,不看书不查资料,凭记忆画。画不出来的地方就是没真正理解的地方,回去补课。这个方法看起来笨,但效果特别好。你能把五个模型的骨架都默画出来的时候,CNN这部分的基础就算真正打牢了。