第 3 章 · 打地基

搭成网络:前向传播

一个神经元能做的判断很有限。但只要把许多神经元并排成一层、再把许多层 叠起来,它们就能逐层提炼信息,拼出惊人的表达能力。 这一章我们就来看:数据是怎么从输入,一层层向前流动,最终变成预测的—— 这个过程叫前向传播(forward propagation)

读完这一章,你会明白

  • “层”和“网络(MLP)”是怎么由神经元搭起来的;
  • 前向传播到底在传什么、怎么一层层算下去;
  • 怎么用一个矩阵公式 z = Wx + b 概括一整层;
  • 这些权重、偏置在代码里是怎么存的,并逐行读懂真实的前向传播实现;
  • 该堆几层、每层几个神经元——「容量」是怎么回事,MNIST 为什么用 784→128→64→10

1. 从一个神经元,到一层,再到一张网

上一章的神经元只有一个输出。现在我们把好几个神经元并排放在一起, 让它们看同样的输入,但各自拥有不同的权重和偏置—— 于是它们会从不同角度去“看”这份输入,得到好几个不同的输出。这就是一个 层(layer)

再把一层的输出,当作下一层的输入,层层往下接,就得到了一张网络。 这种“每一层的每个神经元,都和上一层的所有神经元相连”的结构, 叫全连接网络,也叫多层感知机(MLP)

术语拆解:MLP 到底是什么鬼?

别被这三个字母吓到。MLP = Multi-Layer Perceptron = 多层感知机,拆开看: “感知机(perceptron)”就是上一章那个神经元的老名字(它能“感知”输入、给个判断); “多层”就是把它们叠成好几层。所以 MLP 就是“一堆神经元分层全连接搭成的网络”, 也就是本章画的这种图,没有任何玄机。它是最基础的网络,本书配套的 C++ 代码、MNIST 手写数字识别用的都是它。

先给你一张“网络家族地图”

后面章节会蹦出一堆网络名字,这里先一次性认个脸,以后遇到就不慌了——它们只是神经元的不同“连线方式”:

  • MLP 多层感知机最朴素:层层全连接。适合固定长度的输入(一张图、一行特征)。本书第 2–9 章的主角。
  • CNN 卷积网络擅长图像:用一个小窗口在图片上滑动、找局部花纹(边缘、纹理)。第 13 章的主角。
  • RNN 循环网络擅长序列:像接力一样一个词一个词地读,把“记忆”往后传。第 14 章会细讲它的思路和短板。
  • Transformer今天大模型的主流:靠“注意力”让每个词直接看全局,又快又强。第 16–18 章的主角。

它们的“零件”(神经元、权重、激活、损失、梯度下降)完全一样,只是搭法不同。学透 MLP,后面都是变体。

w₁₀₀ w₁₂₁ w₂₀₀ w₂₀₂ x₀ x₁ 第 0 层 · 输入 0 号 b₁₀ 1 号 b₁₁ 2 号 b₁₂ 第 1 层 · 隐藏 out₂₀ b₂₀ 第 2 层 · 输出

同一个 2 → 3 → 1 网络,这次把“零件”标出来了:每条连线是一个权重 w,每个圆圈(输入层除外)自带一个偏置 b。 约定神经元从 0 号数起:权重 w 用三位下标 = 连到第几层 / 本层第几个 / 上一层第几个——例如 w₁₀₀ 是“输入 0 → 第 1 层 0 号”那根线,w₁₂₁ 是“输入 1 → 第 1 层 2 号”;偏置 b₁₀ 是“第 1 层 0 号”的偏置。这套记法后面几章会一直用。

数一数:这张小网络有多少个参数?

别看它小,参数已经不少:输入→隐藏是 2×3 = 6 个权重 + 隐藏层 3 个偏置; 隐藏→输出是 3×1 = 3 个权重 + 输出层 1 个偏置。合计 13 个可学习参数。 真实网络里这个数字动辄百万、上亿——但每一个的角色,都和这里的某条线、某个圈一模一样。

深度与宽度

一层里神经元的个数,叫这一层的宽度;叠了多少层,叫网络的深度。 更宽,意味着每层能并行抓更多种特征;更深,意味着能把简单特征逐层组合成更抽象的特征。 “深度学习”的“深”,就是指这种多层堆叠。

2. 前向传播:数据一层层往前流

前向传播的规则极其简单,而且每一层都在重复同一件事:

  1. 拿到上一层的输出(对第一层来说,就是原始输入);
  2. 这一层的每个神经元,各自做“加权求和 + 偏置”,得到 z;
  3. 每个 z 过激活函数,得到这一层的输出;
  4. 把这层输出交给下一层,回到第 1 步,直到最后一层吐出预测。

