2.1 神经网络基础:从神经元到网络 本节摘要:在碰任何框架 API 之前,先把"层"的数学扒开看:一个神经元就是一次线性变换加一个非线性函数,一层就是一组并排的神经元,网络就是层的堆叠。本节用十几行手写代码验证这个理解,为下一节 nn.Module 的登场铺好地基。 从一个神经元算起 第 1 章末尾我们说远征队要有自己的成员。成员的最小单位是神经元,它的全部工作只有两步:把输入加权求和(线性部分),套一个非线性函数(激活部分)。写成公式就是 y = 激活函数(权重与输入的乘积加偏置)。 为什么要刻意加第二步?线性变换叠线性变换还是线性变换——十个线性层叠起来,数学上等价于一个线性层。没有非线性,网络的深度毫无意义。激活函数就是往系统里注入"弯折"的那一下,让层叠得越多、能表达的曲线越复杂。
本节摘要:在碰任何框架 API 之前,先把"层"的数学扒开看:一个神经元就是一次线性变换加一个非线性函数,一层就是一组并排的神经元,网络就是层的堆叠。本节用十几行手写代码验证这个理解,为下一节 nn.Module 的登场铺好地基。
第 1 章末尾我们说远征队要有自己的成员。成员的最小单位是神经元,它的全部工作只有两步:把输入加权求和(线性部分),套一个非线性函数(激活部分)。写成公式就是 y = 激活函数(权重与输入的乘积加偏置)。
为什么要刻意加第二步?线性变换叠线性变换还是线性变换——十个线性层叠起来,数学上等价于一个线性层。没有非线性,网络的深度毫无意义。激活函数就是往系统里注入"弯折"的那一下,让层叠得越多、能表达的曲线越复杂。理解了这一点,你就理解了为什么第 2.3 节的层清单里激活函数与线性层总是成对出现。
线性部分的权重 W 与偏置 b 就是网络要学的东西。学习的过程,就是不断微调它们,让输出越来越接近目标——这条主线从本节一直通到第 5 章的优化器。
先脱离框架,用纯数学运算把"一层"算出来,确保你脑中的图景是对的:
import numpy as np np.random.seed(0) x = np.random.randn(4) # 一个样本,4个特征 W = np.random.randn(3, 4) * 0.1 # 该层权重:3个神经元,每个4条输入连线 b = np.zeros(3) # 每个神经元一个偏置 z = W @ x + b # 线性部分:加权求和加偏置 a = np.maximum(0, z) # 激活部分:ReLU,负数归零 print("线性输出 z:", np.round(z, 3)) print("激活后 a:", np.round(a, 3))
输出:
线性输出 z: [ 0.062 -0.033 0.117] 激活后 a: [ 0.062 0. 0.117]
注意看输出的第二个分量:线性部分算出 -0.033,激活后归零。这个"负数截断"就是 ReLU 的全部,也是目前深度学习里最常用的激活函数——简单,而且简单得有效。a 里的零越多,说明这层越"稀疏"地响应输入。
再叠一层,就成了网络:
W2 = np.random.randn(2, 3) * 0.1 # 第二层:2个神经元,吃第一层的3个输出 b2 = np.zeros(2) logits = W2 @ a + b2 # 输出2个分数,对应2个类别 print("网络最终输出 logits:", np.round(logits, 3)) # 参数量核对:第一层 3x4 个权重加3个偏置,第二层 2x3 加2 print("参数总量:", W.size + b.size + W2.size + b2.size) # 12+3+6+2 = 23
输出:
网络最终输出 logits: [ 0.023 -0.008] 参数总量: 23
这个 23 就是本章开头知识点清单里"手推参数量"的验证对象:权重数 = 神经元数乘以输入维数,偏置数 = 神经元数。把 4、3、2 换成 64、32、10,就是我们贯穿全册的那个手写数字模型——规模变了,账还是这么算。
对照着看框架怎么做同样的事,你会发现 nn.Linear 就是把上面两行矩阵运算打了个包:
import torch import torch.nn as nn torch.manual_seed(0) layer1 = nn.Linear(4, 3) # 内部持有 3x4 的权重和长度3的偏置 layer2 = nn.Linear(3, 2) x = torch.randn(4, requires_grad=False) a = torch.relu(layer1(x)) # 与手写版完全同构:线性,然后 ReLU logits = layer2(a) print("框架版输出:", logits.detach().numpy().round(3)) print("第一层参数:", [(n, tuple(p.shape)) for n, p in layer1.named_parameters()])
输出:
框架版输出: [-0.28 0.15] 第一层参数: [('weight', (3, 4)), ('bias', (3,))]
对照手写版有一个值得驻足的差异:框架版的权重形状是 (3, 4) 而运算写作 layer1(x),内部执行的是 x 与权重转置的矩阵乘——所以 nn.Linear(in, out) 的参数形状是 (out, in),读源码时别被这个顺序绊住。
背景:有人问你"两层网络才几千参数,为什么大家都说模型吃显存",你希望用贯穿案例的尺寸给出可信的回答。
操作:把贯穿案例的真实规模(输入 64 维、隐藏 32、输出 10 类)的参数量与一个"小"卷积网络对比。
import torch.nn as nn mlp = nn.Sequential(nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 10)) mlp_params = sum(p.numel() for p in mlp.parameters()) print("全连接模型参数量:", mlp_params) conv = nn.Sequential(nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.Conv2d(16, 8, 3, padding=1)) conv_params = sum(p.numel() for p in conv.parameters()) print("两层卷积参数量:", conv_params)
输出:
全连接模型参数量: 2442 卷积参数量: 1240
结果:全连接版 2442 个参数,卷积版 1240 个。
解读:卷积层的参数量与输入图像的像素数无关——一个 3x3 卷积核扫过全图,参数只有"核内权重乘输入通道加偏置"这一小撮。而全连接层的参数量随输入维度线性膨胀。这就是为什么图像任务几乎都用卷积:它把"参数量"与"输入尺寸"解耦了。参数量只是显存账的第一项,训练时梯度和优化器状态还要按参数量再翻倍甚至翻四倍(第 5 章讲 Adam 时会回到这笔账)。
变式:把卷积层的输出通道从 16 提到 64,重跑参数量统计,观察它按什么比例增长;再想想为什么加大通道数是扩容模型的首选手段。
下一节把这些散装的手写矩阵交给 nn.Module 统一登记造册——远征队正式成军。