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

同一份两条螺旋的数据,三种模型: 1958 年的单层感知器 0.6000,1986 年的两层 S 型网络 1.0000, 2011 年的三层 ReLU 也是 1.0000。 训练流程几乎是同一套,差别只在激活函数和层数。 三次浪潮的卡点各不相同,但都不是「想法不对」,是「当时的训练手段配不上想法」。

📝 本节和前面几节的关系

上一章把深度学习放进机器学习的家族里,这一章开始拆零件。 第一个零件是最小的那个:一个神经元。 但在拆之前,先花一节看历史。 不是为了记年份,是因为神经网络的三次起落,每一次都卡在一个具体的技术困难上。 而那些困难今天还在——只是换了个名字:初始化、梯度消失、学习率。

一、三种模型,一份数据

造两条互相缠绕的螺旋线,每类 150 个点,输入两维。 然后跑三个模型:

年代 模型 激活函数 参数个数 训练准确率
1958 单层感知器 阶跃 3 0.6000
1986 两层网络 S 型(tanh) 33 1.0000
2011 三层网络 ReLU 337 1.0000
第一行 0.6000。
两类瞎猜是 0.5000,所以它确实学到了一点东西,但也就到此为止——直线能做的就那么多。
第二行只用 33 个参数就跳到 1.0000。
参数只多了 30 个,能力却从「画直线」变成「画任意曲线」,因为中间多了一层非线性的变换。
第三行也是 1.0000,参数翻了十倍。
在这个任务上第三层是多余的——这一点第 1 章已经说过,这里再强调一次:层数是容量,容量要够用,
多出来的部分是浪费。

二、1958 年:感知器为什么会轰动

感知器的贡献不在「能分类」,而在「权重能自己学」。 在它之前,M-P 神经元的权重得人手定(下一节会看到,与门、或门的权重都是人挑的)。 感知器把这件事反过来了:给它一堆已经标好的样本,它自己调权重。 规则简单到一句话:分对了不动,分错了把权重往正确答案那边推。 当时的宣传确实过头了,但技术上的突破是真的。 「从数据里学参数」这件事,从此成了这个领域的基本动作。

三、1969 年:一句话让经费断掉

明斯基和佩珀特在《感知器》里证明:单层感知器连异或都做不了。 异或的两类在平面上是对角交叉的,而单层感知器的决策边界是一条直线。 一条直线切不开这个叉形,所以结构上就做不到——不是训练得不好。 这个结论完全正确,但它被推广成了「神经网络这条路走不通」。 推广是错的:加一层就能做。 问题在于当时没人知道怎么训练多层——隐层的权重没有「正确答案」可以对照。 这就是第一次低谷。 卡住的不是表达力,是可训练性。

四、1986 年:反向传播解决了「怎么训多层」

反向传播的思路:把输出层的误差,按权重的比例一层层往回分摊。 每一层只需要知道「我这个权重对最终误差负多大责任」,就能改自己。 这件事用链式法则就能算,数学上并不新鲜。 它解决的是「怎么算梯度」,不是「怎么算得准」。 层数一多,梯度往回传的时候会一层层乘上激活函数的导数。 S 型函数的导数最大只有 0.25,十层乘下来就是千万分之一。 这叫梯度消失,是第二次低谷的主因。 第 2.5 节会把这条链子一层层算给你看,包括「漏乘一个导数会差多少」——实测最多差到 1566 倍, 符号都能反掉。

五、2006 年与 2012 年:条件齐了

2006 年,深层信念网络用「逐层预训练」让深层网络重新可训。 先把每一层单独训好,再整体微调——相当于给梯度下降找了个好起点。 第 4、5 章讲的受限玻尔兹曼机和自编码器,当年主要就是干这个用的。 2012 年,AlexNet 在图像识别比赛上大幅领先。 它用的技术并不新:卷积、ReLU、Dropout、GPU。 新的是这三样同时到位:数据够多(ImageNet)、算力够强(GPU)、 方法够稳(ReLU 解决了梯度消失)。 所以深度学习不是某一天被「发明」出来的。 它是三个条件同时到位之后,水到渠成的结果。

