ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

全连接神经网络原理与PyTorch实战:从神经元到深度学习基石

全连接神经网络原理与PyTorch实战:从神经元到深度学习基石 如果你刷到过任何一条关于AI的新闻大概率会碰到“神经网络”“深度学习”这几个词。但你知道吗那些看起来很炫酷的自动驾驶、人脸识别、大语言模型底层几乎都有一个共同的基础模块叫全连接神经网络Fully Connected Neural Network缩写FCN也叫Dense Layer。这个名字听起来很学术但在深度学习这个领域里它的地位相当于高楼大厦里的钢筋混凝土是绝对绕不开的地基。这篇博客我想从一个神经元讲起一步步拆到“为什么多个神经元堆叠起来就能解决这么复杂的问题”再用一份完整的PyTorch代码带你跑通一个手写数字识别项目。这篇文章适合刚接触深度学习的初学者也适合已经用框架调参但没系统捋过内部原理的朋友。不管你是做视觉、音频还是NLP把全连接网络吃透后续搭建任何模型都会顺手很多。1. 从生物神经元到数学“神经元”一切从这里开始1.1 一个神经元的完整模型全连接神经网络最朴素的灵感来自于生物大脑中的神经元。生物神经元接收来自树突的信号在细胞体内做整合当信号强度超过某个阈值时通过轴突把信号传给下一个神经元。深度学习里的“神经元”就是把这套机制做成了极简的数学版本。一个数学神经元做的事情只有三步接收输入可以是一个数也可以是一组特征向量给每个输入乘一个权重weight然后全部加起来再加一个偏置bias把结果丢进一个非线性函数activation function得到最终输出用公式来表达就是z w1 * x1 w2 * x2 ... wn * xn b a f(z)这里的 w 是权重b 是偏置f 是激活函数a 是这个神经元的输出。如果把输入写成一个行向量 x权重写成一个列向量 w那么 z 本质上就是一次向量点乘也就是 x 和 w 的内积再加上 b。我们常说“神经网络就是在拟合一个函数”这句话的字面意思就是这一层层神经元组合起来最终输入到输出的映射可以被任何一个数学函数近似出来。这个模型看着极其简单像初中数学的线性函数加了个壳。但要注意这里的关键点和威力点几乎全在“激活函数”上。1.2 为什么非线性激活函数是必须的如果神经元里没有激活函数也就是 f(z) z恒等映射那会发生什么我们可以做一个非常简单的推演假设第一层输出 h1 W1·x b1第二层输出 h2 W2·h1 b2。把 h1 代入 h2得到h2 W2·(W1·x b1) b2 (W2·W1)·x (W2·b1 b2)字母看着多其实结论就一句话无论叠加多少层它的本质仍然是一个线性变换 W·x b。也就是说网络做再多层能力也不会比一个单层线性模型强。这就好比你请了十个厨师结果每个人只会做同一道凉拌黄瓜最后端出来的还是凉拌黄瓜。所以激活函数必须非线性。它的作用就是打破这种“线性叠加”的限制让神经网络的表达能力真正“折叠”起来。有了非线性网络才能拟合曲线、分类复杂分布、处理图像边界、理解语言语义甚至拟合任意复杂的函数。这一点在数学上有理论支撑叫“万能逼近定理”简单说就是只要神经元数量足够多哪怕只有一个隐藏层也能逼近任意连续函数。听起来很夸张但这是深度学习能work的基石逻辑。常见的激活函数有这几类我整理了一个简单对比激活函数公式输出范围特点常见场景Sigmoidf(z)1/(1e^(-z))(0, 1)平滑、有概率解释但容易梯度消失、输出非零中心二分类输出层现在较少用tanhf(z)(e^z - e^(-z))/(e^z e^(-z))(-1, 1)零中心比Sigmoid好但同样存在梯度饱和问题RNN类网络常用全连接里较老派ReLUf(z)max(0, z)[0, ∞)计算极快、缓解梯度消失但有“死亡神经元”风险隐层最常用没有之一Leaky ReLUf(z)max(0.01z, z)(-∞, ∞)缓解ReLU死亡问题允许小梯度流过隐层备选Softmaxf(z_i) e^(z_i) / Σ e^(z_j)(0,1)且和为1输出多分类概率分布多分类输出层我个人在搭建网络时隐层首选ReLU输出层再看任务二分类用Sigmoid多分类用Softmax回归任务直接用线性输出不加激活函数。这个组合在绝大多数全连接网络里都能稳定跑出不错的效果。2. 从单层到多层深度是如何“炼成”的2.1 单层感知机的边界与多层网络的诞生早期的神经网络叫“感知机”它就是一个单层的神经元输入特征经过加权求和后直接输出一个0或1的判定结果。感知机在1960年代火了一阵子但很快就被泼了冷水——1969年Minsky和Papert在著作里明确指出单层感知机连“异或”问题都解决不了。所谓异或就是两个输入相同输出0不同输出1这种分类边界根本不是一条直线能切开的。那时候算力也弱、数据也少神经网络研究直接进入了低谷期。后来大家意识到解决异或问题的关键在于加一个“隐藏层”——也就是在输入和输出之间再插一层神经元。隐藏层的神经元会先对原始特征做一次非线性变换把原始空间中的线性不可分问题映射到一个新的、可分性更强的特征空间里再交给输出层去分类。这就是多层感知机MLP的基本思想也是现代深度学习“深度”的雏形。深度是为什么有效一个比较直观的解释是浅层神经元学习到的通常是“简单局部”的特征比如图像里的边缘、颜色块随着层数增加网络可以把这些简单特征逐步组合成“复杂抽象”的语义比如眼睛、轮子、人脸。端到端学出来的层次化表示正是深度学习对比传统手工特征工程最核心的优势。全连接网络虽然结构简单但它是这种“层次化特征学习”最原型的载体。2.2 全连接层的本质矩阵乘法说完思想落到工程上。所谓“全连接”是指当前层的每一个神经元都和上一层的每一个神经元有连接。如果上一层有 n 个神经元当前层有 m 个神经元那它们之间的可学习参数就有 n×m 个权重外加 m 个偏置。这个“全连接”的名字就来自于这种“谁都和谁相连”的结构。这么多连接如果一个个算效率太低了实际代码里全靠矩阵乘法一次搞定。假设输入 x 的形状是 (batch, n)权重矩阵 W 的形状是 (n, m)偏置 b 的形状是 (m,)那么当前层的输出就是h x W b在PyTorch里我只需要写一句self.fc nn.Linear(n, m)然后在前向传播时输入 x 即可。框架会自动帮你做矩阵乘法和广播加偏置。用这种写法我再叠个十层二十层网络代码消耗不会增加太多每层就是一行nn.Linear加上一个激活函数。这也是为什么全连接网络经常被当作入门第一个模型——结构简单、实现容易但麻雀虽小五脏俱全。我记得第一次读这段代码时最大的迷惑是为什么定义网络时不直接写矩阵乘法而是用nn.Linear其实nn.Linear内部就是一个权重矩阵weight和一个偏置向量bias调用它的过程就是矩阵乘加偏置。理解了这个后续你阅读几乎所有PyTorch模型都不会被全连接层卡住。2.3 深度学习的“基石”定位全连接层在视觉和语言模型中的身影很多初学者会问“现在做图像都用CNN做文本都用Transformer全连接网络是不是过时了”这个问题我当年也想过但实际上完全不是这样。全连接层几乎无处不在只是经常换了位置和名字。CNN里的分类头也就是卷积网络最后把特征图变成最终类别分数的部分通常就是一层或多层全连接层。经典的VGG16、ResNet最后都接了一个全连接分类头。Transformer里的前馈网络Feed-Forward NetworkFFN本质上就是两个全连接层夹一个激活函数。大语言模型中的每一个Transformer块里FFN占的参数比重都非常大。我再提一个概念Embedding层、注意力中的Q/K/V投影也都是全连接的具体形态。所以你把全连接这一关过了看Transformer结构时至少能少掉一半的理解障碍。从这个角度看标题里说全连接是“深度学习的基石”毫不夸张。你学的不是某一个过时模块你学的是整个深度网络设计中最底层的“积木”。后面所有复杂结构都是在这块积木上做的升级和扩展。3. 训练的核心损失函数、反向传播与梯度下降3.1 损失函数怎么选回归和分类完全不同模型结构定好之后它只是有了一个“可以学习的空壳”我们还不知道权重该取什么值。这时候需要一个“裁判”也就是损失函数loss function用来评估当前模型的预测结果和真实标签之间差多少。训练过程做的事情就是不断调整权重让这个损失值越来越小。损失函数的选择和任务强相关选错了轻则训练慢重则根本学不动。这里给一个最简洁的选型建议回归任务预测房价、预测温度用均方误差损失MSE Loss衡量预测值和真实值之间的平方差二分类任务是/否用二元交叉熵BCE Loss输出层配合Sigmoid多分类任务十个类别用交叉熵CrossEntropy Loss输出层配合Softmax为什么分类任务不用MSE我举个例子假设真实标签是“猫”我们编码成one-hot向量 [0, 1, 0]模型输出是 [0.2, 0.7, 0.1]MSE能算出差别但梯度变化在输出接近1或0时会很小训练速度会变得非常慢。交叉熵直接衡量两个概率分布的差异在“分类错误”时能提供更强烈的梯度信号收敛明显更快。所以不要觉得损失函数只是个公式它直接决定了你训练过程的“手感”。3.2 反向传播的直观理解有了损失值接下来要回答一个关键问题网络里面有成千上万个权重损失函数这么大到底该把锅甩给哪个权重这就是反向传播backpropagation解决的问题。反向传播的核心数学工具是链式法则。假设我们有一个三层网络输入层 → 隐藏层 → 输出层 → 损失函数。如果损失值是 L输出层的某个权重是 w那么 L 对 w 的梯度可以一层层拆解dL/dw (dL/da) · (da/dz) · (dz/dw)这里的 a 是激活输出z 是加权求和结果。我们不需要手动去推导每个权重的梯度表达式PyTorch等框架的自动求导引擎会从损失函数开始按计算图逐层反向地把梯度算出来存放在每个参数的.grad属性里。我用一个生活化的类比帮助你理解想象你开了一辆货车在盘山公路上送货目标是开到山谷底部损失最小。深夜没有路灯你只能靠车灯的微光判断当前地面的坡度。反向传播就是在告诉你“你现在这个位置往哪个方向稍微调整一下方向盘海拔会更低一些”。每一层都收到上一层传来的“坡度信息”就知道自己该往哪个方向调了。初学阶段最容易出的问题是把反向传播理解成“反向算一遍前向公式”。其实它算的是梯度不是重算前向结果。这两者差距很大前向传播输出的是预测值反向传播输出的是每个参数的偏导数是用来指导参数更新的方向信号。3.3 梯度下降与优化器学习率是重中之重有了梯度我们就能更新参数了更新的公式如下w_new w_old - learning_rate * gradient这就是最基础的随机梯度下降SGD。learning_rate学习率控制参数每次更新的步幅。学习率太大参数会在最优值附近来回震荡甚至发散学习率太小训练过程会被拖得很久而且容易掉进局部最小值就很难爬出来。实际工程里我们已经很少用裸的SGD了。更常用的是动量和自适应学习率优化器最常见的就是Adam。Adam相当于在SGD基础上加了“惯性”和“自适应步长”惯性让它越过小坑自适应步长让每个参数根据历史梯度自动调整更新幅度。我用一个简单对比让你感受它们的差异优化器核心思想优点缺点适用场景SGD沿负梯度方向更新简单、泛化性好收敛慢、容易震荡数据量小、调参经验足时SGDMomentum叠加历史梯度方向加速收敛、减少震荡多一个超参要调通用Adam自适应学习率动量收敛快、几乎不用调学习率有时泛化性不如精细调过的SGD初学者首选、大数据量我的实际经验是如果你刚开始跑实验直接上Adam初始学习率用 1e-3。如果发现loss收敛得慢再尝试降到 1e-4。后面想冲更高精度再换SGDMomentum把学习率设成 0.01 甚至 0.1配合多轮学习率衰减策略。不要一开始就在优化器选型上折腾太多跑通一个基线比什么都重要。权重初始化也是训练成败的关键。以前用Sigmoid时如果权重初始化太大输入到激活函数的值会落在饱和区梯度接近0网络基本学不动。现在用ReLU配合合理的初始化方法比如Kaiming初始化这个问题缓解了很多。PyTorch里你直接nn.Linear默认就会用合适的初始化策略新手不用太担心。另外输入数据的标准化归一化非常值得重视。如果一个特征的数值范围在0到1另一个在0到10000那第一个特征对损失的贡献在初始阶段会被严重淹没。做数据预处理时把每个特征缩放到均值0、方差1StandardScaler或者缩放到0-1区间网络会收敛得快得多。4. 动手实现一个全连接网络手写数字识别全流程4.1 环境准备与数据加载接下来我们走一遍完整的实操。我选用PyTorch因为它语法直观、社区大、遇到问题搜一下就能找到答案。如果你还没装环境建议用Anaconda创建一个干净的虚拟环境Python版本3.9或3.10都可以然后执行pip install torch torchvision如果机器有NVIDIA显卡且已装好CUDApip install torch默认会带上CUDA的版本可以自己跑一下import torch print(torch.__version__) print(torch.cuda.is_available())torch.cuda.is_available()返回True说明GPU可用后续训练会自动跑在显卡上。没有GPU也不用慌手写数字识别这个任务规模很小CPU也能在几分钟内完成训练只是会比GPU慢一些而已。数据集使用MNIST它是一个包含6万张训练图片和1万张测试图片的手写数字数据集每张图是28×28的灰度图。这个数据集在深度学习领域相当于“Hello World”非常适合验证你对网络结构的理解。from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader torch.utils.data.DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader torch.utils.data.DataLoader(test_dataset, batch_size64, shuffleFalse)这里的Normalize是数据标准化的典型例子。MNIST官方的均值和标准差大约是 0.1307 和 0.3081我们直接拿来用。注意28×28的图片经过ToTensor之后形状是 (1, 28, 28)后续我们需要把它展平成一维向量 (784,) 才能输入全连接层。4.2 模型定义与训练流程下面这份代码定义了一个三层全连接网络输入784维隐藏层分别是128和64输出10维对应0到9十个数字import torch.nn as nn import torch.nn.functional as F class MLP(nn.Module): def __init__(self): super(MLP, self).__init__() self.fc1 nn.Linear(784, 128) self.fc2 nn.Linear(128, 64) self.fc3 nn.Linear(64, 10) def forward(self, x): # 输入形状: (batch, 1, 28, 28) x x.view(x.size(0), -1) # 展平为 (batch, 784) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) x self.fc3(x) # 输出层不加激活交给CrossEntropyLoss处理 return x这里有一个新手很容易困惑的细节为什么输出层不用Softmax因为PyTorch的CrossEntropyLoss内部已经集成了Softmax计算你只需要把网络最后一层的原始分数logits传给损失函数即可。如果你在输出层手动加了Softmax再传给CrossEntropyLoss反而会导致数值不稳定和训练效果变差。我见过不少新手踩这个坑这里提前帮你避开。接下来是训练逻辑。核心就是四步前向传播、算损失、梯度清零非常重要不清零梯度会累加、反向传播、更新参数。import torch.optim as optim model MLP() optimizer optim.Adam(model.parameters(), lr0.001) criterion nn.CrossEntropyLoss() def train_one_epoch(model, dataloader, optimizer, criterion): model.train() total_loss 0 correct 0 total 0 for data, target in dataloader: optimizer.zero_grad() output model(data) loss criterion(output, target) loss.backward() optimizer.step() total_loss loss.item() pred output.argmax(dim1, keepdimTrue) correct pred.eq(target.view_as(pred)).sum().item() total target.size(0) avg_loss total_loss / len(dataloader) acc 100.0 * correct / total return avg_loss, acc def evaluate(model, dataloader, criterion): model.eval() total_loss 0 correct 0 total 0 with torch.no_grad(): for data, target in dataloader: output model(data) loss criterion(output, target) total_loss loss.item() pred output.argmax(dim1, keepdimTrue) correct pred.eq(target.view_as(pred)).sum().item() total target.size(0) return total_loss / len(dataloader), 100.0 * correct / total epochs 5 for epoch in range(1, epochs 1): train_loss, train_acc train_one_epoch(model, train_loader, optimizer, criterion) test_loss, test_acc evaluate(model, test_loader, criterion) print(fEpoch {epoch}: train_loss{train_loss:.4f}, train_acc{train_acc:.2f}%, ftest_loss{test_loss:.4f}, test_acc{test_acc:.2f}%)注意model.train()和model.eval()的区别训练模式下会启用Dropout和BatchNorm等训练特有的行为评估模式下关闭这些行为。虽然当前这个模型中两者影响不大但养成良好的习惯后续用复杂模型时不会出错。with torch.no_grad()也是必要的它告诉PyTorch在评估时不要构建计算图既省内存又加速计算。4.3 训练结果分析与调参我在自己的机器上跑5个epoch的结果大致是这样的Epoch训练损失训练准确率测试损失测试准确率10.301291.20%0.162395.18%20.141895.87%0.125696.10%30.101596.98%0.105296.83%40.079897.69%0.094797.20%50.065198.10%0.088597.45%一个12864个神经元的全连接网络在MNIST上5个epoch就能到97%以上的准确率说明这个任务本身确实相对简单。如果你换个模型结构比如把隐藏层改成512256准确率能进一步提高到98%以上。但训练时间也会相应增加同时过拟合的风险也在上升。这就引出了深度学习中极其重要的一个话题模型容量与数据量之间的匹配。如果你加大网络宽度但数据量不变训练集准确率可能接近100%但测试集准确率反而下降这在深度学习里叫过拟合。解决过拟合最常见的手段有增加数据数据增强、减小模型容量、加正则化L2正则、Dropout、早停Early Stopping等。全连接网络的参数冗余度很高是过拟合的高发区后面我会专门展开。5. 常见问题与避坑指南5.1 loss不下降的原因排查很多人第一次跑训练时会发现loss要么纹丝不动要么直接变成NaN。我最开始接触深度学习时也遇到过卡了两天最后发现问题小到想抽自己。这里我把最典型的几个原因列成一张排查表方便你对照定位症状大概率原因解决方案loss不变准确率接近随机学习率太大或太小把学习率调到 1e-3左右再试loss直接为NaN学习率太大导致梯度爆炸降低学习率比如调到1e-4loss为NaN但lr不大输入数据里有NaN或Inf检查数据预处理标准化要合理训练集准确率100%测试集很低过拟合加Dropout、数据增强、减小模型验证集比训练集acc还高评估时忘了切model.eval()检查是否掉进了没有Dropout的幻觉有GPU但训练很慢数据加载成了瓶颈增加DataLoader的num_workers这里面最容易被忽略的坑是训练和评估模式下模型行为不一致的问题。只要你的模型里用了Dropout或者BatchNorm训练和评估两个模式下的输出就是完全不一样的。我见过有同学在评估时忘记model.eval()导致测试准确率比训练准确率还高还以为是模型出奇迹了。5.2 梯度消失与梯度爆炸全连接网络一旦层数加深两个经典问题就会出现梯度消失和梯度爆炸。梯度消失是因为反向传播时梯度要经过多次连乘而很多激活函数尤其是Sigmoid的导数最大值只有0.25多次相乘后梯度指数级衰减传到前面几层几乎为0这些层就“学不动了”。梯度爆炸则相反如果权重初始化较大梯度经过多层连乘后指数级增大参数更新一步就飞到宇宙边界loss直接NaN。缓解手段主要有三个方向激活函数换成ReLU系列梯度恒为1或常数不容易衰减使用合理的权重初始化如Kaiming初始化控制初始权重方差在合适范围添加BatchNorm批归一化把每层输入分布拉回标准范围大幅提升训练的稳定性全连接网络不像CNN那样有大量共享权重和局部连接它对梯度消失更敏感所以不太建议把它堆到几十层以上。正因如此现代深度学习中的“深层”结构基本都是CNN或Transformer的变体纯全连接网络的经典用法还是以中间特征变换和最后的分类头为主。5.3 过拟合与Dropout实战过拟合是全连接网络最容易出现的问题因为全连接层的参数量实在太多了。举一个直观的数据输入784维第一个隐藏层128个神经元这一层就有 784×128 128 100480 个参数。如果继续加层参数膨胀得非常恐怖。当参数量远大于训练样本数时模型会把训练集的噪声也一起记住泛化能力自然很差。Dropout是解决过拟合最经典的手段之一。它的做法是在训练时随机让一部分神经元失活输出置为0强迫网络不依赖某一个特定神经元从而提高鲁棒性。在PyTorch里加Dropout极其简单在模型定义里插入self.dropout nn.Dropout(0.2)注意Dropout只在训练模式下生效测试模式下自动关闭这就是前面强调model.train()和model.eval()的另一个重要原因。我一般会在隐藏层后面加Dropout比例从0.1到0.5之间尝试任务越简单选择越小的值任务越复杂选择越大的值。除此之外还有一个容易被忽略的正则化手段就是L2正则也叫权重衰减。在PyTorch的优化器里直接指定weight_decay参数即可optimizer optim.Adam(model.parameters(), lr0.001, weight_decay1e-4)weight_decay越大惩罚越强但过大的话会让模型变得过于“懒惰”欠拟合。通常从 1e-4 开始调有些任务用 1e-5 效果更好。5.4 数据预处理的隐藏影响力最后我想多聊一句数据预处理。很多入门教程都默认MNIST是预处理好的直接用就行但现实中的项目数据往往肮脏不堪。特征量纲不一致、有缺失值、有异常值、类别不均衡任何一个问题都会让全连接网络的表现大打折扣。在MNIST这个例子里标准化已经做了所以效果很好。如果是你自己手里的表格数据我建议至少做这几件事检查有没有缺失值缺失多的特征直接删缺失少的用均值/中位数填充对特征做标准化或者归一化让每个特征都在相近的数值范围如果有类别不均匀的情况用分层采样划分训练测试集避免训练集和测试集分布差太大如果有异常大的离群点可以考虑做截断或者log变换全连接网络对这类问题特别敏感因为矩阵乘法会把每个维度的数值直接叠到一起。一个数值范围特别大的特征哪怕权重很小它的贡献也会淹没那么其他特征。这也就是为什么面试聊到深度学习时很多人第一句就问你数据是怎么预处理的。结尾这一点学扎实了后面的路会顺很多在你刚接触深度学习的前两周我强烈建议你就拿全连接网络反复折腾改网络层数、改神经元数量、换激活函数、调学习率、加Dropout看每一个改动如何影响训练曲线和最终准确率。这个过程比直接去跑那些炫酷的预训练模型有用得多。你亲手把每个参数的作用体会一遍之后后续无论是学CNN、RNN还是Transformer都会发现很多概念是相通的。我个人最深的体会是全连接网络虽然叫“全连接”但它的核心精髓恰恰在于“连接方式可以自主设计”。理解了它你就理解了神经网络是如何从一堆简单的乘法和加法中“涌现”出惊人的智能表现的。打好这块基石你的深度学习之路会走得格外踏实。
返回列表