两层就能解异或:两个隐单元一个算「或」、一个算「与非」,输出层要求两个都成立。 把它看成「换坐标」更清楚:四个点被搬到新坐标后,一条直线就能分开。 但「能表达」不等于「能训出来」:2 个隐单元 20 个种子里只学会 9 次, 8 个隐单元 20 次全学会。
上一节停在感知器撞墙的地方:一层只能画一条线,异或分不开。 这一节先证明「两层一定能分开」,把墙凿开。 证明方式是手工搭一组权重,逐个验证四行真值表。 这组权重不是训练出来的,是人想出来的——所以它只证明「存在」,不证明「能训出来」。 「存在」和「训得出来」的差别,这一节后半段会用 20 个随机种子量给你看。
一、两条线围出一块区域
一条直线切不开异或,那两条呢? 第一条线把左下角切出来,第二条线把右上角切出来,剩下的两个点自然就归另一边。 两个隐单元各画一条线,输出层再要求「两条线都说是」,就是与。 也就是:先或、再与非、最后与——三个门拼出异或。 这就是为什么两层够用。 隐层把平面切成若干块,输出层在这些块上再做一次判断。
二、手工搭一组权重
第一个隐单元算「或」,第二个隐单元算「与非」:
h1 = step( x1 + x2 - 0.5 ) # 或:至少一个 1
h2 = step( -x1 - x2 + 1.5 ) # 与非:不全是 1
y = step( h1 + h2 - 1.5 ) # 与:两个都成立
四行逐个验:
| x1 | x2 | h1 | h2 | 输出 | 期望 |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 1 | 0 | 0 |
| 0 | 1 | 1 | 1 | 1 | 1 |
| 1 | 0 | 1 | 1 | 1 | 1 |
| 1 | 1 | 1 | 0 | 0 | 0 |
| 四行全对,参数 9 个(隐层 4 个权重加 2 个阈值,输出层 2 个权重加 1 个阈值)。 | |||||
| 9 个参数就够表达一个线性不可分的函数——这就是加层的价值。 |
三、隐层在做什么:换坐标
把四个点经过隐层之后的新坐标算出来:
| 原来的点 | 隐层之后 | 类别 |
|---|---|---|
| (0,0) | (0,1) | 0 |
| (0,1) | (1,1) | 1 |
| (1,0) | (1,1) | 1 |
| (1,1) | (1,0) | 0 |
| 在新坐标里,两类不再对角交叉:一个在 (0,1),一个在 (1,0),两个正例挤在同一个格子 (1,1)。 | ||
| 这时候画一条直线就能分开。 | ||
| 所以「加一层」的本质不是「多了一次判断」,而是「换了一套坐标」。 | ||
| 在原坐标里做不到的事,在新坐标里可能一眼就能做到。 | ||
| 这也是「表示学习」这个词的来源:学到的隐层,就是一套新的表示。 |
四、能表达,但能不能训出来
上面那组权重是人想出来的。换成让梯度自己找:
| 隐单元个数 | 20 个种子里四点全对的次数 | 最好的一次 |
|---|---|---|
| 2 | 9 / 20 | 4 / 4 |
| 3 | 19 / 20 | 4 / 4 |
| 4 | 19 / 20 | 4 / 4 |
| 8 | 20 / 20 | 4 / 4 |
| 2 个隐单元:表达上够用(上一段刚证明),可 20 个种子里只学会 9 次。 | ||
| 也就是说成功率和抛硬币差不多。 | ||
| 3 个以上就稳定了,19 次、20 次。 | ||
| 多的那一个单元没有让模型「更聪明」, | ||
| 只是让损失曲面上多了几条通往低处的路——随机初始化更容易落进去。 | ||
| 这个现象今天叫「过参数化有利于优化」,仍然是个活跃的研究话题。 | ||
| 它说明深度学习的很多技巧,本质上是在跟优化难度打交道,不是在跟表达能力打交道。 |
五、多层带来的第二件事:组合
两层能做异或,三层能做的事更多。 因为每一层都在上一层的输出上再换一次坐标,能表达的函数复杂度是逐层放大的。 但要注意:这个放大是有前提的——每层之间必须有非线性。 两层线性变换的复合还是线性变换,矩阵乘矩阵还是矩阵。 没有激活函数,一百层也等于一层。 这是理解「深度」的关键,也是第 1 章反复强调的那句话。 下一节讲反向传播,会看到非线性除了提供表达力,还带来一个副作用:梯度会衰减。
六、动手 Demo:两个隐单元,手工解异或
这段代码先手工搭一个 2-2-1 的网络,把异或的四行逐个验一遍,再把隐层之后的坐标打出来; 最后用梯度训练,测不同隐单元个数下的成功率。 重点看三处。第一段四行全对;第二段四个点被搬到了哪些格子; 第三段 9/20、19/20、19/20、20/20 这四行。
# -*- coding: utf-8 -*-
import unicodedata
import numpy as np
def wd(s):
return sum(2 if unicodedata.east_asian_width(c) in ('W', 'F') else 1 for c in s)
def pad(s, n):
return s + ' ' * max(0, n - wd(s))
def step(z):
return (z > 0.0).astype(float)
def two_layer(W1, b1, W2, b2, X):
"""两层感知器:隐层过阶跃,输出层也过阶跃。"""
return step(step(X @ W1 + b1) @ W2 + b2)
X = np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]])
y = np.array([[0.0], [1.0], [1.0], [0.0]])
print('[1] 手工搭一个 2-2-1 的感知器,用两个隐单元把四行真值表分开')
print(' 第一个隐单元算「或」,第二个算「与非」,输出层要求两个都为 1')
W1 = np.array([[1.0, -1.0], [1.0, -1.0]])
b1 = np.array([-0.5, 1.5])
W2 = np.array([[1.0], [1.0]])
b2 = np.array([-1.5])
print(' 隐层权重 W1 = [[1, -1], [1, -1]],阈值 = (0.5, -1.5)')
print(' 输出层权重 W2 = [[1], [1]],阈值 = 1.5')
print('')
print(' x1 x2 隐层1 隐层2 输出 期望')
H = step(X @ W1 + b1)
out = two_layer(W1, b1, W2, b2, X)
for i in range(4):
print(' %d %d %.0f %.0f %.0f %.0f'
% (X[i, 0], X[i, 1], H[i, 0], H[i, 1], out[i, 0], y[i, 0]))
print('')
print('[2] 换成坐标看:隐层把四个点搬到了哪里')
for i in range(4):
print(' (%d,%d) -> (%.0f,%.0f)' % (X[i, 0], X[i, 1], H[i, 0], H[i, 1]))
print(' 原来在平面上交叉的两类,搬到新坐标后不再是交叉的')
print(' 新坐标里只有四个格子:(0,0)、(0,1)、(1,0)、(1,1)')
print(' 其中一个格子放的是负例,另外两个格子放正例,第三个格子是空的')
print(' 在这种坐标里画一条直线就能分开——这就是「换坐标再画直线」')
print('')
print('[3] 那能不能不手工设计,让梯度自己找出来')
rg = np.random.default_rng(0)
def train_xor(nh, seed, lr=0.5, epochs=4000):
"""2-nh-1,隐层 tanh,输出 sigmoid,交叉熵。"""
r = np.random.default_rng(seed)
W1 = r.normal(0.0, 1.0, (2, nh))
b1 = np.zeros(nh)
W2 = r.normal(0.0, 1.0, (nh, 1))
b2 = np.zeros(1)
for _ in range(epochs):
h = np.tanh(X @ W1 + b1)
o = 1.0 / (1.0 + np.exp(-(h @ W2 + b2)))
d2 = (o - y) / 4.0
gW2 = h.T @ d2
gb2 = d2.sum(axis=0)
d1 = (d2 @ W2.T) * (1.0 - h * h)
gW1 = X.T @ d1
gb1 = d1.sum(axis=0)
W1 -= lr * gW1
b1 -= lr * gb1
W2 -= lr * gW2
b2 -= lr * gb2
h = np.tanh(X @ W1 + b1)
o = 1.0 / (1.0 + np.exp(-(h @ W2 + b2)))
return int(np.sum((o > 0.5).astype(float) == y)), float(np.mean((o - y) ** 2))
print(' 隐单元个数 20 个种子里学会的 最好一次的四点全对')
for nh in (2, 3, 4, 8):
good = 0
best = 0
for s in range(20):
n_ok, mse = train_xor(nh, s)
good += n_ok == 4
best = max(best, n_ok)
print(' ' + pad('%d' % nh, 13) + pad('%d / 20' % good, 20)
+ '%d / 4' % best)
print('')
print('[4] 结论:2 个隐单元在表达上是够的,但在优化上很难碰到')
print(' 第 [1] 段已经证明「存在」一组 2 个隐单元的权重能解异或')
print(' 第 [3] 段说明「找得到」是另一回事——种子不好就卡在局部解里')
print(' 3 个以上隐单元就宽松多了,因为解的空间变大,随机初始化更容易落进去')
实跑输出:
--------------------------------------------------------------------
多层感知器:两个隐单元,手工解异或
--------------------------------------------------------------------
[1] 手工搭一个 2-2-1 的感知器,用两个隐单元把四行真值表分开
第一个隐单元算「或」,第二个算「与非」,输出层要求两个都为 1
隐层权重 W1 = [[1, -1], [1, -1]],阈值 = (0.5, -1.5)
输出层权重 W2 = [[1], [1]],阈值 = 1.5
x1 x2 隐层1 隐层2 输出 期望
0 0 0 1 0 0
0 1 1 1 1 1
1 0 1 1 1 1
1 1 1 0 0 0
[2] 换成坐标看:隐层把四个点搬到了哪里
(0,0) -> (0,1)
(0,1) -> (1,1)
(1,0) -> (1,1)
(1,1) -> (1,0)
原来在平面上交叉的两类,搬到新坐标后不再是交叉的
新坐标里只有四个格子:(0,0)、(0,1)、(1,0)、(1,1)
其中一个格子放的是负例,另外两个格子放正例,第三个格子是空的
在这种坐标里画一条直线就能分开——这就是「换坐标再画直线」
[3] 那能不能不手工设计,让梯度自己找出来
隐单元个数 20 个种子里学会的 最好一次的四点全对
2 9 / 20 4 / 4
3 19 / 20 4 / 4
4 19 / 20 4 / 4
8 20 / 20 4 / 4
[4] 结论:2 个隐单元在表达上是够的,但在优化上很难碰到
第 [1] 段已经证明「存在」一组 2 个隐单元的权重能解异或
第 [3] 段说明「找得到」是另一回事——种子不好就卡在局部解里
3 个以上隐单元就宽松多了,因为解的空间变大,随机初始化更容易落进去
第一段的权重是人挑的,所以这一段证明的是「存在」。
四行全对就说明:确实有一组 9 个参数的解,问题不在表达力。
第二段是这一节的核心。
看那几个新坐标:(0,1)、(1,1)、(1,1)、(1,0)。
两个正例合并到同一个格子,两个负例分居两侧——原来的对角交叉消失了。
第三段那张表要连着读。
2 个隐单元只有 9/20,不是代码写错了,是优化本身难。
异或的损失曲面有几个局部极小,初始化不好就出不来。
多加一个单元,解的空间变大,落进去的概率就上去了。
注意第三段的「最好的一次」都是 4/4,包括 2 个隐单元那一行。
它说明失败的那些种子并不是「差一点」,而是彻底卡住了——四个点只对两个,那是最差的局部解。
代码里 train_xor 每跑完就重算一次输出,没有再动权重。
这种「训练和评估分开」的写法在小 Demo 里容易省掉,一旦省掉,评估时又更新了一次权重,
结果就会虚高。
30 秒实验
- 把
train_xor的lr从 0.5 改成 0.05,重跑。2 个隐单元那一行会更惨,可能掉到 3/20 以下。 学习率小,卡在局部解之后更不容易弹出来。 - 把隐层激活从
np.tanh改成np.maximum(·, 0)(ReLU),重跑。 2 个隐单元那一行会更不稳定,甚至一个都学不会。 ReLU 有死单元问题,两个单元都死掉就彻底没救了。 - 把隐单元个数扫到 16、32、64,重跑。成功率不会一直涨,会在 20/20 处饱和。 异或只有四个样本,容量过剩之后没有额外好处。
常见误区
- 「多层感知器就是多个感知器并联」。 并联是「同一层多个单元」,串联才是「多层」。 区别在于串联之后中间那层不可见,它的目标没有标签——这正是多层难训的原因。
- 「两个隐单元能表达异或,所以两层就够了」。 表达上够,优化上不够:20 个种子只成功 9 次。 实际做模型时,「够用」要按「能不能稳定训出来」算,不是按「存不存在解」算。
- 「加了隐层,准确率自然就高」。 隐层带来的是表达力,同时也带来优化难度和非凸的损失曲面。 不加配套手段(初始化、激活函数、学习率),两层网络经常比线性模型还差。
- 「隐层学到的特征人能看懂」。 在这个二维例子里能看懂,因为隐层就是两条线。 真实网络里隐层的几百个维度,大部分找不到人能命名的含义——这是「可解释性」问题的起点。
自测三问(点开对答案)
1. 两层网络是怎么用两条线分开异或的? —— 一个隐单元算「或」,一个算「与非」,输出层再要求两个都成立。 几何上就是两条直线围出一块区域,把两个正例圈在里面。9 个参数就够,实测四行全对。 2. 为什么说隐层在做「换坐标」? —— 隐层把每个输入点映射成一个新的坐标向量。 异或的四个点在原坐标里对角交叉,搬到新坐标后,两个正例落到同一个格子,两个负例分居两侧, 这时一条直线就能分开。学到的隐层,就是一套新的表示。 3. 2 个隐单元就能表达异或,为什么 20 个种子只学会 9 次? —— 因为「能表达」和「能训出来」是两件事。 参数少时损失曲面坑洼,梯度下降容易停在局部解,结果强烈依赖初始值。 加到 3 个以上就稳定到 19/20、20/20,不是模型更聪明,是可走的路径更多了。两层能做的事已经看到了,但还有一个问题没回答:隐层的权重是怎么学出来的? 输出层错了,我们能算它该往哪改;隐层错了,错在哪? 下一节把这件事算清楚——这就是误差反向传播。
- ☐ (本节完,下一节 2.5 误差反向传播算法)