六、这段历史该记住什么

第一,表达力和可训练性是两件事。 「存在一组参数能做到」和「梯度下降能不能找到那组参数」,隔着一整个优化问题。 第二,被否定的往往是当时的训练手段,不是想法本身。 1969 年的结论没错,错的是推广。这类误推在技术史上反复发生。 第三,很多「新技术」是老想法等到了配套条件。 反向传播的数学在 1970 年代就有,卷积网络的雏形在 1980 年代末就出现, 真正爆发是在数据和算力跟上之后。

七、动手 Demo:三个年代的三种模型,一起跑同一份数据

这段代码把「感知器、两层网络、三层网络」放在同一份数据、同一批超参数下跑, 看准确率和参数个数怎么变。 重点看三处。第一段那句「一条直线切不开两段螺旋」; 第二段三行的 0.6000、1.0000、1.0000 和对应的 3、33、337;第三段把三次浪潮的卡点各自归到一句话上。

# -*- coding: utf-8 -*-
import unicodedata

import numpy as np


def wd(s):
    """显示宽度:中文算 2。"""
    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 spiral(n=150, C=2, seed=1, noise=0.12):
    """两条互相缠绕的螺旋线。"""
    rg = np.random.default_rng(seed)
    X, Y = [], []
    for c in range(C):
        t = np.linspace(0.4, 4.2, n)
        ang = t + c * 2.0 * np.pi / C
        X.append(np.column_stack([t * np.cos(ang), t * np.sin(ang)])
                 + rg.normal(0.0, noise, (n, 2)))
        Y.append(np.full(n, c))
    return np.vstack(X), np.concatenate(Y)


def onehot(y, C):
    T = np.zeros((len(y), C))
    T[np.arange(len(y)), y] = 1.0
    return T


def percep_train(X, y, lr=0.1, epochs=100):
    """1958 年的感知器学习规则:分错的样本把权重往它那边推。"""
    w = np.zeros(X.shape[1])
    b = 0.0
    for _ in range(epochs):
        bad = 0
        for i in range(len(y)):
            pred = 1 if X[i] @ w + b > 0.0 else 0
            if pred != y[i]:
                bad += 1
                d = lr * (2 * y[i] - 1)
                w = w + d * X[i]
                b = b + d
        if bad == 0:
            break
    return w, b


def percep_acc(X, y, w, b):
    pred = (X @ w + b > 0.0).astype(int)
    return float(np.mean(pred == y))


def init_mlp(sizes, seed=0, act='relu'):
    """He 初始化(relu)或 Xavier(其它激活)。"""
    rg = np.random.default_rng(seed)
    W, b = [], []
    for i in range(len(sizes) - 1):
        sd = np.sqrt(2.0 / sizes[i]) if act == 'relu' else np.sqrt(1.0 / sizes[i])
        W.append(rg.normal(0.0, sd, (sizes[i], sizes[i + 1])))
        b.append(np.zeros(sizes[i + 1]))
    return {'W': W, 'b': b, 'act': act}


def _f(z, act):
    if act == 'relu':
        return np.maximum(z, 0.0)
    if act == 'tanh':
        return np.tanh(z)
    return 1.0 / (1.0 + np.exp(-np.clip(z, -60.0, 60.0)))


def forward(p, X, keep=False):
    Z, A = [], [X]
    h = X
    n = len(p['W'])
    for i in range(n):
        z = h @ p['W'][i] + p['b'][i]
        Z.append(z)
        h = z if i == n - 1 else _f(z, p['act'])
        A.append(h)
    return (A, Z) if keep else h


def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-np.clip(z, -60.0, 60.0)))


def backward(p, X, T):
    """二分类:单个 logit 接 sigmoid,损失取交叉熵。"""
    A, Z = forward(p, X, keep=True)
    n = len(p['W'])
    d = (sigmoid(A[-1].ravel()) - T.ravel())[:, None] / len(X)
    gW = [None] * n
    gb = [None] * n
    for i in range(n - 1, -1, -1):
        gW[i] = A[i].T @ d
        gb[i] = d.sum(axis=0)
        if i > 0:
            a = A[i]
            df = (Z[i - 1] > 0.0).astype(float) if p['act'] == 'relu' \
                else (1.0 - a * a if p['act'] == 'tanh' else a * (1.0 - a))
            d = (d @ p['W'][i].T) * df
    return {'W': gW, 'b': gb}


