资讯动态

PyTorch搭建CNN识别MNIST:从数据加载到精度优化的完整实践

发布时间:2026/10/14 12:34:26 来源:尧图企业网站定制
MNIST这个项目我前前后后完整跑过好几遍最近又顺手把“PyTorch搭建CNN识别MNIST”从头到尾过了一遍从数据加载、网络设计、训练评估到最后的可视化每个环节都有值得深挖的地方。这个项目是PyTorch入门路上几乎绕不开的第一座山它足够小28x28的灰度图CPU也能跑但又足够完整能把深度学习项目的主干流程全部串起来。这篇就当作一份实操笔记把我在这个项目里理解到的东西、踩过的坑、以及最后总结出的一些优化思路一次性讲清楚。它适合刚接触PyTorch、想找一个完整项目跟着一步步实现的读者也适合已经跑通模型但还想把CNN内部参数变化、训练细节琢磨明白的同学。项目本身不难但要把准确率刷到99%以上并且搞清楚每一步为什么这么写确实需要花点心思。1. MNIST项目在解决什么问题1.1 MNIST数据集28x28的小世界MNIST全称是Modified National Institute of Standards and Technology是一个手写数字识别数据集里面全是0到9的手写数字图片。数据集总共包含70000张灰度图其中60000张用于训练10000张用于测试每张图片尺寸都是28x28像素像素值范围在0到255之间。这个数据集最早来自美国人口普查局和美国国家标准与技术研究院收集的样本后来经过标准化处理就成了深度学习领域的事实标准数据集。为什么它这么经典首先因为数据干净、标注准确几乎没有噪声适合初学者把精力集中在模型本身而不是数据清洗上其次它足够小28x28的图片在视觉任务里算非常迷你普通CPU训练一个简单CNN也就几十秒一个epoch迭代速度非常快最后它是多分类任务的经典代表十个类别数字识别可以直接套用CV领域最经典的模型思想。可以说MNIST之于计算机视觉就像“Hello World”之于编程语言学习。训练集和测试集的划分也是固定好的不需要自己去做切分这在工程上很方便。你可以直接通过torchvision拉取数据第一次运行会从网上下载本地缓存后就不需要重复下载。这里有个细节值得留意虽然MNIST数据很干净但它毕竟是几十年前的数据分布和现实场景中的手写数字还是有差距。真拿到生产环境里光靠MNIST训练出来的模型很难应对复杂背景。不过在入门阶段它是检验你代码流程是否正确的绝佳基准。1.2 为什么选CNN而不是全连接网络如果你直接用全连接网络做MNIST理论上也能跑但效果和效率都远不如CNN。关键问题在于全连接网络会把每张28x28的图像拉成一个784维的向量这等于把像素的位置信息全部打散。人的视觉系统识别物体靠的恰恰是空间上的局部特征组合——比如先识别出边缘、弧线再组合成数字的圈和横。如果把这些结构打散成平铺的向量模型就只能从全局像素的组合关系中去硬学不仅参数量巨大而且对空间结构的利用效率很低。CNN的全称是卷积神经网络它专门为处理具有网格结构的数据而设计。它的核心思想是局部感受野加参数共享。所谓局部感受野就是每个卷积核只关注一个局部小区域比如3x3范围内的像素然后在整张图上滑动参数共享则是同一个卷积核在图像不同位置使用同一组权重。这两点加在一起既大大减少了参数量又保留了空间局部特征。用人话说卷积核就像一个扫描窗口在图像上从左到右、从上到下扫过去每扫到一个区域就提取一个局部特征最后把整张图的特征汇总起来交给模型判断。还有一个重要的特性是平移不变性。数字“1”出现在图片左上角还是右下角卷积提取到的特征模式本质上是一样的只是位置不同。CNN对这种位置变化有天然的抗性而全连接网络基本上没有这个能力。理解这一点之后你会发现CNN之所以成为主流视觉模型基础不是因为名字时髦而是因为它符合图像数据本身的结构。1.3 这个项目的最终目标这个项目的目标可以拆成三层。最基础的目标是跑通代码从加载数据开始经过构建模型、定义损失函数、执行训练循环、最终评估精度这个流程是整个PyTorch编程的核心骨架以后做任何视觉项目都绕不开。第二个目标是理解CNN的运作机制什么样的卷积核尺寸合理、为什么需要池化、全连接层输入维度怎么算这些不是背公式而是要在实际搭建中想明白。第三个目标更实际一些就是让模型在测试集上达到一个令人满意的精度。一个简单CNN在MNIST上达到99%以上的准确率并不难通常会停留在98%到99%之间想要超过99%就需要一些调参和优化技巧。这也是这个项目最有意思的地方明明模型很小、数据很旧但在精度提升过程中你能体会到归一化、学习率、网络深度、Dropout等技术到底在发生什么作用。这个“从跑通到刷高精度”的过程比单纯照着别人的代码执行一遍有价值得多。2. 环境准备与工具选型2.1 PyTorch版本选择与虚拟环境开始动手之前环境配置是第一个关卡。我的建议是不要追最新的PyTorch版本优先选择稳定版本。当前主流的PyTorch 2.x有较完善的生态配套torchvision版本和torch版本必须匹配很重要不一致经常会出现无法加载模型或者莫名其妙的运行报错。如果你用conda安装推荐创建独立的虚拟环境不要把自己的基础环境弄乱。项目之后如果涉及其他依赖隔离环境带来的便利性会非常明显。创建环境的命令大概是这样创建完环境后激活它再安装依赖。有个细节是国内网络环境下在线安装可能比较慢可以使用镜像源安装。安装完成后最好不要立刻跑项目先打开Python环境验证一下PyTorch版本和是否支持CUDA这一步能避免后面写半天代码才发现环境有问题。2.2 CPU训练还是GPU训练MNIST这个项目的数据量和模型规模决定了只用CPU训练也完全可行。一个epoch核心代码可能只需要十几秒到几十秒跑十个epoch也就在几分钟左右。这个特点使它成为极佳的入门项目——即使你的机器没有独立显卡也能顺畅地完成整个实验流程。但我的建议是即使你现在只有CPU也要在代码里提前写好设备判断逻辑。因为以后做真正的视觉项目时GPU几乎是必需品。这个简单的判断习惯能帮你省下大量迁移代码的时间device cuda if torch.cuda.is_available() else cpu这句话的意思是如果CUDA可用就用GPU否则退回CPU。后面把模型和数据都通过.to(device)放到对应设备上即可。需要提醒的是数据、模型、标签这三者的设备必须保持一致否则会报“Expected all tensors to be on the same device”的错误。这个错误我至少在初学阶段遇到不下十次尤其是当你从GPU切回CPU调试的时候最容易出问题。关于GPU和CPU在这类小任务上的差别也可以说两句。实测下来当batch_size较小、模型只有两层卷积时GPU的优势并不明显因为数据加载和数据搬运的开销很可能盖过了计算加速的部分。只有把batch_size调大、把网络加深加宽后CPU和GPU的差异才真正拉开。2.3 依赖库清单与安装这个项目需要的依赖库不多一张表就能讲清楚。依赖库版本建议用途Python3.10或3.11基础运行环境PyTorch2.1稳定版深度学习框架torchvision与PyTorch对应数据加载、预训练模型numpy1.24数值计算matplotlib3.7可视化图像和曲线安装时直接用pip即可不过如果要用GPU需要提前确认CUDA版本和PyTorch的对应关系。这里有个经验不必一开始就追求GPU训练环境先把CPU环境跑通整个项目后面再升级到GPU会轻松得多。环境配置这种东西越简单越好避免过度工程化。3. 数据加载与预处理3.1 用Datasets拉取MNIST的细节与避坑PyTorch提供了torchvision.datasets这个模块MNIST的加载几乎是点一下就能用的程度。核心代码如下train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform)第一个参数root表示数据存放路径trainTrue表示加载训练集downloadTrue表示如果本地没有数据就自动下载。transform参数用来做数据预处理稍后会详细讲。值得注意的点有两个。第一第一次运行下载数据时实际上是从国内外的一些开源镜像站点下载文件网络不好时容易中断或者卡住。遇到这种情况不要反复删除数据重试可以先检查./data/MNIST/raw/目录下文件是否已经下载完整有时手动把压缩包放进目录后再把download设置为True程序检测到文件存在就不会重复下载了。第二数据和模型要对应到同一个数据集划分。如果你加载训练集时trainTrue评估时却用了trainTrue的数据就会造成“自我评价”的假象测试准确率高得离谱。正确做法是训练用trainTrue验证和测试用trainFalse。3.2 ToTensor、Normalize与增强选择变换函数是PyTorch对数据进行预处理的统一入口。在MNIST项目里最基本的两步是ToTensor()和Normalize()。ToTensor的作用是将PIL图片或numpy数组转为PyTorch张量并且把像素值从0-255缩放到0-1之间。这个步骤是强制性的因为PyTorch模型内部计算都基于张量原始PIL图片不能直接输入。Normalize则是按通道做标准化。对MNIST来说官方标准通常使用均值0.1307、标准差0.3081。这个数值是基于整个MNIST数据集计算出来的你不需要每次都自己重新算。标准化后数据分布会变成均值为0、方差为1的分布这能让梯度更新更稳定模型的收敛速度也会更快。我在做实验时见过直接不做Normalize的情况损失曲线会明显震荡收敛也慢。数据归一化这个步骤看起来没什么技术含量但往往就是精度上不去的隐性原因之一。还需要注意到是标准化的参数并不是越复杂越好。MNIST的输入只有单通道因此是Normalize((0.1307,), (0.3081,))。如果是三通道RGB图像就需要为每个通道分别提供均值和标准差。这个习惯要尽早建立起来。关于数据增强简单说一下我的看法。MNIST本身是比较简单的数据集常规的随机旋转、平移等增强有时反而会降低测试集精度因为测试集本身也是正常书写的数字。数据增强本身是为现实场景准备的它模拟的是数据多样性不足的问题。在MNIST项目里如果你只是想跑通整个流程把增强关掉就可以了。想挑战99%以上的精度时再谨慎地加一点RandomAffine。3.3 DataLoader与batch_size的工程考量数据准备最后一步是构建DataLoader。它负责从数据集中按小批次取出数据并且在训练时做随机打乱。核心参数是dataset、batch_size、shuffle、num_workers。dataset刚才建好的数据集对象。batch_size每次送入模型的样本数量。shuffle是否打乱数据顺序。训练时要设为True否则模型每个epoch看到的都是同一顺序的数据会引入额外的偏差。评估时设为False因为评估不需要数据多样化保持确定性更好。num_workers数据加载使用的子进程数量。这里有个常见坑Windows系统中num_workers大于0经常报错尤其是在没有ifname main保护的多进程场景下。建议Windows用户先设为0跑通代码后再去优化。batch_size的选择值得多说几句。取太小比如8每次梯度更新基于的样本太少训练过程会很不稳定。取太大比如512模型收敛虽然快但对小模型来说内存消耗增大而且迭代次数会变少调参时不够灵活。对于MNIST这种小数据集64是一个比较稳妥的选择128也可以。它是在收敛稳定性和训练速度之间的一个平衡点。在后面的实验里我对比过32、64、128三个值64和128最终精度差别不大但64的损失曲线更平滑调试体验更好。4. 从零搭建一个CNN4.1 网络结构设计与特征图尺寸变化搭建CNN最关键的是搞清楚每一层输入输出的形状变化。一个经典且有效的MNIST基线网络结构如下第一个卷积层把单通道的28x28图像转换为32个通道的特征图卷积核大小3x3padding为1。padding的作用是让卷积后输出的尺寸保持28x28不变因为默认的卷积操作会缩小尺寸而padding在边缘补零可以控制输出尺寸。经过一个ReLU激活函数然后做一次2x2最大池化特征图变成14x14。第二个卷积层把32通道变为64通道仍然是3x3卷积加padding为1再做一次ReLU和2x2最大池化特征图变成长宽7x7、深度64。此时如果把整个特征图拉平得到的向量维度就是64乘以7乘以7等于3136。这个维度就是全连接层的输入维度。最后接两个全连接层。第一个全连接层从3136降到128中间加ReLU激活函数通常还会加一个Dropout第二个全连接层从128降到10对应10个数字类别。输出层不需要额外加softmax因为在损失函数阶段会统一处理这一点是PyTorch使用中一个比较重要、也容易混淆的细节。这个网络结构没什么新奇之处本质上是LeNet-5的简化变体但它的设计思想在后来很多网络结构里都被继承了下来在通道维度上逐步加深在空间尺寸上逐步缩小最后通过全连接层做分类。这个从局部到全局的层次化特征提取过程也是CNN能够表现良好的原因。4.2 nn.Module实现代码PyTorch里定义模型的标准做法是继承nn.Module类。类中主要包含两块内容__init__方法里定义所有需要的层forward方法里定义数据如何在层之间流动。下面是一个简化的实现思路import torch from torch import nn class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) self.dropout nn.Dropout(0.25) def forward(self, x): x torch.relu(self.conv1(x)) x self.pool(x) x torch.relu(self.conv2(x)) x self.pool(x) x x.view(-1, 64 * 7 * 7) x torch.relu(self.fc1(x)) x self.dropout(x) return self.fc2(x)几个要点需要留意。第一super().__init__()必须调用否则模型注册子层时会报错或者无法正常进行参数管理。第二x.view(-1, 64 * 7 * 7)的作用是把三维特征图拉平成二维矩阵保留batch这个维度这在全连接层之前是不可缺少的一步。第三forward里也可以使用nn.ReLU()模块和torch.relu效果等价选哪种是个人风格问题。关于使用nn.Sequential还是手写forward我的经验是网络结构简单时可以用Sequential代码看起来更紧凑但一旦涉及多分支连接或者需要操作中间特征图手写forward的灵活性更高。对MNIST项目而言两种方式都行你可以都试试体会一下它们之间的区别。4.3 参数量与显存估算理解模型的参数量是从“会搭建”走向“会设计”的重要一步。我统计过上述网络的参数量大头其实不是在卷积层而是在第一个全连接层。第一个卷积层参数量卷积核32个每个尺寸1x3x3再加上偏置总共约320个。第二个卷积层参数量输入通道32输出通道64卷积核尺寸3x3加上偏置约18464个。全连接第一层参数量输入3136输出128加上偏置约401536个。全连接第二层参数量输入128输出10约1290个。可以明显看到全连接层占了整个模型超过95%的参数量。这也解释了一个工程经验当你想压缩模型体积时优先对全连接层动手比如减少第一层全连接的输出维度从128降到64参数量几乎减半对性能影响却不明显。运行时显存占用则不只取决于参数量还要考虑反向传播时保存的中间激活值。训练时PyTorch需要保存每层输入才能在反向传播中计算梯度。所以即使模型本身很小如果设置batch_size太大显存或内存照样会被撑爆。在这个项目的规模下通常不需要太担心显存但养成“用.to(device)把模型搬到GPU再算参数量”的习惯是好的。5. 训练、评估与可视化5.1 损失函数与优化器选用训练一个图像分类模型最常使用的损失函数是交叉熵损失PyTorch里对应nn.CrossEntropyLoss()。它内部把softmax和交叉熵计算合并在一起了所以模型的最后一层直接输出原始logits即可不需要再手动添加softmax层。这是一个容易犯迷糊的地方很多人习惯在网络最后加一个softmax然后再算损失这在数值上往往没问题但和CrossEntropyLoss搭配时相当于做了两次softmax既不必要还可能造成数值不稳定。优化器方面工程上我建议从Adam开始。Adam自带自适应学习率对学习率不那么敏感默认学习率0.001就足够让MNIST项目得到不错的结果。SGD加动量则更传统在充分调参后往往能取得更高精度但需要手动设置动量参数和学习率调试成本更高。我的建议是第一版先用Adam跑通确认代码和模型没有问题之后有余力再换成SGD观察两者在收敛曲线上有什么差异这比单纯背理论更能建立直观认识。学习率调度器也是一个值得使用的工具。比如每隔几个epoch把学习率乘以0.1可以在训练后期帮助模型在最优参数附近精调。这个技术在精度要求较高的常规项目里很实用。5.2 训练循环的标准写法训练循环是整个项目的主干线结构高度模板化。下面的代码是我每次写训练代码时几乎都会用到的骨架它包含了训练和验证两个阶段。model SimpleCNN().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) epochs 10 for epoch in range(epochs): model.train() running_loss 0.0 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() running_loss loss.item() model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs.data, 1) total labels.size(0) correct (predicted labels).sum().item() print(fEpoch {epoch1}, Loss: {running_loss/len(train_loader):.4f}, Accuracy: {100 * correct / total:.2f}%)这个循环里有几个关键点。第一optimizer.zero_grad()必须放在每个batch计算损失之前。它的作用是清除上一次反向传播累积的梯度如果不清理梯度会叠加在旧值上导致更新方向不对。第二model.train()和model.eval()是两种模式。训练模式下Dropout和BatchNorm等层会正常工作切换到eval模式下Dropout会关闭这样推理结果才是确定的。很多人忘记在验证阶段切换模式导致评估时还带着Dropout精度会忽高忽低排查时又难以发现。第三with torch.no_grad()在评估阶段关闭梯度计算可以大幅减少内存消耗并加快速度。评估阶段不需要反向传播所以这些梯度完全是浪费的。我在很多次实践中体会到loss.item()才是正确的取数方式。直接loss打印出来的是一个张量它会在计算图中积累一系列无关的引用如果跑多个epoch很容易造成内存泄漏。这是一个新手容易忽略的隐患。5.3 评估指标与混淆矩阵准确率是分类任务最直观的指标但只看准确率容易掩盖模型在具体类别上的表现差异。MNIST的10个类别中有些数字天生就容易混淆。比如4和9、7和2、3和8。我习惯在每个epoch结束后生成一个混淆矩阵这样能清楚地看到模型在哪些数字之间产生了误判。通过混淆矩阵可以尝试分析原因。数字3和8的差异主要在于下半部分的弧线构成如果模型提取到的特征不够精细确实容易把它们混淆4和9则是因为在整体的拓扑结构上有相似的部分。用混淆矩阵指导后续改进是非常高效的做法与其盲目加卷积层不如针对性观察哪些类别效果差然后从数据或网络结构上想对策。如果不想单独写混淆矩阵代码也可以用sklearn的confusion_matrix工具。把一批测试图片送入模型收集预测标签和真实标签最后对比一下这两组标签的对应情况。整个代码量很小但对理解模型表现的帮助非常大。5.4 画出输入图像与训练曲线可视化是理解深度学习模型最直接的窗口。项目第一个可视化内容我认为应当是把一小批训练图像打印出来。这个操作能让你直观确认数据加载环节有没有出问题比如图像是否被正确归一化、数字是否清晰、标签对应是否正确。第二个可视化是训练损失曲线和准确率曲线。将每个epoch的训练损失和测试准确率记录下来最后用matplotlib绘制成图。损失下降曲线能最直观地暴露模型是否收敛、训练是否过拟合等问题。比如训练损失持续下降但验证准确率停滞不动几乎可以断定存在过拟合。第二个可视化是训练损失曲线和准确率曲线。将每个epoch的训练损失和测试准确率记录下来最后用matplotlib绘制成图。损失下降曲线能最直观地暴露模型是否收敛、训练是否过拟合等问题。比如训练损失持续下降但验证准确率停滞不动几乎可以断定存在过拟合。第三个可视化值得深入尝试的是把第一层卷积核打印出来。训练完成后卷积核实际上学到了一些类似边缘检测的特征模式。把它们以灰度图形式输出后你会发现有些卷积核专门响应纵向边缘有些响应横向边缘有些响应弧线。这种可视化能极大地增强对“CNN自动提取特征”这句话的直观理解。6. 常见问题与排查技巧实录6.1 loss不降反增的现场排查实训中遇到最多的问题就是loss持续不下降甚至不降反增。这种情况背后最典型的原因是数据未归一化。输入的像素值若一直停留在0到255这样的大尺度深层网络的梯度计算很容易出现数值不稳定优化器怎么调都难以收敛。解决办法很简单确保transform里面同时包含ToTensor和Normalize即可。另一个常见原因是学习率设置过大尤其是对小数据集来说过大的学习率会让损失函数在极小值附近大幅震荡表现就是loss曲线像心电图一样波动。排查时直接把学习率降到0.0001再试一次如果曲线变平稳基本可以确定是学习率问题。还有一个原因比较隐蔽就是标签和数据不对应。比如DataLoader在打乱后数据与标签错位这种情况下无论怎么调参准确率都只能停留在接近随机水平10%左右。检查方法也很简单打印一个batch的images和labels肉眼对比一下图片和数字标签是否一致。我的排查习惯是按顺序来先打印一批数据看标签是否正确再检查transform是否归一化再调整学习率只有在这些基础环节都确认后才会去怀疑模型结构本身。6.2 数据加载慢与设备不匹配训练速度问题在MNIST项目里不算严重但有一种情况非常影响体验每个epoch之间停顿时间很长GPU利用率却不高。这种现象往往是数据加载瓶颈造成的。此时可以通过把num_workers适当调大来缓解。不过在Windows上请一定要确认脚本入口是if __name__ __main__:否则多进程的数据加载会直接报错。为了兼容性我在Windows上开发时习惯直接设为0在Linux服务器上跑实验时才把num_workers提到4或更多。设备不匹配的错误也经常会遇到比如提示张量在cpu上而模型在cuda上。这个错误最麻烦的点在于它不一定第一次迭代就出现有时是数据到了GPU而标签还留在CPU有时恰好相反。养成的习惯是模型和数据全部通过device cuda if torch.cuda.is_available() else cpu来统一控制并且在构造数据加载器之后显式地把每个batch迁移过去。只要能做到“模型、输入、标签”三者在同一个设备上这类问题基本能避免。6.3 精度上不去的改进路线如果你跑出来的测试准确率只有97%上下想提高到99%可以参考以下几个步骤按性价比从高到低排列。第一检查归一化参数是否正确。使用0.1307和0.3081这两个数值。很多精度异常往往就出在这一步。第二调整训练轮数。10个epoch到20个epoch之间MNIST模型的精度通常会持续上涨之后再逐渐饱和。第三增加卷积核数量。把第一层32个卷积核改为64第二层64改为128精度会有可感知的提升。但要注意模型参数翻倍后过拟合的风险也会增加。第四添加Dropout层或者提高Dropout概率。在训练集的准确率高于测试集1到2个百分点时在全局连接层前加一层nn.Dropout(0.25)会有明显帮助。第五使用学习率调度器。例如在第5和第8个epoch后把学习率下降一个数量级精度通常能再提升少量百分比。如果把以上手段全部组合起来MNIST测试集准确率达到99.2%到99.5%区间是完全可行的。再往上刷需要更精细的调参或者更复杂的网络结构投入产出比就会下降很多。6.4 过拟合的识别与缓解过拟合是深度学习中无处不在的问题MNIST上同样会出现。在训练集准确率高达99.8%而测试集准确率只有90%多时基本上可以断定模型已经把训练数据背下来了而不是学到了通用的特征。识别过拟合还有一个非常直观的方法就是画训练损失和测试损失的曲线。训练损失持续下降测试损失下降到某个点之后开始回弹就是典型过拟合信号。缓解过拟合的手段里Dropout的原理比较直观训练时以一定概率随机丢弃神经元的输出迫使模型不依赖某一个固定的神经元路径从而学习到更鲁棒的特征。weight decay则是在损失函数中增加L2正则项惩罚过大的权重。最后想分享一个经验当一个项目的精度卡在99%附近很久时不如退一步把feature map打印出来或者把误分类图片集中画成一张网格。你经常会发现那些被分错的图片本身已经模糊到人眼都很难辨认。这往往意味着数据可辨识度的极限已经到了模型本身并没问题。这时候继续调模型不如去看看数据清洗、数据增强或者更合适的损失函数设计。深度学习项目优化到后期瓶颈常常不在网络结构而在数据和评估思路上。这句话在MNIST上成立在更复杂的项目上同样成立。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