从线性模型到神经网络
前面我们给模型准备过“彩色页数”这样的交互特征。原始页数和模式分开看,不容易表达“彩印每页都慢一点”;把它们相乘,线性模型就能利用这条关系。
如果特征越来越多,可能的组合也越来越多,难道要把每一种组合都手工想出来?
神经网络提供了另一条路:先把输入变成一组新的数,再用这些数预测;而这套变换本身,也从数据中学习。这一章先不急着训练大网络,用四个点看清它为什么有用。
1. 一条直线分不开的四个点
输入是两个开关 ,各取 0 或 1。恰好一个开关打开时,标签为 1;两个都关或都开时,标签为 0。这就是异或,常写作 XOR。
| 标签 | ||
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
把四个点画出来,会发现两类分别占据正方形的两条对角线。无论把一条直线怎样旋转,都不能让同类点完整落在同一边。
还可以从中点理解:两类各自的两个点,中点都是 。线性分数在中点等于两端分数的平均。如果两个正例都在分界线正侧,中点也应在正侧;两个负例却又要求同一个中点在负侧,矛盾了。
这不是学习率没调好,而是当前模型的表达方式不够。
2. 多接一层直线,为什么还不够
把两次仿射变换接起来:
将第一式代入第二式:
它仍然只是一次仿射变换。中间虽然多算了一步,却没有获得新的弯曲能力。
因此,我们在中间加一个非线性函数。最常见的例子之一是 ReLU:
负数变成零,正数原样通过。这个小小的折角,让许多直线片段组合出更复杂的形状。
图 1:激活函数改变了层与层之间的关系。tanh 的输出在 −1~1 之间,Sigmoid 在 0~1 之间;ReLU 的正半轴没有上界。
激活函数不一定用于输出层。回归通常直接输出数值;二分类可以用 Sigmoid;多分类把输出分数交给 Softmax。隐藏层与输出层承担不同工作,不必使用同一种函数。
3. 两个隐藏单元,就能把 XOR 重新表示
我们手工安排两个隐藏单元:
再计算 。四条输入会变成:
| 输入 | 新表示 | |
|---|---|---|
| (0,0) | (0,0) | 0 |
| (0,1) | (1,0) | 1 |
| (1,0) | (1,0) | 1 |
| (1,1) | (2,1) | 0 |
现在只需要判断 ,就能分开两类。原空间中难以用直线分开的点,经过非线性变换后,可以在新空间中用直线分开。
图 2:隐藏表示不必保留输入的一切信息。本例把 (0,1) 与 (1,0) 合并,因为对当前标签而言,它们不需要被区分。
如果希望输出落在 0~1,可以再计算:
12 控制曲线在阈值附近变得多陡。它与前面的权重、偏置都由我们手工设置,不是训练出来的结果。这里证明的是“这种网络可以表达这个规则”,不是“任意训练过程都能找到它”,也没有验证新数据上的概率校准。
4. 隐藏层、宽度与张量形状
一个典型的单隐藏层网络可以写成:
对每个分量应用激活函数。隐藏层并不神秘,只是输入与最终输出之间的中间计算;它的数值没有直接作为标签提供给训练程序。
若输入有 个特征,隐藏层有 个单元,输出有 个分数,采用列向量记法时:
| 对象 | 形状 |
|---|---|
| 、 | 、 |
| 、 | 、 |
对于一批按行排列的输入 ,计算会写成 ,结果是 。偏置加到每一行,通常由框架的广播机制完成。
隐藏层有多少单元,叫宽度;串联多少个可学习的层,与网络深度有关。数层数时应说明是否计入输出层,避免同一个网络被叫出不同数字。
输入与相邻层每个单元都连接的结构叫全连接网络。带有隐藏层的前馈全连接网络通常称为多层感知机(MLP);“前馈”表示这次计算按输入到输出的方向进行,没有循环状态。
本例 2→2→1 的可调参数位置共有:
即使我们恰好给某些位置设了相同数值,它们在普通全连接结构中仍是独立位置。只有明确共享同一个参数,才应按共享参数计数。
5. 把四个点送进网络
完整脚本只需要 Python 标准库:
python3 src/examples/neural_network.py
"""第 12 章:手工设置一个能表达 XOR 的网络;只用标准库。"""
from math import exp
def relu(value):
return max(0.0, value)
def sigmoid(value):
if value >= 0:
return 1/(1+exp(-value))
e = exp(value)
return e/(1+e)
def forward(x1, x2):
hidden = [relu(x1+x2), relu(x1+x2-1)]
score = 12*(hidden[0]-2*hidden[1]-0.5)
return hidden, sigmoid(score)
def main():
for x1, x2 in [(0, 0), (0, 1), (1, 0), (1, 1)]:
hidden, probability = forward(x1, x2)
print(f"输入=({x1},{x2}),隐藏表示={hidden},"
f"类别1概率={probability:.4f},预测={int(probability >= 0.5)}")
parameters = (2*2+2)+(2*1+1)
print(f"2→2→1 全连接网络的可调参数位置:{parameters}")
if __name__ == "__main__":
main()
运行结果:
输入=(0,0),隐藏表示=[0.0, 0.0],类别1概率=0.0025,预测=0
输入=(0,1),隐藏表示=[1, 0.0],类别1概率=0.9975,预测=1
输入=(1,0),隐藏表示=[1, 0.0],类别1概率=0.9975,预测=1
输入=(1,1),隐藏表示=[2, 1],类别1概率=0.0025,预测=0
2→2→1 全连接网络的可调参数位置:9
如果把两个 ReLU 去掉,试着重新检查四条输入。你会发现多接一层的计算又折回了线性关系。非线性不是装饰,它决定了这里能表达什么。
6. 从手工表示走向表示学习
刚才是我们替模型找到了 。真正训练时,通常随机初始化权重,用任务损失同时调整隐藏层与输出层。输出错了,梯度会告诉各层参数朝哪个方向改变。
这就是表示学习:中间特征并非全部由人预先写死,而是根据训练目标形成。它不保证每个隐藏单元都对应一个容易命名的概念。多个单元可以共同表示一种模式;改变参数后,相同功能也可能换一种编码方式。
更多单元和层可以扩大可表达的函数范围,但“能表达”不等于“能从有限数据中学到”。优化是否成功、数据是否足够、训练分布是否覆盖真实需求,都要分别考虑。
一个大网络甚至可能记住随机标签。训练集上表现很好,只说明它适应了这些样本;第八章关于验证、测试和泄漏的要求,并没有因为模型叫作神经网络而失效。
7. 自测与小实验
- XOR 的两个正例为什么不能与负例用一条直线分开?
- 两个没有激活函数的仿射层串联,会变成什么?
- ReLU 对 −3、0、2 的输出分别是多少?
- 本例为什么允许两个不同输入拥有相同隐藏表示?
- 输入 4 维、隐藏层 5 个单元、输出 3 个分数的全连接网络,有多少参数?
- 网络能表达正确规则,是否说明梯度训练一定能找到它?
- 把本例输出分数整体乘得更大,分类标签不变,概率与交叉熵一定也不变吗?
展开参考答案
- 两类各自的中点是同一点,而线性分数不可能要求这个中点同时处于分界线两侧。
- 仍是一次仿射变换,只是权重与偏置合并了。
- 依次为 0、0、2。
- 它们对当前分类任务有相同标签,表示只需保留与目标有关的信息;换一个任务时未必还能合并。
- 为 。
- 不说明。表达能力与优化过程是两个问题。
- 不一定。分数大小影响概率的尖锐程度,进而改变交叉熵;更自信也可能让错误代价更大。
选做:在 0~1 之间取一些非整数输入,观察模型的概率。四个角点的标签是否足以决定整个平面上唯一“正确”的分类?这个实验能帮助区分训练约束与模型在未见位置的行为。
下一章反向传播与自动微分会回答最关键的问题:一次预测经过许多层,怎样把最终误差的影响算回每一个参数?更系统的前馈网络介绍,可参阅《动手学深度学习》的多层感知机章节。