def train(p, X, T, lr=0.1, epochs=600, batch=32, seed=0):
    rg = np.random.default_rng(seed)
    n = len(X)
    for _ in range(epochs):
        idx = rg.permutation(n)
        for s in range(0, n, batch):
            j = idx[s:s + batch]
            g = backward(p, X[j], T[j])
            for i in range(len(p['W'])):
                p['W'][i] -= lr * g['W'][i]
                p['b'][i] -= lr * g['b'][i]
    return p


def acc(p, X, y):
    return float(np.mean((sigmoid(forward(p, X).ravel()) > 0.5).astype(int) == y))


X, y = spiral(150, 2, seed=1)
T = y.astype(float)[:, None]

print('[1] 同一份数据:两条互相缠绕的螺旋线')
print('  样本 %d 个,输入 2 维;两类各 150 个点,绕着同一个中心转' % len(y))
print('  一条直线无论怎么摆,都不可能同时把两段螺旋切开——这就是线性不可分')
print('')

print('[2] 三个年代的三种模型,同一个数据、同一批超参数')
print('  ' + pad('模型', 30) + pad('参数个数', 10) + '训练准确率')
w, b = percep_train(X, y, lr=0.1, epochs=100)
print('  ' + pad('1958 单层感知器(阶跃)', 30) + pad('%d' % 3, 10)
      + '%.4f' % percep_acc(X, y, w, b))

p2 = init_mlp([2, 8, 1], seed=1, act='tanh')
n2 = sum(v.size for v in p2['W']) + sum(v.size for v in p2['b'])
p2 = train(p2, X, T, lr=0.2, epochs=600, batch=32)
print('  ' + pad('1986 两层 S 型(反向传播)', 30) + pad('%d' % n2, 10)
      + '%.4f' % acc(p2, X, y))

p3 = init_mlp([2, 16, 16, 1], seed=1, act='relu')
n3 = sum(v.size for v in p3['W']) + sum(v.size for v in p3['b'])
p3 = train(p3, X, T, lr=0.1, epochs=600, batch=32)
print('  ' + pad('2011 三层 ReLU(He 初始化)', 30) + pad('%d' % n3, 10)
      + '%.4f' % acc(p3, X, y))
print('')

print('[3] 三次浪潮卡在哪、又靠什么过去')
print('  第一次浪潮(1958~1969):感知器只能画直线,异或都做不到,1969 年之后停摆')
print('  第二次浪潮(1986~1995):反向传播解决了「怎么训多层」,但 S 型激活一层层衰减')
print('  第三次浪潮(2006~  ):ReLU 让梯度不再衰减,加上数据与算力,层数才真正堆得起来')
print('  这张表里三行代码几乎是同一套训练流程,差别只在激活函数和层数')

实跑输出:

--------------------------------------------------------------------
神经网络的历史:三个年代的三种模型,一起跑同一份数据
--------------------------------------------------------------------
[1] 同一份数据:两条互相缠绕的螺旋线
  样本 300 个,输入 2 维;两类各 150 个点,绕着同一个中心转
  一条直线无论怎么摆,都不可能同时把两段螺旋切开——这就是线性不可分

[2] 三个年代的三种模型,同一个数据、同一批超参数
  模型                          参数个数  训练准确率
  1958 单层感知器(阶跃)       3         0.6000
  1986 两层 S 型(反向传播)    33        1.0000
  2011 三层 ReLU(He 初始化)   337       1.0000

[3] 三次浪潮卡在哪、又靠什么过去
  第一次浪潮(1958~1969):感知器只能画直线,异或都做不到,1969 年之后停摆
  第二次浪潮(1986~1995):反向传播解决了「怎么训多层」,但 S 型激活一层层衰减
  第三次浪潮(2006~  ):ReLU 让梯度不再衰减,加上数据与算力,层数才真正堆得起来
  这张表里三行代码几乎是同一套训练流程,差别只在激活函数和层数

