ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深度学习学习笔记:从感知机到反向传播,深入理解神经网络训练本质

深度学习学习笔记:从感知机到反向传播,深入理解神经网络训练本质

整理这份深度学习学习笔记时,距离我第一次跑通手写数字识别已经过去大半年。那段日子我最大的困惑是:代码可以跑,但为什么参数要这么调、loss曲线这么降、换了激活函数结果就天差地别,我完全说不清楚。于是我开始强迫自己把每一步都记录成笔记,不写清楚“为什么”就不继续往下做。这份笔记就这样慢慢长出了骨架,从感知机的几何意义一路记到反向传播的手推,再到训练时那些说不清道不明的“玄学时刻”。

这篇文章就是我把自己笔记本里最核心的内容重新梳理了一遍,不是完整的入门教程,更像一份“学习过程复盘”。如果你正在学深度学习,跑过一些示例代码但总觉得地基不牢,或者记了一堆公式却不知道它们和代码如何对应,那这篇笔记应该能帮你补上几个关键的连接点。我会把理论直觉、手推过程、框架实操和踩坑经验混在一起讲,因为真实的学习过程本来就是这样交织的。

1. 这份学习笔记的缘起:从只会调包到想弄懂原理

1.1 第一次跑通模型后,我反而更焦虑了

很多人的入门路径和我差不多:找一个现成的代码,装好环境,跑一下 MNIST 手写数字识别,看到准确率接近 99%,觉得自己已经入门了。但当我尝试改一个参数时,比如把学习率从 0.01 改成 0.1,准确率直接崩了;把隐藏层从 128 改成 512,训练时间翻倍但准确率提升却有限。我说不出任何解释,那一刻我知道自己只是在“调包”,不是在学习。

于是我立了一个规矩:每跑通一个实验,必须在本子上回答三个问题。第一,这个模型的前向计算每一步在做什么?第二,梯度是怎么从损失函数传回参数的?第三,如果某个参数变了,模型内部哪些数字会发生什么变化?只有把这三个问题写清楚,我才算真正“跑通”了。这份深度学习学习笔记的第一页,记录的就是这种从“能用”到“想懂”的转变。

1.2 笔记本的载体:手写推导配代码注释

试过几种记录方式后,我固定在一种组合上:A4 纸手写数学推导,再配合在线文档里的 Markdown 笔记,最后把关键代码段复制进笔记并加上逐行注释。手写的好处是能逼自己一步步走链式法则,电子版则方便搜索和整理,代码块能直接对照。

我见过很多同学用印象笔记截图保存别人的博客,收藏无数但从不重读。我的做法相反:所有笔记必须用“自己的话”重新写一遍,哪怕最初写得很笨拙。比如反向传播,我一开始写的是“梯度从后往前传”,后来改成“每个参数对最终损失的贡献程度,按照计算图的链式关系逐层分摊”,再后来手推了一遍才真正理解“分摊”的数学含义。这个过程比看十篇教程都有用。

2. 神经网络的底层直觉:从感知机到多层网络

2.1 感知机的几何意义:一根直线怎么变成分类器

神经网络的起点是感知机。很多人背过公式:输出 = 权重乘输入加偏置,再经过阶跃函数。但几何上,单个感知机就是在特征空间里画一条直线(或一个超平面),把空间分成两类。二维情况下,$w_1x_1 + w_2x_2 + b = 0$ 就是一条直线,直线一侧输出 1,另一侧输出 0。

这个理解非常关键,因为后续所有“深奥”的网络结构,本质上都是在做更复杂的分割。我笔记里画了一个二维平面图:红色点和蓝色点交叉分布,单个感知机无论怎么调直线都分不开,但如果先通过一层变换把坐标旋转、拉伸,再在变换后的空间里画直线,就能分开了。这就是多层网络最朴素的动机:在原始空间做不了线性分割,那就先用线性变换加非线性激活把空间扭曲一下,让数据变得线性可分。

