全连接神经网络从原理到Numpy实现:深度学习基石详解

全连接神经网络从原理到Numpy实现:深度学习基石详解
我翻了翻自己早期的学习笔记发现所有关于深度学习的记录最后都指向同一个起点全连接神经网络。当年第一次正经打开深度学习教材看到全连接神经网络这个词我的第一反应是——这不就是个矩阵乘法和激活函数反复堆叠的东西吗后来我才意识到恰恰是这样一个看起来简单到近乎朴素的结构撑起了后面所有复杂模型的半边天。无论是卷积神经网络里的分类头、循环神经网络里的状态更新还是Transformer里的前馈模块底层逻辑都离不开全连接层那套加权求和 非线性映射的组合拳。这篇内容我打算从一个神经元入手把全连接网络的前向传播、反向传播、激活函数选型、numpy手写实现、以及它在现代深度学习版图中的定位一次讲透。不管你是刚接触深度学习的新手还是已经跑过不少模型、想回头把基础补扎实的开发者这篇文章都能给你一些值得沉淀的东西。1. 从一个神经元说起它到底在算什么1.1 线性加权与偏置神经元的第一层计算很多人第一次接触神经元会被模拟人脑这个概念带偏觉得这东西很玄。实际上在数学层面一个神经元做的事极其简单把输入各自乘以一个权重加起来再加上一个偏置最后塞进一个激活函数。就这么点事。假设输入是 (x_1, x_2, ..., x_n)权重是 (w_1, w_2, ..., w_n)偏置是 (b)那么神经元的输出就是[ z \sum_{i1}^{n} w_i x_i b ]这个 (z) 通常被称为净输入。如果你学过线性回归会发现这式子太眼熟了——没错它就是线性回归的形式。权重 (w_i) 决定了对应输入的重要性偏置 (b) 则负责让决策边界不必过原点相当于给神经元一个平移自由。我见过不少初学者在这里犯迷糊反复问偏置到底有什么用举个例子如果你要根据是否下雨和是否带伞两个特征来决定是否出门数据里所有样本都集中在某个特定区域没有偏置的话你的分类面只能硬生生穿过原点很多情况下根本分不开。加上偏置这条直线就可以上下左右随便平移才能贴合真实数据分布。1.2 激活函数给线性变换注入灵魂如果神经元只做线性加权那无论堆多少层整个网络仍然等价于一个线性变换。你可以自己拿纸笔验证一下把两层的线性变换 (W_2(W_1x b_1) b_2) 展开它本质上还是 (Wx b) 的形式。这就是为什么激活函数不可或缺。它给每个神经元的输出加了一道非线性门槛让网络有能力去逼近那些弯弯曲曲的复杂函数。拿最经典的sigmoid来说它把任意实数压缩到0到1之间像一个软开关[ \sigma(z) \frac{1}{1 e^{-z}} ]我们后面会专门用一节来讲激活函数的选型问题因为它是全连接网络里最容易影响训练成败的细节之一。在这里你先记住一个直觉激活函数就是让神经网络从只能画直线变成可以画曲线的那支笔。1.3 单层神经元的致命缺陷为什么连异或都搞不定上世纪的神经网络研究曾经遭遇过一次著名的寒冬。Minsky和Papert在1969年出版的《感知机》里严格证明了单层感知机无法解决异或XOR问题。异或问题的本质是数据在二维空间里呈对角线分布你找不到一条直线能把四类点完美分开。这个结论在当时基本宣判了单层神经网络的死刑。要解决异或你必须引入隐藏层让网络先对输入做一次非线性变换把原始空间映射到另一个空间在这个新空间里原本线性不可分的数据就可能变成线性可分了。这里我想多说一句理解异或问题是理解为什么要深度学习的最短路径。它告诉我们单靠宽度一个层里堆很多神经元是不够的你需要深度多个层叠起来每一层都在对上一层的输出做重组和抽象才能在越来越高的层次上拟合复杂的决策边界。2. 层与层之间为什么要全连接2.1 全连接名字的由来每一个连接都是一次参数共享的反义词全连接层的定义很简单这一层的每个神经元都与上一层的所有神经元相连。换句话说上一层有 (m) 个神经元这一层有 (n) 个神经元那么连接数就是 (m \times n)每个连接对应一个独立权重。我个人的理解是全连接层本质上是一个完全关系建模器。它不假设输入特征的任何局部结构不觉得某些输入之间应该更亲近而是让每个输出神经元都能看到全部输入信息再通过训练自己决定该看重什么。这种一视同仁的特性既是它表达能力强的根源也是它参数规模容易爆炸的软肋。如果你对比一下卷积层就能更清楚。卷积层是局部连接 权值共享一个3x3的卷积核在整张图上滑动权重是复用的全连接层则相反每个连接权重独一无二没有共享没有局部性假设。在图像任务里全连接的参数往往占到整个网络的大头这也是后来很多网络用全局平均池化替代全连接层的原因之一。2.2 隐藏层到底在隐藏什么我在刚开始学的时候最不理解的就是隐藏层这个名字。后来看到一句话豁然开朗隐藏层的输出不是我们直接监督的对象它是个中间产物是网络自己学到的一种内部表示。用全连接网络做手写数字识别来举例。输入是784维的像素向量输出是10维的概率分布。中间如果有一层128个神经元的隐藏层这128个值可以理解为网络从像素中提取出的128个线索——可能是边缘、拐角、弧线也可能是更高层次的部件组合。没有人事先告诉网络该提取什么它完全是通过反向传播自己摸索出来的。这就是深度学习的核心哲学特征不是人设计的而是学出来的。全连接网络是这种哲学最纯粹的表达方式。后来的卷积网络相当于在这个哲学上加了局部性的归纳偏置让网络在图像任务上更容易学到有用的特征但全连接网络依然是理解特征学习概念的最佳起点。2.3 从两层到多层深度为什么能带来质变理论上一个足够宽的单隐藏层网络可以逼近任意连续函数这就是著名的万能逼近定理。那为什么还要堆很多层答案是效率与泛化。你当然可以用一个宽到离谱的单层网络去拟合任何数据但这个网络需要海量参数而且更容易过拟合——它记住了样本却学不到规律。深层网络则不同第一层学低级特征第二层组合出中级特征第三层形成高级抽象每一层都在前一层的基础上做再组合参数效率高得多。一个经常被引用的类比是乐高单层网络像是用一大堆一模一样的积木试图拼出一个复杂模型而深层网络是一层层搭框架小积木拼出小模块小模块拼出大模块最后组合成完整结构。模块复用的效率远高于逐个零件的堆砌。这也是为什么深度本身成了深度学习的关键词——它让网络具备了分层抽象的能力而全连接网络正是实现这种分层抽象的最基本模板。3. 训练的核心机制前向传播与反向传播如何协同工作3.1 前向传播从输入到损失前向传播很好理解数据从输入层进入逐层做矩阵乘法、加偏置、过激活函数一直到输出层得到预测结果。这就像工厂流水线原料进去成品出来。在分类任务里输出层通常会接一个Softmax函数把网络的原始输出logits转换成一个概率分布。比如手写数字识别最终输出是10个数Softmax让这10个数都变成0到1之间的概率值而且加起来等于1。预测结果就是概率最大的那一类。有了预测和标签就需要一个尺子来衡量网络当前表现有多差这就是损失函数。一个有意思的问题是为什么分类任务很少用均方误差MSE而偏爱交叉熵Cross Entropy答案藏在梯度里。用MSE Sigmoid组合时误差大和误差小的地方梯度变化都不明显容易让网络在训练初期几乎学不动而交叉熵加上Softmax的组合梯度形式非常漂亮误差越大推动更新的力度越大收敛速度快得多。这就是为什么你几乎不会在现代分类网络里看到MSE做损失函数。3.2 反向传播误差如何倒着流反向传播是深度学习最核心的数学技巧本质上就是高数里的链式法则。它解决了一个问题一个含有几百万参数的模型每个参数到底应该往哪个方向调整多少才能让损失变小。你不需要手动算梯度但你需要理解反向传播在做什么。想象一场团队接力赛最后一棒运动员输出层发现成绩不理想他会把问题出在哪逐棒反馈给前一棒隐藏层前棒再往前传每一棒根据反馈调整自己的跑法。这个反馈就是梯度而调整跑法就是利用梯度下降更新权重。实际实现时反向传播分两步走。第一步从后往前计算每一层的误差项也就是损失对每个神经元净输入的偏导第二步利用误差项和上一层的激活值计算每个权重的梯度然后沿负梯度方向更新。这里有一个新手很容易忽略的细节反向传播更新某一层的权重时需要用到两个量——当前层的误差项和上一层的激活值。所以前向传播时一定要把每一层的激活值都缓存下来否则反向传播无米下锅。我见过很多手写代码的人在这里踩坑网络前向跑完就放飞自我不带缓存结果反向传播写不下去。3.3 梯度下降与学习率走多快才不会摔跤有了梯度接下来就是参数更新[ w w - \eta \frac{\partial L}{\partial w} ]这个 (\eta) 就是学习率。它的直观含义是沿着梯度方向走多长一步。学习率是个极其敏感的超参数。设大了损失函数会在最优解附近来回震荡甚至直接发散设小了网络学了N个epoch还在原地慢慢爬浪费时间。我自己的经验是先用较大的学习率比如0.1做几次小规模实验观察损失的下降趋势再逐步缩小。如果损失在前几个epoch就开始剧烈震荡大概率是学习率大了如果损失下降得异常缓慢可能学习率太小。现代框架里学习率调度器learning rate scheduler几乎是标配——先线性预热再按照余弦曲线或阶梯式衰减。这个机制的动机很实际训练初期距离最优点较远可以大步快跑训练后期接近最优点步子要放小避免冲过头。3.4 完整的训练循环一次深呼吸看懂全局把前面所有部分串起来一个标准的全连接网络训练循环长这样for epoch in range(num_epochs): for x_batch, y_batch in data_loader: # 前向传播 logits model(x_batch) loss cross_entropy(logits, y_batch) # 反向传播 optimizer.zero_grad() loss.backward() # 参数更新 optimizer.step()整个过程就是一个预测-量错-反馈-修正的循环。如此反复迭代损失逐渐下降网络的预测越来越准。这个循环模式不仅适用于全连接网络几乎所有深度学习模型的训练过程都是它的变体。4. 激活函数选型一个容易被低估的训练成败手4.1 Sigmoid和Tanh的梯度饱和训练缓慢的元凶前面提到Sigmoid把输入压缩到0到1之间但这里有个问题当输入 (z) 的绝对值很大时Sigmoid的曲线变得非常平缓梯度趋近于0。这意味着反向传播时梯度在这里会变得非常小参数几乎得不到更新。这就是梯度饱和问题。更要命的是这种饱和效应会随网络深度逐层累积。梯度在反向传播过程中每经过一层就乘以一个小于1的值网络越深传到浅层时梯度就衰减得越厉害甚至直接消失。这解释了为什么早期深层网络难以训练——不是因为硬件不行而是因为梯度传递机制本身在堵车。Tanh双曲正切虽然把输出范围变成了-1到1均值接近0缓解了Sigmoid输出全为正的偏移问题但梯度饱和的毛病依然存在绝对值大的输入照样会让梯度趋近于0。4.2 ReLU为什么它成了事实标配ReLU的定义极其简单[ \text{ReLU}(z) \max(0, z) ]它最大的优点就是当输入大于0时梯度恒为1不会饱和。这让梯度可以更顺畅地反向传播深层网络也因此变得实际可训练了。再加上它的计算成本几乎为零在硬件上跑起来飞快所以一经提出就迅速成为主流选择。但ReLU也有它的麻烦。当输入小于0时梯度恒为0如果某个神经元在很多样本上的输入都小于0它的权重就永远得不到更新这就是神经元死亡问题。缓解手段有几个一是使用LeakyReLU给负数部分一个很小的斜率二是用合理的权重初始化避免神经元一开始就陷入死区三是把学习率设置得保守一点减小一次性更新过度导致永久死掉的风险。4.3 实战中的选型建议我个人的习惯是场景推荐激活函数原因隐藏层默认首选ReLU计算快、梯度通畅、效果好隐藏层遇到大量神经元死亡LeakyReLU / GELU保留负半轴信息缓解死亡问题输出层二分类Sigmoid输出天然是0-1概率输出层多分类Softmax输出归一化为概率分布输出层回归None线性激活输出不需要压缩除了隐藏层和输出层的常规选择批归一化BatchNorm也值得搭配使用。在全连接网络中BatchNorm的主要作用是让每一层的输入分布保持稳定防止内部协变量偏移同时把激活值拉回一个合理的区间降低对初始化尺度的敏感度。实际效果就是你可以放心地把学习率调大一点训练速度肉眼可见地提升。5. 不调深度学习框架用Numpy手写一个全连接网络5.1 为什么值得手写一次可能有人会觉得现在PyTorch和TensorFlow这么方便我一个nn.Linear就搞定了手写有什么意义我的看法是手写一次全连接网络是检验你是否真正理解深度学习的最佳方式。当你用框架时一行API调用就把底层全部封装掉了你对反向传播到底在干什么其实没有任何体感。而当你亲手用Numpy把每一层的梯度算出来再亲手更新权重你会对很多框架中的设计恍然大悟——比如为什么打印梯度时有时候会显示None为什么Pytorch的Tensor要设置requires_grad为什么在反向传播前必须清空梯度缓存。这些问题的答案在写完下面这个例子后你几乎不用查资料就能猜到。5.2 网络结构与参数初始化我们来做一个经典的MNIST手写数字分类。输入是28x28像素展平后是784维输出是10类。中间设计两层隐藏层分别为128和64个神经元激活函数用ReLU输出层接Softmax损失用交叉熵。先定义网络结构import numpy as np class FullyConnectedNet: def __init__(self, layer_sizes): # layer_sizes [784, 128, 64, 10] self.params {} for i in range(len(layer_sizes) - 1): fan_in layer_sizes[i] fan_out layer_sizes[i1] # He初始化适合ReLU self.params[fW{i1}] np.random.randn(fan_in, fan_out) * np.sqrt(2.0 / fan_in) self.params[fb{i1}] np.zeros((fan_out,)) self.num_layers len(layer_sizes) - 1关于初始化这里多说几句。全零初始化是绝对不能用的——所有神经元对称梯度也一样网络永远学不出差异化随机太大又可能导致激活值在深层爆炸。He初始化是按 ( \sqrt{2 / fan_in} ) 缩放专门为ReLU设计保证前向传播时激活值的方差维持在一个合理范围。刚开始学的时候我图省事直接用标准正态分布结果训练到一半损失直接变NaN后来才意识到是初始化把梯度搞爆炸了。5.3 前向传播与反向传播的Numpy实现前向传播没什么悬念逐层做矩阵乘法缓存每一层的输入和激活值供反向传播使用def forward(self, X): self.cache {} out X self.cache[A0] X for i in range(1, self.num_layers 1): W self.params[fW{i}] b self.params[fb{i}] z out.dot(W) b self.cache[fZ{i}] z if i self.num_layers: out np.maximum(0, z) # ReLU else: exp_z np.exp(z - np.max(z, axis1, keepdimsTrue)) # 数值稳定 out exp_z / exp_z.sum(axis1, keepdimsTrue) # Softmax self.cache[fA{i}] out return out注意Softmax那里做了一个小技巧先减去每行的最大值再求指数。不这样做的话当logits很大时np.exp(z)会溢出导致NaN。这个细节在框架里已经被处理掉了但手写时你不处理就等着踩坑。反向传播是理解上的重头戏。交叉熵 Softmax的组合有一个好消息最终的梯度形式极简——预测概率减去独热标签。我们直接推导的结论是对于输出层的误差项def backward(self, X, y): grads {} m X.shape[0] # 输出层误差Softmax交叉熵梯度 y_pred self.cache[fA{self.num_layers}] y_onehot np.zeros_like(y_pred) y_onehot[np.arange(m), y] 1 delta y_pred - y_onehot # shape: (batch, 10) for i in range(self.num_layers, 0, -1): A_prev self.cache[fA{i-1}] grads[fW{i}] A_prev.T.dot(delta) / m grads[fb{i}] delta.sum(axis0) / m if i 1: delta delta.dot(self.params[fW{i}].T) delta delta * (self.cache[fZ{i-1}] 0) # ReLU导数 return grads这段代码里从后往前遍历每一层先算当前层的梯度再把误差往上一层传播。delta delta.dot(W.T)就是在做误差的倒流乘以Z 0则对应ReLU导数为1正半轴或0负半轴。整个过程完全对得上链式法则的推导。训练参数更新就是纯粹的梯度下降def train(self, X, y, epochs, lr, batch_size64): for epoch in range(epochs): permutation np.random.permutation(X.shape[0]) total_loss 0 for i in range(0, X.shape[0], batch_size): indices permutation[i:ibatch_size] X_batch, y_batch X[indices], y[indices] y_pred self.forward(X_batch) loss -np.log(y_pred[np.arange(len(y_batch)), y_batch]).mean() total_loss loss * len(y_batch) grads self.backward(X_batch, y_batch) for j in range(1, self.num_layers 1): self.params[fW{j}] - lr * grads[fW{j}] self.params[fb{j}] - lr * grads[fb{j}] if (epoch 1) % 5 0: print(fEpoch {epoch1}/{epochs}, Loss: {total_loss / X.shape[0]:.4f})这个实现跑MNIST用He初始化、Adam优化器替代简单SGD、学习率选0.001、训练10个epoch在测试集上大概能到97%左右的准确率。如果你只用朴素SGD需要把学习率调到0.1左右效果也还行但收敛路径更颠簸。5.4 训练全连接网络时最常见的三个问题我手写这个demo的过程中几乎把所有新手会踩的坑都踩了一遍挑三个最有代表性的说第一个坑是损失变成NaN。原因几乎不是学习率太大就是初始化不当。排查办法很简单把学习率降到1e-4如果损失恢复正常说明是学习率的问题如果照样NaN检查初始化时有没有用标准正态分布——换成He初始化基本能解决。第二个坑是训练集上损失不降。这时候先别急着调模型结构检查输入数据有没有归一化。MNIST的像素范围是0到255如果不除以255直接喂给网络第一层的加权和很容易进入激活函数的饱和区梯度被压得极小。把X归一化到0到1之间训练会瞬间变得流畅。第三个坑是过拟合。全连接网络的参数容量很大MNIST有6万张图片但如果网络宽度加到512甚至1024非常容易在训练集上刷到99%以上测试集却只有95%。这时候正则化手段要跟上Dropout在隐藏层输出随机置零一部分神经元、L2权重衰减、早停法三者配合效果最好。6. 全连接网络的定位与边界它是基石但不是万能的6.1 为什么图像任务首选卷积而不是全连接全连接网络把每个像素都当作独立特征不考虑像素之间的空间关系。对于一张28x28的图片如果把左上角和右下角的像素互换位置全连接网络看到的输入向量是完全不同的但图片本身的意义可能完全没变。卷积神经网络正是针对这个问题设计的卷积核在局部区域滑动天然捕获空间局部性参数大幅减少且具有平移等变性。这就是为什么在图像领域CNN几乎完全取代了全连接层成为特征提取主力。但注意CNN最后通常还是要接一个全连接层或全局平均池化来做分类全连接并没有从图像任务中彻底消失只是退回到了输出端。6.2 Transformer里的全连接所有复杂架构的底牌很多人学Transformer时注意力都放在了注意力机制上容易忽略一个事实Transformer的FFN前馈网络模块就是两层全连接。每个token经过注意力层做信息交换之后都要过一个非线性全连接层对信息做逐位置的加工。RNN里同样如此门控单元的更新本质上也是全连接运算。也就是说不管模型结构再怎么花哨最终承担非线性特征变换职能的依然是全连接。理解全连接的运算逻辑等于拿到了读懂几乎所有主流模型结构的钥匙。6.3 参数规模与计算成本的平衡全连接层的参数量是输入维度乘以输出维度一旦输入维度变大参数会呈平方级上涨。拿一张256x256的彩色图片直接接全连接层输入就是196608维输出层若是1024个神经元那一层的参数量就超过2亿。这个数量级在工程上几乎是不可接受的。所以你会看到现代深度学习实践里全连接层通常用于输入维度已经被压缩得很小比如经过CNN、或使用Embedding后的特征模型末端的分类头特征交叉和变换如Transformer的FFN小规模数据上的基准模型或教学演示。如果数据量不大、特征已经经过人工筛选、任务不是特别复杂全连接网络往往就已经够用了完全没必要为了先进而上大模型。6.4 工程实战里的建议根据我自己在不同项目里的经验给你几条相对务实的建议。第一如果你是在做实验验证想法优先用全连接网络跑通一个最简基线。它能帮你快速确认数据和流程没有问题后续再换成更复杂的模型时至少能排除流程写错了这类低级问题。第二在正式模型里全连接层的权重初始化、正则化、学习率依然值得像手写网络时那样谨慎对待。别因为框架帮你封装了就掉以轻心。比如PyTorch中nn.Linear的默认初始化是均匀分布在实际很多任务中没问题但如果出现损失不收敛检查一下初始化策略总不会错。第三使用框架时我建议你把requires_grad和优化器里的参数分组搞明白。全连接层往往需要和特征提取层用不同的学习率——特征提取层可以用较小的学习率全连接分类头用较大的学习率这种设置在迁移学习中效果很显著。6.5 从全连接到深度学习全景一次系统化的视角如果你现在回头看从单层感知机的困境到多层全连接网络的兴起再到CNN/RNN/Transformer的繁荣你会发现一个清晰的脉络所有复杂的架构演进都是为了解决全连接网络在特定场景下的不足——参数太多、空间结构利用不足、长序列建模困难。但每次升级并没有推翻全连接的基本思想而是在它的基础上加了某种结构性先验。换句话说全连接网络是一种无先验的模型它不对数据做任何假设靠纯粹的拟合能力完成任务。而后续的各种架构本质上都是在往这个无先验框架里注入人类对数据结构的理解。理解了这层关系你再看那些眼花缭乱的模型论文脑子里就会有一个稳定坐标系这个模型在哪些地方用全连接做变换在哪些地方改变了连接方式来利用结构信息。我自己在实际项目里的习惯是无论用多复杂的模型都会在项目初期预留一个全连接网络的baseline。它就像一个最朴素的度量尺帮我判断复杂模型到底带来了多少真实增益。别小看这个习惯它往往能在你被精美架构迷惑的时候把你拉回到地面。从那个只会做线性加权的单一神经元到能够表达任意复杂函数的深度模型全连接网络走过的路恰好是整个深度学习发展的一个缩影。地基不牢后面盖多高的楼都心虚。把全连接网络的数学原理和代码实现吃透了你后面学CNN、RNN、Transformer时会发现每一样东西都变得顺畅许多。

最新新闻

日新闻

周新闻

月新闻