第 2 章 · 打地基

一个神经元

深度学习这台庞大的机器,是由一种极其简单的小零件重复堆叠出来的,这个零件就是 神经元(neuron)。把这一个零件看透,你就拿到了理解后面一切的钥匙—— 因为哪怕是上万亿参数的大模型,它最小的计算单元,和我们这一章拆开的这个,几乎没有区别。

读完这一章,你会明白

  • 一个神经元内部到底在算什么(其实就两步:加权求和、过激活函数);
  • “权重”和“偏置”这两个旋钮分别在调什么;
  • 为什么非要有激活函数不可,没有它会发生什么;
  • Sigmoid / ReLU / GELU 这些激活函数长什么样、各有什么脾气,并对照真实 C++ 实现。

1. 一个神经元,就是一个“打分员”

想象你是面试官,要给一个候选人打分。你手上有几条线索: 工作年限项目经验。这两条线索对你来说重要程度不一样: 也许你觉得项目经验更关键。于是你心里其实在做这样一件事:

恭喜,你刚刚已经在脑子里跑了一遍神经元。那几个“重要程度”就是 权重(weight),那个“底分”就是 偏置(bias),“加起来”就是 加权求和,“根据总分做决定”就是 激活函数(activation)

2. 神经元内部的两步运算

把上面的故事写成式子。设输入是 x1x2,神经元先做第一步:加权求和 + 偏置,得到一个中间值 z:

z = w1x1 + w2x2 + b w 是权重(每条线索的重要程度),b 是偏置(底分),z 叫“净输入 / 净激活值”

然后做第二步:把 z 丢进一个激活函数 f,得到神经元的最终输出 a:

a = f(z) a 是这个神经元的输出,也就是它对当前输入给出的“判断结果”
x₁ x₂ × w₁ × w₂ Σ + b 加权求和 f 激活函数 a

一个神经元:输入各自乘上权重 → 求和再加偏置得到 z → 过激活函数 f → 输出 a。

3. 别光看,动手拧一拧

下面是一个真正能玩的神经元。拖动滑块改变输入 x、权重 w、偏置 b, 再切换不同的激活函数,观察输出怎么变。建议你试试这几件事:

神经元实验台:中间那行式子,就是上面 z = wx + b 再过激活函数的实时计算。

4. 为什么非得有激活函数?

这是初学者最容易忽略、但极其关键的一点。假如去掉激活函数, 神经元就只剩“加权求和”这一步,也就是一个线性变换。 这时候,你把很多层这样的神经元叠起来,会发生一件令人沮丧的事:

第二层(第一层) = W2(W1x) = (W2W1)x = Wx 两层线性叠加,等价于一层线性。叠 100 层也一样,还是一条“直线”

换句话说,没有激活函数,叠再多层都白搭,整个网络的表达能力和单层一模一样, 只能学“按比例缩放”这种最简单的关系,学不会任何“拐弯”的、复杂的模式。

打个比方

线性变换就像“只会把图片整体放大缩小、平移”的工具; 激活函数则给了网络“折叠、弯曲”的能力。 正是这点非线性,让深层网络能拟合出任意复杂的形状。

5. 几种常见的激活函数

它们的差别,就在于“怎么根据总分 z 做决定”这条曲线长什么样:

输入 z 输出 f(z) -3-2-1 123 123 Sigmoid Tanh ReLU LeakyReLU GELU 阶跃(historical)

把输入 z(横轴)喂给不同激活函数,得到输出(纵轴)。注意看:Sigmoid/Tanh 两端压平饱和, ReLU/GELU 在正半轴一路向上不封顶。(LeakyReLU 的负半轴斜率为看清画成了 0.1,实际常用 0.01。)

一段小历史:从“阶跃”到平滑曲线

最早的激活函数是图里那条虚线——阶跃函数(step):输入过 0 就输出 1,否则 0, 完美对应“神经元要么点亮、要么不亮”。但它有个致命伤:在 0 处是断的、不可导, 没法做第 6 章要讲的反向传播(要求处处能求导)。于是人们换成了平滑的 Sigmoid——既非线性,又处处可导。 这也是为什么后来的激活函数几乎都是“平滑曲线”:能求导,才能学习

(上面的实验台里这些都能直接切换,建议挨个切一遍,对照这张图找找手感。)

6. 对照真实代码:激活函数是怎么实现的

在本书配套的 C++ 项目里,每个激活函数都实现两个方法:Activate(前向,把 z 变成输出) 和 DerivActivate(它的导数,留着第 6 章反向传播时用)。先看最经典的 Sigmoid:

src/deeplearning/activate/sigmoid_activate.cpp
double SigmoidActivate::Activate(const double &input) {
  return 1 / (1 + exp(-input));            1
}