2.2 非线性激活:没有它,多少层都白搭

如果只做线性变换,那么多层线性变换最终仍然等价于一个线性变换。因为矩阵乘法满足结合律,$W_2(W_1x + b_1) + b_2$ 完全可以合并成一个新的线性变换。显然,这样堆多少层都没有意义。非线性激活函数是打破这种“多层等价”的关键。

我笔记中对比了几种常见激活函数:

激活函数公式主要特点常见问题
Sigmoid$\sigma(x) = \frac{1}{1+e^{-x}}$输出映射到 (0,1),适合二分类输出层饱和区梯度接近 0,容易梯度消失,均值不为 0
Tanh$\tanh(x) = \frac{e^x - e^{-x}}{e^x + e^{-x}}$输出范围 (-1,1),均值更接近 0仍有饱和区,梯度消失风险
ReLU$ReLU(x) = \max(0, x)$计算快,正区间梯度恒为 1,缓解梯度消失负区间梯度为 0,可能造成神经元“死亡”
Leaky ReLU$\max(0.01x, x)$负区间保留小梯度,缓解死亡斜率超参需要调试

我的体会是:隐藏层默认用 ReLU 通常不会出大错,但要注意学习率过大容易导致大量神经元死亡。如果发现训练一段时间后网络输出的激活值大量为 0,可以考虑换成 Leaky ReLU。输出层则要根据任务选择,二分类用 Sigmoid,多分类用 Softmax,回归问题通常不用任何激活或直接用线性输出。

2.3 为什么深层网络能表达更复杂的函数

理论上有一种说法:一个足够宽的单隐藏层网络可以逼近任意连续函数,那为什么还需要深度?我学习时的理解是:宽度解决的是“能不能逼近”的问题,但要高效逼近复杂函数,深度结构能带来更好的组合性。比如一张人脸图像,浅层网络可能直接学像素到人脸的映射,而深层网络可以逐层组合:边缘 → 部件 → 面部结构。这种层次化表达让深度网络在图像、语音、文本等任务上表现更好。

当然,深度也带来优化困难。并非层数越多越好,笔记里我单独记录了一个实验:相同数据、相同迭代次数下,5 层网络比 3 层网络训练误差更高。这起初让我很困惑,后来才明白是梯度消失、初始化不当、优化器不合适等多重因素叠加的结果。所以深层网络的“好”不是无条件的,需要配套现代初始化、归一化、残差连接等技巧。

3. 反向传播的手推与代码对应:理论落地最关键的一步

3.1 一个 3 层小网络的完整前向计算

我笔记里反复推导了一个最简单的 3 层全连接网络:输入维度 2,隐藏层 3 个神经元,输出层 1 个神经元,隐藏层用 ReLU,输出层用 Sigmoid,损失用均方误差。写代码时可能几行就搭完,但手推一遍才知道每一个中间变量的形状和作用。

假设输入向量为 $x = [x_1, x_2]^T$,隐藏层权重矩阵 $W_h$ 形状是 $3 \times 2$,偏置 $b_h$ 是 $3 \times 1$,输出层权重 $W_o$ 形状是 $1 \times 3$,偏置 $b_o$ 是标量。前向过程就是:

$z_h = W_h x + b_h$

$a_h = ReLU(z_h)$

$z_o = W_o a_h + b_o$

$y_{pred} = \sigma(z_o)$

$L = \frac{1}{2}(y_{true} - y_{pred})^2$

我会拿着这段计算,在纸上写出每步的尺寸。比如 $W_h x$ 就是矩阵乘向量,结果是 3 维;加上偏置后 $z_h$ 是 3 维;ReLU 是逐元素操作;$W_o a_h$ 是 1 维。很多人写代码时出错,往往就是没盯住形状,手推一遍之后就会习惯性检查每一步的 tensor shape。

