浅探 MNIST
浅探 MNIST
MNIST 是一个经典的手写数字识别数据集,也常被用作深度学习入门实验。
0x00 - 基础准备
0x01 - 环境配置
笔者使用的是 Mac mini M4,因此在环境配置方面相对简单,使用 Homebrew 作为包管理器。这里因为没有什么特别重的需求我们直接用 venv 管理 Python 环境而不是使用 Anaconda 之类的。
于是:
brew install python3
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install torch torchvision torchaudio matplotlib考虑一般情况下我们使用 NVIDIA 的显卡的话我们可以使用 CUDA,不过当前环境没有 NVIDIA 的显卡我们使用 mps (Metal Performance Shaders) 作为后端替代以此调用我们的 GPU。
测试一下我们的环境:
import torch
print(torch.__version__)
print(torch.backends.mps.is_built())
print(torch.backends.mps.is_available())
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
print(device)输出:
2.11.0
True
True
mps0x02 - 数据集读取
我们注意到 torchvision.datasets 自带 MNIST,因此我们直接按照用法导入即可。
这里理解一下用到的东西:
tensortensor可以说是 PyTorch 中的标准数据格式了。可能是我才疏学浅,没能把握到和数学意义上的 tensor 张量之间的联系,反而觉得更像一个多维数组。transform
故名思义就是变换的意思。我们可以用transforms.Compose把多个变换串起来。其中我们只使用到了transforms.ToTensor()这个操作,把原本的PIL Image转化成了我们可用的某种形式,并顺带将像素值缩放到 的范围内。DataLoaderDataLoader的作用是把数据集进一步包装成一个可迭代的对象,方便我们按批次读取数据。比如这里设定batch_size=64,就表示每次取出 64 张图片及其对应标签;而shuffle=True则表示在训练时将数据顺序打乱,这通常有利于模型训练。于是我们有下面的代码:
import matplotlib.pyplot as plt
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
def get_loaders(batch_size=64):
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)) # 按 MNIST 训练集的均值和标准差做标准化,使输入分布更稳定
])
train_dataset = datasets.MNIST(
root="./data",
train=True,
download=True,
transform=transform,
)
test_dataset = datasets.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)
return train_loader, test_loader
def main():
batch_size = 64
train_loader, test_loader = get_loaders(batch_size=batch_size)
print("训练集大小:", len(train_loader.dataset))
print("测试集大小:", len(test_loader.dataset))
images, labels = next(iter(train_loader))
print("图像 shape:", images.shape)
print("标签 shape:", labels.shape)
print("第一张图对应的标签:", labels[0].item())
plt.imshow(images[0].squeeze(0), cmap="gray")
plt.title(f"label = {labels[0].item()}")
plt.axis("off")
plt.show()
if __name__ == "__main__":
main()有如下输出结果:
训练集大小: 60000
测试集大小: 10000
图像 shape: torch.Size([64, 1, 28, 28])
标签 shape: torch.Size([64])
第一张图对应的标签: 3
这里图像格式是一个 [batch_size, channels, height, width] 的 tensor,代表「这一批次的图片的数量」、「颜色通道」、「高度」、「宽度」,所以我们面对的就是 64 个为一批的 像素的灰度图。
0x10 - 知识准备
我们可能需要学习一些基础知识以理解后面的内容。
0x11 - 神经元与层
众所周知神经网络也许就是由若干神经元不断堆叠而成的结构,我们姑且理解神经元为接收若干输入然后对这些输入进行加权求和然后经过某种变换后输出结果的东西,也就是:
其中:
表示输入;
表示对应的权重;
表示偏置;
表示某种变换,通常称为激活函数;
表示输出。
而若将多个神经元并列放在一起,便构成了一层。前一层的输出,往往又会成为下一层的输入。于是,神经网络便可以看作由许多「层」首尾相接所形成的结构。
0x12 - 参数、权重与偏置
在前面的公式中, 与 并不是固定不变的常数,而是模型在训练过程中不断调整的量。它们统称为模型的参数。
其中:
**权重(weight)**用于控制每个输入对输出的影响程度;
**偏置(bias)**则可以理解为对整体结果进行额外平移的项。
所谓「训练模型」,从某种意义上说,其实就是不断调整这些参数,使模型的输出越来越接近真实答案。
0x13 - 全连接层
这是我们后面 MLP 会用到的妙妙道具。在 PyTorch 中写作 nn.Linear(in_features, out_features)。
其表示当前层中的每一个输出神经元,都与上一层中的所有输入神经元相连。等同于如下的线性变换:
其中 是输入向量, 是权重矩阵, 是偏置向量,而 表示输出向量。
0x14 - 激活函数
一个显然的结论是如果我们只有线性变换那么我们的整个系统完全就是线性的,我们需要通过激活函数来引入一些非线性,以此来拟合一些比较复杂的东西。比如十分常用的 nn.ReLU(),等效于
0x15 - 前向传播
在定义好神经网络结构之后,我们需要让输入数据真正经过这些层,从而得到最终输出。这个过程通常称为前向传播(forward propagation)。实际上就是给定输入,按照当前模型参数一路计算,最终得到输出。
0x16 - 反向传播
显然机器学习最重要的可能是学习。反向传播就是当我们预测错误的时候,调整参数让预测变得更好的过程。
我们引入损失函数来计算我们的预测结果和真实结果之间的差距,差距越大说明越不准,反之说明越准。于是我们反向传播的过程就是根据当前损失计算模型中的各个参数如何调整的过程。于是我们对损失函数求梯度 来描述当参数 发生变化时,损失 会如何随之变化。
0x17 - 参数更新与优化
在前面的反向传播中,我们已经得到了损失函数对各个参数的梯度。接下来我们便考虑「如何利用这些梯度来降低我们的损失函数?」
一种最基础的方法是梯度下降法:,按梯度下降最快的反方向更新参数,这里的 就是学习率,显然有:
学习率过大,参数更新可能过于剧烈,从而使训练过程不够稳定;
学习率过小,则训练过程可能过于缓慢,收敛速度不理想。
因此,在实际训练中,我们往往会使用学习率调度策略来动态调整学习率,从而在训练速度与训练稳定性之间取得平衡。
同时我们实际上会使用一些优化器来更新参数,例如在 MLP 中我们使用了 Adam 优化器。
0x18 - 基础框架
根据上文的内容,我们在一步训练中所要进行的,在一个 epoch 中按顺序就是如下几步操作:
前向传播,获取目前模型跑在当前 batch 上的数据;
计算损失,计算出当前输出和真实标签之间的误差;
清空梯度,清空上一轮遗留的梯度;
反向传播,根据当前损失反向传播,计算梯度。
更新参数
于是我们可以在
utils/train.py定义如下代码:
import torch
def train_one_epoch(model, loader, criterion, optimizer, device):
model.train()
total_loss = 0.0
total_correct = 0
total_samples = 0
for images, labels in loader:
images = images.to(device)
labels = labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item() * images.size(0)
preds = outputs.argmax(dim=1)
total_correct += (preds == labels).sum().item()
total_samples += images.size(0)
return total_loss / total_samples, total_correct / total_samples
@torch.no_grad()
def evaluate(model, loader, criterion, device):
model.eval()
total_loss = 0.0
total_correct = 0
total_samples = 0
for images, labels in loader:
images = images.to(device)
labels = labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
total_loss += loss.item() * images.size(0)
preds = outputs.argmax(dim=1)
total_correct += (preds == labels).sum().item()
total_samples += images.size(0)
return total_loss / total_samples, total_correct / total_samples
def run_training(
model,
train_loader,
test_loader,
criterion,
optimizer,
device,
epochs,
checkpoint_path=None,
scheduler=None,
):
best_acc = 0.0
for epoch in range(1, epochs + 1):
train_loss, train_acc = train_one_epoch(
model, train_loader, criterion, optimizer, device
)
test_loss, test_acc = evaluate(
model, test_loader, criterion, device
)
print(f"Epoch {epoch}/{epochs}")
print(f" Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f}")
print(f" Test Loss: {test_loss:.4f} | Test Acc: {test_acc:.4f}")
if checkpoint_path is not None and test_acc > best_acc:
best_acc = test_acc
torch.save(model.state_dict(), checkpoint_path)
if scheduler is not None:
scheduler.step()
return best_acc这就是由上文所述构造的一个可复用的训练框架。
在这个训练框架中,scheduler 被设计为一个可选项。若传入了学习率调度器,则每个 epoch 结束后调用一次 scheduler.step(),使学习率随训练过程自动变化;若未传入,则训练过程保持固定学习率不变。
0x20 - 多层感知机 (Multi-Layer Perceptron, MLP)
MLP,全称 Multi-Layer Perceptron,一般译作多层感知机。从结构上看,它可以理解为由若干个全连接层与激活函数串联起来所形成的最基础神经网络之一。笔者在这里粗略的理解为:「将输入数据展平成一个向量后,经过若干层线性变换与非线性激活,最终输出分类结果。」
鉴于我们的任务相对比较简单和基础,因此在这里我们首先使用 MLP 来学习如何跑通一个简单的模型(参数量高达 0.000102B),在下一节我们会使用 CNN 来获得更加靠谱的模型。
0x21 - 模型定义
我们把 像素的图像看作一个 维的向量,我们就可以定义一个简单的 MLP:
这里的 是我们的输入,因为我们有这么多像素,而 是我们的输出,因为我们需要识别 共 个数字。其中每一维代表是这个数字的倾向。至于中间的 是一个隐藏层,这个数字是根据别人的经验选择的。
我们可以根据上面的内容写出下面的代码:
import torch.nn as nn
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Flatten(),
nn.Linear(28 * 28, 128),
nn.ReLU(),
nn.Linear(128, 10),
)
def forward(self, x):
return self.net(x)我们使用 nn.Sequential 来把若干层串起来,self.net 是我们存放网络结构的成员变量,而 forward 表示输入来了以后,这个模型怎么算输出。
0x22 - 损失函数
我们使用交叉熵损失 nn.CrossEntropyLoss() 作为我们的 loss。模型最后输出的 10 个数,例如 被称为 logits。随后,我们可以用 softmax 将其转换为一组 且 的、类似概率的量。具体的,这里有 。实际上,在 PyTorch 中使用 nn.CrossEntropyLoss() 时,不需要手动先做 softmax,因为这一部分已经在损失函数内部处理好了。若我们的真实 label 是 ,那么我们期望 ,因此我们有越接近 损失越少,越接近 损失越大。这里我们可以用 作为单一样本的核心公式。鉴于笔者本人的知识水平就写到这里了。
0x23 - 优化器
根据上文所述,我们在反向传播中计算出了损失的梯度,而优化器的作用,便是根据这些梯度真正更新模型参数。
在这里我们使用 Adam 作为优化器,其名字来自于 Adaptive Moment Estimation。大体上是「利用梯度的一阶矩与二阶矩估计,自适应地调整参数更新步长。」
在本文中,我们先采用如下设置:
optimizer = optim.Adam(model.parameters(), lr=1e-3)其中 lr=1e-3 表示学习率为 。
受限于笔者本人的知识水平,这里不再展开。
0x24 - 训练
感谢前人的努力能让我们站在如此巨人的肩膀上做些工作。在这里我们可以不用去考虑如何具体地进行矩阵运算或者是如何反向传播计算梯度,而是直接调库。
利用我们在 0x18 搭建的框架,我们便有了以下代码:
import torch.nn as nn
import torch.optim as optim
from models.mlp import MLP
from utils.device import get_device
from utils.loader import get_loaders
from utils.paths import MLP_CHECKPOINT
from utils.train import run_training
def main():
train_loader, test_loader = get_loaders(batch_size=64)
device = get_device()
model = MLP().to(device)
run_training(
model=model,
train_loader=train_loader,
test_loader=test_loader,
criterion=nn.CrossEntropyLoss(),
optimizer=optim.Adam(model.parameters(), lr=1e-3),
device=device,
epochs=5,
checkpoint_path=MLP_CHECKPOINT,
)
if __name__ == "__main__":
main()运行结果为:
Epoch 1/5
Train Loss: 0.3439 | Train Acc: 0.9061
Test Loss: 0.1896 | Test Acc: 0.9438
Epoch 2/5
Train Loss: 0.1563 | Train Acc: 0.9544
Test Loss: 0.1277 | Test Acc: 0.9631
Epoch 3/5
Train Loss: 0.1078 | Train Acc: 0.9680
Test Loss: 0.1118 | Test Acc: 0.9656
Epoch 4/5
Train Loss: 0.0814 | Train Acc: 0.9765
Test Loss: 0.0950 | Test Acc: 0.9717
Epoch 5/5
Train Loss: 0.0647 | Train Acc: 0.9805
Test Loss: 0.0839 | Test Acc: 0.9744可以看到,随着训练进行,训练集与测试集上的损失都在逐步下降,而准确率则稳步上升。
0x25 - 小结
至此,我们已经完成了一个基于 MLP 的 MNIST 分类实验。在这一部分中,我们依次完成了:
定义一个最简单的多层感知机;
引入交叉熵损失函数与 Adam 优化器;
在训练集上完成模型训练,并在测试集上评估效果;
保存训练好的模型参数,并能够重新加载模型进行预测。
在实际使用时,我们只需加载保存好的
mnist_mlp.pth文件,进行适当的输入预处理,然后调用model(input).argmax(dim=1).item()即可得到预测结果。可以发现这样一个相对来说较为简单的 MLP 模型正确率能达到 97.4%,比我最初想象的高了不少(我以为可能只有 60% 的样子),使用后文 CNN 章节的一些优化方法,也只能把正确率提升到 98.0% 左右。因为 MLP 把二维结构展平成了一维结构,所以丢失了一部分位置信息。由此若想要进一步提升识别准确率,我们可能要引入新的模型,比如:
0x30 - 卷积神经网络 (Convolutional Neural Network, CNN)
卷积神经网络通常由一个或多个卷积层、池化层以及末端的全连接层组成,同时也包含相应的可学习参数,例如卷积核权重与偏置项。这一结构使得卷积神经网络能够更自然地利用输入数据的二维空间结构。
注意到相比 MLP,CNN 主要多出了两类结构:卷积层和池化层。我们考虑一个基本的一维离散卷积运算:
这个式子可以粗略理解为:我们拿一个较短的卷积核 ,在输入信号 上不断滑动,并在每个位置上做一次局部加权求和,从而得到新的输出信号。若这里取卷积核 ,那么它在某种意义上相当于比较相邻位置之间的差异。若输入信号在某处变化较大,则输出响应往往也较大;若输入信号较为平滑,则输出通常较小。也就是说,这样的卷积核能够帮助我们检测局部的突变,从而提取出类似「边缘」这样的特征。
而二维图像本身也可以看作一种二维离散信号,因此类似的思想自然可以推广到二维情形。正如上文所述,MLP 会在一开始就将图像展平成一维向量,从而打散原本的二维空间结构;而 CNN 则至少在前面的若干层中仍然直接对二维图像做卷积(更准确地说是互相关)运算。这样一来,模型便能够在局部区域上检测横线、竖线、斜线、转角、圆弧等模式,从而更自然地提取图像特征。
同时,若某一卷积层设置了 个卷积核,那么对于输入 的灰度图,卷积之后通常会得到形状为 的特征图。因此,我们的数据会变得越来越多,于是我们就需要池化层来进行压缩。
比如一个 MaxPool2d(2),就表示对每个 的区域取最大值作为输出,于是假设我们的输入是 ,经过这个池化层后就变成了 。因此:
卷积层用于提取特征;
池化层用于压缩特征。
同时我们考虑到卷积仍然是线性运算,我们通常在卷积后增加激活函数来引入一些非线性,也就是 CNN 的通常的流程就是:
0x31 - 模型定义
我们在这里使用一个较为简单的 CNN 结构:
import torch.nn as nn
class CNN(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.Linear(128, 10),
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x基本上的流程就是:
因为我们是灰度图所以我们只有一个通道,然后我们在第一个卷积层用 32 个大小为 的卷积核学习 32 种特征最后得到 32 张特征图,在这里我们设置 padding=1 是为了保持卷积后的尺寸不变。然后我们经过一个 ReLU() 激活函数引入非线性。然后我们经过一个 的池化层来压缩特征。
同理,我们再经过一轮「卷积」、「激活」、「池化」的过程,最后变成 的形式,在这里我们展平成一个长度为 的向量,通过两个全连接层完成最终分类。
0x32 - 模型训练
我们只用把 0x24 的代码中的 MLP 改成 CNN 即可。
有结果:
Epoch 1/5
Train Loss: 0.1321 | Train Acc: 0.9602
Test Loss: 0.0455 | Test Acc: 0.9847
Epoch 2/5
Train Loss: 0.0408 | Train Acc: 0.9872
Test Loss: 0.0350 | Test Acc: 0.9891
Epoch 3/5
Train Loss: 0.0266 | Train Acc: 0.9913
Test Loss: 0.0312 | Test Acc: 0.9904
Epoch 4/5
Train Loss: 0.0198 | Train Acc: 0.9933
Test Loss: 0.0372 | Test Acc: 0.9885
Epoch 5/5
Train Loss: 0.0163 | Train Acc: 0.9949
Test Loss: 0.0329 | Test Acc: 0.9905可以看出,这个简单 CNN 在仅训练 5 个 epoch 的情况下,测试集准确率便已经达到了 99.0% 左右,显著高于前面的 MLP。
0x33 - 模型调优
我们发现,这个简单的 CNN 在 MNIST 上已经能够达到约 99.0% 的准确率。于是,一个自然的问题便是:在不大改模型结构的前提下,我们还能否继续提高准确度?
首先,我们尝试了最直接的做法:增加训练轮数。将训练从 5 个 epoch 延长到 10 个 epoch,得到如下结果:
Epoch 1/10
Train Loss: 0.1352 | Train Acc: 0.9579
Test Loss: 0.0435 | Test Acc: 0.9865
Epoch 2/10
Train Loss: 0.0429 | Train Acc: 0.9867
Test Loss: 0.0379 | Test Acc: 0.9882
Epoch 3/10
Train Loss: 0.0301 | Train Acc: 0.9899
Test Loss: 0.0300 | Test Acc: 0.9904
Epoch 4/10
Train Loss: 0.0222 | Train Acc: 0.9928
Test Loss: 0.0377 | Test Acc: 0.9884
Epoch 5/10
Train Loss: 0.0168 | Train Acc: 0.9947
Test Loss: 0.0341 | Test Acc: 0.9889
Epoch 6/10
Train Loss: 0.0129 | Train Acc: 0.9957
Test Loss: 0.0348 | Test Acc: 0.9890
Epoch 7/10
Train Loss: 0.0122 | Train Acc: 0.9958
Test Loss: 0.0349 | Test Acc: 0.9894
Epoch 8/10
Train Loss: 0.0089 | Train Acc: 0.9969
Test Loss: 0.0377 | Test Acc: 0.9907
Epoch 9/10
Train Loss: 0.0063 | Train Acc: 0.9979
Test Loss: 0.0368 | Test Acc: 0.9911
Epoch 10/10
Train Loss: 0.0071 | Train Acc: 0.9974
Test Loss: 0.0410 | Test Acc: 0.9905可以发现,随着训练轮数增加,准确率虽然仍有提升,但提升幅度已经较为有限。也就是说,在当前配置下,瓶颈并不主要来自训练轮数本身。
于是,我们进一步考虑引入学习率调度策略:在这里我们令 scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1),也就是每 5 轮令 ,这样一来,模型在训练前期可以使用较大的学习率快速收敛,而在训练后期则可以使用较小的学习率进行更细致的参数调整。
有结果如下:
Epoch 1/10
Train Loss: 0.1322 | Train Acc: 0.9593
Test Loss: 0.0392 | Test Acc: 0.9878
Epoch 2/10
Train Loss: 0.0434 | Train Acc: 0.9867
Test Loss: 0.0287 | Test Acc: 0.9898
Epoch 3/10
Train Loss: 0.0289 | Train Acc: 0.9905
Test Loss: 0.0399 | Test Acc: 0.9867
Epoch 4/10
Train Loss: 0.0208 | Train Acc: 0.9930
Test Loss: 0.0334 | Test Acc: 0.9879
Epoch 5/10
Train Loss: 0.0169 | Train Acc: 0.9943
Test Loss: 0.0311 | Test Acc: 0.9890
Epoch 6/10
Train Loss: 0.0054 | Train Acc: 0.9983
Test Loss: 0.0203 | Test Acc: 0.9932
Epoch 7/10
Train Loss: 0.0032 | Train Acc: 0.9992
Test Loss: 0.0207 | Test Acc: 0.9929
Epoch 8/10
Train Loss: 0.0023 | Train Acc: 0.9995
Test Loss: 0.0218 | Test Acc: 0.9933
Epoch 9/10
Train Loss: 0.0018 | Train Acc: 0.9995
Test Loss: 0.0215 | Test Acc: 0.9931
Epoch 10/10
Train Loss: 0.0012 | Train Acc: 0.9997
Test Loss: 0.0229 | Test Acc: 0.9930可以看到,在引入 StepLR 之后,模型的测试集准确率进一步提升到了 99.33%。这说明,除了模型结构本身之外,训练策略同样会显著影响最终表现。
0x34 - 模型对比
前文的实验结果可以粗略总结如下:
| 模型 / 策略 | 测试集准确率 |
|---|---|
| MLP(5 epoch) | 97.44% |
| CNN(5 epoch) | 99.05% ~ 99.11% |
| CNN + StepLR | 99.33% |

