资讯动态

从感知器到反向传播:用NumPy手写神经网络全流程

发布时间:2026/10/22 21:29:41 来源:尧图企业网站定制
我一直觉得深度学习入门最忌讳的一件事就是直接跳进某个框架里调API模型跑起来很爽但一旦出问题你根本不知道去哪排查。真正值得花时间死磕的其实是底层那几条最朴素的主线——感知器反向传播梯度下降。这些概念不搞清楚后面看ResNet、Transformer都是空中楼阁。这篇笔记是我自己从零开始学神经网络时整理的主线就一条从最简单的感知器出发一步步推导出BP反向传播算法最后用numpy手写一个能训练的两层网络。它适合那些已经会一点Python、但还没系统学过深度学习的同学不涉及框架只讲数学原理代码怎么落地。如果你正在看网课但被一堆链式法则梯度消失权值初始化搞得头晕这篇笔记应该能把你拽回正轨。1. 从感知器说起一个阈值开关是如何学会分类的感知器是神经网络最早期的形态它的思想简单到可以用一句话概括把输入信号加权求和如果超过某个阈值就输出1否则输出0。1.1 感知器的数学表达假设我们有n个输入特征 (x_1, x_2, ..., x_n)每个特征对应一个权重 (w_1, w_2, ..., w_n)感知器先计算一个加权和[ z w_1 x_1 w_2 x_2 ... w_n x_n b ]然后通过一个阶跃函数决定输出[ y \begin{cases} 1 z \ge 0 \ 0 z 0 \end{cases} ]这里的b是偏置项它的作用是把决策边界整体平移。你可以把偏置理解成阈值因为把b移到不等式右边就是 (w_1 x_1 ... w_n x_n \ge -b)。1.2 感知器的学习规则感知器最精巧的地方在于它的训练方式。它用一种纠错机制来更新权重每次看预测结果和真实标签是否一致不一致就调整权重让预测朝正确方向挪一点。import numpy as np class Perceptron: def __init__(self, n_features, lr0.1): self.w np.zeros(n_features) self.b 0.0 self.lr lr def predict(self, x): z np.dot(self.w, x) self.b return 1 if z 0 else 0 def train(self, X, y, epochs50): for epoch in range(epochs): for xi, yi in zip(X, y): pred self.predict(xi) error yi - pred self.w self.lr * error * xi self.b self.lr * error这个更新公式非常直观如果预测正确error为0权重不动如果真实标签是1但预测成0error为1就往正方向加大权重反过来如果真实是0但预测成1error为 -1就往反方向收权重。说白了就是错了就认罚罚完下次注意。1.3 感知器的能力边界感知器在1957年被Frank Rosenblatt提出来的时候大家非常兴奋觉得机器马上要拥有学习能力了。但到了1969年Minsky和Papert在《感知器》那本书里直接泼了一盆冷水——他们证明了单层感知器只能解决线性可分的问题连一个最简单的XOR异或函数都学不出来。XOR问题有多简单呢它只有四个样本两个输入要么相同输出0要么不同输出1。可就是这么一个简单的函数单层感知器无论怎么调权重都学不会。原因在于XOR四个点在对角线上分布你没办法用一条直线把它们分成两类。感知器的局限不是工程问题是原理问题。它本质上是个线性分类器。关于感知器我踩过一个小坑当时我用sklearn自带的数据集去训练单层感知器分类效果还挺好就以为感知器很厉害。后来换成自己造的非线性数据模型怎么训练都收敛不了才发现问题所在。所以说用感知器前一定要先确认自己手里的数据到底是不是线性可分的。2. 为什么单层感知器搞不定XOR非线性的第一课XOR问题对整个神经网络领域的影响怎么强调都不过分。它像一个分水岭把线性模型和非线性模型清清楚楚地隔开了。也正是因为这个看似简单的问题人们才被迫思考如何在感知器的基础上引入多层结构和非线性能力。2.1 用几何直觉理解线性不可分拿二维平面来说单个感知器做的事情就是画一条直线把平面分成两个区域一边判为0一边判为1。如果你把XOR的真值表画出来会发现0类的两点在对角线上1类的两点也在对角线上。无论你怎么画直线总有一边会被切错。一个点被误分类就像把一颗豆子放错了口袋只要你坚持只允许用一条线这个问题永远无解。多维空间也是一样的线性模型只能用超平面切分数据。而现实世界的数据大多是高度非线性的图像里猫和狗的区别、语音里声调和含义的区别都不是一个超平面能切开的。2.2 多层的诞生组合出非线性解决XOR的一个思路是用多个感知器分别学不同的线性边界再把它们的输出组合起来做一次判断。比如说用一个神经元学出一条斜线区分左上和右下用另一个神经元学出另一条斜线区分右上和左下然后再用一个输出神经元根据这两个中间结论做最终判断。这其实就是多层感知机的雏形输入层接隐藏层隐藏层接输出层。有了隐藏层网络就能表达非线性函数。理论上说只要隐藏层有足够多的神经元一个前馈网络可以逼近任意连续函数这个性质后来被称为通用近似定理。这给神经网络为什么有效提供了底气。当时我为了验证多层到底能不能搞定XOR手搓了一个两层的numpy网络训练几百轮后真的学会了。那一刻我才真正确信非线性编码的突破口就在于中间那一层不在最终的输出神经元上。2.3 激活函数没有它叠加再多层也是线性很多人会误以为多层就一定非线其实不对。如果你中间层不加激活函数那么每一层做的都是线性变换 (z W^Tx b)多层线性变换的复合还是一个线性变换叠加再多层都相当于只有一个感知器。只有引入非线性激活函数网络的表达能力才会真正起飞。早期神经网络喜欢用sigmoid或者tanh因为它们有平滑的梯度适合用BP算法训练。现代深度学习常用ReLU因为它计算简单、能缓解梯度消失。这里先不细说你只需要记住一句话激活函数是非线性来源没有它多层网络就白搭。激活函数公式优点缺点sigmoid(1/(1e^{-z}))输出范围0-1适合二分类梯度易饱和易导致梯度消失tanh((e^z-e^{-z})/(e^ze^{-z}))零中心化收敛更快同样有饱和问题ReLU(max(0, z))计算快不容易在正区间饱和负区间梯度为零神经元可能死掉3. BP算法误差的反向之旅从输出层一路传回输入层有了多层结构下一个问题就来了怎么训练感知器的学习规则只能更新单层权重因为误差信号没法传播到中间层——中间层没有标准答案去对比。BP算法的出现就是为了解决这个问题。它的核心思想其实一句话就能说完从输出层开始把预测误差对每一层权重的梯度算出来然后沿着梯度反方向更新参数。关键工具就是微积分里的链式法则。3.1 前向传播先算一次预测为了推导方便起见我定义一个简单的两层网络输入层有d个节点隐藏层有H个节点输出层有K个节点。隐藏层的加权输入记为(z^h)激活输出记为(a^h)输出层的加权输入记为(z^o)最终输出记为(y)。前向传播过程如下[ a^h \sigma(W^{(h)} x b^{(h)}) ] [ y \sigma(W^{(o)} a^h b^{(o)}) ]其中(\sigma)是激活函数。这时我们得到一个预测值(y)然后跟真实标签(t)对比算出损失。如果用的是均方误差[ L \frac{1}{2} | y - t |^2 ]这里的1/2只是为了让求导之后系数好看一点不影响最终结果。3.2 反向传播误差反向流动的完整推导我们最关心的是两个量损失对输出层权重(W^{(o)})的梯度以及损失对隐藏层权重(W^{(h)})的梯度。先看输出层。由链式法则[ \frac{\partial L}{\partial W^{(o)}} \frac{\partial L}{\partial y} \cdot \frac{\partial y}{\partial z^o} \cdot \frac{\partial z^o}{\partial W^{(o)}} ]一步步来看[ \frac{\partial L}{\partial y} y - t [ \frac{\partial y}{\partial z^o} \sigma(z^o) [ \frac{\partial z^o}{\partial W^{(o)}} a^h ]于是输出层的误差项可以定义成[ \delta^o (y - t) \odot \sigma(z^o) ]其中(\odot)表示逐元素相乘。权重梯度就是[ \frac{\partial L}{\partial W^{(o)}} \delta^o a^{hT} ]注意一下维度的匹配(\delta^o)是K维向量(a^h)是H维向量两者做外积得到K×H的矩阵正好和(W^{(o)})的维度一致。再看隐藏层。这里要小心权重(W^{(h)})影响的是(z^h)(z^h)影响(a^h)(a^h)又通过(z^o)影响最终损失。所以链式法则要穿透两层[ \delta^h \frac{\partial L}{\partial z^h} \frac{\partial L}{\partial a^h} \odot \sigma(z^h) ]而(\frac{\partial L}{\partial a^h})又可以写成这样[ \frac{\partial L}{\partial a^h} W^{(o)T} \delta^o ]这个式子的含义特别优雅输出层的误差信号(\delta^o)乘上输出层的权重把责任摊回隐藏层的输出。也就是说隐藏层每个神经元犯的错取决于它给输出层每个神经元的贡献有多少贡献越大分到的错误越多。最终隐藏层的权重梯度[ \frac{\partial L}{\partial W^{(h)}} \delta^h x^T ]这个形式和输出层几乎一模一样都是当前层的误差项×上一层输出的转置。因此BP算法的整体流程就是前向算损失反向算误差误差从输出层一层层往输入层流每层的误差项都由后一层的误差项加权求和得到。这也是反向传播这名字的来源。3.3 一个数值例子把公式变成看得见摸得着的数字推导光靠字母很难让人踏实我拿一个简化的例子算一遍。假设有一个样本(x [1.0, 0.5])真实标签(t [1.0])。隐藏层有2个神经元用sigmoid激活输出层1个神经元也用sigmoid。初始权重随便设一组小数值[ W^{(h)} \begin{bmatrix} 0.2 -0.1 \ 0.3 0.4 \end{bmatrix}, \quad b^{(h)} [0.1, -0.2] ][ W^{(o)} [0.5, -0.3], \quad b^{(o)} 0.2 ]前向计算[ z^h_1 0.2 \times 1.0 (-0.1) \times 0.5 0.1 0.25 ] [ a^h_1 sigmoid(0.25) 0.5622 ][ z^h_2 0.3 \times 1.0 0.4 \times 0.5 (-0.2) 0.30 ] [ a^h_2 sigmoid(0.30) 0.5744 ]输出层[ z^o 0.5 \times 0.5622 (-0.3) \times 0.5744 0.2 0.2089 ] [ y sigmoid(0.2089) 0.5520 ]损失[ L \frac{1}{2}(0.5520 - 1.0)^2 0.1003 ]反向传播先算输出层误差(\delta^o)。sigmoid的导数是(\sigma(z) \sigma(z)(1-\sigma(z)))于是[ \delta^o (0.5520 - 1.0) \times 0.5520 \times (1 - 0.5520) -0.1107 ]输出层权重梯度[ \frac{\partial L}{\partial W^{(o)}_1} \delta^o \times a^h_1 -0.1107 \times 0.5622 -0.0622 ] [ \frac{\partial L}{\partial W^{(o)}_2} \delta^o \times a^h_2 -0.1107 \times 0.5744 -0.0636 ] [ \frac{\partial L}{\partial b^{(o)}} \delta^o -0.1107 ]再看隐藏层误差项[ \delta^h_1 (\delta^o \times W^{(o)}_1) \times \sigma(z^h_1) (-0.1107 \times 0.5) \times (0.5622 \times 0.4378) -0.0136 ] [ \delta^h_2 (\delta^o \times W^{(o)}_2) \times \sigma(z^h_2) (-0.1107 \times (-0.3)) \times (0.5744 \times 0.4256) 0.0081 ]那么隐藏层权重梯度[ \frac{\partial L}{\partial W^{(h)}{1,1}} \delta^h_1 \times x_1 -0.0136 \times 1.0 -0.0136 ] [ \frac{\partial L}{\partial W^{(h)}{1,2}} \delta^h_1 \times x_2 -0.0136 \times 0.5 -0.0068 ] [ \frac{\partial L}{\partial W^{(h)}{2,1}} \delta^h_2 \times x_1 0.0081 \times 1.0 0.0081 ] [ \frac{\partial L}{\partial W^{(h)}{2,2}} \delta^h_2 \times x_2 0.0081 \times 0.5 0.0041]算完后用梯度下降更新权重比如学习率(\eta 0.1)[ W^{(o)}_1 0.5 - 0.1 \times (-0.0622) 0.5062 ] [ W^{(o)}_2 -0.3 - 0.1 \times (-0.0636) -0.2936 ]整个推导过程看着繁琐但你会发现所有步骤都在重复一个模式算误差项再把误差项和上一层的输出相乘得到梯度。这也是为什么用numpy实现BP网络时代码可以写得非常干净循环都不需要几层。3.4 三种符号写法别被不同教材晃晕学BP那段时间我翻过好几本教材发现同一条链式法则能有各种长相。有的用(\delta)有的用(\epsilon)有的干脆把梯度表达式直接写在损失函数旁边。其实它们的本质都一样。你只需要盯住三个东西前向传播各层的输出、损失函数对当前层加权输入的偏导误差项、以及各层权重的梯度表达式。只要掌握了这三个核心任何教材都只是换了皮。我推荐入门时自己手推一遍上面那个数值例子哪怕算得慢也没关系。算过一次之后再看代码里的backward函数你会发现每一行的来历都门儿清。4. 从公式到代码用numpy手写一个能训练的两层网络理论再漂亮最终要落到代码上才算真的掌握。前面我给了感知器的代码现在把BP网络也手写一遍。这一步做完你的感知器到BP闭环就合上了。4.1 完整实现前向、反向、训练一个不少我用numpy实现一个两层全连接网络隐藏层用sigmoid激活输出层也先用sigmoid这样方便和上面的推导对照。import numpy as np class NeuralNetwork: def __init__(self, n_features, n_hidden, n_output, lr0.1): # 小随机初始化避免节点输出的对称性 np.random.seed(42) self.lr lr self.W1 np.random.randn(n_features, n_hidden) * 0.5 self.b1 np.zeros((1, n_hidden)) self.W2 np.random.randn(n_hidden, n_output) * 0.5 self.b2 np.zeros((1, n_output)) def sigmoid(self, z): return 1.0 / (1.0 np.exp(-z)) def sigmoid_deriv(self, a): return a * (1.0 - a) def forward(self, X): self.z1 np.dot(X, self.W1) self.b1 self.a1 self.sigmoid(self.z1) self.z2 np.dot(self.a1, self.W2) self.b2 self.a2 self.sigmoid(self.z2) return self.a2 def backward(self, X, y, output): m X.shape[0] # 输出层误差 delta2 (output - y) * self.sigmoid_deriv(self.a2) dW2 np.dot(self.a1.T, delta2) / m db2 np.sum(delta2, axis0, keepdimsTrue) / m # 隐藏层误差 delta1 np.dot(delta2, self.W2.T) * self.sigmoid_deriv(self.a1) dW1 np.dot(X.T, delta1) / m db1 np.sum(delta1, axis0, keepdimsTrue) / m # 梯度下降更新 self.W2 - self.lr * dW2 self.b2 - self.lr * db2 self.W1 - self.lr * dW1 self.b1 - self.lr * db1 def train(self, X, y, epochs2000, verboseFalse): for epoch in range(epochs): output self.forward(X) loss np.mean((output - y) ** 2) self.backward(X, y, output) if verbose and epoch % 200 0: print(fepoch {epoch}, loss: {loss:.6f})可以看到forward就是照着前向传播公式写的三行矩阵运算backward就是照着误差项公式写的五个更新表达式。整个代码不超过四十行但它已经具备深度学习核心训练过程的全部要素参数初始化、前向计算、反向传播、梯度下降更新。4.2 造一份非线性数据验证XOR能不能学会直接拿XOR来测试最直观。我构造四个样本标签按照异或规则生成X np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) y np.array([[0], [1], [1], [0]]) model NeuralNetwork(n_features2, n_hidden4, n_output1, lr0.5) model.train(X, y, epochs5000, verboseTrue) for xi in X: pred model.forward(xi.reshape(1, -1)) print(xi, -, round(float(pred[0][0]), 4))我试过的结果是2000轮以内loss已经收敛到接近0四个样本的预测值分别非常接近0、1、1、0。这个实验很简单但它证明了BP算法确实能解决单层感知器解决不了的XOR问题。如果你跑完发现预测值还是乱的优先检查是不是学习率太大导致震荡或者训练轮数不够。4.3 为什么初始化不能全设为0有一个细节容易被新手忽略就是参数初始化。如果把(W_1)和(W_2)都初始化为0那在第一次前向传播时隐藏层所有神经元都会得到相同的输入、输出相同的结果梯度也完全相同。所有神经元都在同步划水网络实际上退化成了一个线性模型再多的隐藏神经元都没有意义。只有用随机初始化打破对称性每个神经元才能学到不同的特征。我刚开始实践的时候天真地把权重初始化为全0然后惊讶地发现训练半天loss纹丝不动。排查了很久才发现问题出在初始化。后来我在代码里固定了随机种子并让权重的初始范围尽量小比如乘0.5保证激活不会一开始就饱和。5. 训练中的暗坑与对策学习率、过拟合、特征工程的实操经验网络能跑起来之后你会发现真正花时间的不是写模型而是调参数、排故障。这一节写几个我用血泪换来的经验也是很多入门教程不会细讲的地方。5.1 学习率调大调小都不行得刚刚好学习率是BP里最敏感的超参数之一。选大了权重更新步幅过大loss会在底部来回震荡甚至越训越高选小了收敛慢得让人怀疑人生。我在上面XOR实验里用了学习率0.5稍微偏大但在简单问题上还能收敛。如果你拿去跑更复杂的任务建议先从0.01开始观察loss曲线的下降速度。有个非常实用的技巧每过一定轮数就把学习率调低一些比如从0.1降到0.01再到0.001前期大步逼近后期小幅精调。这可以显著提升训练的稳定性。学习率典型表现建议操作过大1loss发散、NaN立即调小0.01量级适中0.01-0.1平稳下降可先跑100轮观察曲线过小0.001下降极慢轮数充裕可接受否则调大5.2 归一化输入让每个特征站在同一起跑线如果输入特征之间的数值范围差距很大比如一个特征在0到1之间另一个在0到10000之间那么模型会天然更看重数值大的特征。这不是因为它聪明而是因为加权求和对输入尺度非常敏感。解决办法也简单就是把所有输入特征缩放到差不多同一个区间常见做法是标准化[ x \frac{x - \mu}{\sigma} ]其中(\mu)和(\sigma)是从训练数据里统计出来的均值和标准差。做数据归一化之后loss曲线的收敛速度往往会肉眼可见地变快。我们前面XOR实验里输入只有0和1不存在这个问题但一旦你开始处理真实数据这一步几乎是必须的。5.3 过拟合训练集好不代表测试集好初学者最常见的幻觉是训练loss很低很漂亮就觉得自己模型很厉害。结果拿到新数据上一测全崩。这就是过拟合模型把训练数据里的个性化噪声都背下来了而不是学到真实的规律。应对手段有很多入门阶段最值得先掌握两个早停法训练时边训练边看验证集的表现一旦验证loss开始上升就停手防止模型继续死记硬背。增大数据量或做数据增强把更多样的样本喂给模型让它见过的世界更全面自然就没法闭门背书了。正则化技术我在这里不展开讲但你至少要记住这个现象遇到loss和实际效果严重不符时要第一时间怀疑过拟合。5.4 小批量训练不要一次性把整个数据集塞进去在梯度下降的实现上可以一次计算所有样本的梯度再更新批量梯度下降也可以每来一个样本就更新一次随机梯度下降前者稳定但慢后者快但很抖。更常用的做法是折中取一小批样本算平均梯度再更新也就是mini-batch训练。批量大小是又一个需要调的参数。太大会导致内存压力和收敛变慢太小则loss曲线会剧烈抖动。一般来说32或64是一个不错的起点。如果你是自己在numpy里写着玩用批量梯度下降更容易排查问题因为每一步的方向更稳定代码也更简单。5.5 神经元死亡现象与ReLU时代的坑如果你以后换成ReLU激活函数还会遇到一个新坑有些神经元的输出在训练过程中一直为0梯度也一直是0它再也不会更新了像死了一样。原因是ReLU在负区间的导数恒为0一旦某次更新让它的加权输入老落在负区间它就翻不了身。这个问题的缓解方法有几种换用LeakyReLU让负半轴保留一点小斜率或者用适当的学习率避免更新步幅过大跳进负区。虽然我们这节笔记还在用sigmoid但我提前说一句是为了让你将来迁移的时候少走弯路。6. 学会手写BP之后再回看深度学习框架有些读者可能会问既然现在有大把框架为什么还要手写BP我的回答是手写不是目的理解才是。框架封装的接口太顺滑了你几乎感觉不到梯度是怎么流动的也就谈不上真正掌控模型。手写一遍之后你再看那些框架里的model.fit()脑子里会自动浮现出前向和反向的每一个中间步骤。这将帮助你在调试模型时精准定位到层、到参数、到数据维度而不是把问题怪到玄学头上。6.1 从手写代码到框架代码维度匹配是最大差异框架会替你管理张量的维度但你自己写代码时每次矩阵乘法都要仔细检查形状。我认为初学者最常见的报错就是维度不匹配。我刚才给的代码里前向传播各层的维度匹配关系可以这样检查[ X: m \times d ] [ W1: d \times H \quad \Rightarrow \quad z1: m \times H ] [ W2: H \times K \quad \Rightarrow \quad z2: m \times K ]把这个表格刻在脑子里以后不管是看框架文档还是调试自己的代码都不会慌。6.2 一个更有意思的实验用BP拟合任意曲线XOR分类通过之后你可以尝试一个稍微进阶一点的实验造一段正弦曲线数据用BP网络去拟合。你会发现只要隐藏层神经元数量够比如8个网络就能拟合出像模像样的曲线而单层感知器做这事一点办法都没有。这就是非线性拟合的力量。我当时做了这个实验之后突然理解了为什么神经网络能用于回归、分类、生成如此多不同的任务。核心都在于它拥有足够强的函数逼近能力以及BP算法能帮它找到合适的参数。后面再接触卷积网络、循环网络时你看到的每一步都可以追溯到你今天写过的这段代码。6.3 从会写到会调你还差三个好习惯最后想分享几个经验层面的建议都是我实际学习过程中的总结第一每改一个参数就记录一次loss曲线别追求一次调对要养成看曲线的习惯。曲线能告诉你太多信息发散、震荡、收敛慢、欠拟合都有不同的曲线形态。第二第一次用新数据集先建一个极简版本的小网络跑通全流程再逐步加宽加深。先跑通再优化。我有一次一上来就搭了个大网络结果前排一个错误导致后排所有指标都不对排错排到崩溃。第三不在无表征意义的情况下硬加正则化和复杂结构。简单模型训不动时不一定是要变复杂也可能只是数据没归一化、学习率不对。先把基础条件弄好再考虑Complexity。如果你能把这篇笔记里的推到和代码都自己动手过一遍我相信你对神经网络的理解——尤其是BP的那种从错误中层层纠正的直觉——会比光看十遍教程要扎实得多。接下来你可以顺着这条路继续去啃梯度消失、动量优化、卷积网络那时再回头看一切都顺理成章了。

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

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

免费获取报价 →
↑