神经网络与激活函数

一个神经元只做两件事:把上级送来的数字加权求和,再把结果掰弯一次。把这两步叠成层、把层叠成网,就是深度学习的全部结构。这一页把神经元、全连接层、四个激活函数和参数初始化拆到能自己手算参数量。

30″30 秒看懂神经网络

把一个神经网络想成一家汇报层级极其严格的公司:一线业务员把原始情况报上去,部门主管挑着听、加上自己的判断再报给总监,总监再报给老板,最后由老板拍板给出一个结论。整家公司只有一条铁规矩——同级之间不说话,每个人只向上一级汇报

公司里的每个人就是一个神经元(neuron)。每个人做的事只有两步:先把下属送来的信息按信任度加权,凑成一个总分;再按自己的表态风格把这个总分转成一句话报上去。第一步的结果叫内部状态值 z,第二步的结果叫激活值 a

图① 30 秒看懂:神经网络就是一家只向上一级汇报的公司
图① 30 秒看懂:神经网络就是一家只向上一级汇报的公司

这张图里有四样东西要一次认清:权重 w 是「我对这个下属说的话信几分」,偏置 b 是「我自己本来就有的一个基础判断」,激活函数 f 是「我的表态风格」,而 就是公司的一级级职级。整页后面所有内容,都是在讲这四样东西怎么设、怎么算、怎么初始化。

比喻里的角色对应的技术概念它到底是什么
一线业务员输入层 input layer数据的入口,一个特征对应一个神经元;它不做任何计算,也不含任何参数
部门主管、总监隐藏层 hidden layer真正做加工的层;隐藏层的数量就是网络的「深度」
老板拍板输出层 output layer给出最终结果;神经元个数由任务决定——回归 1 个,N 分类 N 个
对某个下属的信任度权重 w每条连线一个数;训练要调的主要就是它
主管自己的经验偏置 b每个神经元一个数,和输入无关,决定这个神经元「多容易被激活」
凑成一个总分内部状态值 z = w·x + b加权求和,纯线性运算
表态风格激活函数 f把 z 掰弯的非线性变换,网络能拟合曲线全靠它
报上去的那句话激活值 a = f(z)这才是交给下一层的东西,z 只是中间产物
同层之间不说话全连接 fully connected第 N 层每个神经元连到第 N−1 层的全部神经元,同层之间零连线
⛔ 整页只有一条铁律 少了激活函数这一步,堆多少层都等于一层。 因为线性变换套线性变换,结果仍然是一个线性变换——两层 Linear 串起来,总能找到一个矩阵一步算完,深度带来的表达能力是。激活函数是整个深度学习里唯一把「深」变成有意义的东西。这条铁律在 2.2 节会用代码逐位验证。
这个比喻的边界在哪 公司比喻管用,但有一处要提前说清:现实里的主管能自己跑去问别的部门,神经元不能。 全连接网络的信息只沿一个方向流动,同层之间没有任何连线,也不能跨级直连(后面 CNN、Transformer 会打破这条,但那是另外的结构了)。比喻里的「只向上一级汇报」是硬规则,不是习惯。

01概念:神经元、层,以及「全连接」到底连了什么

从一个神经元讲到一整张网,把术语和图里的角色一一对上

1.1 一个神经元的两步运算

人工神经网络(Artificial Neural Network,简称 ANN 或 NN)模仿的是生物神经元:树突接收若干路电信号,细胞体把电荷累积起来,达到一定电位才通过轴突发出信号。人工神经元把这件事简化成了两步,一步线性、一步非线性。

图② 一个神经元的两步:先加权求和得到 z,再非线性变换得到 a
图② 一个神经元的两步:先加权求和得到 z,再非线性变换得到 a
步骤公式叫法它在比喻里是什么
第一步z = w₁x₁ + w₂x₂ + w₃x₃ + b内部状态值(加权求和值)主管按信任度把下属的话折算成一个总分,再加上自己的经验分
第二步a = f(z)激活值按自己的表态风格,把总分转成实际报上去的那句话

两个细节要立刻钉死:

  • 权重 w 一条连线一个,偏置 b 一个神经元一个。 三个输入连进来就有三个 w,但 b 始终只有一个。这条规则是后面手算参数量的唯一依据。
  • 往下一层传的是 a,不是 z z 只是中间产物。把它们混为一谈,后面推反向传播时链条会断——反向传播恰恰要分别对 za 求梯度。
偏置 b 到底有什么用 没有 b 的话,z = w·x 这条直线被钉死在原点上:输入全 0 时输出必然是 0。加上 b 才能整体上下平移,也就是让这个神经元「更容易」或「更难」被激活。对照比喻:一个天生乐观的主管,下属没说什么好消息他也会报个正面结论——这就是偏置。

1.2 全连接:层与层之间到底怎么连

把多个神经元并排放成一层,把多层前后接起来,就是一张网络。全连接神经网络(fully connected,也叫 FC 层、稠密层)的连接规则只有一句:第 N 层的每个神经元,都和第 N−1 层的所有神经元相连。由此推出四条必须记住的性质:

01同层之间零连线

同一层的神经元互不通气,各算各的。它们之间唯一的联系是共用同一批输入。

02信息单向流动

从输入层往前走,穿过隐藏层,到输出层为止,不回头。这个过程就叫前向传播。

03上一层的输出即下一层的输入

第 N−1 层输出 3 个激活值,第 N 层每个神经元就有 3 个权重。维度必须首尾相接,接不上直接报错。

04数据始终是二维的

全连接网络吃的张量形状固定是 (batch_size, in_features),出来是 (batch_size, out_features)第 0 维永远是这一批有几条样本。

三种层的分工,以及各自有几个神经元,是搭网络时第一个要定的事:

神经元个数由谁决定有没有参数说明
输入层特征个数,没得选没有只是数据入口。20 个特征就是 20 个神经元,改不了
隐藏层自己定,是超参数层数决定「深度」,每层宽度决定「容量」;这是调参的主战场
输出层任务决定,没得选回归 1 个;二分类 1 个(配 sigmoid)或 2 个;N 分类 N 个
数层数时别把输入层数进去 习惯上说「三层网络」,指的是两个隐藏层加一个输出层,输入层不计入。原因很直白:输入层没有任何权重,不做任何运算。按「有参数的层有几个」来数,永远不会数错。

1.3 神经网络强在哪、弱在哪

它不是万灵药。清楚地知道它的代价,才知道什么时候不该用它:

维度优点代价
精度在图像、文本、语音这类高维数据上大幅领先传统方法结构化小表格上常常打不过梯度提升树
表达能力可以逼近任意非线性函数,前提是有激活函数、层够宽容量越大越容易把噪声也背下来,过拟合风险随之上升
可解释性黑箱。参数以百万计,说不清某个结论是怎么来的
成本框架与预训练模型生态成熟,起步快训练时间长、依赖算力,超参数多且需要反复调
数据量数据越多优势越明显小数据集上表现不佳,几百条样本时优先考虑传统模型

把这张表和上一页的张量放一起看,本页在整个体系里的位置就清楚了:张量解决「数据怎么装」,自动微分解决「梯度怎么来」,而神经网络解决「中间到底算什么」。三者凑齐,才能开始训练。

02原理:一层怎么算、为什么必须掰弯、参数从哪来

矩阵形式的全连接层、非线性的必要性、四个激活函数的取舍,以及初始化与参数量

2.1 一层到底在算什么

一个神经元是一次点积加一个偏置。一层有多个神经元时,把每个神经元的权重排成矩阵的一行,整层就能一次算完:

Z = X @ Wᵀ + B

符号形状含义
X(batch, in_features)这一批数据,一行一条样本
W(out_features, in_features)权重矩阵,一行是一个神经元的全部权重——这个顺序最容易记反
B(out_features,)每个神经元一个偏置,靠广播加到每一行上
Z(batch, out_features)整层的内部状态值,再逐元素过一次激活函数就是 A

PyTorch 里这一整套封装成了 nn.Linear(in_features, out_features)。它内部做的就是上面这一行,连转置都替你写好了。下面这段把两种算法摆在一起对账,误差为 0 才说明真的理解了它在算什么:

linear_layer_manual.py —— 手写矩阵版与 nn.Linear 对账
"""全连接层:手写矩阵版与 nn.Linear 版算出来必须一模一样。

一层全连接做的事只有一句:把上一层送来的一排数字,
用一个权重矩阵乘过去,再加一排偏置。
这份代码把 nn.Linear 内部那一行摊开写,两边结果对账。
"""
import torch
import torch.nn as nn