第一段那句「一条直线无论怎么摆都切不开」要先接受下来。 这不是数据太难,是模型族不够——换成能画曲线的模型,同一个数据立刻就能分。 第二段第一行 0.6000 值得算一下:两类瞎猜 0.5000,它多出来 0.10。 说明线性模型不是完全没用,它至少抓住了一部分结构(螺旋的半径和角度有关系)。 但也就到这儿了。 第二段第二行只用 33 个参数就到 1.0000,这个数字要记住。 33 个参数描述两条螺旋的分界,说明「一层隐层」的表达力已经远远超过线性模型。 它靠的不是参数多,而是中间那次非线性变换。 第三行 337 个参数没有换来更好的结果,这不是坏事。 它说明在这个任务上,一层已经足够表达,多加的两层纯属浪费。 参数多不等于能力强,只等于「能表达更复杂的东西」。 代码里 percep_train 就是感知器学习规则,train 是反向传播加小批量梯度下降。 注意这两个训练的代码几乎一样长——感知器并不比反向传播简单多少,它输在模型族,不输在实现。

30 秒实验

  1. 把 spiral 的 C 从 2 改成 3,重跑。 感知器那一行会掉到 0.33 附近(三类瞎猜),因为三类螺旋更不可能靠直线分开。 两层和三层仍然是 1.0000。
  2. 把 percep_train 的 epochs 从 100 改成 2000,重跑。准确率不会变。 感知器不是训练不够,是它的决策边界只能是直线——加轮数改变不了这件事。
  3. 把 1986 那一行的 act 从 tanh 改成 relu,其它不动,重跑。 大概率也是 1.0000,但换个种子可能掉。 ReLU 的梯度不衰减,但它有「死单元」问题,初始化不好会有单元一直输出 0。

常见误区

  1. 「感知器是最早的神经网络,所以它很弱」。 它的弱在于「只有一层」,不在于「早」。 单层感知器今天仍然在用,就是线性分类器;换个损失函数就是逻辑回归。
  2. 「1969 年明斯基证明了神经网络不行」。 他证明的是「单层感知器表达不了异或」,这个结论完全正确。 被误传成「神经网络不行」,是后人的过度推广。
  3. 「2012 年是深度学习元年,之前都是白干的」。 反向传播 1986 年就普及,卷积网络 1989 年就用在邮编识别上, ReLU 和 Dropout 也都在 2012 年之前。 2012 年只是这些零件终于凑齐了数据与算力。
  4. 「历史只是故事,对动手没帮助」。 这段历史里的每个卡点今天都还在:梯度消失、初始化、学习率、过拟合。 知道它们曾经怎么卡住人,比记住年份有用得多。
自测三问(点开对答案) 1. 三种模型在同一份数据上的准确率分别是多少?差在哪里? —— 单层感知器 0.6000,两层 S 型网络 1.0000,三层 ReLU 也是 1.0000。 差别不在训练算法,在模型族:感知器只能画直线,两层网络能画曲线。 第三层没有带来提升,因为数据太简单,一层隐层已经够用。 2. 为什么「表达力」和「可训练性」要分开看? —— 表达力问的是「存不存在一组参数能做到」,可训练性问的是「梯度下降能不能找到那组参数」。 两个隐单元就能表达异或,但 20 个种子里只有 9 个训得出来。 隔在这两件事中间的,是一整个优化问题。 3. 三次浪潮分别卡在什么地方? —— 第一次卡在「多层网络不知道怎么训练」,反向传播解决了它。 第二次卡在「层一深梯度就消失」,2006 年的逐层预训练缓解了它。 第三次没有低谷,因为数据、算力、方法三样同时到位了。

历史讲完了,回到最基础的那个零件。 一个神经元到底在算什么?它的权重和阈值各管什么?为什么换一组数就能从「与门」变成「或门」? 下一节从 1943 年那个式子讲起。

相关笔记