深度学习基础实战:从反向传播到调参,用numpy复现神经网络

📅 发布时间:2026/10/17 3:17:02
深度学习基础实战:从反向传播到调参,用numpy复现神经网络
最近我在系统啃深度学习教材整个过程用一个编号为deeplearningbook_030-1的项目文件来沉淀。这个项目听起来像是个文件名其实是我给自己定的一套学习规则每读一个章节必须把核心概念用代码复现出来再把踩过的坑整理成笔记。目前这个 030-1 是深度学习基础部分的第一个实践片段主要围绕神经网络的核心原理、手动实现和调参过程展开。写这篇文章不是想告诉你“我学完了”而是想把这一路从“看懂公式”到“跑通代码”之间的沟壑填平。很多朋友看书时觉得什么都明白了一动手就发现梯度算错、损失不降、模型不收敛这些问题我全都遇到过。下面这些内容适合刚入门深度学习、想把基础打扎实的读者也适合那些已经用框架写过模型、但没搞懂底层原理的人。我会用一段段真实操作过程来讲包括公式推导、完整代码和排查思路。1. 为什么把深度学习教材啃成“项目”来搞1.1 从“看书”到“复现”的转变只看书而不动手深度学习是学不会的。举一个最简单的例子反向传播公式里有一堆雅可比矩阵和链式法则书上看一遍觉得“这不就是求导嘛”但真让你去更新一个权重矩阵你会发现维度都对不上。我一开始就吃过这个亏照着公式抄代码结果np.dot报错因为把转置放错了位置。后来我把学习模式改成“项目驱动”每读完一章就给自己布置一个小任务比如“用 numpy 实现一个两层神经网络并在 XOR 数据上跑通”。这样一来看书不再是线性地翻页而是带着问题去检索。你会主动思考激活函数为什么必须非线性交叉熵为什么比均方误差更适合分类这些问题如果不写代码很难产生真正的体感。这个 030-1 项目就是在这套思路下诞生的。文件名里的 030 是我给学习内容的顺序编号-1 表示这部分内容聚焦在神经网络基础后面的 -2、-3 可能会扩展到处处是坑的优化器和 CNN。用编号管理知识有一个好处当你想回顾某个概念时不用翻密密麻麻的笔记直接找到对应编号就能看到当时的实现和心得。1.2 030-1 这个编号背后的组织方式项目的组织方式其实很简单就是“主题文件夹 编号笔记”。我把所有内容按主题拆分每个主题下有一个目录里面包含 README、代码和踩坑记录。比如 030 这个编号归档的是“前馈神经网络与反向传播”相关内容-1 代表基础动手篇。后面如果要补充 Batch Normalization就会变成 030-2。这种编号方式特别适合长期学习。深度学习知识体系很庞杂如果只用“第一章、第二章”的逻辑去组织后期学 CNN、RNN、Transformer 时会很难横向对比。而用编号加主题的方式你可以在 030 系列里放所有关于神经网络的内容然后再开一个 031 系列放优化器。这样做笔记目录本身就是一张“知识地图”。我把这种组织方式也推荐给一起学习的朋友。大家的第一反应是“有必要这么折腾吗”我的看法是深度学习的知识密度太高不建立一个能快速检索的体系学完三个月后基本就忘干净了。项目编号看起来冷冰冰但它能让你很清楚地知道自己的学习进度也方便回头查漏补缺。2. 核心概念拆解从线性模型到神经网络2.1 神经元和激活函数为什么非线性的灵魂在动手写代码之前必须先弄清楚一个最基本的问题神经网络里的“神经元”到底干了什么一个神经元接收多个输入对它们做加权求和再加一个偏置然后过一个激活函数。用数学表达就是[ z \sum_i w_i x_i b ] [ a f(z) ]如果去掉激活函数或者用线性函数 ( f(z)z )那么无论堆多少层整个网络最终都等价于一个线性模型。就像你把几张透明纸叠在一起上面画的直线加起来还是直线。要让网络具备拟合非线性关系的能力激活函数必须是非线性的。我在这部分笔记里重点比较了三种激活函数Sigmoid、Tanh 和 ReLU。Sigmoid 的公式是 ( \sigma(z) 1/(1e^{-z}) )输出范围在 0 到 1 之间早期神经网络很喜欢它。但它有两个问题输出不是零中心的会导致优化变慢而且两端梯度趋近于零容易造成梯度消失。Tanh 解决了零中心问题输出范围是 -1 到 1但两端依然会饱和。ReLU 的公式是 ( \max(0,z) )计算简单正区间梯度恒为 1能有效缓解梯度消失但现在成为了默认选择。不过 ReLU 也有自己的毛病比如“神经元死亡”。当一个神经元的输入总是负数时它的输出恒为 0梯度也恒为 0后续永远不会更新。实践中最直观的表现就是训练一段时间后某些隐藏层的输出全部是 0损失不再下降。所以我做实验时会把激活函数的选择也当成一个超参数而不是“抄个默认值就完事”。在 030-1 这个项目里我用了 Sigmoid 来演示因为它便于推导反向传播但对真实任务我一般会优先考虑 ReLU 或者 Leaky ReLU。2.2 损失函数与似然模型在优化什么模型有了前向传播还需要一个“打分标准”这就是损失函数。我在项目里做了个对比实验同样的网络结构分别用均方误差MSE和交叉熵损失训练结果差别很大。MSE 的公式是[ L \frac{1}{N} \sum_{i1}^N (y_i - \hat{y}_i)^2 ]它做回归任务很自然但拿到分类任务上配合 Sigmoid 输出层会出现一个问题Sigmoid 输出接近 0 或 1 时导数很小而 MSE 对误差的梯度还会再乘上这个很小的导数导致参数更新极其缓慢。交叉熵损失则不是这样二分类下的公式是[ L -\frac{1}{N} \sum_{i1}^N \left[ y_i \log \hat{y}_i (1-y_i) \log(1-\hat{y}_i) \right] ]它的梯度形式恰好能抵消掉 Sigmoid 导数中的饱和项让模型在“错得离谱”的时候能快速修正。用生活化的类比来说MSE 像是一个对分数波动很敏感的判卷老师而交叉熵更像一个“错得越狠、扣分越狠”的严格老师。分类任务需要后者这种更果断的反馈。我在代码里验证过这个差异用 MSE 训练一个普通的 XOR 分类器初始损失下降很慢中间还可能卡住换成交叉熵之后收敛明显更顺。所以你现在看到许多分类模型默认用交叉熵不是没有原因的。这里也提醒一句如果你在输出层用了 Softmax那么多分类的交叉熵形式是[ L -\frac{1}{N} \sum_{i1}^N \sum_{c1}^C y_{i,c} \log \hat{y}_{i,c} ]这套对应关系值得在笔记里单独列一张对照表。2.3 正则化防止模型“背答案”刚跑通第一个神经网络时很多人都会发现一个诡异的现象训练集准确率能到 100%测试集却一塌糊涂。这就是过拟合模型把训练数据里的噪声也背下来了。正则化就是给模型“减负”的手段。我在 030-1 里实践了三种最常用的正则化方法。第一种是 L2 正则化做法是在损失函数后面加一项 ( \lambda \sum |w|^2 )让权重尽量小。模型权重越小输出对输入的扰动就越不敏感相当于逼着网络去学更简单的模式。第二种是 Dropout训练时随机让一部分神经元失活使得网络不能过度依赖某几个神经元。第三种是早停也就是在验证集损失不再下降时提前终止训练。这个最简单也最实用。我自己的经验是不要一上来就堆正则化。先看看训练集和验证集的 loss 曲线如果训练 loss 高说明欠拟合这时候该增大模型容量或者调低正则化强度如果验证 loss 开始反弹说明过拟合再加 dropout 或者提高 ( \lambda )。正则化不是越多越好这一点在项目中务必自己动手尝试一下调整不同参数看看曲线怎么变。3. 动手实现一个最小可运行的神经网络3.1 环境准备与数据生成深度学习项目听起来很重但为了理解原理完全可以用 numpy 从零实现。我这部分实验的环境很简单Python 3.8 以上加上 numpy 和 matplotlib。不需要安装 TensorFlow 或 PyTorch这样能把注意力集中在真正的计算过程上。如果你手头已经有 Python 环境直接pip install numpy matplotlib就够了。我选的数据集是经典的 XOR 问题。XOR 也就是“异或”输入是二维的输出为 1 当且仅当两个输入不相同。它的数据不是线性可分的所以单层感知机怎么也学不好而一个带隐藏层的两层网络就能解决。这正是检验神经网络表达能力的绝佳例子。数据生成代码很简单import numpy as np X np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtypenp.float32) y np.array([[0], [1], [1], [0]], dtypenp.float32)注意标签是列向量形状(4,1)后面算梯度时会方便很多。很多新手一开始把y写成(4,)结果np.dot时的维度就对不上了。我建议所有实验数据都统一成“样本数 × 特征数”的矩阵形式标签也是“样本数 × 类别数”的矩阵。3.2 前向传播与反向传播的代码实现两层神经网络的网络结构是输入层 2 个神经元隐藏层 4 个神经元输出层 1 个神经元。隐藏层用 Sigmoid输出层也用 Sigmoid这样输出可以解释成概率。整个前向传播就是从输入算出隐藏激活值再算出最终预测值。def sigmoid(x): return 1 / (1 np.exp(-x)) def sigmoid_derivative(x): return x * (1 - x) np.random.seed(42) w1 np.random.randn(2, 4) * 0.5 b1 np.zeros((1, 4)) w2 np.random.randn(4, 1) * 0.5 b2 np.zeros((1, 1)) learning_rate 0.7 losses [] for epoch in range(10000): # 前向传播 z1 X.dot(w1) b1 a1 sigmoid(z1) z2 a1.dot(w2) b2 a2 sigmoid(z2) loss np.mean((y - a2) ** 2) losses.append(loss) # 反向传播 error2 (a2 - y) * sigmoid_derivative(a2) error1 error2.dot(w2.T) * sigmoid_derivative(a1) # 梯度下降更新 w2 - learning_rate * a1.T.dot(error2) b2 - learning_rate * np.sum(error2, axis0, keepdimsTrue) w1 - learning_rate * X.T.dot(error1) b1 - learning_rate * np.sum(error1, axis0, keepdimsTrue)反向传播的推导是这里面最容易劝退的部分但代码其实很短。核心只有三行。error2是输出层的误差信号等于预测值减去真实值再乘以激活函数的导数error1则是把error2通过w2传回隐藏层同样乘上隐藏层激活函数的导数。这就是链式法则的代码表达。你可能会问为什么中间要乘sigmoid_derivative(a2)因为损失对输出层加权输入 ( z_2 ) 的梯度需要经过 Sigmoid 这一层它的导数正好是 ( a_2(1-a_2) )。权重更新的公式也很有讲究。a1.T.dot(error2)是在计算“误差对 w2 的梯度”但因为我们要做梯度下降所以朝着负梯度方向更新。如果你不小心把转置写错了训练就会直接发散。我在项目里加了一段维度断言每次更新前都检查矩阵形状这个小习惯帮我避免了很多低级错误。3.3 训练循环与超参数选择训练循环本身很简单重复前向、算损失、反向、更新。但真正影响模型效果的是超参数设置。我在项目里测试了三种不同的学习率0.1、0.7、2.0。学习率是 0.1 时训练 10000 轮后输出还在 0.3 附近收敛太慢学习率是 2.0 时损失直接发散变成了 NaN只有 0.7 时分步适中最后输出能逼近 0 和 1。这个现象背后的逻辑是学习率太小每一步走得太谨慎需要很长的训练时间学习率太大可能会跨过最优点甚至因为来回震荡导致损失增大。如果你用的是 Mini-batch 梯度下降这个问题会更明显因为每个 batch 的梯度都有噪声。实际调参时我通常先从一个中等量级比如 0.01开始绘制损失曲线如果曲线下降太慢就增大如果震荡就减小。还有一个容易被忽略的细节是权重初始化。我在上面的代码里用np.random.randn(2, 4) * 0.5这个 0.5 是控制初始权重的幅度。如果初始值太大Sigmoid 很容易进入饱和区梯度趋近于零如果初始值太小信号传递会逐渐衰减。更成熟的方案是 Xavier 初始化或 He 初始化它们根据输入输出维度自动调整方差。但在手工实验里乘以一个小常数已经足够让你感受初始化对收敛速度的影响。除了学习率和初始化batch size 在这里没有体现因为用的是全量数据。但在更大数据集上batch size 也是一个需要权衡的超参数太小梯度震荡大太大每次更新计算慢。030-1 项目里我刻意没有引入 batch 的概念因为先把单个样本的梯度算明白后面切 batch 才是水到渠成的事。4. 训练过程中的常见问题与排查实录4.1 梯度消失与梯度爆炸症状、原因、对策我训练这个两层网络时最早遇到的诡异问题是损失一开始下降然后卡在某个值不再动输出层的误差始终在 0.5 附近。后来检查隐藏层的a1发现很多值都变成了 0 或 1对应的sigmoid_derivative接近 0导致梯度消失。这种情况在深层网络里更常见但即便是两层网络如果初始权重太大同样会发生。梯度消失的本质是链式法则中多个小于 1 的因子相乘。Sigmoid 导数的最大值是 0.25如果权重也不大每反向传播一层梯度的模就会被衰减一次。层数越多衰减越明显。梯度爆炸则相反当权重的绝对值大于 1且激活函数导数也较大时梯度会指数级增长训练时损失很快变成 NaN。排查梯度消失/爆炸我建议在训练循环里加上梯度范数统计。比如每隔 100 轮打印np.linalg.norm(error1)和np.linalg.norm(error2)。如果发现某个层的梯度比上一层小了几个数量级就要警惕了。对策无非几种换成 ReLU 系激活函数、使用归一化层、调整初始化方式、或者用梯度裁剪。030-1 里我用的是最简单的方法把初始化权重缩小并换用 ReLU 重新测试效果立竿见影。4.2 过拟合观察验证集曲线训练到后期训练集损失一直在降但验证集损失反而上升这是过拟合的典型信号。在这个小实验里因为数据只有 4 个样本其实很难谈真正的泛化但我们可以用一个更大的噪声数据集来复现这个现象。我在项目里额外做了一组实验生成 100 个线性可分但带有噪声的数据点用一个超大隐藏层去训练轻松达到训练集 100% 准确率但测试集只有 80%。这个过程的观察要点是画两条曲线训练 loss 和验证 loss。如果训练 loss 下降验证 loss 也下降说明模型还在正常学习如果验证 loss 开始回头就说模型在“背题”了。这时候我建议按顺序尝试增加训练数据、降低模型容量、加大正则化强度、加早停。记住正则化只是治标数据量和数据质量才是治本。4.3 学习率设置从 0.1 到 0.001 的踩坑记录学习率是最容易让人抓狂的超参数。刚开始跑实验时我把学习率设成 0.1发现网络学得很慢于是自作聪明地改成 1.0结果 loss 曲线跟心电图一样剧烈震荡后面直接变成 NaN。后来习惯了使用学习率衰减策略比如每隔一定轮数降低学习率或者用 Adam 这样的自适应优化器才少踩很多坑。在从零实现的代码里我推荐一个经验性做法先用 0.01 跑 1000 轮看 loss 是否下降。如果下降太慢调大 3 倍如果震荡调小 1/3。反复几次就能确定一个合适的量级。更严谨的方法是学习率范围测试从小到大跑几个短实验画出 loss 随学习率变化的曲线选曲线下降最陡的位置附近。这听起来有点麻烦但比起盲目乱试效率高得多。我还踩过一个坑忘记对数据做标准化。XOR 数据的特征都是 0 和 1没什么影响但换成一般数据如果特征尺度差异很大会让不同权重的梯度幅度差异很大学习率设得‘顾此失彼’。标准的做法是把每个特征减去均值、除以标准差让数据分布处于一个相对统一的尺度上。5. 实际操作中最容易被忽略的几个细节代码跑通之后我回头看整个 030-1 项目发现真正决定实验成败的往往不是复杂的网络结构而是一些看起来微不足道的细节。第一个是随机种子。如果你希望在调试时能复现结果一定在代码最开始加上np.random.seed(...)。我刚开始没加每次训练出来的曲线都不一样导致我根本没法判断是代码问题还是运气问题。第二个是数据 shuffle。如果用 batch 训练每个 epoch 都要把数据重新打乱一下否则模型可能会学到样本顺序里的伪特征。第三个是打印信息的格式。每过几百轮打印一次 loss 和当前输出方便实时观察状态。我还有一个个人小习惯会在每次实验后把关键超参数和最终结果记录在一个 Markdown 表格里。比如学习率、隐藏层大小、初始化幅度、是否正则化、最终损失。这样做的好处是当你连续调整多个参数时可以一眼看出哪个变量起了主要作用。深度学习实验本质上是一个搜索过程不记录过程的搜索和盲人摸象没有区别。这个 030-1 项目学完之后我最大的感受是书上那些看似枯燥的公式其实每一个都在代码里有对应的形状。你理解了(a2 - y)是损失函数的导数理解了sigmoid_derivative(a2)来自链式法则就不会再被各种深度学习框架的自动求导“惯坏”。后面学习卷积神经网络、循环神经网络时我也依然沿用这套“编号 复现 踩坑记录”的方法。希望这篇记录能给你一些启发让你也能把知识真正内化成自己的项目。