def main():
    torch.manual_seed(0)

    batch, in_features, out_features = 4, 3, 2

    # 一批数据:4 条样本,每条 3 个特征。第 0 维永远是 batch
    x = torch.randn(batch, in_features)

    layer = nn.Linear(in_features, out_features)

    # ---------------------------------------------------------- 权重的形状
    # 这里最容易记反:weight 的形状是 (out_features, in_features),
    # 也就是「每一行是一个神经元的全部权重」,有几个神经元就有几行。
    print('weight 形状', tuple(layer.weight.shape))   # (2, 3)
    print('bias   形状', tuple(layer.bias.shape))     # (2,)

    # ---------------------------------------------------------- 官方版
    out_official = layer(x)

    # ---------------------------------------------------------- 手写版
    # 因为 weight 是 (out, in),要和 (batch, in) 相乘就得先转置成 (in, out)
    out_manual = x @ layer.weight.t() + layer.bias

    print('输出形状', tuple(out_official.shape))      # (4, 2)
    print('两种算法最大误差 = %.10f'
          % (out_official - out_manual).abs().max().item())

    # ---------------------------------------------------------- 逐个神经元拆开
    # 第 0 号神经元对第 0 条样本的内部状态值,就是一次点积加偏置
    z00 = torch.dot(layer.weight[0], x[0]) + layer.bias[0]
    print('第 0 条样本在第 0 号神经元上的 z:拆开算 = %.6f,整层算 = %.6f'
          % (z00.item(), out_official[0, 0].item()))

    # ---------------------------------------------------------- 全连接的含义
    # 第 N 层的每个神经元都连到第 N-1 层的全部神经元上,
    # 所以连线条数 = in_features × out_features,再加上每个神经元一个偏置。
    print('本层连线条数 =', in_features * out_features)
    print('本层参数总量 =', in_features * out_features + out_features,
          '实测 =', sum(p.numel() for p in layer.parameters()))

    # ---------------------------------------------------------- 不带偏置的层
    no_bias = nn.Linear(in_features, out_features, bias=False)
    print('去掉偏置后的参数量 =', sum(p.numel() for p in no_bias.parameters()))
    print('去掉偏置后 bias 属性 =', no_bias.bias)      # None

    # ---------------------------------------------------------- 输入必须是二维
    # 全连接网络吃的是 (batch_size, in_features)。单条样本要先补出 batch 维,
    # 否则矩阵乘法的维度对不上。
    single = torch.randn(in_features)
    print('单条样本补维前', tuple(single.shape),
          '补维后', tuple(single.unsqueeze(0).shape))
    print('单条样本的输出', tuple(layer(single.unsqueeze(0)).shape))


if __name__ == '__main__':
    main()
weight 的形状是 (out, in),不是 (in, out) nn.Linear(3, 2)weight.shape(2, 3)。按「有几个神经元就有几行」去记,而不是按参数书写顺序去记。手写矩阵乘法时必须转置:x @ w.t()。这个点几乎每个人都会记反一次。

2.2 为什么激活函数必须是非线性的

回到铁律。假设去掉激活函数,两层网络就是:

推导式子
第一层z₁ = x · W₁ + b₁
第二层z₂ = z₁ · W₂ + b₂ = (x · W₁ + b₁) · W₂ + b₂
展开合并z₂ = x · (W₁W₂) + (b₁W₂ + b₂)
结论W = W₁W₂b = b₁W₂ + b₂,则 z₂ = x·W + b —— 又是一层线性层

叠一百层也一样:一串矩阵连乘的结果还是一个矩阵。换句话说,没有激活函数的深层网络,表达能力和一条直线完全相同,永远拟合不了弯曲的边界。下面这段代码把等效矩阵直接算出来,和两层网络的输出逐位比对:

linearity_collapse.py —— 两层线性塌缩成一层的逐位验证
"""没有激活函数,堆多少层都等于一层——这份代码把它算给你看。

两层线性层串起来:
    z1 = x @ W1ᵀ + b1
    z2 = z1 @ W2ᵀ + b2
       = x @ (W2 @ W1)ᵀ + (b1 @ W2ᵀ + b2)
等号右边仍然是「x 乘一个矩阵再加一个向量」,也就是一层线性层。
所以不加激活函数时,深度带来的表达能力是零。
"""
import torch
import torch.nn as nn


def main():
    torch.manual_seed(7)

    x = torch.randn(5, 4)            # 5 条样本,4 个特征

    l1 = nn.Linear(4, 6)             # 第一层:4 -> 6
    l2 = nn.Linear(6, 3)             # 第二层:6 -> 3

    # ---------------------------------------------------------- 不加激活函数
    two_layers = l2(l1(x))

    # 把两层合并成等效的单层:W = W2 @ W1,b = b1 @ W2ᵀ + b2
    W = l2.weight @ l1.weight                       # (3,6) @ (6,4) -> (3,4)
    b = l1.bias @ l2.weight.t() + l2.bias           # (6,) @ (6,3) -> (3,)
    one_layer = x @ W.t() + b

    print('两层线性 与 等效单层 的最大误差 = %.8f'
          % (two_layers - one_layer).abs().max().item())
    print('等效单层权重形状', tuple(W.shape), '—— 和直接写 nn.Linear(4, 3) 一样')

    # ---------------------------------------------------------- 加上激活函数
    # 中间插一个 ReLU 之后,就再也找不到一个矩阵能等效替代它了
    with_act = l2(torch.relu(l1(x)))
    print('插入 ReLU 后 与 等效单层 的最大差距 = %.6f'
          % (with_act - one_layer).abs().max().item())
    print('这个差距不为 0,说明非线性确实带来了单层做不到的表达能力')

    # ---------------------------------------------------------- 再直观一点
    # 线性函数的图像永远是直线/平面,无论叠多少层;
    # 要拟合一条弯曲的曲线,必须让每层的输出先被掰弯一次。
    t = torch.tensor([-2.0, -1.0, 0.0, 1.0, 2.0])
    print('原始输入          ', t.tolist())
    print('线性变换 3t+1     ', (3 * t + 1).tolist(), '  —— 等距点仍然等距')
    print('ReLU(3t+1)        ', torch.relu(3 * t + 1).tolist(),
          '  —— 负半边被压平,等距被打破')


if __name__ == '__main__':
    main()

插进一个 ReLU 之后,同一份对比立刻出现差距——这个差距就是「深度」值钱的地方。用比喻说:如果每级主管都只会按固定比例转述下属的话,那么中间设多少层管理岗都没有意义,老板直接听一线汇报是一样的;只有当主管会「这个不重要,不往上报」(ReLU 把负数压成 0)这类判断时,层级才产生了价值。

2.3 四个激活函数各自什么脾气

图③ 四个常见激活函数的曲线、适用位置与各自的毛病
图③ 四个常见激活函数的曲线、适用位置与各自的毛病
激活函数把值压到导数范围该用在哪要命的毛病
Sigmoid(0, 1)(0, 0.25]只用于二分类的输出层导数最大才 0.25,连乘几层就归零;输入超出 ±6 后几乎不变,信息被抹平;不以 0 为中心
Tanh(−1, 1)(0, 1]隐藏层的备选以 0 为中心、梯度比 sigmoid 大,收敛更快;但两侧导数同样趋 0,照样会梯度消失
ReLU[0, +∞)01隐藏层首选正半轴导数恒为 1,不衰减、计算还快;但负半轴导数恒为 0,落进去的神经元再也醒不过来
Softmax一组和为 1 的概率只用于多分类的输出层不是逐点函数,作用在一整行上;用了 CrossEntropyLoss 就不要再手动加它

把「导数范围」这一列单独拎出来看,就能自己推出后面那一页要讲的梯度消失:反向传播是把各层的导数连乘,sigmoid 最好的情形也只有 0.25,0.25⁵ ≈ 0.000980.25¹⁰ ≈ 9.5×10⁻⁷——五层之内就基本没梯度了。ReLU 正半轴恒为 1,连乘多少次都是 1,这才是它成为默认选择的根本原因。

activation_compare.py —— 四个激活函数的函数值、导数与连乘衰减
"""四个激活函数的数值对照:函数值、导数值,以及导数连乘之后还剩多少。

不画图,只打数字。看数字比看曲线更能说清「为什么 sigmoid 会梯度消失」。
"""
import torch


def value_table():
    """同一批输入,四个函数各自压成什么样。"""
    x = torch.tensor([-20.0, -6.0, -3.0, -1.0, 0.0, 1.0, 3.0, 6.0, 20.0])

    print('%8s %10s %10s %10s' % ('x', 'sigmoid', 'tanh', 'relu'))
    for i in range(len(x)):
        print('%8.1f %10.6f %10.6f %10.3f'
              % (x[i].item(),
                 torch.sigmoid(x[i]).item(),
                 torch.tanh(x[i]).item(),
                 torch.relu(x[i]).item()))

    # 看首尾两行:输入从 -20 变到 -6,sigmoid 的输出几乎没变化,
    # 输入差 14 的信息在这一步被抹平了,这就是「饱和区丢信息」。


def grad_table():
    """导数才是反向传播真正要连乘的东西。"""
    x = torch.linspace(-10, 10, 2001, requires_grad=True)

    # 对一批点求和后反向,x.grad 里就是逐点导数
    torch.sigmoid(x).sum().backward()
    sig_grad = x.grad.clone()

    x.grad.zero_()
    torch.tanh(x).sum().backward()
    tanh_grad = x.grad.clone()

    x.grad.zero_()
    torch.relu(x).sum().backward()
    relu_grad = x.grad.clone()

    print('sigmoid 导数最大值 = %.4f(出现在 x=0 处)' % sig_grad.max().item())
    print('tanh    导数最大值 = %.4f(出现在 x=0 处)' % tanh_grad.max().item())
    print('relu    导数最大值 = %.4f(正半轴恒为 1)' % relu_grad.max().item())
    print('relu    导数为 0 的点占比 = %.2f%%'
          % (100.0 * (relu_grad == 0).float().mean().item()))