3.2 链式法则与梯度流动:从 loss 开始往前推

反向传播的核心是链式法则。还是上面那个网络,要求 $W_o$ 和 $b_o$ 的梯度,直接对 $L$ 求偏导:

$\frac{\partial L}{\partial W_o} = \frac{\partial L}{\partial y_{pred}} \cdot \frac{\partial y_{pred}}{\partial z_o} \cdot \frac{\partial z_o}{\partial W_o}$

其中 $\frac{\partial L}{\partial y_{pred}} = y_{pred} - y_{true}$,$\frac{\partial y_{pred}}{\partial z_o} = \sigma(z_o)(1 - \sigma(z_o))$,而 $\frac{\partial z_o}{\partial W_o} = a_h^T$。这里有个初学者容易搞错的地方:$\frac{\partial z_o}{\partial W_o}$ 其实是向量对矩阵求导,写起来有点别扭,所以我在笔记里直接把它理解为“局部梯度乘以流入梯度,再和输入向量做外积”。

更形象的讲法是:把损失看成一条小河,梯度就是水流。每经过一个算子,水流都可能被放大、缩小或反转。ReLU 的梯度是一个“开关”:输入为正时水流不变,输入为负时水流变成 0。Sigmoid 的梯度在输出接近 0 或 1 时几乎为 0,于是水流被彻底关掉,这就是梯度消失的直观来源。

3.3 计算图视角:为什么自动微分能偷懒

现代框架里你很少手写反向传播,因为它们用计算图自动求导。PyTorch 这种动态图机制,本质上就是在前向执行时记录每个 Tensor 是由什么操作产生的,形成一个有向无环图。反向时从最终 loss 节点出发,沿着图反向遍历,自动应用局部梯度。

我在笔记里画了一个小型计算图:$x$ → 线性变换 → ReLU → 线性变换 → Sigmoid → 损失。每个节点旁边标注前向结果和局部梯度表达式。这样看下来,自动微分只是在替你机械重复链式法则,但如果你不理解每一步在做什么,遇到梯度为 NaN、梯度不更新这类问题时就会毫无头绪。

我建议新手至少手动推导一次上面那个 3 层网络的完整反向传播,然后用 PyTorch 的autograd对比梯度值。如果两者对不上,哪怕差一点,都说明你推导或代码有误。这一步可以说是深度学习学习笔记里最值得反复修改的部分。

4. 训练过程中的“科学”与“玄学”:那些我踩过的坑

4.1 学习率:不是越小越好,也不是越大越快

我在笔记里专门列了一个“学习率事故清单”。第一次事故是学习率设为 0.5,loss 直接发散到 NaN。第二次是把学习率设为 0.0001,loss 下降缓慢,训练了好几个小时还没收敛。后来我总结出规律:学习率决定的是参数更新步长,步长太大会越过最优点甚至震荡发散,步长太小则收敛极慢。

常用做法是先用对数尺度做粗调:1e-1、1e-2、1e-3、1e-4,跑少量迭代观察 loss 曲线。如果 loss 在几轮内快速下降,说明学习率可用;如果 loss 抖动剧烈或上扬,就得调小;如果 loss 几乎不动,就调大。还有一种更省心的方式:使用带自适应学习率的优化器,比如 Adam。但 Adam 也不是万能,我在使用中发现在某些任务里 Adam 收敛后精度不如精心调过的 SGD + momentum。

4.2 权重初始化:完全相同的网络,换个初始化就崩了

第一次用全零初始化训练网络,loss 完全不下降。我查了原因:如果所有权重相同,则同一层所有神经元会计算出相同梯度,永远无法“分化”,这叫对称权重问题。所以不能用全零或全同常数初始化。

