YAOTU INSIGHTS

神经网络入门:从线性模型到反向传播与激活函数的原理详解

神经网络入门:从线性模型到反向传播与激活函数的原理详解
很多想系统入门深度学习的朋友最先撞上的就是“神经网络”这四个字。我见过太多人兴致勃勃翻开资料结果被神经元、权重、偏置、激活函数、反向传播这一串术语劝退——每个词单看都懂连在一起就懵。我当年真正开窍不是靠背公式而是把一个小网络从零用代码搭出来亲眼看着损失一点一点往下掉。所以这一篇作为深度学习系列的第一篇我打算把神经网络这座“地基”彻底讲明白不跳过关键步骤也不堆晦涩公式尽量说人话。无论你是刚接触机器学习的新人还是写过传统代码、想搞懂神经网络运行机制的开发者这篇都适合你。读完你至少能回答三个问题神经网络到底在算什么它凭什么能学习以及训练效果不好时该从哪里排查。1. 从线性模型到神经元神经网络到底在算什么1.1 为什么线性模型搞不定异或传统机器学习里最常见的就是线性模型对输入特征做加权求和再加一个偏置输出一个数字。放到二分类问题上它学到的实际是一条直线或一个超平面作为决策边界。数据能被一条线分开线性模型就够用一旦数据不能用一条线分开线性模型再怎么调参也无能为力。异或XOR就是最经典的例子。输入是两个0/1取值输出规则是两个输入相同则0不同则1。把四个样本画在二维平面上(0,0)和(1,1)属于一类(0,1)和(1,0)属于另一类你会发现任何方向上都找不到一条直线能把两类点完全分开。正因如此一个线性模型在异或任务上几乎永远学不会损失会停在一个较高的位置不往下走。为什么单层感知机有局限这大概是最直观的证明了。1.2 一个神经元内部发生什么加权求和、偏置与激活神经网络的基本单元叫神经元翻译成大白话就是一个小计算盒子。它接收若干个输入数字给每个数字配一个权重做加权求和再加上一个偏置最后过一个激活函数。公式写出来是z w1x1 w2x2 ... wN*xN b a f(z)权重决定了每个输入对结果的影响程度偏置是一个可调整的底线激活函数负责把加权和转换成最终输出。这个结构并不神秘用点菜来类比你评估一家店会看菜品、价格、环境每个维度的权重就是你对它的重视程度偏置是你的既有偏好——哪怕所有输入都是0你也有一个默认态度激活函数则是你把打分转成“要不要去”这个最终决定的那一步。单看一个神经元它的决策能力本质上就是一条直线这也是为什么单个神经元也解决不了异或。1.3 偏置不是摆设它决定决策边界的位置很多人一开始只盯着 wxb 里的 wx觉得偏置可有可无。真实情况是如果没有偏置加权求和永远过原点。意思是当所有特征都恰好是0时无论权重怎么学习神经元的输出永远不变决策边界也被钉在原点附近。有偏置以后决策边界就灵活了。拿sigmoid举例σ(z)0.5 发生在 z0 的地方也就是 wxb0 时解得 x-b/w。b 的数值直接决定这条“分界线”落在哪个位置。你想让神经元在 x 大于5 时才激活就把 b 设在 -5w 附近没有偏置这个阈值根本调不出来。所以在网络里偏置和权重一样都是可以训练的参数不是装饰品。很多入门推导会把偏置简化掉方便理解先行计算但实际训练中必须留它一席之地。1.4 神经元组成层从散装公式到矩阵运算实际任务里我们不会只用一个神经元。分类有好几个类别特征也需要多个维度的组合所以我们会把大量神经元并列排在一起形成一层。当一层里有多个神经元时写法就从散装公式变成矩阵运算z Wx bW 的每一行对应一个神经元的一组权重x 是输入向量结果 z 是一个向量每一位对应一个神经元的加权输出。矩阵写法不只是为了省事它能让现代计算框架用并行的方式高效处理整层神经元。用一个小例子示意。假设输入 x 是三维向量这层有2个神经元import numpy as np W np.array([[0.2, -0.4, 0.1], [0.3, 0.5, -0.2]]) b np.array([0.1, -0.1]) x np.array([0.8, 0.6, 0.4]) z W x b print(z)第一个神经元的加权输出是0.06第二个是0.36。这两个数再分别过激活函数才算真正的神经元输出。矩阵乘法一个就完成了一层计算这就是后面所有深度学习代码的地基。2. 让多层结构真正有用激活函数、初始化与网络骨架2.1 激活函数的第一要务引入非线性如果把神经元直接串起来却不在出口加任何变换会发生什么两层线性变换拼在一起y W2(W1xb1)b2展开后还是 (W2W1)x 一个常数项本质上依然是一个线性变换。也就是说网络不管堆多深能表达的依旧只是直线、平面这类线性关系和单个神经元没有本质区别。激活函数就是专为打破这个局限而存在的。它在每个神经元的出口做一次非线性变换让叠加起来的整个函数能拐弯、能拟合各种曲线。没有激活函数的网络是“纸糊的”有了激活函数网络才算真正获得表达能力。2.2 sigmoid、tanh、ReLU该如何选入门阶段只需要记住三个sigmoid、tanh、ReLU。直接说结论隐藏层默认用ReLU输出层做二分类用sigmoid做多分类用softmax做回归干脆不用激活函数。激活函数公式输出范围优点常见问题sigmoidσ(x)1/(1e^-x)(0,1)平滑可解释为概率两端饱和、非零中心tanhtanh(x)(-1,1)输出零中心训练更稳两端仍会饱和ReLUmax(0,x)[0,∞)计算快缓解梯度消失神经元可能死亡为什么说sigmoid“非零中心”不好因为如果一层里所有神经元的输出恒为正上游梯度乘上这一层的输出符号就容易被单一方向主导优化路径会来回震荡。ReLU在正区间导数恒为1不会把梯度越乘越小所以成了当前隐藏层的默认选择。但ReLU也有自己的问题后面单独说。2.3 参数初始化全零初始化的坑与随机初始化全零初始化和相同常数初始化是新手最容易犯的错误。如果一层里所有神经元的权重都一样前向传播时它们拿到相同输入、算出相同输出反向传播的梯度也完全相同于是它们永远保持一模一样地更新。这样的网络等于只有一个有效的神经元再宽的层也没有意义。所以必须用随机初始化打破这种对称性。随机到什么程度也有讲究。全都用特别小的数前向传播虽然能跑但梯度信号偏弱用特别大的数sigmoid或tanh直接进入饱和区梯度立刻消失。标准做法是让初始权重标准差跟上一层神经元数量挂钩框架里常见的he、xavier这类初始化就是解决这件事的。你现在不需要背公式直接用框架默认设置就足够但要明白“随机”不是随便背后有梯度传播的考虑。2.4 深度和宽度先别急着堆层数网络设计里经常听到“深”和“宽”。宽度指某一层放几个神经元深度指从输入到输出一共多少层。宽的网络能在同一层并行提取很多特征深的网络能让特征层层组合、逐步抽象。但初学者最容易犯的错是一上来就堆50层。层数越多训练难度会指数级上升调参的坑也越深。我的建议是从两到三层开始先让网络学会任务再慢慢加深。这个系列后续讲到卷积和残差连接时会专门展开“深网络到底怎么训练”这个问题。现在你只需要理解网络表达能力强不等于训练容易能力只是基础能不能训出来是另一回事。3. 学习发生的地方损失函数与反向传播3.1 损失函数用什么尺子量“错得有多远”网络输出了一串预测数字怎么判断它好不好损失函数就是这把尺子。回归问题最常用均方误差MSE把所有样本的预测值与真实值之差的平方求平均。平方的作用是让大误差受到更重的惩罚训练时会更关注那些错得离谱的点。分类问题常用交叉熵它衡量预测概率分布和真实标签之间的差异在配合softmax输出时梯度更稳定。一个问题为什么分类不用准确率直接当损失准确率是阶梯函数样本分对了梯度为0分错了也没有连续的梯度信息可供调整。损失函数必须是可导的而且梯度要能告诉网络“往哪个方向调、调多少”。准确率做不到这一点所以只能当评估指标不能当训练目标。3.2 反向传播的链条链式法则怎么变成代码反向传播的核心就一句话把损失对输出层的梯度从输出层开始沿着计算图一层层往回传每一步用链式法则乘上当前环节的局部导数。链式法则是这样接起来的如果损失L受神经元的输出a影响而a又受加权和z影响z再受权重w影响那么dL/dw (dL/da) × (da/dz) × (dz/dw)看起来有点抽象但用一个小例子算一遍就通了。下面的例子我们手算单步梯度更新你会看到这个链条是怎么样一环扣一环的。3.3 一个数字例子手算单步梯度更新设输入 x2权重 w1没有偏置神经元用sigmoid激活目标值 t0。先做前向传播z w*x 2 a σ(2) ≈ 0.8808 L (a - t)^2 0.8808^2 ≈ 0.7758反向传播要算 dL/dw它就等于三个局部导数的乘积dL/da 2*(a-t) 2×0.8808 1.7616 da/dz a*(1-a) ≈ 0.8808×0.1192 ≈ 0.1050 dz/dw x 2 dL/dw 1.7616 × 0.1050 × 2 ≈ 0.3699然后执行参数更新学习率 lr1w_new w - lr*(dL/dw) 1 - 0.3699 0.6301。验证一下新的 w0.6301前向传播 z1.2602aσ(1.2602)≈0.7793loss 变成了0.6073确实比0.7758小。这个例子虽然只有一个神经元但链条和真实网络一模一样。真实网络里的每个权重前面可能隔着十几层梯度就是从损失出发一层层乘回去的。你调用框架的.backward()它做的就是这件事。3.4 梯度下降和学习率下山该怎么迈步有了梯度参数怎么更新也就定了。把损失函数想象成一个高低起伏的山谷我们要找到谷底。对某个参数求梯度得到的是当前点上函数上升最快的方向要下降就走反方向再乘一个步长这个步长就是学习率。w_new w - lr * gradient学习率是训练里最敏感的超参数之一。太大一步跨过谷底loss来回震荡甚至变成NaN太小下山过程慢得让人抓狂。后续系列会讲动量、Adam这类优化器改进但万变不离其宗它们都在回答同一个问题往哪走走多快。4. 训练中的典型翻车现场学习率、过拟合与梯度问题4.1 学习率太大太小从loss曲线读出问题我把训练问题分成“能跑但跑不好”和“根本不能跑”两种先看前者。最典型的场景是loss曲线像过山车忽高忽低。每次看到一个阶梯状上升又下降的曲线优先怀疑学习率太大如果loss一上来就打出NaN多半也是震荡太狠或者数据没归一化。反过来如果loss下降像蜗牛爬几百步才从0.5降到0.49那学习率多半太小。标准做法是拿一组学习率做小规模实验0.1、0.01、0.001、0.0001分别跑一小段观察loss下降速度。后面会讲到学习率调度器它会随训练过程自动调小步长但底层思路和这个实验一致。建议写代码时把loss打印在一个固定间隔上比如每100步打印一次。没有loss曲线的调试都是盲人摸象。4.2 过拟合与欠拟合把“背题”和“没学会”分开另一个高频问题是过拟合与欠拟合。欠拟合很好认训练集和验证集的loss都偏高模型根本还没学会。过拟合比较狡猾训练loss持续下降验证loss却在一个节点开始回升这时候模型已经把训练集里的细节背下来了换没见过的新样本反而不行。直觉一点说欠拟合是“没学会老师讲的内容”过拟合是“把练习册答案都背下来题改一个字就不会了”。遇到过拟合常用手段是加数据、减小模型容量、加正则化、早停遇到欠拟合反过来加大模型容量或改善输入特征。初学者最容易混淆的一点是模型在训练集上表现很好不代表真的学得好验证集才是更诚实的裁判。4.3 梯度消失与梯度爆炸网络深了为什么难训网络一旦加深训练难度急升核心原因藏在反向传播的连乘过程中。梯度每过一层就要乘一个激活函数局部导数。sigmoid导数的最大值只有0.25两端饱和处接近0连续乘上二三十次梯度会小到几乎看不见。靠近输入层的权重基本收不到更新信号这就是梯度消失。反过来如果某些权重数值偏大梯度一路被放大更新一步整个参数就飞了loss直接飘出天际这是梯度爆炸。ReLU在正区间导数恒为1缓解了“连乘越乘越小”的问题所以成了默认选择。但它的代价是负区间梯度为0这又引出下面要说的“死神经元”问题。深度网络的训练本质上就是和这种连乘效应做斗争。4.4 数据归一化让优化器少走弯路数据没归一化是新手排查时最容易漏掉的原因。假如两个特征的数值范围差几个数量级一个是0到1另一个是0到100000损失函数的等高线会被拉成一个极扁的椭圆形。梯度下降在这种地形上会走之字形看起来loss在降其实每一步都很低效。归一化的常见做法是标准化减去均值除以标准差让每个特征都落在0附近、量级接近1图像数据通常直接除以255压到0到1之间。代码很简单mean X_train.mean(axis0) std X_train.std(axis0) X_train_norm (X_train - mean) / (std 1e-8)那个1e-8是防止某列标准差为0时除出无穷大。注意一个细节mean和std必须用训练集算出来测试集沿用同一组统计量不能拿测试集重新算否则就造成了信息泄漏。4.5 死神经元隐藏层里的无声罢工ReLU在负数区间梯度恒为0好处是稀疏、省计算坏处是如果某次更新让某个神经元的输入长期落在负区间它的输出就会一直为0梯度也一直为0从此这个神经元彻底“罢工”。学习率越大越容易触发这种局面。症状是网络看起来层数不少实际有效神经元却在减少容量虚高、表达能力下降。解决办法很简单学习率别开太大如果问题反复出现可以把隐藏层换成LeakyReLU这类变体让负区间也保留一条小梯度通道。入门阶段不用纠结但对训练异常保持警惕会帮你省下大量排查时间。5. 用numpy从零实现一个小网络异或任务实战5.1 为什么用异或做第一个实战前面一直拿异或当反面例子这里就让它当实验对象。特征只有4个样本答案只有0或1计算量小到每一步都能手动核对但它确实需要非线性才能解决。所以它是验证“隐藏层到底有没有用”的最小实验。如果一个小网络能把异或学会就说明前面讲的层、激活函数、反向传播这套机制是真实有效的。反过来说如果只用单个神经元这个任务永远学不会这正好能让你亲手体会到隐藏层的价值。5.2 前向传播与反向传播numpy手写全程我们定的网络结构是2-4-1输入2个特征隐藏层4个神经元用tanh输出层1个神经元用sigmoid。tanh输出范围是-1到1对隐藏层来说通常比sigmoid好用sigmoid用在最后把结果压到0到1之间正好对应标签的0/1。import numpy as np np.random.seed(42) X np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtypefloat) y np.array([[0], [1], [1], [0]], dtypefloat) input_dim 2 hidden_dim 4 output_dim 1 w1 np.random.randn(input_dim, hidden_dim) b1 np.zeros((1, hidden_dim)) w2 np.random.randn(hidden_dim, output_dim) b2 np.zeros((1, output_dim)) lr 1.0 def sigmoid(x): return 1.0 / (1.0 np.exp(-x)) for step in range(10000): # 前向传播 z1 X w1 b1 a1 np.tanh(z1) z2 a1 w2 b2 a2 sigmoid(z2) loss np.mean((a2 - y) ** 2) # 反向传播 dL_da2 2.0 * (a2 - y) / len(X) dL_dz2 dL_da2 * (a2 * (1.0 - a2)) dw2 a1.T dL_dz2 db2 np.sum(dL_dz2, axis0, keepdimsTrue) dL_da1 dL_dz2 w2.T dL_dz1 dL_da1 * (1.0 - a1 ** 2) dw1 X.T dL_dz1 db1 np.sum(dL_dz1, axis0, keepdimsTrue) w1 - lr * dw1 b1 - lr * db1 w2 - lr * dw2 b2 - lr * db2 if step % 1000 0: print(fstep {step:5d}, loss {loss:.6f}) print(预测结果阈值0.5) print((a2 0.5).astype(int).flatten()) print(真实标签, y.astype(int).flatten())代码虽短但每个环节都对应前面讲过的内容前向的三行分别计算隐藏层加权和、隐藏层激活、输出层加权和与激活反向的三段分别对应输出层梯度、向隐藏层回传的梯度、再向输入层回传的梯度。update那里就是前面反复说的 w w - lr * gradient。如果你跑出来loss下降很慢把hidden_dim调到8或者lr改成0.5再试这是完全正常的调参过程。随机初始化不同收敛速度本来就会不一样。5.3 跑完训练之后看loss和输出预期你会看到损失从0.25左右一路往下掉几千步后降到0.01以下最后的预测结果和真实标签完全一致也就是[0 1 1 0]。这个结果的含金量在于它证明了区区几个参数组成的网络已经能表达一个线性不可分的函数。你手动去调参数很难调出这个效果但梯度下降自己找到了。这就是“学习”的本来面目——不是玄学而是一步步沿着梯度方向修正权重。每次看到loss下降你都可以在心里换算梯度指了个方向学习率迈了一小步参数更新了一点预测更接近真实了。这套循环就是整个深度学习的核心循环。5.4 同样的事用PyTorch写到底省了什么你可能会问真实项目里没人这样手写反向传播。确实现代框架把这套机制封装好了。用PyTorch写同样的事情import torch import torch.nn as nn X torch.tensor([[0., 0.], [0., 1.], [1., 0.], [1., 1.]]) y torch.tensor([[0.], [1.], [1.], [0.]]) model nn.Sequential( nn.Linear(2, 4), nn.Tanh(), nn.Linear(4, 1), nn.Sigmoid() ) loss_fn nn.MSELoss() optimizer torch.optim.SGD(model.parameters(), lr1.0) for step in range(10000): pred model(X) loss loss_fn(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() if step % 1000 0: print(f{step:5d}, loss {loss.item():.6f}) print((model(X) 0.5).int().flatten().tolist())框架版本里少了的那一大段numpy手写梯度正是.backward()和optimizer.step()替我们做的。所以我一直不建议完全跳过手写阶段直接上框架——哪怕只手写一次你对反向传播的理解都会扎实得多。以后遇到任何“为什么网络不收敛”的疑难杂症你都会多一个思考方向而不是只会换个学习率碰运气。6. 跑通代码之后的调试习惯以及下一步怎么走6.1 我调试小网络时最常用的三个动作第一永远先把loss曲线打出来。任何“模型不work”的问题第一步永远是看loss。loss在下降问题多半出在后续环节loss卡住不动才值得怀疑网络结构或参数设置。别急着改模型先问数据流有没有问题。第二固定随机种子。训练几乎全靠随机初始化不固定种子每次跑结果都不一样你根本分不清这次改进是有效还是运气。加一行np.random.seed(...)或torch.manual_seed(...)成本极低收益极高。第三先在极小数据上跑通。拿十个样本跑几十步如果连这十个样本都过拟合不了那基本是代码有bug如果loss能顺利降到很低说明网络本身没问题再上全量数据。这个习惯能帮你把“算法问题”和“工程问题”快速分开。6.2 先怀疑数据再怀疑网络还有一个经常被排到最后才排查的原因数据本身。标签反了、训练集和验证集切分时有重叠、缺值没处理、特征量纲差太大……这些问题引发的loss异常出现频率比网络结构问题高得多。我自己调试的顺序是先看数据的shape和标签对不对再检查归一化是否一致然后看网络最后才动学习率。顺序反过来往往会浪费大量时间。6.3 下一步怎么走数学、框架和后续系列这篇作为系列开篇到这里已经给了你一个完整的基础框架神经元与层、激活函数与初始化、损失与反向传播、常见训练问题、以及一个能亲手跑通的最小实验。接下来可以做的三件事一是把线性代数里的矩阵乘法和导数链式法则再巩固一下这是看懂后面一切材料的地基二是把PyTorch自动求导机制配合官方入门Demo跑一遍让框架和原理对上号三是准备好迎接系列的下一篇——顺着这条线我们会往卷积网络的方向走。我在反复带新人入门时最深的体会是神经网络的学习不是神秘事件它就是梯度信号驱动下的参数调整。把这篇里的每个公式亲手算一遍、每段代码亲手跑一遍后面再花哨的结构对你来说都会容易得多。