从零实现SimpleCNN:Fashion-MNIST图像分类实战指南

📅 发布时间:2026/7/5 21:06:25 👁️ 浏览次数:
从零实现SimpleCNN:Fashion-MNIST图像分类实战指南
1. 为什么从零实现SimpleCNN是理解深度学习的必经之路很多刚入门深度学习的同学一上来就喜欢用PyTorch或者TensorFlow的现成模块几行代码就能搭出一个看起来挺厉害的模型。我刚开始也是这么干的但很快就发现一个问题模型跑是跑起来了但里面到底发生了什么为什么卷积层能提取特征为什么池化层能降维心里完全没底。这就好比你会开车但不知道发动机怎么工作一旦车子抛锚你就只能干瞪眼。所以我强烈建议每个想真正搞懂CNN的人都亲手从零实现一遍。这里的“从零”不是让你去写CUDA代码而是不依赖高级框架的封装自己用基础的矩阵运算或者像PaddlePaddle、PyTorch这样的框架但亲手定义每一个卷积核、计算每一次特征图的变化。这次我们拿Fashion-MNIST这个“时尚界的Hello World”数据集开刀它比手写数字MNIST难一点但又不像ImageNet那样需要巨大的计算资源是绝佳的练手材料。Fashion-MNIST里面有6万张28x28的灰度训练图1万张测试图共10类衣服鞋包。我们的目标就是建一个叫SimpleCNN的小型网络把它分清楚。别小看这个任务它能帮你把卷积、激活、池化、全连接这些概念从抽象的公式变成实实在在的、能跑出结果的代码。我敢说完整走完这一趟你对CNN的理解会比只看十篇论文深刻得多。2. 环境搭建与数据准备万事开头要利索工欲善其事必先利其器。咱们先把环境搭好把数据准备好。这里我用PaddlePaddle来演示你用PyTorch或TensorFlow思路也完全一样。2.1 安装依赖与数据加载首先确保你的Python环境建议3.7以上里装好了必要的库。除了深度学习框架数据处理和可视化库也少不了。# 安装PaddlePaddle以CPU版本为例 pip install paddlepaddle # 安装其他辅助库 pip install numpy matplotlib数据加载是第一步也是容易踩坑的地方。Fashion-MNIST数据不大很多框架都内置了用起来很方便。但我们要的不只是下载还要做好预处理把它变成模型爱吃的样子。import paddle from paddle.vision import datasets, transforms import paddle.io as io import sys import matplotlib.pyplot as plt def get_dataloader_workers(): 根据操作系统设置数据加载的进程数Windows/Mac用单进程避免问题 if sys.platform.startswith((win, darwin)): return 0 # Windows和MacOS下多进程容易出错稳妥起见用0 else: return 4 # Linux系统可以用多进程加速读取 def load_data_fashion_mnist(batch_size256, resizeNone): 加载Fashion-MNIST数据集 参数: batch_size: 每次训练喂给模型的图片数量 resize: 是否调整图片大小默认28x28 返回: train_iter, test_iter: 训练和测试的数据迭代器 # 1. 定义数据变换管道 trans [transforms.ToTensor()] # 核心把PIL图片转成Tensor并自动归一化像素值到[0,1] if resize: # 如果指定了resize就在最前面插入一个调整大小的操作 trans.insert(0, transforms.Resize(resize)) # 用Compose把多个操作串起来 trans transforms.Compose(trans) # 2. 下载并加载数据集 # modetrain 和 test 分别对应训练集和测试集 mnist_train datasets.FashionMNIST(modetrain, transformtrans, downloadTrue) mnist_test datasets.FashionMNIST(modetest, transformtrans, downloadTrue) # 3. 构建DataLoader这才是真正喂数据给模型的“勺子” train_iter io.DataLoader(mnist_train, batch_sizebatch_size, shuffleTrue, # 训练集一定要打乱顺序防止模型学到数据顺序 return_listTrue, num_workersget_dataloader_workers()) # 用几个进程读数据 test_iter io.DataLoader(mnist_test, batch_sizebatch_size, shuffleFalse, # 测试集不用打乱 return_listTrue, num_workersget_dataloader_workers()) return train_iter, test_iter, mnist_train, mnist_test # 测试一下数据加载是否正常 if __name__ __main__: train_iter, test_iter, train_set, test_set load_data_fashion_mnist(batch_size16) print(f训练集样本数: {len(train_set)}) # 应该输出 60000 print(f测试集样本数: {len(test_set)}) # 应该输出 10000 # 取一个批次看看形状 for X, y in train_iter: print(f一个批次图片的形状: {X.shape}) # 应该是 [16, 1, 28, 28] print(f一个批次标签的形状: {y.shape}) # 应该是 [16] break这里有几个细节值得唠叨一下。ToTensor()这个变换干了件大事它不仅把图片从PIL格式变成张量还悄悄把像素值从0-255的整数除以255转换成了0-1之间的浮点数。这种归一化操作对模型训练至关重要能让梯度下降更平稳。shuffleTrue在训练时是必须的想象一下如果数据是按类别排好序的模型可能会“偷懒”只根据批次顺序来猜而不是真正学习特征。2.2 数据可视化先看看我们“吃”的是什么数据加载好了别急着喂给模型。先拿出来看看长什么样心里有个数。这就像做饭前先看看食材新不新鲜。def get_fashion_mnist_labels(labels): 把数字标签0-9转换成我们能看懂的文字 text_labels [t-shirtT恤, trouser裤子, pullover套衫, dress连衣裙, coat外套, sandal凉鞋, shirt衬衫, sneaker运动鞋, bag包, ankle boot短靴] # 把标签列表里的每个数字索引转换成对应的文本 return [text_labels[int(i)] for i in labels] def show_images(imgs, num_rows, num_cols, titlesNone, scale1.5): 画一堆图片的小工具 figsize (num_cols * scale, num_rows * scale) # 根据行列数计算画布大小 fig, axes plt.subplots(num_rows, num_cols, figsizefigsize) axes axes.flatten() # 把二维的子图数组压成一维方便循环 for i, (ax, img) in enumerate(zip(axes, imgs)): if paddle.is_tensor(img): # 如果输入是Paddle张量先转成NumPy数组才能显示 ax.imshow(img.numpy(), cmapgray) # 灰度图指定cmapgray else: ax.imshow(img, cmapgray) ax.axis(off) # 关掉坐标轴让图更干净 if titles: ax.set_title(titles[i]) plt.tight_layout() # 自动调整子图间距防止标题重叠 plt.show() # 实际看一眼数据 train_iter, test_iter, train_set, _ load_data_fashion_mnist(batch_size18) # 从数据加载器里取一个批次 for X, y in train_iter: break # X的形状是[18, 1, 28, 28]为了显示需要去掉通道维度变成[18, 28, 28] show_images(X.reshape([18, 28, 28]), 2, 9, titlesget_fashion_mnist_labels(y))跑一下这段代码你会看到18张衣服鞋子的小图上面标着对应的名字。这一步非常治愈也很有用。你能直观感受到数据的质量图片是否清晰、类别是否分明也能提前发现一些问题比如标签是不是错了虽然Fashion-MNIST质量很高但自己收集的数据经常会有这种问题。我建议你在做任何图像项目前都花几分钟做这个可视化它能建立你对数据的直觉。3. 深入核心亲手搭建SimpleCNN的每一层好了热身结束现在进入正餐搭建我们的SimpleCNN模型。CNN之所以在图像上厉害是因为它有两个看家本领局部感知和参数共享。全连接层MLP是把整张图片展平成一个长向量每个像素都和下一层的每个神经元连接这既浪费参数又破坏了图片的空间结构。而CNN用一个小窗口卷积核在图片上滑动每次只关注一个小区域局部感知而且同一个窗口用在图片的不同位置参数共享这非常符合图像的特征——比如一个检测横边的卷积核应该对图片任何位置的横边都有效。3.1 卷积层模型的眼睛负责提取特征我们来定义一个最简单的CNN它包含两个卷积块每个块是“卷积激活池化”的标准组合。import paddle.nn as nn class SimpleCNN(nn.Layer): def __init__(self): super().__init__() # 第一个卷积块从1个通道灰度图变成16个通道 self.conv1 nn.Conv2D(in_channels1, # 输入通道数灰度图是1RGB图是3 out_channels16, # 输出通道数也就是用16种不同的卷积核去扫描 kernel_size3, # 卷积核大小3x3是最常用的尺寸 padding1) # 填充一圈0保证输入输出尺寸不变28x28 - 28x28 self.relu1 nn.ReLU() # 激活函数引入非线性没有它网络就是一堆线性变换的叠加 self.pool1 nn.MaxPool2D(kernel_size2, stride2) # 2x2最大池化尺寸减半28x28 - 14x14 # 第二个卷积块通道数翻倍提取更复杂的特征 self.conv2 nn.Conv2D(in_channels16, # 输入通道数要等于上一层的输出通道数 out_channels32, kernel_size3, padding1) self.relu2 nn.ReLU() self.pool2 nn.MaxPool2D(kernel_size2, stride2) # 再次减半14x14 - 7x7 # 全连接层把提取到的特征映射到10个类别上 # 经过两次池化图片尺寸从28变成了7。特征图数量是32。 # 所以展平后的向量长度是 32 * 7 * 7 1568 self.flatten nn.Flatten() self.fc1 nn.Linear(32 * 7 * 7, 128) # 压缩到128维 self.relu3 nn.ReLU() self.fc2 nn.Linear(128, 10) # 最终输出10个类别的分数 def forward(self, x): # 第一个卷积块 x self.conv1(x) # 输入: [batch, 1, 28, 28] - 输出: [batch, 16, 28, 28] x self.relu1(x) x self.pool1(x) # - [batch, 16, 14, 14] # 第二个卷积块 x self.conv2(x) # - [batch, 32, 14, 14] x self.relu2(x) x self.pool2(x) # - [batch, 32, 7, 7] # 展平准备进入全连接层 x self.flatten(x) # - [batch, 32*7*71568] x self.fc1(x) # - [batch, 128] x self.relu3(x) x self.fc2(x) # - [batch, 10] return x # 实例化模型看看 net SimpleCNN() print(net)我来解释一下几个关键参数的选择。kernel_size3是经验之谈3x3是小尺寸卷积核的黄金标准它既能捕捉局部模式比如边缘、角点又不会产生太多参数。padding1是为了保持空间尺寸。如果不填充一个3x3卷积会让特征图每边缩小1个像素。我们这里希望先保持尺寸用池化层来专门做下采样。out_channels16和32是逐渐增加通道数让网络有能力学习从简单到复杂的特征。第一个卷积层可能学到的是“边缘”、“斑点”第二个卷积层就能把这些简单特征组合成“格子纹理”、“袖口形状”等更抽象的概念。3.2 权重初始化别让模型一开始就“输在起跑线”模型结构搭好了但里面的参数权重一开始是随机给的。如果随机得不好可能会导致梯度消失或爆炸训练根本没法开始。特别是我们用了ReLU激活函数它有个特点会把负数都变成0。如果权重初始化不当很多神经元可能一开始就“死掉”输出永远是0再也学不到东西。def init_weights(m): 专门为卷积层和全连接层做初始化 if isinstance(m, (nn.Conv2D, nn.Linear)): # Kaiming初始化专门为ReLU设计能保持每一层输出的方差稳定 nn.initializer.KaimingNormal(m.weight, nonlinearityrelu) # 偏置项一般用常数0初始化就行 if m.bias is not None: nn.initializer.Constant(m.bias, value0.0) # 把初始化函数应用到网络的每一层 net.apply(init_weights)KaimingNormal初始化是何恺明大神在2015年提出的现在已经成为ReLU网络的标配。它的核心思想是根据前一层的神经元数量来调整当前层权重的方差确保信号在前向传播和反向传播时幅度都能保持在一个合理的范围内。你可能会问为什么不用更简单的随机初始化我试过用普通的正态分布初始化训练初期损失下降会慢很多有时甚至不收敛。这个细节看似微小但对训练稳定性影响巨大。3.3 损失函数与优化器告诉模型该往哪走模型输出10个分数每个类别一个我们需要一个标准来衡量它预测得有多“错”这就是损失函数。对于多分类问题交叉熵损失Cross-Entropy Loss是绝对的主流选择。# 损失函数交叉熵损失内部会自动做Softmax loss_fn nn.CrossEntropyLoss() # 优化器Adam自适应学习率比普通的SGD更省心 optimizer paddle.optimizer.Adam(parametersnet.parameters(), learning_rate0.001, weight_decay1e-4) # 加一点L2正则化防止过拟合交叉熵损失干了件什么事呢它先把模型输出的10个分数叫logits通过Softmax函数转换成概率分布10个概率加起来等于1然后计算这个预测概率分布和真实标签一个one-hot向量之间的“距离”。距离越小说明预测越准。Adam优化器是我最推荐新手使用的它融合了动量Momentum和自适应学习率RMSProp的优点不用手动调学习率衰减在大多数情况下都能有不错的表现。我在这里加了一个很小的weight_decay1e-4这是L2正则化相当于对权重的大小做了惩罚能稍微抑制过拟合让模型更泛化。4. 训练循环与评估让模型真正“学”起来模型、损失、优化器都齐了现在可以开始训练了。训练深度学习模型就像教小孩认东西一遍遍地给他看图片前向传播告诉他哪里错了计算损失然后他根据错误调整自己的认知反向传播更新权重。4.1 编写训练与评估的基础工具函数我们先写几个辅助函数让代码更清晰。def accuracy(y_hat, y): 计算预测准确率y_hat是模型输出y是真实标签 # y_hat形状是[batch, 10]取每行最大值的索引就是预测的类别 if len(y_hat.shape) 1 and y_hat.shape[1] 1: y_hat y_hat.argmax(axis1) # 确保y的形状和y_hat匹配去掉多余的维度 y y.squeeze() y_hat y_hat.squeeze() # 比较预测和真实标签是否相等返回正确的数量 cmp y_hat.astype(y.dtype) y return float(cmp.astype(y.dtype).sum()) class Accumulator: 一个小工具用来累加多个指标比如总损失、总正确数、总样本数 def __init__(self, n): self.data [0.0] * n # 初始化n个累加器 def add(self, *args): self.data [a float(b) for a, b in zip(self.data, args)] def __getitem__(self, idx): return self.data[idx] def evaluate_accuracy(net, data_iter): 评估模型在某个数据集比如测试集上的准确率 net.eval() # 切换到评估模式这很重要会关闭Dropout等训练特有的行为 metric Accumulator(2) # 累加正确样本数和总样本数 with paddle.no_grad(): # 不计算梯度节省内存和计算 for X, y in data_iter: metric.add(accuracy(net(X), y), y.numel()) # y.numel()是当前批次的样本数 net.train() # 评估完记得切换回训练模式 return metric[0] / metric[1] # 准确率 正确数 / 总数这里有个关键点net.eval()和net.train()。我们的SimpleCNN虽然没有Dropout层但养成这个习惯很重要。在评估时我们不需要计算梯度paddle.no_grad()这能大幅提升速度并减少内存占用。Accumulator这个类是个小巧思它让我们不用写一堆临时变量来累加代码更干净。4.2 实现单轮训练的逻辑def train_epoch(net, train_iter, loss_fn, optimizer): 跑一遍整个训练集更新一次模型参数 net.train() # 确保是训练模式 metric Accumulator(3) # 累加总损失、总正确数、总样本数 for X, y in train_iter: # 1. 前向传播计算预测值 y_hat net(X) # 2. 计算损失 l loss_fn(y_hat, y) # 这里l是一个向量每个样本一个损失 # 3. 反向传播 optimizer.clear_grad() # 清空上一轮计算的梯度防止累积 l.mean().backward() # 对损失求平均然后反向传播计算梯度 optimizer.step() # 根据梯度更新模型参数 # 4. 记录指标 metric.add(float(l.sum()), accuracy(y_hat, y), y.numel()) # 返回这一轮的平均损失和训练准确率 return metric[0] / metric[2], metric[1] / metric[2]这个训练循环是核心中的核心。我解释几个容易困惑的地方l.mean().backward()为什么要求平均因为loss_fn默认返回的是每个样本的损失reductionnone我们反向传播时需要的是一个标量。求平均是最常见的做法它相当于认为每个样本的权重是一样的。optimizer.clear_grad()必须在backward()之前调用否则梯度会累加到上一轮的结果上导致更新方向错误。我早期就犯过这个错误模型怎么训都不收敛排查了半天才发现是梯度没清零。4.3 整合训练流程并可视化监控把上面的零件组装起来加上可视化我们就能完整地看到模型是怎么一步步变强的。import matplotlib.pyplot as plt from matplotlib.ticker import MaxNLocator def train(net, train_iter, test_iter, loss_fn, optimizer, num_epochs10): 完整的训练函数包含多轮训练、评估和可视化 # 初始化一个画图工具用来记录训练过程 animator Animator(xlabelepoch, xlim[1, num_epochs], ylim[0, 1], legend[train loss, train acc, test acc]) for epoch in range(num_epochs): # 训练一个epoch train_loss, train_acc train_epoch(net, train_iter, loss_fn, optimizer) # 在测试集上评估 test_acc evaluate_accuracy(net, test_iter) # 记录数据 animator.add_data(epoch 1, (train_loss, train_acc, test_acc)) # 打印日志 print(fEpoch {epoch1:2d}: ftrain loss{train_loss:.4f}, ftrain acc{train_acc:.4f}, ftest acc{test_acc:.4f}) # 训练结束后一次性画出所有曲线 animator.plot() return train_loss # 开始训练 train_loss train(net, train_iter, test_iter, loss_fn, optimizer, num_epochs20)这里我自定义了一个Animator类代码略长思路是存储每轮的数据最后统一画图而不是每轮都画。因为实时绘图会拖慢训练速度尤其是数据量大、轮次多的时候。训练20轮左右你应该能看到类似下面的输出Epoch 1: train loss0.5123, train acc0.8121, test acc0.8014 Epoch 2: train loss0.3456, train acc0.8732, test acc0.8543 ... Epoch 20: train loss0.0987, train acc0.9654, test acc0.9168重点观察两条线训练准确率train acc和测试准确率test acc。理想情况下它们应该一起上升最后测试准确率稳定在91%以上。如果训练准确率远高于测试准确率比如98% vs 85%说明模型过拟合了它把训练集的特例都记住了但没学到通用规律。这时候你可能需要增加数据增强、加大Dropout或者加强正则化。5. 模型效果分析与调优思路训练完成测试准确率达到了91%以上恭喜你但这只是开始。我们可以深入分析一下模型到底学得怎么样还有没有提升空间。5.1 查看模型在哪些类别上容易犯错光看总体准确率不够我们得看看“混淆矩阵”了解模型具体在哪些衣服鞋子之间分不清。from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns def evaluate_details(net, test_iter): 详细评估输出混淆矩阵和分类报告 net.eval() all_preds [] all_labels [] with paddle.no_grad(): for X, y in test_iter: y_hat net(X) preds y_hat.argmax(axis1) all_preds.extend(preds.numpy()) all_labels.extend(y.numpy()) # 计算混淆矩阵 cm confusion_matrix(all_labels, all_preds) plt.figure(figsize(10, 8)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsget_fashion_mnist_labels(range(10)), yticklabelsget_fashion_mnist_labels(range(10))) plt.xlabel(Predicted) plt.ylabel(True) plt.title(Confusion Matrix) plt.show() # 输出详细的分类报告精确率、召回率、F1分数 print(classification_report(all_labels, all_preds, target_namesget_fashion_mnist_labels(range(10)))) evaluate_details(net, test_iter)运行这段代码你会看到一个10x10的热力图。对角线上的数字越大越好表示预测正确。你可能会发现模型最容易混淆的是“衬衫Shirt”和“T恤T-shirt/top”、“套衫Pullover”因为它们形状确实很像。而“裤子Trouser”和“包Bag”这类特征独特的类别准确率往往接近100%。这很符合直觉也告诉我们如果想让模型更好可能需要针对这些易混淆的类别收集更多样化的数据或者设计更精细的特征提取模块。5.2 超参数调优实战指南第一次训练我们用的是默认参数但深度学习很大程度上是“炼丹”调参至关重要。下面是我总结的一些调优方向和经验1. 学习率lr最重要的超参数现象损失震荡不下降 - 学习率可能太大了。损失下降极其缓慢 - 学习率可能太小了。试试lr0.01激进、lr0.0001保守。可以用学习率预热Warmup或余弦退火Cosine Annealing等动态策略。2. 批量大小batch_size影响训练稳定性和速度现象batch太小如16可能导致训练不稳定波动大batch太大如1024可能收敛慢且容易陷入尖锐的极小值点泛化性差。试试batch_size128或512。通常GPU显存能装下的最大batch size是个不错的起点。3. 网络深度与宽度通道数现象模型在训练集上表现就很差欠拟合 - 可能模型太简单。试试增加一个卷积块conv3或者把通道数翻倍out_channels64, 128。但要注意参数越多越容易过拟合可能需要配合更强的正则化。4. 正则化技巧Dropout在全连接层后加入nn.Dropout(0.5)随机“关闭”一半神经元强制网络学习更鲁棒的特征。数据增强在训练时对图片进行随机裁剪、旋转、翻转等。Fashion-MNIST是中心对齐的水平翻转是安全的。这能显著提升泛化能力。更早停止监控测试集准确率当连续几轮不再提升时就停止训练防止过拟合。这里给一个增强版的SimpleCNN示例加入了Dropout和BatchNorm批归一化后者能让训练更稳定、更快。class ImprovedSimpleCNN(nn.Layer): def __init__(self, dropout_rate0.5): super().__init__() # 卷积块1 self.conv1 nn.Conv2D(1, 32, kernel_size3, padding1) self.bn1 nn.BatchNorm2D(32) # 批归一化加速收敛 self.relu1 nn.ReLU() self.pool1 nn.MaxPool2D(2, 2) # 卷积块2 self.conv2 nn.Conv2D(32, 64, kernel_size3, padding1) self.bn2 nn.BatchNorm2D(64) self.relu2 nn.ReLU() self.pool2 nn.MaxPool2D(2, 2) # 全连接层 self.flatten nn.Flatten() self.fc1 nn.Linear(64 * 7 * 7, 256) self.relu3 nn.ReLU() self.dropout nn.Dropout(dropout_rate) # Dropout层 self.fc2 nn.Linear(256, 10) def forward(self, x): x self.pool1(self.relu1(self.bn1(self.conv1(x)))) x self.pool2(self.relu2(self.bn2(self.conv2(x)))) x self.flatten(x) x self.relu3(self.fc1(x)) x self.dropout(x) # 只在训练时起作用 x self.fc2(x) return x5.3 常见问题排查踩坑记录在我带新手的这些年里下面这几个问题是最高发的如果你遇到了别慌按这个清单查一查损失是NaNNot a Number可能原因学习率太高导致梯度爆炸。可以尝试调低学习率一个数量级比如从0.001调到0.0001。检查数据里有没有异常值比如像素值不是0-255。可以在数据加载后加一句print(X.min(), X.max())看看。准确率卡在10%左右不动可能原因这正好是随机猜测的概率10个类。说明模型根本没在学习。检查数据标签是不是对的数据加载的shuffle有没有开损失函数和模型输出维度是否匹配比如10分类问题模型最后一层输出是不是10训练集准确率很高但测试集准确率很低过拟合严重试试增加Dropout比例、加入更强的数据增强、减小模型规模减少通道数、增加L2正则化系数weight_decay。训练速度非常慢检查DataLoader的num_workers在Windows/Mac上是否设成了0应该设成0在Linux上可以尝试设为CPU核心数。检查是否在训练循环里做了耗时的操作比如每批次都保存模型、每批次都画图。6. 从SimpleCNN出发更广阔的计算机视觉世界通过这个项目你已经掌握了CNN最核心的构建、训练、评估流程。但Fashion-MNIST只是一个开始。现实中你遇到的挑战会复杂得多更大的图像Fashion-MNIST是28x28的灰度小图。真实世界的图片往往是高分辨率彩色的。这意味着你需要更深的网络如ResNet、EfficientNet、更大的显存以及更复杂的数据增强。更复杂的任务不仅仅是分类还有目标检测YOLO、Faster R-CNN、图像分割U-Net、Mask R-CNN等。这些任务的网络结构、损失函数都更为复杂。数据不足现实项目中你很可能没有6万张标注好的数据。这时候需要用到迁移学习Transfer Learning用在大数据集如ImageNet上预训练好的模型在你的小数据上微调Fine-tune。这是目前工业界最实用的技巧之一。我建议你的下一步是用同样的流程在CIFAR-10彩色小物体数据集上训练一个CNN。然后尝试加载在ImageNet上预训练好的ResNet18在你自己收集的少量图片上进行微调。这两个项目做下来你对深度学习解决图像问题的能力边界会有一个质的认识。最后别忘了把代码整理好上传到GitHub。这不仅是你的学习记录也是未来面试时展示你动手能力的最好证明。深度学习是一门实践学科看再多教程也不如自己动手调通一个模型来得实在。希望这篇长文能帮你扎扎实实地迈出第一步。