第 2 章 · 打地基

一个神经元

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

读完这一章,你会明白

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

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

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

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

2. 这个结构是怎么来的?

你可能会有个很自然的疑问:凭什么是“加权求和再过一个函数”?这套结构是谁拍脑袋定的? 答案是——它不是拍脑袋,而是照着我们脑子里真实的神经元“抄”来的。这也是“神经”网络这个名字的由来。

树突 (收信号) 胞体 累加信号 轴突(传出) 超过阈值才“点火” → 下一个

一个生物神经元:树突从上游收进许多电信号,胞体把它们累加起来,一旦总量超过某个阈值就沿轴突“点火”放电,传给下一个神经元。

1943 年,McCulloch 和 Pitts 把这套生物机制抽象成了一个数学模型——这就是我们今天用的人工神经元。 对照着看,你会发现每一部分都能对上号,而那三个“为什么”也就随之有了答案:

生物神经元人工神经元它在干嘛
多根树突收信号多个输入 x1, x2, …接收上游传来的多条信息
每个突触连接强弱不同每个输入配一个权重 w有的信号被放大,有的被压弱
胞体把信号累加加权求和 Σ w·x把所有证据汇总成一个总量
有个“点火阈值偏置 b(挪动阈值)决定“多兴奋才算数”
超过阈值就点火放电激活函数 f把总量转成“要不要输出、输出多强”

左边是生物事实,右边是它的数学抽象。所谓“神经元”,就是把这一列翻译成了能算、能求导、能训练的式子。

2.1 为什么偏偏是“加权求和”?

因为它是“把多条证据汇总成一个判断”最朴素、也最好用的方式。回到上一节的面试例子:你要综合“工作年限”和“项目经验”两条线索给出一个总分。 最自然的做法就是——每条线索按它的重要程度打个折,再全部加起来。这正是加权求和 Σ w·x

它的好处不止“简单”:加权求和是线性的,而线性运算处处可导、求导还特别简单——这一点等到第 6 章训练时会变成巨大的便利 (线性部分的导数就是输入本身)。换句话说,选加权求和,一半是因为它符合直觉,另一半是因为它好训练

2.2 为什么要有权重 w?

因为不同的输入,重要程度天生就不一样,而且这个“重要程度”不该由我们手写死,而应该让模型自己从数据里学出来。 权重 w 就是每条输入的“音量旋钮”:

最关键的是:整个神经网络的“学习”,本质上就是在调这些 w(和 b)。 一个大模型的“上千亿参数”,数的就是这些 wb。没有 w,模型就没有任何“可以被调整、可以被学习”的东西了。

2.3 为什么要有偏置 b?

光有加权求和还差一口气:它被钉死在原点——所有输入都是 0 时,输出必然是 0。可现实里,“什么证据都没有”时的默认倾向不一定是 0。 偏置 b 就是那个可调的“底分 / 门槛”,让神经元能自由地整体上移或下移:

一句话记住 w 和 b 的分工

w 管“听谁的、听多少”,b 管“默认偏向哪边、门槛多高”。 两者都不是我们手动设定的,而是训练过程自动拧出来的——这也是后面几章的全部主题。

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

把上面的故事写成式子。设输入是 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。

4. 别光看,动手拧一拧

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

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

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

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

第二层(第一层) = W2(W1x + b1) + b2 = (W2W1)x + (W2b1 + b2) = Wx + b多层仿射变换叠加,仍等价于一层仿射变换

换句话说,没有激活函数,叠再多层都白搭,整个网络的表达能力和单层仿射层一样, 只能学直线 / 超平面式的边界,学不会任何“拐弯”的、复杂的模式。

打个比方

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

6. 几种常见的激活函数

它们的差别,就在于“怎么根据总分 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——既非线性,又处处可导。 这也是为什么后来的激活函数几乎都是“平滑曲线”:能求导,才能学习

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

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

在本书配套的 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 存下来。

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