常见做法是随机初始化,但要控制方差。过大方差容易让激活值爆炸,过小方差容易让梯度消失。PyTorch 默认的初始化方式通常没有大问题,但如果自己搭网络且发现前期 loss 特别高或梯度异常,可以先用固定种子初始化,并打印每一层激活值的均值、标准差。我在笔记里记录过一个案例:某线性层的权重标准差设为 10,前向一层后激活值就全部超过 100,再经过 Sigmoid 梯度归零,整个网络几乎停训。换成 Xavier 初始化后问题立刻缓解。

4.3 过拟合的判定与抑制:从训练误差和验证误差的背离开始

过拟合不是玄学,而是有明确迹象:训练误差持续下降,验证误差却在一段时间后开始上升。我刚开始经常忽略验证集,只盯着训练 loss,结果模型在训练集上准确率接近 100%,测试集表现却很差。后来笔记里我强制要求自己每次实验都同时记录训练/验证两条曲线。

抑制过拟合的常用方法我按使用频率排序:增加数据、降低模型容量、加入权重衰减(L2 正则化)、使用 Dropout、早停、数据增强。其中 Dropout 的直觉是随机让一部分神经元失活,迫使网络不依赖某个单一特征。但要注意,Dropout 只应在训练阶段开启,推理时需要关闭或按比例缩放权重。这个细节我在第一次手动实现时搞错过,导致推理结果偏小,排查了整整一个下午。

4.4 归一化的必要性:不改结构也能显著提升训练速度

Batch Normalization(批归一化)大概是性价比最高的“玄学破解器”。我在笔记里写道:归一化的本质是让每一层的输入分布尽量稳定,避免内部协变量偏移。实际操作中,加了 BN 层之后,网络对初始化不那么敏感,可以用更大的学习率,训练速度也明显加快。

我第一次用 BN 时也犯过直觉错误:以为 BN 是对模型输入做归一化,其实它是对隐藏层中某个一层的输出(通常是线性变换之后、激活之前)做标准化,并且引入两个可学习参数缩放和平移。如果要手动实现,需要注意训练和推理时行为不同:训练时用当前 batch 的均值和方差,推理时用累计滑动平均。框架已经封装好这些细节,但你在踩坑时还是要理解它“为什么到 eval 模式后 loss 会不同”。

5. 框架实操笔记:把理论搬进代码时要注意的细节

5.1 动态图与静态图的区别:调试体验完全不同

我用 PyTorch 比较多,它采用动态图,每次前向都重新建图,这意味着你可以随意在中间打印变量、修改流向,非常利于调试。而 TensorFlow 早期用的静态图是先定义完整计算图,再传入数据执行,图结构一旦定义就难以修改,调试起来总觉得隔着一层。虽然现在两个框架都在互相靠拢,但我的笔记里明确写了一句:新手调试优先用动态图。

动态图的代价是额外开销,但在绝大多数项目里可忽略不计。如果你追求极致的性能或部署便利,再考虑静态图。我的建议是学习阶段不要被这些复杂度干扰,选一个动态图框架,把精力放在理解模型和训练过程上。

5.2 损失函数的选择:MSE、CrossEntropy 要分场景

我在写第一个图像分类模型时,习惯性地用 MSE 作为损失函数,因为觉得“回归和分类不都是把预测和真实值比较吗”。后来发现训练既慢又不稳定。原因是分类任务通常配合 Softmax 输出一个概率分布,而 MSE 对概率差异的梯度并不理想,尤其是输出接近 0 或 1 时,梯度会变得很小。

正确的组合是:分类问题用 Softmax + 交叉熵损失(CrossEntropyLoss),回归问题用 MSE。交叉熵的梯度更“直接”,它衡量的是两个概率分布之间的距离,并且配合 Softmax 时导数形式非常简洁,就是预测值减真实值(one-hot 形式下),这个性质数学上很优雅,实际训练也快很多。如果你在多分类任务中看到 loss 下不去,先检查输出层和损失函数是否匹配。

5.3 调试技巧:从 loss 到梯度检查