def chain_product():
    """把导数连乘 n 次,看还剩多少——这就是层数一深就学不动的原因。"""
    print('%6s %16s %16s' % ('层数', 'sigmoid 最好情形', 'relu 正半轴'))
    for n in (1, 3, 5, 10, 20):
        # sigmoid 导数的上限是 0.25,取最好的情形连乘都是这个数
        print('%6d %16.10f %16.1f' % (n, 0.25 ** n, 1.0 ** n))
    # 0.25 的 5 次方不到千分之一,10 次方约百万分之一:
    # 传到前几层的梯度基本等于 0,参数根本不动。


def softmax_demo():
    """softmax 不是逐点函数,它把一组分数整体压成概率。"""
    scores = torch.tensor([0.2, 0.02, 0.15, 0.15, 1.3,
                           0.5, 0.06, 1.1, 0.05, 3.75])
    prob = torch.softmax(scores, dim=0)
    print('概率之和 = %.6f' % prob.sum().item())      # 恒等于 1
    print('最大概率的下标 =', torch.argmax(prob).item(),
          ',与分数最大的下标一致')

    # softmax 只关心分数之间的「差」,整体加减同一个常数结果不变
    shifted = torch.softmax(scores + 100.0, dim=0)
    print('全部加 100 之后的最大差异 = %.8f'
          % (prob - shifted).abs().max().item())
    # 这条性质也是各框架内部做数值稳定处理(减去最大值)的依据


if __name__ == '__main__':
    value_table()
    print('-' * 46)
    grad_table()
    print('-' * 46)
    chain_product()
    print('-' * 46)
    softmax_demo()
softmax 只看分数之差 给一行分数整体加上同一个常数,softmax 的结果一个字都不变(代码里验证了这一点)。所以它只关心「谁比谁高多少」,不关心绝对大小。也正因如此,只想知道预测哪一类时不必算 softmax——直接对原始分数取 argmax 就够了,softmax 不改变大小顺序。

2.4 选激活函数:一张表就够

位置任务选谁理由
隐藏层任何ReLU不衰减梯度、计算量小、还能让一部分输出为 0 带来稀疏性
隐藏层ReLU 效果不好Leaky ReLU / Tanh先怀疑神经元死亡,用 Leaky ReLU 给负半轴留条缝;tanh 也可以试,但深了仍会梯度消失
输出层二分类Sigmoid输出一个 (0,1) 的概率,配 nn.BCELoss
输出层多分类Softmax输出一组和为 1 的概率;nn.CrossEntropyLoss 时不要自己写
输出层回归什么都不加预测值可正可负、范围不限,套任何激活函数都是在人为设限
隐藏层少用 sigmoid,这不是风格问题 sigmoid 在隐藏层的问题是结构性的:导数上限 0.25,网络五层之内就会出现梯度消失,前几层的参数几乎不更新。它在二分类输出层仍然不可替代,因为那里只需要一次变换,不存在连乘。位置决定能不能用,不是这个函数本身好坏。

2.5 参数初始化:起点决定能不能学得动

权重的初始值不是随便给的。两个极端各有各的死法:

1全 0 / 全 1 / 全同一个值

同一层所有神经元收到的梯度完全相同,更新之后仍然完全相同,永远是彼此的复制品。隐藏层开再宽也等于只有一个神经元,这叫对称性没被打破

2随机值给得太大

内部状态值 z 的方差随层数逐层放大,sigmoid / tanh 被顶进两端的饱和区,导数趋 0;ReLU 则大批神经元直接死掉。

所以初始化的目标很明确:打破对称,同时让每一层输出的方差保持稳定。Xavier 和 He 就是按这个目标算出来的两个公式:

方法正态版的标准差均匀版的边界配哪种激活PyTorch 写法
Xavier(Glorot)√(2 / (fan_in + fan_out))±√(6 / (fan_in + fan_out))Sigmoid / Tanhnn.init.xavier_normal_(w) / xavier_uniform_
He(Kaiming)√(2 / fan_in)±√(6 / fan_in)ReLU 系nn.init.kaiming_normal_(w, nonlinearity='relu')
正态 / 均匀随机自己给自己给浅层网络够用nn.init.normal_(w, 0, 0.01)
全 0 / 全 1 / 固定值权重一律不用nn.init.zeros_(b) —— 偏置用全 0 没问题

fan_in 是这一层的输入神经元个数,fan_out 是输出神经元个数。两个公式的差别只在分母:ReLU 会把一半的输出砍成 0,方差平白少一半,所以 He 用 2/fan_in 把它补回来。

init_symmetry.py —— 全 0 初始化的对称性实验与初始化尺度的影响
"""全 0 初始化为什么不行:同一层的神经元会拿到一模一样的梯度。

结论不靠背,靠跑:把一层的权重全设成 0,反向一次,
打印每个神经元拿到的梯度,会发现所有行完全相同——
它们从此永远是同一个神经元的复制品,隐藏层再宽也没用。
"""
import torch
import torch.nn as nn


def all_zero():
    torch.manual_seed(0)

    layer = nn.Linear(4, 3)
    nn.init.zeros_(layer.weight)
    nn.init.zeros_(layer.bias)

    out = nn.Linear(3, 1)
    nn.init.constant_(out.weight, 0.5)     # 输出层给个非 0 值,保证梯度能传回来
    nn.init.zeros_(out.bias)

    x = torch.randn(8, 4)
    y = torch.randn(8, 1)

    loss = ((out(torch.sigmoid(layer(x))) - y) ** 2).mean()
    loss.backward()

    print('全 0 初始化,隐藏层三个神经元各自拿到的梯度:')
    for i, row in enumerate(layer.weight.grad):
        print('  神经元 %d : %s' % (i, [round(v, 8) for v in row.tolist()]))

    first = layer.weight.grad[0]
    same = all(torch.allclose(first, r) for r in layer.weight.grad)
    print('三行是否完全相同 ->', same)
    print('相同意味着:更新之后它们的权重还是相同的,永远学不出差异')


def random_init():
    torch.manual_seed(0)

    layer = nn.Linear(4, 3)
    nn.init.normal_(layer.weight, mean=0.0, std=0.1)
    nn.init.zeros_(layer.bias)             # 偏置给 0 没问题,对称已被权重打破

    out = nn.Linear(3, 1)
    nn.init.constant_(out.weight, 0.5)
    nn.init.zeros_(out.bias)

    x = torch.randn(8, 4)
    y = torch.randn(8, 1)

    loss = ((out(torch.sigmoid(layer(x))) - y) ** 2).mean()
    loss.backward()

    print('随机初始化,隐藏层三个神经元各自拿到的梯度:')
    for i, row in enumerate(layer.weight.grad):
        print('  神经元 %d : %s' % (i, [round(v, 8) for v in row.tolist()]))

    first = layer.weight.grad[0]
    same = all(torch.allclose(first, r) for r in layer.weight.grad)
    print('三行是否完全相同 ->', same, '(各走各的,才谈得上分工)')


def scale_matters():
    """初始化的大小同样要紧:太大直接把 sigmoid 顶进饱和区。"""
    x = torch.randn(256, 128)

    for std in (0.01, 0.1, 1.0, 5.0):
        w = torch.randn(64, 128) * std
        z = x @ w.t()
        a = torch.sigmoid(z)
        # 激活值挤在 0 或 1 附近的比例越高,导数越接近 0,越学不动
        saturated = ((a < 0.01) | (a > 0.99)).float().mean().item()
        print('std=%-5s  z 的标准差 %7.3f   激活值落入饱和区的比例 %6.2f%%'
              % (std, z.std().item(), saturated * 100))

    print('这就是 xavier / kaiming 存在的理由:它们按扇入扇出算出一个')
    print('刚好让各层输出方差稳定的 std,不用手工试。')


if __name__ == '__main__':
    all_zero()
    print('-' * 56)
    random_init()
    print('-' * 56)
    scale_matters()
init_params.py —— 七种初始化写法速查
"""参数初始化:七种写法,以及该挑哪一种。"""
import torch.nn as nn

linear = nn.Linear(5, 3)     # 5 个输入、3 个输出,w 的形状是 (3, 5)

# 1) 均匀分布:默认从 (0, 1) 取值
nn.init.uniform_(linear.weight)

# 2) 固定值:调试时好用,正式训练别用,全一样等于只有一个神经元
nn.init.constant_(linear.weight, 5)

# 3) 全 0:同一层所有神经元收到的梯度完全相同,永远学不出差异
nn.init.zeros_(linear.weight)

# 4) 全 1:同样对称,同样学不动
nn.init.ones_(linear.weight)

# 5) 正态分布
nn.init.normal_(linear.weight, mean=0, std=1)

