MLP从原理到实战:多层感知机与MNIST手写识别完全解析
发布时间:2026/10/1 15:17:36来源:尧图网络
1. 神经网络家族里MLP到底站在哪个位置很多人第一次接触“神经网络”这个词是被卷积神经网络CNN在各种视觉比赛里的亮眼成绩吸引来的打开教程一看扑面而来的就是卷积、池化、特征图这些词。但说实话绝大多数人真正亲手训练并跑通的第一个神经网络项目其实是手写数字识别MNIST而背后那个朴实无华、甚至有点“笨重”的模型就是多层感知机MLPMultilayer Perceptron。MLP可以说是整个深度学习大厦的地基。如果你想搞懂后面那些看起来高深的概念——CNN里的卷积为什么有效、RNN/LSTM是怎么建模序列的、Transformer里的前馈模块在干什么你都得先弄明白MLP。它不是过时的老古董恰恰相反它是理解一切现代神经网络的最短路径。这篇文章我打算彻底把MLP讲透从它和感知机、BP神经网络的关系到前向传播和反向传播的数学原理再到用PyTorch从零实现一个手写数字识别模型并训练到不错的准确率。中间会穿插我个人在实战中踩过的一些坑以及调参、排查问题的经验。适合刚入门深度学习、想真正搞懂而不是只跑通代码的同学也适合那些会调库但对内部机制一知半解的开发者。1. 神经网络家族里MLP到底站在哪个位置1.1 感知机的故事单层网络为什么“死”在了异或上MLP的全称是Multilayer Perceptron直译就是“多层感知机”所以聊MLP之前必须先聊它的祖宗——感知机Perceptron。感知机是1958年Frank Rosenblatt提出的一种非常简单的线性分类器它的数学形式就一句话[ y \text{sign}(w \cdot x b) ]就是把输入向量和权重向量做点积加上偏置再套一个符号函数输出±1。感知机简单到令人发指但它在当时引起了不小的轰动因为它能自动学习权值来完成分类这在当时被认为是朝着“思考机器”迈出的一大步。但是1969年Minsky和Papert在《Perceptrons》一书中指出了感知机的致命弱点它本质上是一个线性分类器只能处理线性可分的问题连最简单的异或XOR问题都解决不了。异或问题是什么输入(0,0)输出0(0,1)输出1(1,0)输出1(1,1)输出0这四个点在二维平面上是怎么都无法用一条直线分开的。感知机连这么简单的问题都搞不定这让神经网络研究进入了长达近20年的寒冬。注意很多人以为“多层感知机”这个名字是因为感知机一定能处理异或问题其实关键是“多层”两个字。单层感知机的输出是一个线性超平面而多层感知机在中间加了隐藏层并且隐藏层引入了非线性激活函数这时候网络的能力就完全不一样了它可以逼近任意复杂的函数。1.2 MLP和BP-ANN是啥关系别再傻傻分不清在你搜索MLP相关话题的时候一定会碰上一个高频词BP-ANNBack-Propagation Artificial Neural Network反向传播人工神经网络。很多人会问MLP和BP-ANN到底是不是同一个东西从严格定义来说MLP是一种网络结构它描述了神经元怎么分层连接——输入层、一个或多个隐藏层、输出层层与层之间全连接神经元内部先做加权求和再经过非线性激活函数。而BP-ANN强调的是训练算法即用反向传播Back Propagation算法来更新权值的神经网络。MLP是一种前馈神经网络Feedforward Neural Network训练时最常用的算法恰好就是反向传播。但在工程实践中这两个词经常被混用。因为几乎所有全连接结构的神经网络在训练时都跑的是BP算法大家说“BP神经网络”的时候十有八九指的就是MLP。如果你想细分可以这样理解MLP是模型结构层面的概念BP是优化算法层面的概念。一个MLP可以用BP训练也可以用其他优化方法比如进化算法训练一个BP算法理论上也可以被用在其他结构的网络上但实践中我们几乎只在MLP里看到它最标准的形态。在搜索热词里还有“前馈神经网络”这个词。前馈神经网络是更大的一个类别它指的是信息从输入层单向流动到输出层、没有反馈连接和跨层循环的网络结构。MLP是前馈神经网络里最经典、最基础的一种。所以这三者范围的大小关系是前馈神经网络 ⊃ MLP而BP-ANN通常≈MLP反向传播训练算法。1.3 前馈、卷积、循环一张表看清不同网络在解决什么问题为了不让你以后看到一堆网络缩写就头大我先把几个最常碰到的网络结构做个横向对比它们之间的根本区别在于对输入数据的结构假设不同。网络类型核心结构擅长处理的数据核心思路MLP多层感知机全连接层堆叠结构化表格数据、特征向量学习特征之间的非线性组合CNN卷积神经网络卷积层池化层图像等具有空间结构的网格数据利用局部感受野和权值共享提取空间特征RNN/LSTM循环神经网络循环连接、门控单元时间序列、文本等序列数据利用循环结构建模时间依赖图神经网络GNN消息传递机制社交网络、分子结构等图数据聚合邻居节点信息学习节点表示MLP在处理图像时是什么状态就是把28×28的像素矩阵展平成784维的向量然后一股脑塞进全连接层。这个过程会丢失像素的空间关系——右上角像素和左上角像素在MLP眼里是完全平等、没有位置概念的。所以MLP在MNIST这种简单灰度数字识别任务上能跑出不错的准确率98%左右但一旦遇到复杂自然图像就力不从心了。这正是CNN崛起的原因。不过MLP作为万物之基理解它仍然是理解后面所有网络的关键。2. MLP的核心原理用大白话拆完2.1 前向传播数据是怎么从输入走到输出的一个标准的3层MLP结构长这样输入层→隐藏层→输出层。以MNIST手写数字识别为例每张图片是28×28像素的灰度图所以输入层有784个神经元输出层有10个神经元分别对应数字0到9中间隐藏层的神经元数量是个超参数常用128或256为什么选这个数后面再说。前向传播Forward Propagation的实际计算过程分两步第一步线性变换。当前层的每个神经元把上一层所有神经元的输出做加权求和再加上一个偏置。用矩阵写就是[ z^{(l)} W^{(l)} \cdot a^{(l-1)} b^{(l)} ]其中(W^{(l)})是第l层的权重矩阵(a^{(l-1)})是上一层的激活输出。你可以把W想象成一本“接线账本”账本的第i行第j列写着“上一层的第j个神经元对当前层第i个神经元的影响有多大”。第二步非线性激活。加权求和的结果z要经过一个非线性函数再输出[ a^{(l)} \sigma(z^{(l)}) ]这个σ就是激活函数。如果只有第一步没有第二步那么不管叠加多少层网络最终都只是一个线性变换的复合另一个线性变换等于白加深。非线性的引入才是多层网络真正强大的原因。我举个具体的维度例子帮你建立直觉。输入层784个神经元隐藏层128个神经元那么W^(1)的维度就是128×784b是128维向量隐藏层到输出层W^(2)的维度是10×128b是10维向量。这样一算这个网络的参数总量是[ (128 \times 784 128) (10 \times 128 10) 100352 1290 101642 ]大约10万个参数。别小看这10万个参数MNIST数据集只有6万张训练图片用10万参数去拟合一个相对简单的分类问题其实是有点“大炮打蚊子”的——这也是为什么这个模型容易过拟合后面实战部分我会讲怎么应对。2.2 激活函数选择为什么一定得是非线性的激活函数是整个MLP里最不应该被忽略的组件。前面说了没有非线性激活函数多层网络就会退化成单层线性变换那整个深度学习的基石就塌了。但具体选哪个激活函数里面的讲究可不少。早期神经网络最常用的是sigmoid函数公式是[ \sigma(x) \frac{1}{1 e^{-x}} ]它的输出被压缩在0到1之间数学性质很好很适合作为输出层去模拟概率分布。但它有两个明显的缺点一是输出不是零中心的这会让梯度更新时出现“Z字型”震荡二是在输入绝对值较大的区域导数趋近于0容易造成梯度消失。后来大家发现ReLURectified Linear Unit更适合做隐藏层激活函数公式更简单[ \text{ReLU}(x) \max(0, x) ]输入为正则梯度为1输入为负则梯度为0。这个设计极大地缓解了梯度消失问题而且计算便宜训练速度远快于sigmoid。ReLU最大的问题是“神经元死亡”——当一个神经元的输入长期为负它的梯度永远是0权重再也更新不了。我个人的经验是默认用ReLU如果出现大量死亡神经元就换LeakyReLU或ELU。LeakyReLU在负数区域给了一个很小的斜率比如0.01保证梯度不会完全消失。到输出层的话分类问题就用softmax回归问题直接用线性输出。sigmoid和tanh在做二分类输出或者特定研究场景下还有用但在隐藏层里的地位早已被ReLU家族取代了。2.3 反向传播猜重量游戏里学到的重要思想如果只看前向传播MLP就是一堆矩阵乘法和激活函数套来套去训练的核心难点在于怎么根据预测结果的好坏回头调整每一层的权重这就是反向传播Back Propagation要做的事。理解反向传播的最简单类比是猜重量游戏。假设你面前放着10个大小不一的苹果我告诉你它们总重是2斤但你只能猜每个苹果的重量。你第一次乱猜总重算出来是1.8斤——差了0.2斤。然后你怎么调整你会在心里算是不是我猜的每个苹果的重量都偏小了如果苹果A实际偏小得更多我会更大程度地上调它。反向传播做的就是类似的事情先算出输出层的误差然后把这个误差沿着网络从后往前“分摊”告诉每一层的每个权重“你的调整方向是变大还是变小调整幅度大概多少”。用数学语言表述反向传播的核心是链式法则。假设损失函数是L我们要计算损失对第l层权重W^(l)的梯度(\frac{\partial L}{\partial W^{(l)}})可以通过链式法则逐层回传误差项delta来完成[ \delta^{(l)} (W^{(l1)})^T \delta^{(l1)} \odot \sigma(z^{(l)}) ]这个公式看着唬人但拆开看就三步先看上一层传来的误差信号把误差信号通过权重矩阵传回当前层这就是那个转置矩阵乘法的含义再乘上当前层激活函数的导数值表示“这个神经元的输出变化对损失的影响被激活函数的斜率放大了多少倍”。对分类问题来说如果你在输出层用了softmax 交叉熵损失有一个非常好用的简化结论输出层的误差信号就等于预测概率减去真实标签的one-hot向量即(\delta^{(L)} y_{pred} - y_{true})。这个简化让代码实现变得非常简单后面实战代码里你会看到。2.4 损失函数、优化器与学习率训练“玄学”背后的确定性有了反向传播计算出梯度接下来就是用优化算法更新权重。最基础的方法是梯度下降公式[ W \leftarrow W - \eta \cdot \frac{\partial L}{\partial W} ]这里的(\eta)是学习率它决定每一步更新的步子有多大。学习率太小训练慢得让人怀疑人生学习率太大损失函数会在最小值附近来回震荡甚至发散。我见过太多新手把学习率设成0.1去跑交叉熵损失结果loss直接变成NaN。后来大家更常用Adam优化器它给每个参数单独适配了学习率对初始学习率不那么敏感——但注意Adam也不是万能药初始学习率太高照样会炸。损失函数的选择也很关键。对多分类问题最标准的组合是softmax输出 交叉熵损失CrossEntropyLoss。交叉熵度量的是预测概率分布和真实标签分布之间的差异它有两个性质非常讨人喜欢一是梯度形式简洁就是我上面说的那个(y_{pred} - y_{true})二是当预测置信度很低时梯度会很大让模型快速纠正错误。如果你用MSE做分类损失不仅梯度形式复杂而且训练会慢得多因为MSE在输出层饱和区域梯度很小对分类问题的优化很不利。3. 动手做手写数字识别从零撸一个MLP并跑通3.1 数据准备MNIST的获取、归一化与数据加载MNISTModified National Institute of Standards and Technology是深度学习界的“Hello World”由6万张训练图片和1万张测试图片组成每张是28×28的灰度手写数字图。PyTorch的torchvision.datasets里直接内置了这个数据集一行代码就能下载不用费劲去官网找。在喂给MLP之前数据预处理里最重要的一步是归一化。MNIST像素值范围是0到255直接用原始值训练会让梯度更新变得很不稳定——不同特征的数值尺度差距过大优化器在参数空间里走的路线会很歪。我的做法是把像素值压缩到0到1之间再减去均值除以标准差做标准化。虽然MNIST因为数据本身已经很规范只除以255就够了但养成标准化的习惯对以后处理真实数据非常有帮助。用PyTorch写数据加载代码大概长这样import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms # 数据预处理转Tensor 归一化 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) # MNIST的均值和标准差 ]) # 加载训练集和测试集 train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) # 放到DataLoader里方便按batch迭代 train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse)这里的batch_size我选了64一个epoch的迭代次数就是60000÷64约938次迭代。batch_size不是越大越好太小了梯度噪声大训练不稳定太大了每个batch的冗余信息多而且显存不够。64或128是MNIST任务上一个很均衡的选择。3.2 网络构建3层MLP的PyTorch实现与参数计算接下来定义网络结构。我选的方案是784→128→10一个隐藏层隐层神经元128个。为什么是128没有特别深刻的数学原因但有个一般规律隐藏层神经元数量太少模型的表达能力不足准确率上不去数量太多参数量暴增容易过拟合且计算变慢。MNIST这种相对简单的任务128足够用了。用PyTorch的nn.Module定义网络非常简洁class MLP(nn.Module): def __init__(self, input_dim784, hidden_dim128, num_classes10): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, num_classes) ) def forward(self, x): # 输入形状是 [batch_size, 1, 28, 28]展平成 [batch_size, 784] x x.view(x.size(0), -1) return self.net(x)注意forward里面有一个view操作因为DataLoader返回的每个batch是4维张量batch, channel, height, width对于灰度图channel1。MLP是全连接网络必须把每个样本展平成784维向量再喂进去。上面这个网络的参数量我前面算过了约10万个。你可以在PyTorch里这样验证model MLP() total_params sum(p.numel() for p in model.parameters()) print(f参数量{total_params}) # 输出参数量101642这里输出的101642和前面手算的对上了。3.3 训练循环loss曲线、梯度的可视化验证训练代码是整个实验最核心的环节也是最容易出错的地方。很多新手觉得PyTorch训练循环就那么几行抄过来就能跑但你要是不理解里面每一步的顺序调试起来会非常痛苦。标准训练循环分六步顺序不能乱清空上一轮batch的梯度optimizer.zero_grad()前向传播得到预测输出计算损失反向传播得到梯度loss.backward()用优化器更新参数optimizer.step()每隔一段时间打印loss和进度我完整写一下训练代码并加了loss记录方便画图model MLP() optimizer optim.Adam(model.parameters(), lr0.001) criterion nn.CrossEntropyLoss() num_epochs 10 train_losses [] for epoch in range(num_epochs): model.train() total_loss 0 for batch_idx, (images, labels) in enumerate(train_loader): optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() if (batch_idx 1) % 200 0: avg_loss total_loss / (batch_idx 1) print(fEpoch {epoch1}/{num_epochs} | Batch {batch_idx1:4d} | Loss {avg_loss:.4f}) train_losses.append(total_loss / len(train_loader))这里最关键的是loss.backward()和optimizer.step()之间的顺序。backward()会从输出端开始用链式法则把梯度一直往回传算好每个参数的更新方向step()才真正使用这些梯度去更新参数。如果你忘了调zero_grad()梯度会在每个batch里累积参数更新方向就会被历史梯度干扰训练几乎必然发散。训练10个epoch后loss曲线应该呈现出阶梯式下降的趋势每个epoch内loss略有震荡因为不同batch的数据分布不同但每个epoch结束时的loss比上一个epoch低。这个曲线是判断模型是否正常训练的最直观指标。3.4 测试评估准确率、混淆矩阵与样本可视化模型训练完了要在从未见过的测试集上评估。评估时注意两点一是务必用model.eval()把模型切到推理模式影响某些层的行为比如BatchNorm、Dropout二是在with torch.no_grad():下做推理不计算梯度省内存并加速。def evaluate(model, test_loader): model.eval() correct, total 0, 0 all_preds, all_labels [], [] with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, preds torch.max(outputs, 1) # 取概率最大的类别索引 total labels.size(0) correct (preds labels).sum().item() all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) acc 100.0 * correct / total print(f测试集准确率{acc:.2f}%) return acc, all_preds, all_labels acc, preds, labels evaluate(model, test_loader)一个合理训练的3层MLP在MNIST测试集上准确率大概在97%到98%之间。如果你看到准确率只有90%左右大概率是哪里出了问题如果超过99.5%那就要怀疑是不是数据泄露了——比如测试集混进了训练集。我还建议你把预测错误的样本打印出来看看。大多数错误样本其实“非人之过”那些数字写得太潦草人眼都分不清机器判断错很正常。看看这些错误样本能让你对模型的能力边界有个直观认识。4. 实战项目里的坑我帮你全踩了一遍4.1 最常见问题速查表训练MLP过程中你会遇到各种千奇百怪的报错和反直觉的现象。这里整理一张速查表几乎覆盖了新手会遇到的大部分问题现象可能原因解决方向loss输出NaN学习率过大、权重初始化不当、输入含NaN调小学习率检查数据改用Xavier初始化loss一直不降学习率过小、数据没归一化、网络结构有问题尝试梯度检查标准化数据调大学习率训练300多步后loss极低但测试集很差过拟合加Dropout、正则化、增大数据量accuracy卡在某个值上不去隐藏层宽度不够、数据预处理不对增加神经元数量检查预处理测试时warn“不对应”torch.no_grad没调用model.eval()评估时加model.eval()和no_grad块维度报错Dimension mismatch全连接层输入维度不对检查forward里的view操作是否正确4.2 为什么我的loss卡住不降排查思路三条线loss卡住不动是训练神经网络时最挫败的情况。我把它总结为三条排查线第一条线检查输入数据。把训练数据可视化出来确认标签和图片对不对得上确认有没有做归一化。归一化没做或者做错了代价函数的地形会变得非常扭曲梯度下降极易陷入停滞。我见过有人把Normalize的均值和标准差填反了像素值分布完全错乱模型还能勉强跑到80%左右的准确率——真到了测试阶段才发现问题。第二条线检查梯度。在训练代码里手动打印某个权重层的梯度的最大值和最小值。如果梯度全为0很可能ReLU导致大量神经元死亡或者权重初始化太小如果梯度过大可能梯度爆炸。这个方法能帮你快速定位梯度消失/爆炸的问题到底出在哪一层。第三条线从简到繁地调试模型。先只用一个batch的数据训练如果网络连一个batch都不能过拟合那问题一定出在网络本身或者数据加载上然后再用更大的训练子集逐步发现问题。这个“单batch过拟合测试”是我每次搭新模型都会做的一个快速自检能帮你排除掉一大半无关因素的干扰。4.3 过拟合的应对正则化、Dropout与数据增强MNIST上MLP过拟合的表现很典型训练集准确率接近100%但测试集准确率停在97%左右。要提升泛化能力有几种实用手段。Dropout是最常用也最容易落地的。它的思想非常粗暴训练时随机让一部分神经元失活输出置为0强迫网络学习冗余特征避免某个神经元“一家独大”。在PyTorch里加一行就行self.net nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.2), # 训练时有20%的概率随机失活 nn.Linear(hidden_dim, num_classes) )Dropout一般放在隐藏层激活函数之后输出层之前。在测试时Dropout会自动关闭因为model.eval()会处理好这点。L2正则化权重衰减也很有效它给损失函数加一项权重的平方和让权重趋向于小值从而降低模型复杂度。PyTorch里实现起来非常方便直接在优化器里设weight_decay参数比如optim.Adam(model.parameters(), lr0.001, weight_decay1e-4)。数据增强算是最对症的“补药”。手写数字识别里可以把图片随机旋转3度以内、平移2个像素以内、稍微加一点噪声这些变换不会改变数字的语义却能有效扩充训练集。torchvision提供了transforms.RandomAffine、transforms.RandomRotation这些现成工具。不过注意MNIST本身比较规范数据增强的效果不如在复杂视觉数据集上那么显著但依然能带来一两个百分点的提升。5. 从MLP再往前走一步延伸与影响5.1 CNN在什么地方踩在MLP的肩膀上看完上面的实战你应该还记得一个细节我们用view把28×28的图片展平成784维向量喂进网络图片的空间结构信息在这一步就被丢弃了。CNN的设计就是为了解决这个问题——它用卷积核在二维平面上滑动天然保留局部空间关系权值共享让CNN的参数量远少于同等表达力的MLP。但CNN并不是推翻了MLP而是站在它的肩膀上。CNN的最后一个阶段通常要把特征图展平接入一个全连接层本质就是MLP或全局平均池化后再接分类层它的卷积核提取特征、全连接层做分类的组合是把“局部感知”和“全局整合”分开掌握。从MLP到CNN的演进逻辑是从“假设输入特征独立同分布”到“利用输入的结构先验”的一次飞跃。5.2 结构化数据与时间序列场景里的MLP变体虽然CNN占据了图像战场RNN/LSTM占据了序列战场但在结构化表格数据比如风控评分、用户画像、销售预测领域MLP和梯度提升树依然是绝对的霸主。因为表格数据没有明显的空间或时序结构特征之间是“平权”的关系MLP恰恰最擅长捕捉特征之间的高阶非线性交互。在时间序列任务里MLP的变体也能发挥作用比如用滑动窗口把历史数据组织成特征然后用MLP做预测在很多场景下效果不输LSTM还更快更好训。近两年还冒出来一个热点方向叫神经ODENeural ODE它用神经网络直接参数化微分方程中的函数本质上还是用神经网络的表达能力去拟合一变化律只不过从离散的层变成了连续的动态系统。这类高级玩法涉及到对神经网络本质的理解而所有的基础都在于你把MLP的前向传播和反向传播吃透了没有。5.3 硬件落地上的一点思考矩阵乘法和多核调度在搜索热词里出现了一个很有趣的偏硬件方向“通用神经网络处理器下的多核调度问题”。这个方向乍一看和MLP关系不大但其实MLP是最适合硬件加速的模型之一因为它的核心计算就是矩阵乘法GEMM。矩阵乘法天然可以被分成若干独立子块并行执行所以在多核处理器或多核加速器上MLP的每一层计算都可以被切分到不同核心上核心间只需要在层与层之间有同步点。但多层全连接的并行调度有个隐性问题层与层之间是严格串行的比如第2层必须等第1层的激活输出全部算完才能开始。这就导致单纯增加核数不见得能线性加速。解决思路通常是做层内并行 层间流水线——层内把矩阵乘法拆分到多个核心同时让不同核错开处理不同batch的数据减少等待时间。如果你将来要做模型在嵌入式或专用硬件上的部署理解MLP的计算量分布和访存瓶颈会比对着一堆网络结构图死记硬背有用得多。我自己在把MNIST这个MLP模型部署到树莓派这种低功耗设备上时实测发现推理瓶颈不在计算而在内存访问——784维输入经过第一次矩阵乘法时大量权重被反复加载缓存命中率低。针对这个问题把权重矩阵按行分块、并提前调整填充顺序能显著降低延迟。这其实就回到了多核调度本质问题的本源内存墙和带宽瓶颈。如果你真的把上面的代码从头到尾敲了一遍并且把loss曲线、准确率、预测错误的样本都认真观察过我猜你大概率会有一种感觉原来神经网络并没有那么神秘它本质上是“一堆可学习的参数 精心设计的计算图 一套能让参数找到最优点的梯度算法”。这种对模型内部机制不再恐惧的感觉才是你真正迈进深度学习大门的标志。最后再分享一个小技巧每次训练完一个模型我习惯把最终的测试准确率连同网络结构、超参数、随机种子一起记录在一个实验日志里。这个习惯帮了大忙——因为深度学习实验的变量太多了跑完十个版本之后你根本记不清是哪个配置有效哪个无效随手记录几行字能省下一整天的复现时间。从MNIST这个入门实验开始养成这个习惯你会受益很久。
网站建设高端定制企业官网