📗 第2章 神经网络
📄 一句话概括

两层就能解异或:两个隐单元一个算「或」、一个算「与非」,输出层要求两个都成立。 把它看成「换坐标」更清楚:四个点被搬到新坐标后,一条直线就能分开。 但「能表达」不等于「能训出来」: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 秒实验

  1. 把 train_xor 的 lr 从 0.5 改成 0.05,重跑。2 个隐单元那一行会更惨,可能掉到 3/20 以下。 学习率小,卡在局部解之后更不容易弹出来。
  2. 把隐层激活从 np.tanh 改成 np.maximum(·, 0)(ReLU),重跑。 2 个隐单元那一行会更不稳定,甚至一个都学不会。 ReLU 有死单元问题,两个单元都死掉就彻底没救了。
  3. 把隐单元个数扫到 16、32、64,重跑。成功率不会一直涨,会在 20/20 处饱和。 异或只有四个样本,容量过剩之后没有额外好处。

常见误区

  1. 「多层感知器就是多个感知器并联」。 并联是「同一层多个单元」,串联才是「多层」。 区别在于串联之后中间那层不可见,它的目标没有标签——这正是多层难训的原因。
  2. 「两个隐单元能表达异或,所以两层就够了」。 表达上够,优化上不够:20 个种子只成功 9 次。 实际做模型时,「够用」要按「能不能稳定训出来」算,不是按「存不存在解」算。
  3. 「加了隐层,准确率自然就高」。 隐层带来的是表达力,同时也带来优化难度和非凸的损失曲面。 不加配套手段(初始化、激活函数、学习率),两层网络经常比线性模型还差。
  4. 「隐层学到的特征人能看懂」。 在这个二维例子里能看懂,因为隐层就是两条线。 真实网络里隐层的几百个维度,大部分找不到人能命名的含义——这是「可解释性」问题的起点。
自测三问(点开对答案) 1. 两层网络是怎么用两条线分开异或的? —— 一个隐单元算「或」,一个算「与非」,输出层再要求两个都成立。 几何上就是两条直线围出一块区域,把两个正例圈在里面。9 个参数就够,实测四行全对。 2. 为什么说隐层在做「换坐标」? —— 隐层把每个输入点映射成一个新的坐标向量。 异或的四个点在原坐标里对角交叉,搬到新坐标后,两个正例落到同一个格子,两个负例分居两侧, 这时一条直线就能分开。学到的隐层,就是一套新的表示。 3. 2 个隐单元就能表达异或,为什么 20 个种子只学会 9 次? —— 因为「能表达」和「能训出来」是两件事。 参数少时损失曲面坑洼,梯度下降容易停在局部解,结果强烈依赖初始值。 加到 3 个以上就稳定到 19/20、20/20,不是模型更聪明,是可走的路径更多了。

两层能做的事已经看到了,但还有一个问题没回答:隐层的权重是怎么学出来的? 输出层错了,我们能算它该往哪改;隐层错了,错在哪? 下一节把这件事算清楚——这就是误差反向传播。

相关笔记