# 6) kaiming(HE)初始化:方差按 2/fan_in 算,配 ReLU 系
nn.init.kaiming_normal_(linear.weight, nonlinearity='relu')
nn.init.kaiming_uniform_(linear.weight, nonlinearity='relu')

# 7) xavier(Glorot)初始化:方差按 2/(fan_in+fan_out) 算,配 sigmoid / tanh
nn.init.xavier_normal_(linear.weight)
nn.init.xavier_uniform_(linear.weight)

print(linear.weight.data)

# 选法只有一句话:
#   隐藏层用 ReLU  -> kaiming
#   隐藏层用 sigmoid / tanh -> xavier
#   偏置 b 一律 zeros_,不需要打破对称
✅ 三条就能收工 权重必须随机,偏置可以全 0(权重已经把对称打破了)· 隐藏层用 ReLU 就配 kaiming,用 sigmoid/tanh 就配 xavier · nn.Linear 本身已有一套默认初始化,浅层网络不写也能跑,写是为了在深层网络里可控。

2.6 参数量:一条公式手算到底

公式只有一条,来自 1.1 节的两个事实(一条连线一个 w,一个神经元一个 b):

某层参数量 = 该层神经元个数 × (上一层神经元个数 + 1)

3 → 3 → 2 → 2 这个结构为例:

算式参数量拆开看
隐藏层 13 × (3 + 1)129 个权重 + 3 个偏置
隐藏层 22 × (3 + 1)86 个权重 + 2 个偏置
输出层2 × (2 + 1)64 个权重 + 2 个偏置
合计26输入层不贡献任何参数
param_count.py —— 手算与实测逐层核对,含冻结参数
"""参数量:先手算,再让代码数一遍,两个数必须相同。

手算规则只有一条:
    某一层的参数量 = 该层神经元个数 × (上一层神经元个数 + 1)
括号里的 +1 是每个神经元自带的那一个偏置 b。
输入层不算参数——它只是数据入口,没有权重。
"""
import torch
import torch.nn as nn


def hand_count(sizes):
    """按 [输入, 隐藏1, 隐藏2, ..., 输出] 的神经元个数手算总参数量。"""
    total = 0
    for i in range(1, len(sizes)):
        fan_in, fan_out = sizes[i - 1], sizes[i]
        layer = fan_out * (fan_in + 1)
        print('  第 %d 层:%d 个神经元 × (%d + 1) = %d'
              % (i, fan_out, fan_in, layer))
        total += layer
    return total


def build(sizes):
    """按同一组神经元个数建出真实网络。"""
    layers = []
    for i in range(1, len(sizes)):
        layers.append(nn.Linear(sizes[i - 1], sizes[i]))
        if i < len(sizes) - 1:
            layers.append(nn.ReLU())
    return nn.Sequential(*layers)


def report(model):
    """逐层列出形状与参数量,相当于自己写的一份模型摘要。"""
    print('%-10s %-16s %10s' % ('参数名', '形状', '个数'))
    total, trainable = 0, 0
    for name, p in model.named_parameters():
        print('%-10s %-16s %10d' % (name, str(tuple(p.shape)), p.numel()))
        total += p.numel()
        if p.requires_grad:
            trainable += p.numel()
    print('合计 %d,其中可训练 %d' % (total, trainable))
    # 一个 float32 参数占 4 字节,换算成显存占用
    print('权重本身约占 %.2f KB(float32,每个参数 4 字节)' % (total * 4 / 1024))
    return total


if __name__ == '__main__':
    sizes = [3, 3, 2, 2]        # 输入 3,隐藏层 3 和 2,输出 2

    print('手算:')
    manual = hand_count(sizes)
    print('  手算合计 =', manual)

    print('\n实测:')
    model = build(sizes)
    real = report(model)

    print('\n手算 %d,实测 %d,一致 -> %s' % (manual, real, manual == real))

    # ---------------------------------------------------------- 常见误解
    # 输入数据的条数(batch_size)跟参数量毫无关系:
    # 一次喂 5 条还是 500 条,网络的权重个数一模一样。
    for batch in (5, 500):
        x = torch.randn(batch, sizes[0])
        print('batch=%3d  输出形状 %s  参数量 %d'
              % (batch, tuple(model(x).shape), sum(p.numel() for p in model.parameters())))

    # ---------------------------------------------------------- 冻结参数
    # 迁移学习里常把前面几层冻住:requires_grad=False 之后它们不再出现在
    # 可训练参数里,优化器也就不会更新它们。
    for p in model[0].parameters():
        p.requires_grad = False
    print('\n冻结第一层之后:')
    report(model)
输入数据和网络权重是两回事 一次喂 5 条样本还是 500 条,网络的参数个数一模一样batch_size 只影响这一次前向要算多少遍,不影响权重的数量。初学时把这两件事搅在一起,是理解参数量时最大的绊脚石——代码最后一段专门把它跑出来了。

03最小代码:先把一个神经元跑出来

不用任何封装,手写一次两步运算,再看四个激活函数怎么调

3.1 一个神经元:z 和 a 各打印一次

这段代码里没有 nn.Linear、没有模型类,只有三个张量和两行运算。它的唯一目的是:wbza 四个符号在屏幕上各出现一次,从此不再混淆。

neuron_min.py —— 手写一个神经元的两步运算最小可跑
"""一个神经元的两步计算:加权求和,再过激活函数。

不用 nn.Linear,全手写一遍,看清楚 w、b、z、a 到底是谁。
"""
import torch

# 一条样本,3 个特征
x = torch.tensor([1.0, 2.0, 3.0])

# 这个神经元自己的参数:每个输入一个权重,外加一个偏置
w = torch.tensor([0.2, -0.5, 0.1])
b = torch.tensor(0.4)

# 第一步:加权求和,得到内部状态值 z
z = (w * x).sum() + b          # 等价于 torch.dot(w, x) + b
print('内部状态值 z =', z.item())      # 0.2*1 + (-0.5)*2 + 0.1*3 + 0.4 = -0.1

# 第二步:非线性变换,得到激活值 a,这才是交给下一层的东西
a = torch.sigmoid(z)
print('激活值 a =', a.item())

# 一层有多个神经元时,就把每个神经元的 w 排成矩阵,一次算完
W = torch.tensor([[0.2, -0.5, 0.1],     # 神经元 1 的三个权重
                  [0.7, 0.3, -0.2]])    # 神经元 2 的三个权重
B = torch.tensor([0.4, -0.1])
Z = W @ x + B                  # (2,3) @ (3,) -> (2,)
print('整层的 z =', Z)
print('整层的 a =', torch.sigmoid(Z))

这段可以完全手算核对:w = [0.2, −0.5, 0.1]x = [1, 2, 3]b = 0.4,那么

z = 0.2×1 + (−0.5)×2 + 0.1×3 + 0.4 = 0.2 − 1.0 + 0.3 + 0.4 = −0.1

再过 sigmoid 得到一个略小于 0.5 的数(因为 z 是负的,而 sigmoid(0) = 0.5)。能自己算出 −0.1 这个数,这一页的第一步就过了。

代码最后一段把两个神经元的权重排成一个 (2, 3) 的矩阵,一次算出整层的 Z。这正是 2.1 节那条矩阵公式的手写版——一层无非就是把多个神经元的点积并起来算。

3.2 四个激活函数:函数图像与导数图像

激活函数要同时看两张图:函数图像决定它把值压成什么样,导数图像决定梯度还剩多少。后者才是判断能不能用在隐藏层的依据。

activations.py —— 四个激活函数的函数图像与导数图像最小可跑
"""四个激活函数:既画函数值,也画导数,导数才是梯度消失的证据。"""
import torch
import matplotlib.pyplot as plt

plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False


def draw(name, fn):
    """左边画函数图像,右边画导数图像。"""
    _, axes = plt.subplots(1, 2, figsize=(8, 3))

    x = torch.linspace(-20, 20, 1000)
    axes[0].plot(x, fn(x))
    axes[0].grid()
    axes[0].set_title('%s 函数图像' % name)

    # 导数图像的取法:对同一批点求和后反向传播,x.grad 里就是逐点导数
    x = torch.linspace(-20, 20, 1000, requires_grad=True)
    fn(x).sum().backward()
    axes[1].plot(x.detach(), x.grad)      # x 带梯度,画图前要 detach
    axes[1].grid()
    axes[1].set_title('%s 导数图像' % name)
    plt.show()


if __name__ == '__main__':
    draw('Sigmoid', torch.sigmoid)   # 导数最大只有 0.25,层数一深就消失
    draw('Tanh', torch.tanh)         # 导数范围 (0,1),以 0 为中心,比 sigmoid 快
    draw('ReLU', torch.relu)         # 正半轴导数恒为 1,负半轴恒为 0

    # Softmax 不是逐点函数,单独看:把一组分数压成和为 1 的概率
    scores = torch.tensor([0.2, 0.02, 0.15, 0.15, 1.3, 0.5, 0.06, 1.1, 0.05, 3.75])
    prob = torch.softmax(scores, dim=0)
    print(prob)
    print('概率之和 =', prob.sum().item())        # 1.0
    print('预测类别 =', torch.argmax(prob).item())  # 9,分数最高那一个