double SigmoidActivate::DerivActivate(const double &output) {
  return output * (1 - output);            2
}
  1. 前向:这一行就是 Sigmoid 的全部。输入 z 很大时 exp(-z) 趋近 0,输出趋近 1;z 很小(很负)时输出趋近 0;z = 0 时正好是 0.5。所以它把任意实数“压”进了 (0, 1)。
  2. 导数:Sigmoid 有个非常漂亮的性质——它的导数可以直接用输出 a 表示,即 a(1 − a),完全不用再碰输入。这让反向传播算起来特别省事(细节见第 6 章)。

再看现在最流行的 ReLU,简单到几乎像在开玩笑:

src/deeplearning/activate/relu_activate.cpp
double ReluActivate::Activate(const double &x) {
  return x > 0 ? x : 0;                     1
}

double ReluActivate::DerivActivate(const double &output) {
  return output > 0 ? 1 : 0;               2
}
  1. 前向:正数原样放过,负数一律压成 0。就这么简单。它的好处是计算极快,而且在正区间梯度恒为 1,不会像 Sigmoid 那样“饱和”到学不动。
  2. 导数:像一个开关——输出为正时斜率是 1,否则是 0。负区间梯度为 0,正是它偶尔会把神经元“关死”的原因(于是有了 LeakyReLU 这种改良版)。

最后看 Transformer 时代的宠儿 GELU。它不像 ReLU 那样“一刀切”,而是按“x 有多大概率为正”来平滑地决定保留多少,所以训练更稳:

src/deeplearning/activate/gelu_activate.cpp
// 精确 GELU = x * 0.5 * (1 + erf(x / sqrt(2)))
double GeluActivate::Activate(const double &input) {
  return 0.5 * input * (1.0 + std::erf(input * kInvSqrt2));   1
}

double GeluActivate::DerivActivate(const double &input,
                                   const double &output) {
  double cdf = 0.5 * (1.0 + std::erf(input * kInvSqrt2));     2
  double pdf = std::exp(-0.5 * input * input) * kInvSqrt2Pi;
  return cdf + input * pdf;                                   3
}
  1. 前向:std::erf 是“误差函数”,这里用它算出 Φ(x)——也就是“标准正态分布里,取值小于 x 的概率”。直觉上,x 越大越该保留,GELU 就用这个概率去平滑地缩放 x
  2. cdf 就是上面说的 Φ(x)。
  3. 导数:GELU 的导数是 Φ(x) + x·φ(x)(φ 是正态分布的“钟形曲线”)。它处处平滑,没有 ReLU 在 0 处的那个硬拐角,这也是它训练大模型时表现更稳的原因之一。
注意一个工程细节

你可能发现 GELU 有两个 DerivActivate:一个只接收 output,一个同时接收 inputoutput。 原因是:像 Sigmoid 那样能“只用输出就算出导数”的激活很省事,但 GELU 做不到,它必须知道原始的 z(也就是 input)才能算准导数。 所以项目里前向时顺手把每一层的 z 都存了下来——这个伏笔到第 6 章会用上。

小结

  • 一个神经元只做两步:① 加权求和加偏置得到 z = Σ w·x + b;② 过激活函数 a = f(z)。
  • 权重决定每条输入“有多重要”(可正可负),偏置是一个可调的“底分 / 门槛”。
  • 没有激活函数,叠多少层都等价于一层线性变换——非线性是深度网络的命根子。
  • 不同激活函数差别只在那条曲线的形状:Sigmoid 平滑开关、ReLU 一刀切、GELU 软开关。
  • 代码里每个激活都配一个前向 Activate 和一个导数 DerivActivate,后者留给反向传播。

动手与思考

问题 1:在实验台里把 w1 调成负数,输出发生了什么?为什么?

对应输入越大,反而把总分 z 拉得越低。因为负权重表示“这条线索是反向证据”——它出现得越多,神经元越倾向于给出更低的激活值。

问题 2:如果把所有神经元的激活函数都拿掉,一个 100 层的网络还能学复杂模式吗?

不能。100 层线性变换叠加仍然等价于一层线性变换(Wx + b′),表达能力和单层一样,学不会任何非线性的、需要“拐弯”的关系。

问题 3:为什么 Sigmoid 的导数写成 output * (1 - output) 这么简洁,而 GELU 却需要 input?

因为 Sigmoid 的导数恰好能用它自己的输出 a 表示为 a(1−a);而 GELU 的导数公式 Φ(x)+x·φ(x) 里离不开原始的 x(input),光有输出反推不出来,所以必须把 z 存下来。

一个神经元只能做很有限的判断。下一章,我们把成百上千个神经元堆成“层”、再叠成“网络”, 看数据怎么一层层向前流动,最终算出预测。