注意第 2、3 步,正是上一章那个神经元做的两件事——只不过现在一层里有很多个神经元同时在做。 所以可以说:前向传播 = 把第 2 章那个“单个神经元的两步运算”(加权求和 + 激活),按层、按神经元,从左到右重复一遍。

用矩阵把“一整层”一次写完

一层里有很多神经元,每个都有一组权重。把它们的权重摞成一个表格(矩阵)W, 把偏置排成一列 b,那么一整层的加权求和就能浓缩成一行公式:

z = Wx + b,    a = f(z) x 是上一层输出(列向量),W 的每一行是一个神经元的全部权重,a 是这一层的输出

这就是你以后会反复见到的 z = Wx + b。它没有任何新东西, 只是把“这一层每个神经元各算各的”用矩阵语言打了个包而已。

具体到我们这个网络的第 1 层(2 个输入 → 3 个神经元):权重矩阵 W 就是 3 行 × 2 列—— 每一行是一个神经元的两个权重,每一列对应一个输入。把它乘上输入列向量、再加偏置列,就一次性得到三个神经元的 net:

W(3×2,每行一个神经元) 1.0−2.0 0.50.5 −1.01.0 × x(2×1) 0.50.8 + b(3×1) 0.1−0.20.3 = z = net(3×1) −1.000.450.60 → f out(3×1) −0.7620.4220.537 第 1 行 “1.0 −2.0” 点乘 “0.5 0.8” 再 + 0.1 = −1.00,正是 0 号神经元的 net₁₀

把“三个神经元各算各的”摞成矩阵:W每一行是一个神经元的权重,和输入列向量 x矩阵乘法、再加偏置列 b, 一步得到三个 net;逐元素过激活 f,就是这一层的输出 out。数字和下面那张逐层图完全一致——矩阵只是把它们并排写在一起

拿真实数字,把整个网络算一遍

光有公式还是抽象。我们给上面那张网络填上具体数字,让数据真正从左流到右走一遍。 设两个输入是 x₀ = 0.5x₁ = 0.8,激活函数用 tanh,权重和偏置就用下图标出的这些值。 请顺着箭头看:每个隐藏神经元先各自算出 net(加权求和 + 偏置)、再过 tanh 得到 out; 三个 out 汇进输出神经元,再算一次,就得到最终预测。

第 0 层 · 输入 第 1 层 · 隐藏(各自算 net → tanh → out) 第 2 层 · 输出 x₀ 0.5 x₁ 0.8 net₁₀ = 1.0·0.5 + (−2.0)·0.8 + 0.1 = −1.00 out₁₀ = tanh(−1.00) = −0.762 net₁₁ = 0.5·0.5 + 0.5·0.8 + (−0.2) = 0.45 out₁₁ = tanh(0.45) = 0.422 net₁₂ = (−1.0)·0.5 + 1.0·0.8 + 0.3 = 0.60 out₁₂ = tanh(0.60) = 0.537 out₂₀ −0.282 net₂₀ = 1.5·(−0.762) + (−1.0)·0.422 + 2.0·0.537 + 0.2 = −0.29 out₂₀ = tanh(−0.29) = −0.282 ← 这就是网络对 (0.5, 0.8) 的最终预测

前向传播实算:数据从左边的输入 x₀, x₁ 出发,每个隐藏神经元用自己的权重各算一份 net,过 tanh 得到 out; 三个 out 再作为输出神经元的输入,重复同样的“加权求和 + 激活”,最终吐出预测 −0.282。 换一组输入或权重,算法一字不改,只是数字变。

对着图核对一遍 net₁₀ 怎么来的

0 号隐藏神经元连着两个输入,权重是 w₁₀₀ = 1.0(来自 x₀)、w₁₀₁ = −2.0(来自 x₁),偏置 b₁₀ = 0.1。 于是 net₁₀ = w₁₀₀·x₀ + w₁₀₁·x₁ + b₁₀ = 1.0×0.5 + (−2.0)×0.8 + 0.1 = −1.00, 再过 tanh 得 −0.762。另外两个神经元看的是同一对输入,只是各自权重不同,所以结果不同——这正是“一层里的神经元从不同角度看同一份输入”。

3. 边看动画边理解

光看静态算式还不够。下面这个动画把同一个 2 → 3 → 1 网络画成可以一步步播放的样子。请先点 “切到前向传播”,然后反复点 “下一步”, 看数据是怎么从输入层、逐个神经元、一层层算到输出层的——正好和上面那张带数字的图对上号。 (反向传播的部分先不用管,那是第 6 章的内容,这里你可以只看前向。)

前向传播:亮起的节点和连线,就是当前正在参与计算的部分;数据从左流到右。

4. 这些参数在代码里是怎么存的

在配套项目里,整张网络的结构用一个数组描述,比如 layer_ = {2, 3, 1} 就表示“输入 2 个、隐藏 3 个、输出 1 个”。参数则存成嵌套数组:

src/deeplearning/neural_network.h(成员定义)
std::vector<int> layer_;                          1
// 偏置: neuron_bias_[第几层][这层第几个神经元]
std::vector<std::vector<double>> neuron_bias_;     2
// 权重: neuron_weight_[第几层][这层第几个][上一层第几个]
std::vector<std::vector<std::vector<double>>> neuron_weight_;  3
  1. layer_ 描述每一层有几个神经元,网络的整体“骨架”。
  2. neuron_bias_[l][o]:第 l 层、第 o 个神经元的偏置(就是公式里的 b)。
  3. neuron_weight_[l][o][i]:第 l 层第 o 个神经元,连到上一层i 个神经元的那条线的权重。三个下标连起来读,就是“哪一层、这层第几个、来自上层第几个”。

5. 逐行读懂前向传播

下面是真实的前向传播代码(为聚焦核心,略去了边界检查,逻辑完全一致)。 你会看到它和上面那四步规则一一对应:

src/deeplearning/neural_network.cpp · ForwardPropagationBatch(精简)
// 第 0 层 = 输入层: 把这一批样本原样放进去当作"第 0 层的输出"
for (int b = 0; b < B; b++)
  for (int j = 0; j < layer_[0]; j++)
    neuron_output_[0][b][j] = batch_data[b][j];          1

// 第 1 层到最后一层: 每层都做 "加权求和 + 激活"
for (int l = 1; l < L; l++) {
  for (int b = 0; b < B; b++) {
    const auto &in_vec = neuron_output_[l - 1][b];        2
    for (int o = 0; o < layer_[l]; o++) {
      double z = neuron_bias_[l][o];                      3
      const auto &w_row = neuron_weight_[l][o];
      for (int i = 0; i < layer_[l - 1]; i++)
        z += w_row[i] * in_vec[i];                        4
      neuron_preact_[l][b][o] = z;                        5
      neuron_output_[l][b][o] = activate_function_->Activate(z);  6
    }
  }
}
  1. 第 0 层不做任何运算,直接把样本放进 neuron_output_[0]。这样后面每一层都能统一地“读上一层的输出”。
  2. in_vec 指向上一层的输出——当前层的输入,就是上一层的输出,前向传播的精髓。
  3. 每个神经元先用自己的偏置 b 作为 z 的起点(对应公式里的 + b)。
  4. 把上一层每个输出乘对应权重,逐个累加进 z。这三层循环 l / o / i 念作:“第 l 层的第 o 个神经元,收集来自上一层第 i 个输入的贡献”——正是 z = Σ w·x + b
  5. 把激活前的 z 存进 neuron_preact_。还记得第 2 章的伏笔吗?反向传播算梯度时要用到它。
  6. z 过激活函数,得到这个神经元的输出;它马上会成为下一层的 in_vec,继续向前流。
那个多出来的 for b 是什么?

代码里比我们的“四步规则”多了一层 for (int b ...),它表示“一次同时算一批(batch)样本”。 这纯粹是为了效率——与其一个一个样本算,不如一批一起算更快。 原理和算单个样本一模一样,你完全可以先在脑子里把这层循环忽略掉。

顺便提一句:对单个样本做一次预测的 Predict,内部其实就是把这个样本包成一个“只有一条”的 batch, 跑一遍上面的前向传播,然后取最后一层的输出当作结果。所以 Predict 和训练用的前向,是同一套代码。

6. 该堆几层、每层几个神经元?

搭完网络你多半会冒出一个问题:中间到底该放几层隐藏层、每层放几个神经元? MNIST 那个例子为什么是 784 → 128 → 64 → 10,而不是三层、五层? 先说结论:你不是在「选层数」,而是在给网络定一个「容量」—— 它能记住、能表达多复杂规律的本事。层数(深度)和每层的神经元数(宽度)合在一起决定了这个容量。

容量太小,网络连训练集都学不会(欠拟合);容量太大,它会把训练集里的 噪声和偶然细节也死记下来,考新题就露馅(过拟合),而且更难训、更慢、更费显存。 所以答案不是「越多越好」,而是刚好够用——这正是第 10 章「过拟合 vs 欠拟合」要展开的主题,这里先建立直觉。

把「容量」想成书包大小

书包太小,课本装不下(欠拟合,连该学的都没学会);书包大得离谱,你会把废纸、零食 一股脑全塞进去(过拟合,把噪声也背了)。合适的书包刚好装下该带的东西。 调层数和宽度,就是在挑这个书包的大小。

容量(层数×宽度)会发生什么在数据上的表现怎么办
太小表达力不够,规律抓不住训练集、验证集都差(欠拟合)加宽 / 加层,或训练久一点
刚好抓住了真规律,没背噪声两者都不错、差距小就停在这
太大把训练集噪声也背下来训练很好、验证拉垮(过拟合),还更难训更贵减容量,或加正则