求导数图像的写法值得单独记一下,它用到了上一页的自动微分:

代码在干什么
x = torch.linspace(-20, 20, 1000, requires_grad=True)在 −20 到 20 之间均匀取 1000 个点,并打开梯度开关
fn(x).sum().backward().sum() 是关键backward() 只接受标量。求和之后每个点的偏导互不干扰,仍是各自的导数
x.grad这一千个点各自的导数值,直接就是导数曲线的纵坐标
x.detach()画图要的是数值本身。带梯度的张量不能直接交给绘图库,必须先脱离计算图
中文标题显示成方块怎么办 绘图库默认字体不含中文,图上会出现一排方框。开头两行 plt.rcParams['font.sans-serif'] = ['SimHei']plt.rcParams['axes.unicode_minus'] = False 就是解决这件事的:前者换成有中文的字体,后者防止负号也跟着变方块。

3.3 环境要求

✅ 跑完本页所有代码需要什么 Python 3.10 以上 + pip install torch(CPU 版足够)。画激活函数图像另需 matplotlib。是否有 GPU 不影响本页任何结论——本页所有网络都只有几十个参数。

04完整案例

搭一个三层网络、跟踪一条数据穿过全网、对比三种等价写法,再把神经元死亡复现一次

4.1 搭一个三层网络并数清参数

目标结构:3 个输入特征 → 隐藏层 1(3 个神经元,sigmoid)→ 隐藏层 2(2 个神经元,ReLU)→ 输出层(2 个神经元,softmax)。按 2.5 节的配对规则,第一层配 xavier,第二层配 kaiming。

nn.Module 搭网络,固定只写两个方法:

1__init__

定义有哪些层、各层多宽、权重怎么初始化。第一行必须是 super().__init__(),否则层注册不进去,model.parameters() 会是空的。

2forward

定义数据怎么流:先过哪层、在哪里插激活函数。model(x) 就会自动走到这里,不要手动调 model.forward(x)

build_network.py —— 三层网络的搭建、初始化与参数统计完整案例
"""搭一个三层网络并数清参数量。

结构:3 -> 3(sigmoid)-> 2(relu)-> 2(softmax)
每层的初始化方法按激活函数配对:sigmoid 配 xavier,relu 配 kaiming。
"""
import torch
import torch.nn as nn


class Model(nn.Module):
    def __init__(self):
        super().__init__()

        # 第 1 个隐藏层:3 个输入特征 -> 3 个神经元
        self.linear1 = nn.Linear(3, 3)
        nn.init.xavier_normal_(self.linear1.weight)   # 后面接 sigmoid
        nn.init.zeros_(self.linear1.bias)

        # 第 2 个隐藏层:上一层的 3 个输出 -> 2 个神经元
        self.linear2 = nn.Linear(3, 2)
        nn.init.kaiming_normal_(self.linear2.weight, nonlinearity='relu')
        nn.init.zeros_(self.linear2.bias)

        # 输出层:2 -> 2
        self.out = nn.Linear(2, 2)

    def forward(self, x):
        # 实例化后直接 model(x) 就会走到这里,不要手动调 forward
        x = torch.sigmoid(self.linear1(x))
        x = torch.relu(self.linear2(x))
        x = torch.softmax(self.out(x), dim=-1)   # dim=-1:每一行加起来为 1
        return x


if __name__ == '__main__':
    model = Model()

    # 输入是二维的 (batch_size, in_features),输出是 (batch_size, out_features)
    data = torch.randn(5, 3)
    output = model(data)
    print('输入形状', data.shape)      # torch.Size([5, 3])
    print('输出形状', output.shape)    # torch.Size([5, 2])

    # 参数量手算:每个神经元的参数 = 输入个数 + 1 个偏置
    #   linear1: 3 个神经元 × (3 + 1) = 12
    #   linear2: 2 个神经元 × (3 + 1) = 8
    #   out    : 2 个神经元 × (2 + 1) = 6
    #   合计 26
    total = sum(p.numel() for p in model.parameters())
    print('参数总量', total)           # 26

    for name, p in model.named_parameters():
        print(name, tuple(p.shape), p.numel())

这段代码的四个检查点

代码位置为什么这么写
super().__init__()让父类把层登记到模块表里。漏了它,后面 model.parameters() 拿不到任何参数,优化器等于空转,而且不报错
nn.init.xavier_normal_(self.linear1.weight)第一层后面接 sigmoid,按配对规则选 xavier;注意初始化的是 .weight,偏置单独用 zeros_
nn.init.kaiming_normal_(..., nonlinearity='relu')第二层后面接 ReLU,改用 kaiming;nonlinearity 参数告诉它按哪种激活的增益系数来算
torch.softmax(self.out(x), dim=-1)dim=-1 表示在最后一维上求和为 1,也就是每一行(每条样本)各自成为一组概率。写成 dim=0 会变成按列归一,跨样本混算,结果毫无意义

输出是什么形状,参数量是多少

喂进去 (5, 3),出来 (5, 2)批量大小原样保留,特征维被换成输出层的神经元个数。参数量按 2.6 节的公式手算是 3×4 + 2×4 + 2×3 = 26,代码里 sum(p.numel() for p in model.parameters()) 数出来的也必须是 26。

参数名形状个数它是谁
linear1.weight(3, 3)9隐藏层 1 的三个神经元,每个 3 个权重
linear1.bias(3,)3隐藏层 1 每个神经元一个偏置
linear2.weight(2, 3)6隐藏层 2 的两个神经元,每个 3 个权重
linear2.bias(2,)2
out.weight(2, 2)4输出层两个神经元,每个 2 个权重
out.bias(2,)2
想要一份排版好的结构摘要 pip install torchsummary 之后 summary(model, input_size=(3,), batch_size=5) 能打出逐层的输出形状和 Param #,以及汇总的 Total params。它的数值和上面这张手算表必须一致——对不上就是结构没按预期搭出来,比如漏了一层或宽度写错。

4.2 一条数据穿过整张网络

上一段只看了首尾形状,中间发生了什么还是黑箱。这一段把每一层的 za 全部打出来——前向传播不该是玄学,它就是六次矩阵运算

forward_trace.py —— 打印每一层的内部状态值与激活值完整案例
"""一条数据穿过整个网络:每一层的 z 和 a 全部打出来。

网络:3 -> 3(sigmoid)-> 2(relu)-> 2(softmax)
把每一步的形状和数值摊在眼前,前向传播就不再是黑箱。
"""
import torch
import torch.nn as nn


class TracedNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.linear1 = nn.Linear(3, 3)
        nn.init.xavier_normal_(self.linear1.weight)   # 后接 sigmoid,配 xavier
        nn.init.zeros_(self.linear1.bias)

        self.linear2 = nn.Linear(3, 2)
        nn.init.kaiming_normal_(self.linear2.weight, nonlinearity='relu')
        nn.init.zeros_(self.linear2.bias)

        self.out = nn.Linear(2, 2)

    def forward(self, x, trace=False):
        z1 = self.linear1(x)          # 内部状态值
        a1 = torch.sigmoid(z1)        # 激活值
        z2 = self.linear2(a1)
        a2 = torch.relu(z2)
        z3 = self.out(a2)
        a3 = torch.softmax(z3, dim=-1)

        if trace:
            for name, t in (('输入 x', x), ('z1', z1), ('a1=sigmoid(z1)', a1),
                            ('z2', z2), ('a2=relu(z2)', a2),
                            ('z3 原始分数', z3), ('a3=softmax(z3)', a3)):
                print('%-16s 形状 %-10s %s'
                      % (name, str(tuple(t.shape)),
                         [round(v, 4) for v in t.detach().flatten().tolist()]))
        return a3


def main():
    torch.manual_seed(3)
    net = TracedNet()

    # 一条样本也要补出 batch 维:全连接网络只吃二维数据
    one = torch.randn(1, 3)
    print('== 单条样本穿过网络 ==')
    prob = net(one, trace=True)
    print('概率之和 = %.6f,预测类别 = %d'
          % (prob.sum().item(), prob.argmax(dim=-1).item()))

    print('\n== 一批 5 条样本 ==')
    batch = torch.randn(5, 3)
    out = net(batch)
    print('输入形状', tuple(batch.shape), '-> 输出形状', tuple(out.shape))
    print('每一行各自加起来为 1:',
          [round(v, 6) for v in out.sum(dim=-1).tolist()])

    # 同层神经元之间没有任何连线:把 a1 的某一维改掉,
    # 只会影响下一层,不会影响 a1 的其他维度。
    print('\n== 逐层看形状变化 ==')
    print('3 个输入特征 -> 隐藏层 1 的 3 个神经元 -> 隐藏层 2 的 2 个神经元 -> 2 个输出')
    print('参数量 =', sum(p.numel() for p in net.parameters()),
          '(手算 3×4 + 2×4 + 2×3 = 26)')


if __name__ == '__main__':
    main()

跑起来能观察到三件事,正好对应比喻里的三条规矩:

观察到的现象对应的规矩
形状按 (1,3) → (1,3) → (1,2) → (1,2) 一路变换上一层的输出个数必须等于下一层的输入个数,层与层首尾相接
a1 的每个值都落在 0 到 1 之间sigmoid 把主管的表态压在 (0,1) 这个尺度上
a2 里出现了 0ReLU 把负的内部状态值砍成 0,也就是「这条不重要,不往上报」
最后一行的概率加起来是 1softmax 的定义如此,它是一整行一起算的

把单条样本换成一批 5 条之后,输出的每一行各自加起来仍然是 1,而不是整个矩阵加起来是 1——这就是 dim=-1 的实际含义。

4.3 三种写法,同一个网络

同一个结构有三种写法,参数量完全相同,区别只在可读性和灵活度:

写法怎么写什么时候用代价
逐层定义__init__ 里一层层列,forward 里手写流程需要分支、拼接、取中间结果时只能用它层多了 forward 会很长
nn.Sequential把层和激活按顺序塞进去纯粹一条直线堆下去拿不到中间层输出,做不了分支
Sequential + OrderedDict给每层起名字层多、需要按名字取层或冻结某几层写起来略啰嗦
build_three_ways.py —— 三种等价写法与参数量互相验证完整案例
"""用 nn.Module 搭网络的三种写法,结构完全等价,挑顺手的用。

同一个结构:4 -> 8(relu)-> 8(relu)-> 3
三种写法建出来的参数量必须相同,最后一段验证这一点。
"""
from collections import OrderedDict

import torch
import torch.nn as nn


# ------------------------------------------------------------ 写法一:最常用
class NetA(nn.Module):
    """__init__ 里定义层,forward 里写数据怎么流。

    层数不多、但中间要做拼接/分支/取中间结果时,只能用这种写法。
    """

    def __init__(self):
        super().__init__()                  # 必须先调父类初始化,否则层注册不上
        self.fc1 = nn.Linear(4, 8)
        self.fc2 = nn.Linear(8, 8)
        self.out = nn.Linear(8, 3)

    def forward(self, x):
        # 实例化后写 model(x) 就会走到这里,不要手动写 model.forward(x)
        x = torch.relu(self.fc1(x))
        x = torch.relu(self.fc2(x))
        return self.out(x)                  # 多分类到此为止,softmax 交给损失函数


# ------------------------------------------------------------ 写法二:Sequential
class NetB(nn.Module):
    """纯粹一条直线堆下去时,用 Sequential 省掉整个 forward 的手写。"""

    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(4, 8), nn.ReLU(),
            nn.Linear(8, 8), nn.ReLU(),
            nn.Linear(8, 3),
        )

    def forward(self, x):
        return self.net(x)


# ------------------------------------------------------------ 写法三:带名字
class NetC(nn.Module):
    """给每层起名字,打印模型和按名字取层时都方便。"""

    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(OrderedDict([
            ('fc1', nn.Linear(4, 8)),
            ('act1', nn.ReLU()),
            ('fc2', nn.Linear(8, 8)),
            ('act2', nn.ReLU()),
            ('out', nn.Linear(8, 3)),
        ]))

    def forward(self, x):
        return self.net(x)


def count(model):
    return sum(p.numel() for p in model.parameters())


if __name__ == '__main__':
    a, b, c = NetA(), NetB(), NetC()

    x = torch.randn(6, 4)
    for name, m in (('NetA', a), ('NetB', b), ('NetC', c)):
        print('%s 输出形状 %s  参数量 %d'
              % (name, tuple(m(x).shape), count(m)))

    # 手算核对:8×(4+1) + 8×(8+1) + 3×(8+1) = 40 + 72 + 27 = 139
    print('手算参数量 =', 8 * (4 + 1) + 8 * (8 + 1) + 3 * (8 + 1))

    print('\n直接打印模型,能看到层的嵌套结构:')
    print(c)

    print('\n按名字取出某一层:', c.net.fc1)
    print('遍历全部可训练参数:')
    for name, p in a.named_parameters():
        print('  %-12s %-10s %d' % (name, tuple(p.shape), p.numel()))
Sequential 里必须写 nn.ReLU(),不是 torch.relu nn.Sequential 收的是模块对象torch.relu 是个函数,塞进去会直接报错。反过来,在 forward 里写 torch.relu(x) 完全正确,因为那里是在调用函数。同一个激活函数有「模块版」和「函数版」两副面孔,放对地方就行。

4.4 把神经元死亡复现一遍

ReLU 的缺点不是纸上谈兵。只要某个神经元对所有样本的 z 都小于 0,它的激活值恒为 0、梯度恒为 0、权重永远不再更新——这个神经元就废了,而且训练日志上看不出任何异常。

制造一个必死的神经元很简单:把它的偏置调到极负。下面这段就是这么干的,并打印出每个神经元收到的梯度:

dead_relu_demo.py —— 神经元死亡的复现、LeakyReLU 修法与预防完整案例
"""神经元死亡:ReLU 的负半轴梯度为 0,一旦掉进去就再也出不来。

复现路径很简单——把某个神经元的偏置调得很负,
它对所有样本的内部状态值 z 都小于 0,
于是激活值恒为 0、梯度恒为 0、权重永远不更新,这个神经元就废了。
"""
import torch
import torch.nn as nn


def dead_neuron():
    torch.manual_seed(0)

    layer = nn.Linear(4, 3)
    with torch.no_grad():
        # 让 1 号神经元的偏置极负,制造一个必死的神经元
        layer.bias[1] = -50.0

    x = torch.randn(32, 4)
    z = layer(x)
    a = torch.relu(z)

    print('每个神经元的 z 最大值:', [round(v, 3) for v in z.max(dim=0).values.tolist()])
    print('每个神经元被激活的样本比例:',
          [round(v, 3) for v in (a > 0).float().mean(dim=0).tolist()])

    a.sum().backward()
    print('每个神经元收到的权重梯度绝对值之和:',
          [round(v, 8) for v in layer.weight.grad.abs().sum(dim=1).tolist()])
    print('1 号神经元的梯度是 0,它的权重从此纹丝不动——这就是神经元死亡')


def leaky_fix():
    """LeakyReLU 给负半轴留一个很小的斜率,梯度不再是 0。"""
    x = torch.tensor([-3.0, -1.0, 0.0, 1.0, 3.0], requires_grad=True)

    torch.relu(x).sum().backward()
    relu_grad = x.grad.clone().tolist()

    x.grad.zero_()
    nn.LeakyReLU(negative_slope=0.01)(x).sum().backward()
    leaky_grad = x.grad.clone().tolist()

    print('%8s %12s %12s' % ('x', 'ReLU 导数', 'LeakyReLU 导数'))
    for i, v in enumerate([-3.0, -1.0, 0.0, 1.0, 3.0]):
        print('%8.1f %12.2f %12.2f' % (v, relu_grad[i], leaky_grad[i]))
    print('负半轴 ReLU 是 0、LeakyReLU 是 0.01:小是小,但不为 0,还能爬回来')


def how_to_avoid():
    """真正的预防手段不是换激活函数,而是别让 z 一开始就跑偏。"""
    x = torch.randn(512, 128)

    for std in (0.01, 0.05, 0.2, 1.0):
        w = torch.randn(64, 128) * std
        b = torch.zeros(64)
        a = torch.relu(x @ w.t() + b)
        dead = (a.sum(dim=0) == 0).float().mean().item()
        print('权重 std=%-5s  整批样本都没激活过的神经元占比 %5.1f%%'
              % (std, dead * 100))

    print('把学习率压下来、用 kaiming 初始化,都是同一个目的:')
    print('别让某一步更新把 z 整体推到负半轴去。')


if __name__ == '__main__':
    dead_neuron()
    print('-' * 52)
    leaky_fix()
    print('-' * 52)
    how_to_avoid()

代码第三段更有实战价值:它扫了几档初始化尺度,统计「整批样本里一次都没被激活过的神经元占比」。这个比例才是判断网络是否健康的可观测指标——比例高,说明学习率或初始化把 z 整体推到了负半轴。

✅ 神经元死亡的三种处理顺序 先把学习率调小——大多数死亡是某一步更新迈太大造成的 · 换 kaiming 初始化,让初始的 z 分布居中 · 还不行再换 nn.LeakyReLU(0.01),给负半轴留一个小斜率,梯度不为 0 就还有机会爬回来。顺序反过来做,等于用换零件掩盖装配问题。

05骨架模板:拿去改就能用

一份只管网络结构的模板,把 TODO 填掉就能接上训练循环

4.1 的网络是为了讲清参数量而写死的三层。真实项目里层宽会反复调,所以模板把层的宽度做成参数,并且把初始化和激活函数的配对规则固化进去——这两件事一旦手工写,就一定会有一天忘记改。

model_skeleton.py —— 网络结构骨架,只改 TODO 处可复用模板
"""网络结构骨架模板:只管定义模型,训练循环见训练骨架。"""
import torch
import torch.nn as nn


