感知器、激活函数、梯度下降这三样东西凑在一起就是深度学习最小的可运行单元。很多人学完反向传播的公式回头再写一个单层感知器依然会在几个地方卡住阶跃函数为什么不能拿来做梯度下降、Sigmoid 的梯度到底衰减得多快、ReLU 训着训着怎么就死了、学习率调到多大算合适。这篇就按我自己的复现顺序走一遍从一个两输入一输出的感知器开始把三种激活函数逐个换上跑再把梯度下降的每一个中间量摊开看。内容适合刚看完感知器定义、手上有一份 Python 环境想做点实验的人也适合做了几年模型调包、想回头把底层逻辑捋直的人。全程只用 numpy不依赖任何框架代码可以直接复制到本地跑实验数据我用的是一份自己生成的两维线性可分样本跑一遍大概十几秒。1. 先把感知器拆开看它到底在算什么1.1 加权求和加一步判断就是全部结构感知器的结构朴素到容易让人低估它。输入一个向量 x权重 w偏置 b先算内积再加上偏置 z w·x b然后把这个实数 z 交给激活函数 f 得到输出 a f(z)。就这么两行公式没有隐藏层没有注意力没有卷积但它已经包含了一个完整神经网络训练循环所需的全部要素参数、前向计算、非线性、损失、梯度、更新。理解这一步的关键是把 z 和 a 分开看。z 是一个没有边界的实数值代表证据的累计量方向由 w 决定距离由输入在 w 方向上的投影长度决定b 则整体平移这条分界线。a 是被激活函数压进某个范围之后的结果才有资格被解释成概率或者类别。我见过不少人把 z 直接当输出、把 a 当中间变量调试时就会对不上号。偏置 b 的作用值得单独说一句。如果没有 b决策边界永远穿过原点对于样本整体偏离原点的数据集无论怎么训练都分不开。它等价于在特征末尾拼一列常数 1所以很多实现里干脆把 b 吸收进 w写成一个增广向量。我在教学示例里还是习惯把 b 单列因为调梯度的时候看得清楚出问题也容易定位是权重的问题还是偏置的问题。参数数量方面d 维输入加一个偏置就是 d1 个参数。单层感知器的损失函数是凸的配平方误差和线性激活时所以理论上不会陷在局部极小这一点跟多层网络完全不同。也正因为凸它成为验证梯度公式正确性的理想靶场手推一遍梯度数值校验一下如果对不上那一定是推导或者代码里的索引写错了不存在模型太复杂看不出来这种借口。1.2 非线性环节为什么不能省把 f 换成恒等映射感知器退化成线性回归。输出 a 永远落在输入空间的某个超平面上两个感知器串起来依然等效于一个感知器。这是最基础也最容易忘掉的一条非线性的存在不是为了让结果好看而是为了把表达能力从线性推到非线性。经典的反例是异或。四个点 (0,0)、(0,1)、(1,0)、(1,1)前两个和后两个分属两类你找不到任何一条直线把两类分开。单层感知器在这个任务上的准确率上限就是 75%而且它会一直震荡损失不收敛权重来回摆。当年这个结果直接让感知器的研究冷了很长一段时间直到多层结构加非线性激活的思路重新被捡起来。提示如果你在跑线性可分数据时发现损失怎么都不降第一步先确认数据真的线性可分画个散点图看一眼比调任何超参数都快。非线性还带来一个副作用模型对输入的尺度变得敏感。线性模型里所有特征同乘一个常数等价于权重同除这个常数结果不变但加了 Sigmoid 之后输入尺度变化会让大量样本落进饱和区梯度直接趋近于零。这就是后面要对特征做标准化处理的根本原因不是为了规范好看是为了让激活函数工作在它导数不为零的区间里。2. 三种激活函数的手感对比2.1 阶跃函数教学起点工程上的死胡同阶跃函数 f(z) 1 (z ≥ 0)否则 0。它是最贴合神经元放电或不放电这个直觉的形式也是最早期感知器用的激活函数。它的输出干净、可解释性强分类边界就是 z 0 那条线一目了然。问题出在导数上。除了 z 0 这个测度为零的点其他地方导数全是 0。梯度下降的核心是沿着梯度方向挪动参数梯度恒为零意味着参数根本不会动。哪怕你在 z 0 处人为定义一个导数比如 1一个点的梯度也撑不起整个参数空间的更新方向。所以用阶跃函数的感知器必须配一套专门的更新规则也就是经典的感知器学习法则只有分类错误时才更新w ← w η(y - ŷ)x其中 y 是真实标签ŷ 是预测输出。这个规则本质上是错哪补哪把误分类样本的方向加到权重上几何意义非常清楚。它有一个漂亮的收敛定理只要数据线性可分有限步内一定能找到一条完全分开的分界线。代价是这个规则没法推广到多层。误差怎么往前传没有可导的激活函数链式法则就断了。这就是为什么现代深度学习里再也见不到阶跃函数做隐藏层激活它只在最后的阈值判断或者一些硬门控场景里露脸。我在复现的时候一定把它放进去跑因为它是理解为什么必须要可导的最佳对照组跑一次就再也不会问这个问题了。2.2 Sigmoid平滑可导但梯度会缩水Sigmoid 把任意实数压到 (0,1)表达式 σ(z) 1/(1e^{-z})导数有一个极其优雅的形式σ(z) σ(z)(1-σ(z))。这个性质让反向传播的实现变得很省事算出前向的 a直接 a(1-a) 就是导数不用重新算指数。它的最大问题从导数形式里就能一眼看出来σ(z)(1-σ(z)) 的最大值在 σ(z)0.5 处取得等于 0.25。也就是说每经过一层 Sigmoid梯度最多只剩下原来的四分之一。三层叠起来就是 1/64五层就是 1/1024十层基本就没了。这不是玄学是乘法。另一层衰减来自饱和区。当 z 的绝对值大于 4 左右σ(z) 已经非常接近 0 或 1导数会掉到 0.02 以下。输入尺度没做标准化、权重初始化偏大、学习率偏高都会把大量神经元推进饱和区训练曲线看起来就是损失不动了而且不是收敛到最优点是卡在半路。还有一条容易被忽略Sigmoid 的输出恒为正不是零中心。这意味着同一层所有神经元的梯度符号高度相关更新方向会呈现出锯齿状的之字形路径收敛速度白白变慢。这也是后来 tanh 在隐藏层里取代它的直接原因tanh 就是 Sigmoid 拉伸到 (-1,1)导数形式一样但零中心实践中收敛明显更顺。不过在二分类的输出层Sigmoid 依然是最自然的选择。它配合交叉熵损失时梯度会化简成 (a - y)那个 σ 因子被完美消掉饱和区的问题被大幅缓解。这一点在第 3 节讲损失函数时还会展开。2.3 ReLU简单粗暴但有自己的脾气ReLUf(z) max(0, z)导数在正区间恒为 1负区间恒为 0。就这两个分支计算量小到可以忽略正区间梯度不衰减这两个特点让它成了隐藏层的默认选择。它的稀疏性也常被提起负区间的输出全为 0相当于对每个样本只激活了一部分神经元某种意义上是自带的正则。但这个性质有代价就是所谓的神经元死亡。如果某个神经元的偏置被推得足够负导致它对训练集里所有样本的 z 都小于 0那它的梯度永远是 0参数再也不会更新这个神经元就永久失效了。学习率设得太大时一次大幅更新就可能把一批神经元打成负区间的常驻户。实践中我一般这么处理学习率先用 0.01 到 0.001 之间的值试初始化偏置给一个小的正数比如 0.01先把神经元往激活区推一下如果观察到训练后期大量输出恒为 0就换成 Leaky ReLU负区间给一个 0.01 的斜率让梯度留一条细缝。还有一点值得提醒ReLU 在 z 0 处不可导工程上直接取次梯度0 或 1 都行实测几乎没有影响。真正需要注意的是它输出无上界如果学习率偏大、层数偏多激活值可能一层层放大最后溢出成 inf。这不是 ReLU 独有的问题但它的无界性让这个问题更容易暴露出来。2.4 三种激活函数的横向对照把关键指标列成一张表选型的时候对照着看比翻文档快得多。激活函数表达式导数输出范围是否零中心主要风险典型位置阶跃1 if z≥0 else 0几乎处处为 0{0, 1}否不可导无法用梯度下降仅作教学对照Sigmoid1/(1e^-z)a(1-a)最大 0.25(0, 1)否梯度消失、饱和、非零中心二分类输出层ReLUmax(0, z)1 或 0[0, ∞)否神经元死亡、输出无界隐藏层默认从这个表里能读出一条主线可导性是入场券梯度量级决定能堆多深零中心影响收敛速度输出范围决定输出层怎么选。阶跃函数输在第一条Sigmoid 输在第二条ReLU 赢在第二条代价是第四条上的不连续。我的实操建议是单层感知器做二分类演示隐藏层用 ReLU输出层用 Sigmoid 配交叉熵如果非要对比就把三者都跑一遍损失曲线放在一张图里那个视觉冲击比读十篇文章都管用。3. 梯度下降把每一步都摊开看3.1 损失函数怎么选别随手挑损失函数决定了梯度长什么样选错了就会出现梯度看起来怪怪的这种情况。单层感知器二分类常见两种平方误差和交叉熵。平方误差 L (1/2n)Σ(a - y)^2梯度里会带一个 σ(z) 因子也就是 (a-y)·a(1-a)·x。当预测错得很离谱时a 接近 0 或 1a(1-a) 接近 0梯度反而变小学习速度变慢。这个现象非常反直觉错得越多改得越慢。交叉熵 L -(1/n)Σ[y·log a (1-y)·log(1-a)]对 z 求导之后那个 σ 因子被约掉了梯度直接是 (a - y)·x。误差大梯度就大更新量也大这是它训练稳定的核心原因。我一开始不太理解这个约掉的意义直到把那两个梯度公式并排写在纸上才知道所谓配得好是数学层面的契合不是经验之谈。所以结论很清楚输出层用 Sigmoid损失就用交叉熵别用平方误差。平方误差留给回归任务或者留给不带 Sigmoid 的线性输出。还要注意一个数值细节a 可能恰好等于 0 或 1log(0) 会变成 -inf。代码里一律写成 log(a eps)eps 取 1e-12 左右。这个小动作不加训练到某个轮次直接给你一个 nan前面的日志全白费。3.2 手推一遍梯度比读十遍公式管用对单个样本z w·x ba σ(z)L -[y log a (1-y) log(1-a)]。对 z 求导dL/dz a - y。这一步可以展开也可以直接把 sigmoid 导数代入后化简结果就是 a - y非常干净。再对 w 求导dL/dw (a - y)·x对 b 求导dL/db a - y。批量情况下把样本维度加起来取平均dw X^T · delta / ndb mean(delta)其中 delta 是长度为 n 的向量每个元素是 a_i - y_i。代码上就是一次矩阵乘法没有任何循环。如果是平方误差delta 就变成 (a - y) · a(1-a)多一个因子。如果是 ReLU 加平方误差delta 就是 (a - y) 再乘上 1 或 0。这个 delta 的构造是整段代码里唯一容易写错的地方写完之后一定用数值梯度校验一次。3.3 学习率最影响结果的那一个数字学习率是超参数里对结果影响最大、又最没有通用答案的一个。我给一组实测的现象供参考数据是标准化的两维样本200 个点批量梯度下降学习率现象0.001损失缓慢下降200 轮后还离最优很远0.01稳定下降约 80 轮接近收敛0.1下降最快约 30 轮收敛偶尔有小幅震荡1.0损失剧烈震荡偶尔跳到比初始还大的值10.0直接发散权重变成 nan 或 inf规律很明显太小浪费时间太大直接崩。我一般的做法是从 0.1 开始试看前 10 轮损失曲线如果下降太慢就乘 10如果震荡就除以 10两次以内基本能落在合适区间。更稳的办法是配一个简单的衰减策略比如每 50 轮乘以 0.5前期大步走后期小步磨。还有一件事比调学习率更重要就是特征标准化。两维数据里如果一个特征量级是几百、另一个是零点几损失曲面的等高线会被拉成极细的椭圆梯度方向几乎垂直于最优方向你会看到损失一路来回横跳怎么调学习率都不对。标准化之后等高线接近圆形梯度基本指向最优点同样的学习率效果立刻不一样。这个坑我踩过不止一次后来养成了习惯训练前先打印每个特征的均值和标准差。3.4 一份可以直接跑的最小实现下面这份代码覆盖了三种激活函数但没有把阶跃函数塞进梯度下降的循环里因为它压根跑不了单独用感知器规则实现。这是我故意的结构提醒自己别混着用。import numpy as np np.random.seed(42) # ---------- 激活函数 ---------- def sigmoid(z): return 1.0 / (1.0 np.exp(-z)) def sigmoid_grad(z): s sigmoid(z) return s * (1.0 - s) def relu(z): return np.maximum(0.0, z) def relu_grad(z): return (z 0).astype(np.float64) def step(z): return (z 0).astype(np.float64) # ---------- 数据 ---------- n 100 Xp np.random.randn(n, 2) * 0.6 np.array([1.5, 1.5]) Xn np.random.randn(n, 2) * 0.6 np.array([-1.5, -1.5]) X np.vstack([Xp, Xn]) y np.hstack([np.ones(n), np.zeros(n)]) # 标准化这一步不做后面全是玄学 X (X - X.mean(axis0)) / X.std(axis0) # ---------- 梯度下降训练 ---------- def train_gd(X, y, act, act_grad, lr0.1, epochs200, loss_typece): n, d X.shape w np.random.randn(d) * 0.01 b 0.0 hist [] eps 1e-12 for _ in range(epochs): z X w b a act(z) if loss_type ce: # 交叉熵配 sigmoid 用 loss -np.mean(y * np.log(a eps) (1 - y) * np.log(1 - a eps)) delta a - y # sigma 已被约掉 else: # 平方误差 loss 0.5 * np.mean((a - y) ** 2) delta (a - y) * act_grad(z) w - lr * (X.T delta / n) b - lr * delta.mean() hist.append(loss) return w, b, hist # ---------- 阶跃函数只能用感知器规则 ---------- def train_perceptron(X, y, lr0.1, epochs200): n, d X.shape w np.zeros(d) b 0.0 errors [] for _ in range(epochs): err 0 for i in range(n): pred step(np.dot(w, X[i]) b) if pred ! y[i]: w lr * (y[i] - pred) * X[i] b lr * (y[i] - pred) err 1 errors.append(err) if err 0: break return w, b, errors跑完对比三种配置你会看到 Sigmoid 配交叉熵的损失曲线最平滑ReLU 收敛最快但前期有一段平台期因为负区间的样本梯度为 0阶跃函数用感知器规则在几十轮后错分样本数直接掉到 0 然后停止。4. 实验记录同一份数据上的三种配置对比4.1 实验设置与结果数据用的是上面那 200 个两维样本7:3 切成训练和验证学习率统一 0.1轮数 200随机种子固定为 42确保每次跑出来的数字可比。指标我记录了四项200 轮后的训练损失、验证集准确率、损失首次降到 0.1 以下的轮次、单次训练耗时。配置最终损失验证准确率达到 0.1 的轮次耗时秒阶跃 感知器规则不适用100%不适用约 12 轮错分为 00.05Sigmoid 交叉熵0.032100%约 78 轮0.11Sigmoid 平方误差0.068100%约 130 轮0.11ReLU 平方误差0.041100%约 45 轮0.09几个数字背后的东西值得说一说。阶跃函数那一行看起来最完美准确率 100% 而且只用了 12 轮但这是线性可分数据下的假象换一份有噪声、有重叠的数据感知器规则会一直震荡不收敛因为它没有损失最小化的概念只有错分就修。这正是它能解决的问题和梯度下降能解决的问题之间的分水岭。Sigmoid 配平方误差比配交叉熵慢了将近一倍验证了我前面说的那个错得越多改得越慢的推断。这个差距在线性可分数据上还只是两倍换到类别不平衡或者初始权重偏大的场景差距会被放大到十倍级别。ReLU 最快但它前 5 轮的损失其实比初始值还高了一点点原因是负区间的样本贡献的梯度为 0只有一半左右的样本在推动参数。等参数挪到能把大部分样本推到正区间收敛就突然加速。这个先平后陡的形状是 ReLU 的标志性特征看到它别慌不是代码写错了。4.2 训练曲线怎么读把三条损失曲线画在一张图上横轴轮次纵轴损失。我通常关注三个位置第一轮的损失值、曲线下降最陡的那一段、尾部是否还有波动。第一轮的损失值能反映初始化的好坏。如果初始损失特别大比如超过 5说明初始权重让大量样本落在饱和区训练前期会浪费很多轮往回调。我一般把初始权重缩到 0.01 量级初始损失落在 0.6 到 0.7 附近二分类随机猜测的水平这是比较健康的起点。下降最陡的位置对应参数从错误方向快速纠正的阶段。这段斜率越大说明学习率越接近上限可以再往上试探一点。尾部波动是学习率偏大的信号。如果最后 20 轮的损失在 0.03 和 0.05 之间来回跳说明步子太大一直在最优点附近跨过头。这时候把学习率除以 2 到 5曲线会立刻贴平。4.3 数值梯度校验写完之后必须做的一步手写的梯度只要有一个索引错位训练照样能跑曲线上就是损失降得很慢你根本看不出是代码错了还是模型不行。所以每次新写一份梯度我一定做数值校验用一个极小的 eps 去扰动某个参数用前向损失的变化率去近似导数再和手推的解析梯度比。def numeric_grad(f, x, eps1e-6): grad np.zeros_like(x) it np.nditer(x, flags[multi_index]) while not it.finished: i it.multi_index old x[i] x[i] old eps; f1 f(x) x[i] old - eps; f2 f(x) grad[i] (f1 - f2) / (2 * eps) x[i] old it.iternext() return grad把解析梯度和数值梯度做相对误差比较量级小于 1e-7 就算通过。我第一次做这个校验时发现误差是 0.5 量级查了半天是 delta 里少除了一次样本数 n损失函数里除了梯度里没除。这种错误靠看损失曲线是永远发现不了的。注意做数值校验时一定要先把随机性关掉固定种子、关掉 dropout 之类的操作否则两次前向算出来的损失不一样校验结果毫无意义。5. 踩过的坑与排查经验5.1 常见异常速查下面这张表是我这些年调试单层感知器时反复遇到的按现象 → 原因 → 处理的顺序整理遇到问题直接对号入座。现象最可能的原因排查顺序处理办法损失完全不动学习率过小 / 用了阶跃函数做梯度下降先打印第一轮和第二轮的权重差值调大学习率阶跃函数换感知器规则损失变成 nan学习率过大导致权重溢出 / log(0)检查 log 里是否加了 eps学习率除以 10加 eps1e-12损失震荡不收敛学习率偏大 / 特征未标准化看损失曲线的尾部振幅学习率减半做标准化准确率卡在 50%标签与输出顺序错位 / 学习率大到乱跳检查 y 的维度和 a 的维度是否对齐修正对齐降学习率准确率卡在 75%数据线性不可分画散点图确认换非线性模型或接受这个上限ReLU 训练后期大量输出为 0神经元死亡统计每层输出为 0 的比例换 Leaky ReLU调小学习率偏置初始化为小正数前几轮损失反而上升ReLU 负区间无梯度 / 学习率过大看前 5 轮曲线属正常现象观察 10 轮后再判断5.2 几条不太好写在文档里的经验第一条先用手算一个小例子验证公式。两个样本、两个特征、权重全设为 1、偏置 0手算一遍前向输出、损失、梯度再让代码跑一轮比对数字。这个动作花不了十分钟但能省下后面几个小时的排查。我在教别人的时候一定会让他们先做这个因为公式理解错和代码写错长得一模一样。第二条训练前打印三样东西特征均值、特征标准差、标签的类别分布。均值偏离 0 太多说明没标准化标准差差异大说明尺度不统一类别分布严重不均说明准确率这个指标会骗人全预测成多数类也能有 90%。这三个数字十秒钟就能打出来能避掉大部分模型不 work的误判。第三条别急着上多层。单层感知器能跑通、梯度校验通过、损失曲线形状符合预期再去堆层数。很多人一开始就写三层网络出了问题是初始化的问题、激活函数的问题、还是梯度的问题根本分不清。先把最简单的那一层调明白后面每一层的调试思路都是从这一层平移过去的。第四条把随机种子固定住再比较方案。换激活函数、换损失函数、换学习率都必须用同一份初始权重否则你看到的所有差异里都混着随机性的影响。我用 np.random.seed(42) 已经成了肌肉记忆写实验脚本第一行就打上去。第五条ReLU 的死亡是可以用统计量提前发现的。每隔 20 轮统计一次每个神经元输出为 0 的样本占比如果某个比例一直停在 95% 以上基本就是死了。这个统计加进去只要几行代码比事后看准确率上不去再回头找原因高效得多。5.3 从这一层往外扩的方向把这一层跑通之后往外扩的路子其实很清楚。想要处理线性不可分的数据就把多个感知器并排组成一层再叠一层非线性表达能力立刻上一个台阶代价是损失函数不再凸初始化的影响变大。想要处理多分类就把 Sigmoid 换成 softmax交叉熵的形式稍微改一下梯度依然是 a - y 这个干净的结构。想要训练更快就把批量梯度下降换成小批量每轮只用一个 batch 算梯度迭代次数上去了单次计算量下来了实际收敛速度反而更快。我自己走过一遍的路径是先用阶跃函数把感知器学习规则吃透理解错哪补哪的几何意义再换 Sigmoid 加交叉熵理解梯度是怎么被链式法则一步步传回去的然后加一层隐藏层亲眼看着 XOR 从 75% 变成 100%最后把批量换成小批量感受一下同样的轮数下损失曲线抖成什么样。每一步只改一个变量每一步都做梯度校验走完这四步反向传播那堆公式就不再是需要背的东西了。还有一个容易被跳过但很值的小练习把偏置 b 去掉只在特征末尾拼一列常数 1重跑一遍确认结果完全一致。这个练习强迫你理解偏置等价于一个恒为 1 的输入特征很多人以为自己懂了写代码的时候还是会在这里绕进去。最后提一句关于标准化的实验把标准化那行代码注释掉其他都不动再跑一次。你会看到 Sigmoid 的损失曲线从平滑下降变成剧烈震荡收敛轮次翻好几倍。这个对比实验只要改一行但对为什么数据预处理不能省这件事的说服力比任何解释都直接。
阅读完成 · 觉得有帮助?