「几层、每层几个」没有唯一正确答案,只有对着数据表现来回调这一条路。

为什么不是「层数越多表达力越强」?

有个经典结论叫通用逼近定理:哪怕只有一层隐藏层,只要它足够宽,理论上就能逼近几乎任意函数。 所以「表达力不够」通常不是加层的首要理由。真正让大家把网络做「深」的原因是: 深网络能把简单特征逐层组合成复杂特征,同样的本事往往比一味加宽省得多的参数—— 这才是「深度学习」要「深」的意义。但层一多,梯度更难往回传、训练更慢,是要付代价的(第 6 章第 9 章会看到)。

回到 MNIST:为什么是 784 → 128 → 64 → 10

两头其实是被任务定死的,你并不能选:输入必须是 784(28×28 像素摊平), 输出必须是 10(0–9 十个类别)。真正由你决定的,只有中间那两层 128、64

实践里到底怎么定?

没人是拍脑袋一次定死的,老手的做法通常是这三步:

  1. 先抄。 找同类任务里公认好用的结构直接拿来改,别从零硬猜——这是最省事也最靠谱的起点。
  2. 从小往大加。 先用一个小网络跑通,再逐步加层 / 加宽,盯着验证集看收益还在不在,涨不动了就停。用哪一版,由验证集说了算(第 11 章讲的正是「拿验证集选超参数」)。
  3. 照着症状调。 训练集都学不好 → 加容量;训练好、验证差 → 减容量或加正则。对着上面那张表来回试。
比纠结层数更重要的一件事:选对结构

与其在 MLP 上死堆层数,不如先问「这是什么数据」:图像用 CNN、序列用 RNNTransformer, 往往比给全连接网络多加几层有用得多选对结构 > 盲目堆层,这也是本书后半程的主线。

小结

  • 把神经元并排成“层”,把层叠起来成“网络(MLP)”;连线是权重,节点是偏置 + 激活。
  • 前向传播 = 从输入开始,逐层重复“加权求和 + 激活”,直到最后一层给出预测。
  • 一整层的运算可以打包成矩阵公式 z = Wx + b,本质没变;W 的每一行就是一个神经元的权重。
  • 拿具体数字走一遍就一清二楚:输入逐层做“加权求和 + tanh”,数字从左流到右,最后一层的输出就是预测。
  • 参数存成 neuron_weight_[层][本层第几个][上层第几个]neuron_bias_[层][第几个]
  • 前向时顺手保存了每层的净输入 z(neuron_preact_),为反向传播做准备。
  • 层数×宽度决定网络的容量:太小欠拟合、太大过拟合,要对着验证集「刚好够用」;选对结构往往比盲目堆层更管用。

动手与思考

问题 1:layer_ = {784, 128, 64, 10} 描述的是一个什么样的网络?

输入 784 维(比如 28×28 的手写数字图片摊平),两层隐藏层分别是 128 和 64 个神经元,输出 10 维(对应 0–9 十个类别)。这正是本项目 MNIST 例子用的结构。

问题 2:前向传播里,“当前层的输入”到底是什么?

就是“上一层的输出”。代码里的 in_vec = neuron_output_[l-1][b] 表达的正是这件事;数据就这样被一层接一层地往前传递。

问题 3:为什么前向时要特地把每层的 z 存进 neuron_preact_?

因为第 6 章反向传播算梯度时,需要每个神经元“激活前的净输入 z”来计算激活函数的导数(尤其是像 GELU 这种必须用 input 才能求导的激活)。前向时顺手存好,反向时直接取用。

问题 4:有人说“隐藏层当然越多越好,层数越多网络越聪明”,哪里不对?

层数不是越多越好。层数×宽度决定网络的容量:容量给过头,网络会把训练集的噪声也背下来(过拟合),验证集反而变差,而且更难训、更慢、更费显存。而且靠通用逼近的直觉,表达力往往不是加层的首要理由。目标是对着验证集找到「刚好够用」的容量,不是越大越好。

问题 5:MNIST 用的 784 → 128 → 64 → 10 里,哪些数字是你能改的?128、64 换成 100、50 会怎样?

两头改不了:输入 784 是 28×28 像素定死的,输出 10 是 10 个类别定死的。你能调的只有中间两层的宽度 128、64(以及要不要加/减一层)。换成 100、50 通常差别很小——它们是“差不多这个量级”的经验值,不是唯一正确解;真要定用哪版,看验证集表现。

现在网络能“向前算出一个预测”了,但它八成是错的。下一章,我们要给“错”下一个精确的定义—— 损失函数,这是让网络开始“学习”的第一步。