class MyNet(nn.Module):
    """把 TODO 填掉即可。层与层之间的维度必须首尾相接。"""

    def __init__(self, in_features, out_features, hidden=(128, 256)):
        super().__init__()                       # 必须先调父类初始化

        # TODO: 按需要增减隐藏层;hidden 里写每层的神经元个数
        self.fc1 = nn.Linear(in_features, hidden[0])
        self.fc2 = nn.Linear(hidden[0], hidden[1])
        self.out = nn.Linear(hidden[1], out_features)

        # TODO: 隐藏层用 ReLU 就配 kaiming,用 sigmoid/tanh 就配 xavier
        for layer in (self.fc1, self.fc2):
            nn.init.kaiming_normal_(layer.weight, nonlinearity='relu')
            nn.init.zeros_(layer.bias)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = torch.relu(self.fc2(x))
        # TODO: 多分类到此为止,softmax 交给 CrossEntropyLoss;
        #       二分类才在这里补 torch.sigmoid;回归直接返回。
        return self.out(x)


if __name__ == '__main__':
    net = MyNet(in_features=20, out_features=4)
    print(net)
    print('参数量', sum(p.numel() for p in net.parameters()))
    print(net(torch.randn(8, 20)).shape)        # torch.Size([8, 4])

要改的四处

位置回归任务二分类多分类
out_features11类别数
forward 的最后一步直接返回,不加任何激活补一个 torch.sigmoid仍然直接返回,softmax 交给损失函数
hidden先从 (128, 256) 这个量级起步特征少就往小调;层宽不是越大越好,大了容易过拟合
初始化隐藏层用 ReLU 就保留 kaiming改用 sigmoid/tanh 时换成 xavier_normal_
✅ 模板替你固化的四个习惯 super().__init__() 写在第一行,层才注册得进去 · 权重按激活函数配对初始化,偏置统一 zeros_ · 层宽用参数传入而不是写死,改结构不用翻代码 · 末尾用 print(net) 和一次随机前向自检形状,结构错了当场就能发现,而不是等训练跑了半小时才报维度错误。
模板没替你做的事 没有 Dropout、没有 BatchNorm、没有残差连接,也没有训练循环。前三样属于正则化与优化范畴,会在《优化器、学习率与正则化》那一页展开;训练循环见《损失函数与反向传播》那一页的骨架。先用这份最朴素的结构把数据跑通,再逐样往上加——一次只加一样,才分得清是哪一样起了作用

自检清单:新网络跑起来之前先过一遍

检查项怎么查不查会怎样
层与层维度接得上随机造一批数据跑一次前向训练启动后才报矩阵维度错误,白等
输出形状对得上标签打印 model(x).shapey.shape形状不一致时损失函数可能不报错,广播出一个错误的损失
参数量在预期量级sum(p.numel() for p in model.parameters())层宽写错一位数,参数量差百倍,显存和时间全跑偏
输出层没有多余激活回看 forward 最后一行多分类时多加一次 softmax,损失失真、梯度变钝
初始化和激活配对__init__ 里用的是 kaiming 还是 xavier深层网络训练初期损失不动或直接 nan

06易错点汇总

按「搭建 / 激活函数 / 初始化 / 形状与参数量」四类归并,每条都给现象和修法

⚠️ 一、搭建网络

  • 忘写 super().__init__() 现象:model.parameters() 是空的,优化器创建时报 optimizer got an empty parameter list;即使侥幸跑起来,参数也一个都不更新。修法__init__ 的第一行永远是 super().__init__()
  • 手动调 model.forward(x) 现象:一般能算出结果,但绕过了 nn.Module 的 hook 机制,日后加 Dropout/BatchNorm 或用可视化工具时行为异常。修法:一律写 model(x)
  • 把层建在 forward 里。 现象:每次前向都新建一层,权重每次都是随机的,loss 永远不降。修法层在 __init__ 里建一次,forward 里只调用
  • 层与层维度接不上。 现象:mat1 and mat2 shapes cannot be multiplied (5x3 and 2x2)修法:上一层的 out_features 必须等于下一层的 in_features,改宽度时两处一起改。
  • nn.Sequential 里写了 torch.relu 现象:报错说收到的不是 Module。修法Sequential 里用模块版 nn.ReLU()forward 里用函数版 torch.relu(x)
  • 把层放进普通 Python 列表。 现象:self.layers = [nn.Linear(...), ...] 里的层不会被注册,参数拿不到、也不会跟着 .to(device) 搬走。修法:用 nn.ModuleListnn.Sequential

⚠️ 二、激活函数

  • 隐藏层全用 sigmoid。 现象:网络一深,前几层的梯度接近 0,损失下降到某个值就再也不动。原因是 sigmoid 导数上限只有 0.25,五层之内就会梯度消失修法:隐藏层默认 ReLU。
  • 输出层多加了一次 softmax。 现象:不报错,但配 nn.CrossEntropyLoss 时等于做了两次 softmax,概率被压平、梯度变钝,训练明显变慢。修法:用 CrossEntropyLoss输出层直接返回原始分数
  • 回归任务的输出层套了激活函数。 现象:预测值被硬压进 (0,1) 或 [0,+∞),真实标签一旦超出这个范围就永远学不到。修法:回归任务输出层什么都不加
  • softmaxdim 写错。 现象:不报错,但 dim=0 是按列归一,把不同样本混在一起算概率,结果毫无意义。修法:用 dim=-1,含义是「每一行各自加起来为 1」。
  • 训练中大批神经元死亡。 现象:损失早早停住,检查发现某些神经元对所有样本的激活值恒为 0。修法:先降学习率,再换 kaiming 初始化,最后才考虑 nn.LeakyReLU
  • z 当成要传给下一层的东西。 现象:代码里写成 x = self.fc1(x); x = self.fc2(x),中间漏了激活——整个网络退化成一层线性模型,不报任何错,只是永远拟合不了非线性关系。

⚠️ 三、参数初始化

  • 权重全 0 初始化。 现象:不报错,但同一层所有神经元的梯度完全相同,隐藏层再宽也等价于一个神经元,模型表现远低于预期。修法:权重必须随机;偏置全 0 没问题。
  • 初始化写在层之外。 现象:nn.init.kaiming_normal_(linear.weight) 写完之后又把 linear 重新赋值,初始化白做。修法:建层和初始化紧挨着写,中间不插别的逻辑。
  • 初始化方法和激活函数配错。 ReLU 配 xavier、sigmoid 配 kaiming 都会让各层输出的方差逐层偏移,深层网络训练初期损失不动或直接 nan修法:ReLU 系用 kaiming,sigmoid/tanh 用 xavier。
  • nn.init.constant_ 用在权重上。 调试时打印方便,正式训练时和全 0 是同一种病——所有神经元完全对称
  • 以为不写初始化就没有初始化。 nn.Linear 自带一套默认初始化,浅层网络不写也能训。显式写是为了在深层网络里把方差控制住,不是必须,但深了必须。

⚠️ 四、形状与参数量

  • weight 的形状记反。 nn.Linear(3, 2)weight.shape(2, 3),按「几个神经元就几行」记。手写矩阵乘法时必须 x @ w.t()
  • 单条样本直接喂进网络。 现象:维度少一维,矩阵乘法报错。修法x.unsqueeze(0) 补出 batch 维——全连接网络只吃 (batch, features) 的二维张量。
  • batch_size 算进参数量。 一次喂 5 条还是 500 条,参数个数完全一样。输入数据和网络权重是两回事。
  • 数层数时把输入层数进去。 输入层没有任何参数,不计入层数。按「有权重的层有几个」来数。
  • 手算参数量时漏掉偏置。 公式是 神经元数 × (上一层神经元数 + 1),括号里的 +1 就是偏置。漏了它,三层小网络就会从 26 算成 19。
  • 冻结参数只改了 requires_grad,却仍把它传给了优化器。 现象:不报错,但优化器里挂着一堆不更新的参数,某些优化器还会为它们维护状态、白占显存。修法optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr)

把现象和原因对起来

上面这些坑里,最难办的不是报错的那些,而是不报错的那些。下面这张表按「你看到的现象」反查原因,训练不对劲时从这里开始找:

你看到的现象最可能的原因怎么确认
loss 完全不动,一轮和一百轮一样参数没进优化器打印 len(list(model.parameters())),为 0 就是漏了 super().__init__()
loss 降一点就停,远不到预期梯度消失,或神经元大批死亡打印各层 weight.grad.norm(),看是不是前几层接近 0
拟合能力像线性模型漏写激活函数forward 逐行读一遍,每个隐藏层后面都该有一次非线性
加宽隐藏层完全没效果权重全 0 或全同值初始化打印 model.fc1.weight[:3],看几行是不是一模一样
训练初期就变 nan初始化尺度过大,或学习率过大先把 lr 降一个数量级;仍然 nan 就查初始化
多分类准确率卡在随机水平输出层多加了 softmax,或标签类型不对forward 最后一行,以及标签是不是 int64 的类别下标

07自测题

点击题目展开答案;这些题都能说清楚,这一页就通了

一、神经元与层
一个神经元前向时产生哪两个值?哪一个才会传给下一层?

内部状态值 z = w·x + b(加权求和的结果)和激活值 a = f(z)。传给下一层的是 az 只是中间产物。这两个名字必须分清——反向传播时要分别对 za 求梯度,混为一谈链条就推不下去。

一个有 3 个输入的神经元,有几个权重、几个偏置?