笔记里记了一套排障顺序:先看 loss 是否下降;如果下降,再观察训练/验证差距;如果不下降,则可能是模型结构、数据预处理、优化器配置、梯度消失等问题。

一个非常实用的工具是梯度检查:用数值梯度近似验证反向传播是否正确。做法是对某个参数施加一个极小扰动,用 (loss(x+epsilon) - loss(x-epsilon)) / 2epsilon 近似梯度,再和框架自动计算的梯度对比。如果相对误差小于 1e-6 左右,基本可以确定反向过程没问题。我第一次实现自定义网络时就靠这招抓到一个 bug:某个矩阵转置写反了,前向算出的 loss 正常,但梯度完全错误,用数值检查一眼就发现了。

另外,我习惯在训练循环里手动加一些断言:检查 loss 是否为 NaN;检查梯度范数是否异常增长;每过固定轮数打印参数范数。这些看似简单的检查能省下大量排查时间,尤其是模型规模变大以后,不可见的错误往往比明显报错更危险。

6. 学习笔记的整理方法:如何让知识形成闭环

6.1 用自己的话重述概念:终极检验标准

我笔记里最常用的一句话是“如果只能对别人讲 30 秒,你会怎么解释这个概念?”如果只能憋出“就是那个做啥啥的”,说明还没懂。比如“残差连接”,我一开始只会说“就是把输入加到输出上”,后来重述成“当深层网络容易丧失梯度信号时,残差连接提供了一条高速公路,让梯度可以跳过某些层直接回传,从而让网络更深也不容易退化”。这种重述才是真正理解。

学习深度学习很容易陷入“看懂了”的假象。看别人的推导觉得每一步都对,合上书却写不出第一行。我的解决办法是:把教程里的推导盖住,自己在纸上重推一遍,卡住的地方就是笔记里需要补充的地方。这个过程很慢,但一遍顶十遍。

6.2 定期回顾与重构:笔记不是写一次就完了

我的笔记有大量“二改”“三改”痕迹。过两周回头看之前的记录,经常发现自己当初的理解不够准确。比如我对学习率的理解,一开始记的是“大学习率快但容易发散”,后来补充了“不同优化器对学习率的敏感度不同”,再后来加上了“warmup 策略可以让大学习率更稳定”。笔记需要和知识同步迭代。

每隔一段时间,我会把分散在几节里的同一主题整合到一处。比如“梯度消失”相关的内容最初散落于激活函数、初始化、深度网络三个位置,后期统一合并成一个专题,把触发条件、危害、解决手段放在一起。这样的重构让知识形成闭环,而不是一堆碎片。

6.3 从笔记中提炼可复用实验模板

这份学习笔记给我最大的额外收益是:整理出了一个自己的实验模板。每次跑新模型,我只需要在模板基础上改动少数地方,就能快速搭建实验环境,并且自动记录关键指标。模板包括:固定随机种子、数据加载流程、模型结构、loss 曲线打印、梯度检查接口、参数量统计、训练/验证评估函数。这些代码片段都是从笔记的“代码注释”部分抽出来的,踩过的坑都被写成了注释。

比如数据预处理部分,我专门标注了“图像归一化时用的是 ImageNet 的均值和标准差,千万别直接用错;如果不是预训练模型,可以用数据自带的均值和标准差”。很多初学者直接复制预训练预处理流程,导致结果异常。笔记里这类“操作细节 + 原因说明”的价值,远高于背诵公式。

最后再分享一个小技巧:我每完成一轮笔记整理,会把当天最满意的理解讲给身边的同学听,如果能回答对方的追问,才算真正过关。深度学习的知识链路很长,笔记的价值不在于写得多漂亮,而在于它能不能在你下次翻到的时候,一秒唤回当时的思考和教训。到现在我依然保持这个习惯,只是笔记本从纸上搬到了平板里,但核心逻辑没变:先写下来,再消化成自己的话,最后让每次实验都不白做。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进