第 3 章 · 打地基

搭成网络:前向传播

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

读完这一章,你会明白

  • “层”和“网络(MLP)”是怎么由神经元搭起来的;
  • 前向传播到底在传什么、怎么一层层算下去;
  • 怎么用一个矩阵公式 z = Wx + b 概括一整层;
  • 这些权重、偏置在代码里是怎么存的,并逐行读懂真实的前向传播实现。

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

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

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

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

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

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

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

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

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

x₁ x₂ 输入层 隐藏层 y 输出层

一个 2 → 3 → 1 的小网络。每条连线都是一个权重,每个圆圈(除输入层)都有一个偏置。

深度与宽度

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

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

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

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

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

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

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

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

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

3. 边看动画边理解

下面这个动画,把一个 2 → 3 → 1 的网络画了出来。请先点 “切到前向传播”,然后反复点 “下一步”, observe 数据是怎么从输入层,逐层算到输出层的。 (反向传播的部分先不用管,那是第 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 和训练用的前向,是同一套代码。

小结

  • 把神经元并排成“层”,把层叠起来成“网络(MLP)”;连线是权重,节点是偏置 + 激活。
  • 前向传播 = 从输入开始,逐层重复“加权求和 + 激活”,直到最后一层给出预测。
  • 一整层的运算可以打包成矩阵公式 z = Wx + b,本质没变。
  • 参数存成 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 才能求导的激活)。前向时顺手存好,反向时直接取用。

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