3 个权重、1 个偏置。 规则是「一条连线一个 w,一个神经元一个 b」。这条规则就是参数量公式 神经元数 × (上一层神经元数 + 1) 里那个 +1 的来源。

「全连接」到底连了什么?同一层的神经元之间有连线吗?

第 N 层的每个神经元都连到第 N−1 层的全部神经元上,所以连线条数是 in_features × out_features同一层的神经元之间没有任何连线,它们各算各的,唯一的联系是共用同一批输入。信息也只向前流动,不回头、不跨级。

三种层各自的神经元个数由谁决定?哪一层没有参数?

输入层由特征个数决定,没得选,而且不含任何参数——它只是数据入口。隐藏层的层数和宽度由你自己定,是超参数。输出层由任务决定:回归 1 个,二分类 1 个(配 sigmoid)或 2 个,N 分类 N 个。说「三层网络」时不把输入层数进去。

nn.Linear(5, 3)weight 形状是什么?为什么手写矩阵乘法要转置?

形状是 (3, 5),也就是 (out_features, in_features)一行是一个神经元的全部权重。数据的形状是 (batch, 5),要和 (3, 5) 相乘就得先把它转成 (5, 3),所以写 x @ w.t() + b

二、激活函数
为什么激活函数必须是非线性的?用公式说明。

两层线性层串起来:z₂ = (x·W₁ + b₁)·W₂ + b₂ = x·(W₁W₂) + (b₁W₂ + b₂),令 W = W₁W₂b = b₁W₂ + b₂,结果仍然是 x·W + b——一层线性层。叠多少层都一样,矩阵连乘的结果还是矩阵。所以没有非线性时,深度带来的表达能力是零,永远拟合不了弯曲的边界。

sigmoid 的导数最大是多少?由此推出它在隐藏层的什么问题?

导数范围是 (0, 0.25]最大 0.25,出现在 x=0 处。反向传播要把各层的导数连乘,0.25⁵ ≈ 0.000980.25¹⁰ ≈ 9.5×10⁻⁷,所以 sigmoid 网络五层之内就会梯度消失,靠近输入的层几乎收不到梯度。它在二分类输出层仍然好用,因为那里只做一次变换,不存在连乘。

ReLU 的优点和它的「神经元死亡」分别来自同一个性质吗?

是的,来自同一条定义:负半轴输出 0、正半轴原样输出。正半轴导数恒为 1 → 连乘不衰减,这是优点;负半轴导数恒为 0 → 一旦某神经元对所有样本的 z 都小于 0,它的梯度永远是 0、权重永远不更新,这就是神经元死亡。处理顺序是:先降学习率,再换 kaiming 初始化,最后才换 LeakyReLU

隐藏层、二分类输出层、多分类输出层、回归输出层分别该用什么激活函数?

隐藏层 ReLU;二分类输出层 sigmoid;多分类输出层 softmax(但配 nn.CrossEntropyLoss 时不要自己写,它内部已经含了);回归输出层 什么都不加——预测值可正可负、范围不限,套激活函数就是人为设限。

给一行分数整体加上 100,softmax 的结果会变吗?这说明什么?

不变。 softmax 只关心分数之间的差,不关心绝对大小。两个推论:一是各框架内部会先减去最大值做数值稳定处理,不影响结果;二是只想知道预测哪一类时不必算 softmax,直接对原始分数取 argmax 即可,因为 softmax 不改变大小顺序。

三、初始化与参数量
为什么权重不能全 0 初始化?偏置全 0 行不行?

权重全 0 时,同一层所有神经元收到的梯度完全相同,更新之后仍然相同,永远是彼此的复制品,隐藏层再宽也等价于一个神经元——这叫对称性没被打破。偏置全 0 完全没问题,因为权重已经随机,对称早就打破了。全 1、固定值初始化和全 0 是同一种病。

Xavier 和 He 分别配哪种激活函数?它们的公式差在哪?

Xavier(Glorot)配 sigmoid / tanh,正态版标准差 √(2/(fan_in+fan_out))He(Kaiming)配 ReLU 系,正态版标准差 √(2/fan_in)。差别只在分母:ReLU 会把约一半的输出砍成 0,方差平白少一半,He 用 2/fan_in 把它补回来。两者的目标是同一个——让每层输出的方差保持稳定,不逐层放大或缩小。

结构 3 → 3 → 2 → 2 一共有多少个参数?手算过程写出来。

26 个。神经元数 × (上一层神经元数 + 1):隐藏层 1 是 3×(3+1)=12,隐藏层 2 是 2×(3+1)=8,输出层是 2×(2+1)=6,合计 26。输入层不贡献参数。拆开看是 19 个权重加 7 个偏置。

把 batch_size 从 5 改成 500,参数量会变吗?

完全不变。 batch_size 只决定这一次前向要算多少遍,和权重的个数毫无关系。输出形状会从 (5, 2) 变成 (500, 2),但参数仍然是那 26 个。把输入数据和网络权重分清楚,是理解参数量的前提。

四、排错
创建优化器时报 optimizer got an empty parameter list,第一个要查什么?

__init__ 的第一行有没有写 super().__init__()。漏了它,层不会被注册进模块表,model.parameters() 就是空的。另一个同类原因是把层放进了普通 Python 列表——要用 nn.ModuleListnn.Sequential

网络有三个隐藏层,但表现和线性模型一样,最该怀疑哪里?

漏写了激活函数。 forward 里写成 x = self.fc1(x); x = self.fc2(x) 这种连着调用而中间没有非线性,整个网络就塌缩成一层线性模型——而且不报任何错。逐行读 forward,确认每个隐藏层后面都有一次非线性变换。

把隐藏层从 64 加宽到 512,效果完全没变化,可能是什么原因?

最可能是权重被全 0 或固定值初始化了:同层神经元完全对称,加宽只是复制了更多份相同的神经元,有效容量没变。确认方法是打印 model.fc1.weight[:3],看几行是不是一模一样。其次要怀疑是不是漏了激活函数,或者已经过拟合、瓶颈不在容量上。

术语表

术语含义
神经元 neuron网络的最小计算单元;只做两件事——加权求和得到 z,非线性变换得到 a
ANN / NN人工神经网络,由大量神经元按层连接而成的计算模型
权重 w每条连线一个数,表示「对这一路输入的重视程度」;训练主要在调它
偏置 b每个神经元一个数,与输入无关;决定这个神经元「多容易被激活」
内部状态值 z加权求和的结果 z = w·x + b,纯线性运算,是中间产物
激活值 aa = f(z)真正传给下一层的东西
激活函数 fz 掰弯的非线性变换;没有它,多层塌缩成一层
输入层数据入口,一个特征一个神经元;不做运算,不含任何参数
隐藏层输入层与输出层之间的层;数量决定网络的「深度」,宽度决定容量
输出层给出最终结果;神经元个数由任务决定——回归 1 个,N 分类 N 个
全连接 fully connected第 N 层每个神经元连到第 N−1 层全部神经元;同层之间零连线
前向传播数据从输入层逐层向前算到输出层的过程,单向、不回头
Sigmoid压到 (0,1),导数上限 0.25;只用于二分类输出层,隐藏层会梯度消失
Tanh压到 (−1,1),以 0 为中心,收敛比 sigmoid 快;两侧导数仍趋 0
ReLU负的取 0、正的原样;隐藏层首选,代价是负半轴梯度为 0 会造成神经元死亡
Leaky ReLU给负半轴留一个小斜率(如 0.01),梯度不为 0,用来救死掉的神经元
Softmax把一行分数变成和为 1 的概率;只用于多分类输出层,且只看分数之差
神经元死亡 dead ReLU某神经元对所有样本的 z 恒小于 0,激活值与梯度恒为 0,权重永不更新
梯度消失各层导数连乘后趋近 0,靠近输入的层几乎收不到梯度,等于没在训练
fan_in / fan_out一层的输入神经元个数 / 输出神经元个数,是初始化公式里的分母
Xavier(Glorot)初始化标准差按 √(2/(fan_in+fan_out)) 取;配 sigmoid / tanh
He(Kaiming)初始化标准差按 √(2/fan_in) 取;配 ReLU 系,补上 ReLU 砍掉的那一半方差
对称性没被打破权重全 0 或全同值导致同层神经元梯度相同、永远相同,加宽也无效
nn.Module所有网络的基类;__init__ 里定义层,forward 里定义数据怎么流
nn.Linear(in, out)一个全连接层,内部算 x @ Wᵀ + bweight 形状是 (out, in)
nn.Sequential把层按顺序串起来,省掉手写 forward;只适合无分支的直线结构
参数量神经元数 × (上一层神经元数 + 1) 逐层相加;与 batch_size 无关
✅ 一句话收束本页 神经元 = 加权求和 + 掰弯一次;层 = 多个神经元并排;网络 = 多层首尾相接。权重和偏置是要学的东西,激活函数是让「深」有意义的东西,初始化是决定它学不学得动的起点。 到这里网络的结构已经完整,还差一件事:怎么衡量它错得有多离谱、错了之后责任怎么摊回每个参数——那是下一页的内容。