demo.py,可以手写数字并调用 MLP 和 CNN 两个模型进行识别可以看出,相比前面的 MLP,CNN 的提升并不是“小修小补”的级别,而是直接将模型从 97% 档提升到了 99% 档。这说明在图像任务中,保留二维空间结构并提取局部特征确实更加自然。
进一步地,在 CNN 结构已经较为合理的前提下,训练策略仍然能够继续提升模型表现。通过引入学习率衰减,我们又将准确率从约 99.1% 提高到了 99.33%。
因此,从这一部分实验中可以得到两个很直接的结论:
对于图像任务而言,CNN 往往比简单的 MLP 更合适;
在模型结构之外,学习率及其调度策略同样会显著影响最终结果。
至此,我们已经从最基础的 MLP 过渡到了更适合图像任务的 CNN,也初步体会到了结构设计与训练策略对模型性能的共同影响。
0xFF - 总结
至此,我们已经围绕 MNIST 完成了一次相对完整的入门实验。
在本文中,我们从环境配置与数据集读取出发,补充了神经网络中的一些基础概念,并依次实现了 MLP 与 CNN 两种模型。其中,MLP 在测试集上取得了约 97.4% 的准确率,而 CNN 则进一步提升到了约 99.0%;结合 StepLR 学习率调度器之后,模型精度又提高到了 99.33%。
从这些实验中可以看到:对于图像任务而言,模型结构的选择十分重要。相比一开始就将图像展平的 MLP,CNN 更能够利用图像中的二维空间结构,因此也更容易取得更好的效果。与此同时,训练策略同样不可忽视,学习率及其调度方式也会对最终结果产生明显影响。
当然,本文仍然只是一个开头。后续若继续深入,我们还可以尝试更经典的 LeNet 结构,加入 BatchNorm、Dropout 或数据增强,甚至进一步研究模型在真实手写输入上的泛化表现。
某种意义上说,MNIST 的有趣之处正在于:它规模不大,却足够让我们完整体验一次从数据、模型到训练与调优的深度学习流程。
相关代码及本文的 Github 仓库:https://github.com/mohaoz/py-mnist