1. 选题拆解:为什么数字识别能同时搞定课程设计和毕业设计
先说结论:数字识别这个题目在学校项目里出现频率极高,不是因为它简单,而是因为它恰好踩中了课程设计和毕业设计需要满足的几乎所有考核点。
从课程设计的角度看,数字识别任务足够聚焦,不需要像人脸识别、目标检测那样处理复杂的背景和多类别问题。手写数字就10个类别,黑白图像,尺寸统一,任何一个刚接触机器学习的人都能在有限时间内跑通一条完整流程。从毕业设计的角度看,它又有足够的深度可以挖掘——数据增强、模型结构改进、训练策略优化、部署应用,每个方向都能撑起一个完整的研究章节。
我当时接手这个项目时,最真实的感受是:它像一块空白的画布,基础任务大家都能做,但想拿高分,就看你在数据、模型、实验设计三个层面能挖多深。
做这类项目,最忌讳的就是把它当成“调库题”——数据下载下来、模型套上去、准确率打印出来,报告写完就完事。老师和答辩评委看重的从来不是那行测试准确率数字,而是你有没有真正理解数据在模型里走了一趟之后发生了什么变化。
下面我按自己做项目时的完整流程来拆解,从数据集理解、模型演进、代码实现、调参技巧到报告撰写,每个环节都写下我在实际操作中踩过的坑和后来总结出的经验。
2. 数据集理解:MNIST到底难在哪,以及它为什么适合练手
2.1 数据集本质:不只是“一堆手写数字图片”
这个项目的核心数据集,最常见的是MNIST(Modified National Institute of Standards and Technology)。它包含6万张训练图片和1万张测试图片,每张图片是28x28像素的灰度图,像素值范围在0到255之间。
很多同学第一次接触这个数据集时,有一个严重的认知误区:觉得28x28分辨率太小了,数字看起来歪歪扭扭的,任务应该很简单。但实际训练时才会发现,手写数字的“人味”太足了——同一个数字“7”有人写带横杠的,有人不带;同一个“2”有人写成曲线有人写成折线。这种类内方差极大、类间边界时而模糊的情况,恰恰是机器学习分类问题最典型的挑战。
画个直观一点的类比:这就像让你从字迹分辨人的性格,看多了会发现同一类字迹内部也有千差万别。模型要学的不是“记住每个样本”,而是从大量样本中抽象出“数字7的共性特征”。
实际操作中我会做的一件事是:把数据集可视化出来,随机抽25张图拼成5x5网格。这件事请一定要做,它有两个直接好处——一是验证你数据加载和格式转换的代码没问题,否则预处理阶段出错会延续到训练阶段,排查起来非常痛苦;二是让自己直观感受手写数字的形态多样性,对后面设置合适的模型复杂度会有参考。
2.2 数据预处理:归一化为什么比想象中重要
MNIST的像素值默认在0到255之间,直接喂给神经网络会有两个问题。第一,数值过大容易让初始梯度计算不稳定,特别是对于没有BatchNorm层的早期网络结构;第二,不同特征(像素)的数值范围差距大,会拖慢模型收敛速度。
最标准的做法是除以255归一化到[0, 1]区间,或者进一步标准化为均值0、方差1的分布。我实际测试过,同样一个简单的全连接网络,输入直接喂255范围像素值时,训练约10轮准确率徘徊在85%左右,而归一化到[0, 1]后在同样的超参数下能到93%以上。这个差距在后来的深层网络里没有那么明显,因为BatchNorm层会自动调整数据分布,但在基础模型中,归一化是性价比最高的性能提升手段。
提示:数据预处理的代码要放在模型训练代码之外独立封装成函数。别问为什么,等你需要换数据集、调格式时就明白了。
2.3 训练集、验证集、测试集的划分逻辑
MNIST官方已经分好了训练集和测试集,但做课程设计时我强烈建议你从训练集中再切出一部分作为验证集。原因很简单:你需要在训练过程中评估模型表现、调整超参数,如果直接拿测试集来调参,就会导致“窥探测试集分布”,最终跑出来的准确率虚高,答辩时一问一个准。
我常用的划分方式是把原始6万张训练集分成5.4万张训练和6000张验证,保持类别分布大致均匀。在PyTorch里用random_split或SubsetRandomSampler都可以,但要注意设置随机种子,否则每次实验划分都不同,对照组就没意义了。
这里有个容易被忽视的细节:手写数字数据集的像素分布有偏移特性。通俗地说,不同人写数字的风格和书写位置有系统性差异,官方MNIST自己就承认训练集和测试集的书写者来源不完全一致。应对方式是在预处理时加入随机位移、随机旋转等数据增强,或者在评估时观察验证集和测试集准确率差异是否过大——如果测试集准确率明显低于验证集,往往不是过拟合,而是数据分布偏移带来的自然落差。
3. 模型选型:从传统机器学习到深度学习的递进式实现
3.1 经典机器学习路线:SVM和kNN也能打
课程设计如果偏“机器学习”方向,不涉及深度神经网络,常见组合是HOG特征(Histogram of Oriented Gradients,方向梯度直方图)配合SVM分类器,或者直接用kNN(k近邻)。
这里有个经常被问到的点:“为什么不用原始像素直接喂SVM?”理论上可以,但效果一般。原始像素作为特征,维度是784,SVM在高维稀疏特征下容易过拟合,而且像素之间的相对位置信息没有被利用。用HOG提取边缘梯度和方向分布信息后,特征维度降低到几百维,同时保留了数字的局部形状特征,SVM在这个特征空间里效果会好很多。
我实测过一组数据:直接用kNN(k=3,欧氏距离)在原始像素上做到约97%的准确率;用HOG特征加SVM(RBF核)能到98%左右;但同等量级的简单MLP(多层感知机)能到98%以上,CNN(卷积神经网络)则稳定在99%以上。传统方法仍然有意义,一是便于讲解原理,二是能体现查资料和实验对比的能力——这在毕业设计里是加分项。
注意:kNN在MNIST上有个著名的“捷径”——对测试图片做轻微膨胀或偏移后,在k近邻中投票占比会变化,最高能刷到99%以上。但这种方法本质上是数据分布层面的小动作,不建议在正式项目里用,答辩时容易被追问到原理层面回答不上来。
kNN的细节其实很多人会忽略——k值的选择和距离度量方式直接影响结果。我做过一个小实验:
| k值 | 距离度量 | 准确率 |
|---|---|---|
| 1 | 欧氏距离 | 96.8% |
| 3 | 欧氏距离 | 97.1% |
| 5 | 欧氏距离 | 97.0% |
| 3 | 曼哈顿距离 | 96.5% |
| 5 | 余弦相似度 | 96.9% |
k值过小时,模型对噪声敏感;k值过大时,分类边界过于平滑,会丢失细节。这个“拐点”在实验中非常直观,适合写进报告作对比。
3.2 深度学习路线:MLP到CNN的演进逻辑
如果项目选择深度学习方向,演进路线通常是从MLP开始理解神经网络的基本结构,再过渡到CNN理解特征提取。这两步如果跨得太大,很多概念会衔接不上。
MLP的结构很简单:输入层784个神经元接收28x28的像素值,经过若干全连接层,最后输出层10个神经元对应0至9十个类别的打分。我做过一组对比实验:
| 网络结构 | 参数量 | 测试准确率 |
|---|---|---|
| 784-30-10(单隐藏层30) | 2.4万 | 95.8% |
| 784-128-64-10 | 11万 | 97.2% |
| 784-256-128-64-10 | 22万 | 97.8% |
可以看到,模型容量提升能带来准确率提升,但边际收益在递减。更关键的问题是:为什么需要CNN?MLP已经能到98%了,再往上提为什么这么难?
答案是全连接层无法有效利用图像的局部空间结构。在MLP中每个输出神经元都会“看到”整张图片的所有像素,但手写数字的笔画、拐角、交叉点这些关键局部特征被分散在784个输入中,隐藏层很难在训练中自动聚合出“左上角有弧线”“中间有斜杠”这种局部模式。一定意义上说,MLP仍然在处理一个全局问题。
CNN的卷积核就像一个小型扫描窗口——每个卷积核在整张图上滑动提取局部特征,多个卷积核堆叠之后能组合出从简单边缘到复杂数字部件的高层抽象特征。这种结构天然更适合图像分类。卷积层的核心思想是把特征检测器做成了小窗口在整图上共享复用。
我的建议是:在报告中即使最终采用CNN,也花一个章节把MLP的效果放进来做对比。这个对比说明你清楚“深度学习为什么会有效”的本质,而不只是会用框架。
3.3 CNN结构设计:LeNet风格还是自定义结构
手写数字识别领域最经典的CNN结构是LeNet-5,它由卷积层、池化层、全连接层交替组成:
- 输入:32x32的图像(LeNet原版接收的是32x32,MNIST的28x28会先做扩边)
- C1卷积层:6个5x5卷积核,输出特征图
- S2池化层:2x2平均池化
- C3卷积层:16个5x5卷积核
- S4池化层:2x2平均池化
- C5卷积层:120个卷积核
- F6全连接层:84个神经元
- 输出层:10个神经元,Softmax分类
我在实际操作中发现,完整LeNet-5对MNIST已经是“杀鸡用牛刀”,准确率能到99.3%左右,但训练时间和参数量可以进一步精简。如果目的是毕业设计做改进实验,可以保留框架、微调超参数;如果只是想课程设计过关,稍微简化版的CNN足矣。
一个简化但效果稳定的结构是这样:
Conv1: 3x3, 32个卷积核, padding=1, ReLU -> MaxPool 2x2 Conv2: 3x3, 64个卷积核, padding=1, ReLU -> MaxPool 2x2 Flatten -> Dense 128 -> ReLU -> Dropout(0.5) -> Dense 10这个结构在MNIST上测试准确率稳定在99%附近,参数量约70万,训练速度很快,几轮就能看到明显收敛。如果你后续想做“模型压缩”“剪枝”等毕业设计改进方向,这个结构是一个适合做减法的起点。
4. 核心代码实现与训练流程详解
4.1 数据加载与预处理代码
用PyTorch实现时,数据加载的推荐做法是直接用torchvision.datasets.MNIST,它内部已经处理好了下载、划分和格式转换。但我建议不要直接跳到模型代码,先把数据加载写清楚:
import torch from torchvision import datasets, transforms # 定义对训练数据的预处理 train_transform = transforms.Compose([ transforms.ToTensor(), # 转换像素值为[0, 1]区间的Tensor transforms.Normalize((0.1307,), (0.3081,)), # 标准化,两个值分别是MNIST全局均值/标准差 ]) # 加载训练集 train_dataset = datasets.MNIST( root='./data', train=True, transform=train_transform, download=True ) # 加载测试集 test_dataset = datasets.MNIST( root='./data', train=False, transform=train_transform, download=False )这里有两个细节值得解释:第一,Normalize参数中的0.1307和0.3081是官方计算好的MNIST像素均值与标准差,直接用即可,不必自己重新统计;第二,ToTensor()会在转换时自动把像素值从0到255缩放到0到1,所以之后的Normalize才是真正做标准化的步骤。
4.2 数据集划分与加载器构建
前面提到需要从训练集中切出验证集,做法如下:
from torch.utils.data import random_split, DataLoader # 切分:5.4万训练,6千验证 train_subset, val_subset = random_split( train_dataset, [54000, 6000], generator=torch.Generator().manual_seed(42) ) train_loader = DataLoader(train_subset, batch_size=128, shuffle=True) val_loader = DataLoader(val_subset, batch_size=128, shuffle=False) test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False)Batch size的设定我建议从128开始,这是一个在速度和稳定性之间比较平衡的值。如果你在GPU上跑可以适度增大到256,但如果batch size太大,梯度估计变得平滑,模型在训练初期容易收敛到较差的平坦极小值。
4.3 模型搭建代码与参数量分析
我把简化的CNN和MLP都贴出来,方便对照:
import torch.nn as nn class MLP(nn.Module): def __init__(self, hidden_dims=[128, 64]): super().__init__() layers = [] input_dim = 28 * 28 # 784 for h_dim in hidden_dims: layers.append(nn.Linear(input_dim, h_dim)) layers.append(nn.ReLU()) input_dim = h_dim layers.append(nn.Linear(input_dim, 10)) self.net = nn.Sequential(*layers) def forward(self, x): x = x.view(x.size(0), -1) # 展平为 (batch, 784) return self.net(x) class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Dropout(0.5), nn.Linear(128, 10), ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x注意CNN里64 * 7 * 7的计算:输入28x28,经过一次2x2最大池化变成14x14,经过第二次池化变成7x7,输出通道为64,所以展平后维度是64x7x7=3136。全连接层从3136降到128,再降到10。这个维度计算是要能现场推出来的,答辩时很常问。
4.4 训练循环中的损失函数与优化器选择
数字识别是标准的10分类问题,损失函数用交叉熵(CrossEntropyLoss),优化器可以用Adam或SGD。我在项目初版用的是Adam,学习率设为0.001,训练速度确实快,但后期会发现在接近极优点附近波动较大。后来换成SGD加动量(momentum=0.9),配合学习率衰减,最终收敛更好、测试集准确率更高一点点。
训练循环的写法我建议整理成一个可复用的函数,输出每个epoch的训练损失和验证集准确率:
def train_one_epoch(model, loader, optimizer, criterion): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() epoch_loss = running_loss / total epoch_acc = correct / total return epoch_loss, epoch_acc def validate(model, loader, criterion): model.eval() running_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_loss = running_loss / total val_acc = correct / total return val_loss, val_acc训练循环中有一个很容易忽略的点:model.train()和model.eval()的切换。train模式下Dropout会随机丢弃节点,BatchNorm会使用当前batch统计量;eval模式下Dropout关闭,BatchNorm使用全局统计量。不切换模式会导致评估结果忽高忽低,尤其在测试阶段。
4.5 模型保存与评估
训练完成后,不仅要在测试集上评估准确率,还要把模型权重保存下来,方便后续做界面演示或部署:
torch.save(model.state_dict(), 'cnn_mnist.pt')评估时除了看整体准确率,我建议输出每个类别的准确率和混淆矩阵。手写数字的错分类模式非常有意思——最常见的是“4”和“9”互相混淆,“3”和“5”互相混淆,“7”和“1”偶尔混淆。这种错误模式可以写到报告的分析章节,体现你已经从“模型能跑”进步到了“分析模型行为”的层次。
5. 训练过程实录与超参数调优
5.1 训练过程到底在发生什么
很多同学在训练时会盯着准确率曲线看,但我建议你同时盯两类曲线:训练损失和验证集准确率。训练损失下降表示模型在训练集上拟合能力增强;验证集准确率则是泛化能力的信号。两者共同变化才能判断模型是否健康。
我在一次实验中记录了完整的训练日志,大体是这样的趋势:
| Epoch | 训练损失 | 训练准确率 | 验证准确率 |
|---|---|---|---|
| 1 | 0.3201 | 90.2% | 92.1% |
| 2 | 0.1287 | 95.3% | 96.2% |
| 3 | 0.0842 | 97.1% | 97.4% |
| 5 | 0.0428 | 98.3% | 98.3% |
| 8 | 0.0221 | 99.0% | 98.7% |
| 10 | 0.0149 | 99.3% | 98.8% |
注意看第8轮之后的变化:训练损失还在下降,训练准确率继续增长,但验证集准确率几乎不动甚至略微下降。这就是过拟合信号——模型在学习训练集噪声,而不再提升泛化能力。这在课程设计阶段是非常有价值的一个观察点,如果你能在报告里结合这个现象讨论“早停”(early stopping)或“正则化”的必要性,答辩会感觉很扎实。
5.2 学习率怎么定,PyTorch上怎么调
学习率可能是整个训练过程中最难调的超参数。我个人的经验分三步走:
第一步,先把学习率设为一个相对通用的初始值。SGD建议从0.01开始,Adam建议从0.001开始。
第二步,观察训练前几个batch的损失下降速度。如果损失在前50个batch里纹丝不动或者反而上升,大概率是学习率太大,梯度在震荡;如果下降极其缓慢,则是学习率太小,需要调大。
第三步,使用学习率衰减策略。PyTorch中可以用torch.optim.lr_scheduler.StepLR,每隔一定epoch把学习率乘一个衰减系数,比如每5轮乘0.1。也可以直接用ReduceLROnPlateau,当验证损失连续若干轮不下降时自动降低学习率,这个对课程设计来说更省心:
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=2 )我自己的对比实验显示,使用StepLR衰减后,SGD+momentum在MNIST上能达到99.2%的测试准确率,而固定学习率的SGD只有98.9%。差距不算大,但毕业后做更复杂的视觉任务时,学习率策略的影响会成倍放大,提前养成习惯是好事。
5.3 过拟合怎么应对:Dropout、Weight Decay与数据增强
过拟合在MNIST这个数据集上不明显,因为数据量相对足够、类别较少,但做毕业设计如果追求极致准确率还是会遇到。常见三个武器按性价比排序:
Dropout是最简单的。在训练时随机“关掉”一部分神经元,迫使神经网络学习到更鲁棒的特征表示。一般在较大的全连接层之后加,概率设为0.3到0.5之间。注意Dropout只在全连接层有意义,卷积层后面通常不用,因为卷积层的参数共享结构本身已经有正则化效果。
Weight Decay(也叫L2正则化)是在损失函数里给大权重增加惩罚项,抑制模型依赖少数“过强”的特征。PyTorch中直接设置优化器参数weight_decay=1e-4即可。效果偏温和,用于锦上添花。
数据增强对MNIST是非常有效的手段。随机平移、轻微旋转、缩放、弹性形变,都能扩充有效训练样本。在实际训练中,我把随机旋转范围设为±15度,随机平移不超过3个像素,验证集准确率提升约0.3到0.5个百分点。学术上有一篇经典成果是通过大量数据增强加集成平均把MNIST准确率刷到了99.8%,说明数据层面的收益空间是真实存在的。
注意:数据增强只应用于训练集,验证集和测试集必须保持原始图像。增强本身是在构造“新的训练样本”,如果把验证集也做了增强,评估结果就没有意义了。
5.4 随机种子、可复现性与实验记录
做深度学习项目,最怕的就是结果时好时坏却找不到原因。我强烈建议在代码开头设置全局随机种子:
import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True set_seed(42)然后每次跑实验都在日志文件里记录:模型结构、超参数、数据集划分方式、训练轮数、最终准确率、当时的机器环境。我原本觉得这是“记流水账”,直到某次调整了数据增强的随机种子后验证集准确率波动了0.4个百分点,翻了日志才发现是数据划分方式变了。从此以后,记日志的习惯再没断过。
6. 项目扩展方向:从课程设计到毕业设计的加分项
6.1 方向一:注意力机制与特征可视化
CNN虽然效果不错,但它的“黑盒”属性在答辩时是个软肋。如果能在模型里加入注意力机制相关的可视化手段,比如绘制卷积核提取到的特征图、用Grad-CAM可视化模型关注区域,报告的学术深度会提升一大截。
实际操作中,提取中间特征图很简单:在forward里把某一层的输出单独返回,然后以热力图方式显示。你很快会发现,浅层卷积核大多在学习边缘和笔画方向,深层卷积核在学习数字的局部部件,这个观察结果非常好写进“特征分析”章节。
6.2 方向二:模型压缩与轻量化
如果目标是“从99%到99.2%”,意义有限;但如果是“让千亿参数量模型跑到小设备上”,那课题价值就完全不同了。一个可行方向是训练一个标准的CNN或MLP,然后做剪枝(pruning)或量化(quantization),对比压缩前后的准确率损失和推理速度变化。这类工作量不大,却能体现对部署应用的思考,毕业设计评分时有明显优势。
6.3 方向三:GUI交互与实时识别
做课程设计时,一个能够运行的图形界面往往比一堆测试数据更“加分”。常见做法是搭建一个简单界面,画板功能配合模型推理,用户手写一个数字,界面实时显示识别概率分布。
当时我用一个轻量级的桌面应用框架做了这个交互界面,处理流程是:
- 用户鼠标在画布上写字,画布内容被缩放为28x28的灰度图
- 对图像做和训练时相同的归一化处理
- 加载保存好的模型权重,以推理模式输出10个类别的概率
- 在界面上同时显示概率条形图与最高概率对应的数字
这里有一个必须注意的细节:画布采集到的图像和MNIST训练图像的数字灰度背景正好相反。MNIST是黑底白字,而用户在GUI画布上通常是白底黑字。如果不做像素翻转,模型预测会接近随机水平。这个细节我踩过一次坑后,每次都提前写进代码注释里。
6.4 方向四:跨数据集迁移能力分析
如果还想再加深度,可以考虑在MNIST上训练、在另一个手写数字数据集上做零样本测试,分析模型的分布外泛化能力。这类实验的本质是探讨“训练集分布和测试集分布不一致时会出什么事”,在工业实践中很有意义。不过这个方向需要更多数据预处理经验,如果时间紧张,不建议作为首选。
7. 踩过的坑、避坑技巧与项目经验心得
7.1 最容易踩的五个坑
- 数据集加载错误没发现:打印了几张图片才敢确定数据长什么样。
- 训练和验证阶段忘了切换
train/eval模式:Dropout和BatchNorm在不同模式下行为不同,漏切会导致验证集准确率忽高忽低。 - 归一化参数不一致:训练时用了标准差归一化,推理时却直接用原始像素值,结果准确率大幅下降。
- 标签没有对齐:数据加载器返回的索引顺序、数据增强后的顺序、标签顺序不对齐,排查时需要打印check名称。
- 过早用太复杂的网络:MLP还没调好就直接上深层CNN,出了问题很难判断是结构问题还是超参数问题。
7.2 关于调参经验的一些个人体会
我记得做第一版MNIST模型时,总觉得准确率能上98%就“差不多了”。后来做实验对比才发现,通过简单的数据增强和超参调整,同一结构能稳定到99%以上。关键是你要有一种“不满足于基线”的意识,每改动一个变量都要训练几个epoch验证效果,而不是相信别人博客里的结论。
另一个体会是,做这种项目要留足时间余量。前面看着简单,但数据增强调参、可视化、写报告、做PPT,每一项都会吃掉大量时间。尤其是报告里的实验对比图和表格,远比想象中耗时。
7.3 报告撰写时的实用建议
课程设计或毕业论文评审时,最忌讳的就是把报告写成代码说明书。代码怎么写的谁都能看,报告的重点应该是“为什么这样设计”和“实验结果说明了什么”。
一个比较稳妥的报告章节结构是:
- 第一章:概述,说明课题背景和意义,介绍数字识别的基本概念
- 第二章:相关工作与理论基础,包括传统机器学习方法、神经网络基础、CNN原理
- 第三章:数据集介绍与预处理方案
- 第四章:模型设计与实验方法,包括MLP、CNN的结构设计、超参数选择
- 第五章:实验结果与分析,包括损失曲线、准确率对比表、错分类案例分析
- 第六章:总结与展望
在答辩时,评委最常追问的三个点是:为什么选这个模型结构?准确率提升的来源是什么?模型在错分类样本上的行为表现如何?如果你能在报告里主动讨论这些问题,答辩压力会小很多。
最后再分享一个小技巧:**不要把“测试准确率99%”当成项目终点。**试着随机挑几张测试图片,把它们的真实标签、预测结果、每个类别的置信概率分别打印出来,你可能会看到一些特别有意思的错分类模式。比如某些“4”被预测为“9”的置信度其实高达80%以上,这说明在模型眼中它们确实长得很像。理解这些错误,是你从这个项目里获得的最大收获——它教会你的不只是训练一个模型,而是理解一个模型如何看世界。