同一个隐层,接三个不同的头:回归输出 1 个数用平方损失,二分类输出 1 个概率用 sigmoid, 多分类输出类别数用 softmax。 前面的层一模一样,只有最后一层和损失函数不同。 实测三个任务:回归均方误差 0.000168、二分类准确率 1.0000、多分类 1.0000。 这就是本书的组织方式:骨架讲一次,各种变体各讲一次。
这一节是全书的地图,本来可以只列个目录。 但目录看不出「为什么这样分」,所以用一个 Demo 把八章的关系串起来。 核心观察是:深度学习里大部分「不同的任务」,差别只在最后一层。 同一个隐层表示,接上不同的头和不同的损失,就变成回归、二分类、多分类。 这个观察能省掉很多重复学习——你只要把骨架搞懂一次。 所以本书的顺序是:先讲骨架(第 2 章),再讲三种给骨架换形状的办法 (第 3 章卷积、第 4 章能量模型、第 5 章自编码器)。 然后讲怎么让它不过拟合(第 6 章)、怎么落到工具上(第 7 章)。
一、三个头,一套骨架
先看三种输出层:
| 任务 | 输出个数 | 最后一层 | 损失 |
|---|---|---|---|
| 回归 | 1 | 不激活 | 平方误差 |
| 二分类 | 1 | sigmoid | 交叉熵 |
| 多分类 | 类别数 | softmax | 交叉熵 |
| 「不激活」的意思是输出层直接给出实数,要多大就多大。 | |||
| sigmoid 把实数压到 0 和 1 之间,当成概率。 | |||
| softmax 把一组实数变成一组加起来等于 1 的概率。 | |||
| 实测三个任务在同一份数据上(三条螺旋,360 个点): |
| 任务 | 指标 | 结果 |
|---|---|---|
| 回归(预测到原点的距离) | 均方误差 | 0.000168 |
| 二分类(是不是第一类) | 准确率 | 1.0000 |
| 多分类(属于哪一条螺旋) | 准确率 | 1.0000 |
| 三个任务的隐层结构完全一样,都是 2 到 16。 | ||
| 换句话说:同一层特征,既能支撑数值预测,也能支撑分类。 |
二、为什么 softmax 之后还要交叉熵
这是一个初学者常卡住的地方:既然 softmax 已经把输出变成概率了,为什么不用平方误差, 非要换成交叉熵? 原因是梯度。 平方误差配 softmax 时,输出层梯度里会多乘一个 softmax 的导数, 而这个导数在输出「很确信但错得很离谱」时接近零——也就是说,错得越狠,学得越慢。 交叉熵配 softmax 时,两者的一堆导数恰好约掉,输出层梯度直接变成「预测减目标」。 错得越狠,梯度越大,学得越快。 这个「巧合」其实是设计出来的:交叉熵就是对数似然,而 softmax 是对数线性的,两者天然配对。 第 2.6 和 2.7 节会把这件事从似然函数的角度再讲一遍。
三、八章各自的位置
| 章 | 讲什么 | 在全书的角色 |
|---|---|---|
| 1 | 绪论:从哪来、为什么、是什么 | 建立直觉 |
| 2 | 神经网络:前向、反向、梯度下降 | 地基,后面全部靠它 |
| 3 | 卷积神经网络:换一套更省参数的结构 | 给图像专用的骨架 |
| 4 | 受限玻尔兹曼机:能量模型与逐层预训练 | 无监督那条线的起点 |
| 5 | 自编码器:学压缩,也学到特征 | 无监督那条线的延续 |
| 6 | 提高泛化能力:样本、预处理、激活、Dropout | 让它别过拟合 |
| 7 | 深度学习工具:Theano 到 TensorFlow | 落到工程上 |
| 8 | 现在和未来:应用与局限 | 收尾与展望 |
| 第 2 章是唯一必须逐字读的一章。 | ||
| 后面每一章都在它上面加东西:第 3 章换了层的连接方式,第 4、5 章换了训练目标, | ||
| 第 6 章换了正则手段——但没有一章换掉前向、反向、更新这三步。 | ||
| 第 4、5 章讲的无监督方法,在今天已经不太常用了。 | ||
| 但它们的历史地位很重要:2006 年正是靠逐层预训练,深层网络才第一次训得动。 | ||
| 而且它们提出的「用重构误差当训练目标」这个思路,后来的自监督学习一直在用。 |
四、怎么读这本书
如果只想快速上手:第 2 章加第 7 章,够你把一个网络跑起来。 如果想理解为什么:第 2 章逐字读,第 3 章认真读,第 6 章反复读。 第 6 章讲的是「为什么你的模型在测试集上不行」,这是实战里最常遇到的问题。 第 4、5 章可以快读。 它们是这本书写作年代(2015 年前后)的热点,现在的地位已经变了,但里面的数学(能量、 配分函数、对比散度)在生成模型里还能见到。 每章的 Demo 都建议亲手跑一遍。 这本书的 Demo 全部用 numpy 从零写,不依赖框架——因为框架会把「发生了什么」藏起来, 而初学阶段最需要的恰恰是看见每一步。
五、动手 Demo:本书结构:同一个隐层,接三个不同的头
这段代码用三条螺旋的数据,让同一个 2 到 16 的隐层分别接上回归、二分类、多分类三个头, 看三种任务能不能共用同一套特征。 重点看两处。第二到四段的三个结果 0.000168、1.0000、1.0000; 第五段那张表——三种任务的差别只在最后一层和损失。
# -*- coding: utf-8 -*-
import numpy as np
def spiral(n=120, C=3, 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 init(sizes, seed=0):
rg = np.random.default_rng(seed)
W, b = [], []
for i in range(len(sizes) - 1):
W.append(rg.normal(0.0, np.sqrt(2.0 / sizes[i]), (sizes[i], sizes[i + 1])))
b.append(np.zeros(sizes[i + 1]))
return {"W": W, "b": b}
def fwd(p, X):
h = X
n = len(p["W"])
A = [X]
Z = []
for i in range(n):
z = h @ p["W"][i] + p["b"][i]
Z.append(z)
h = z if i == n - 1 else np.maximum(z, 0.0)
A.append(h)
return A, Z
def softmax(S):
S = S - S.max(axis=1, keepdims=True)
E = np.exp(S)
return E / E.sum(axis=1, keepdims=True)
def sigmoid(z):
return 1.0 / (1.0 + np.exp(-np.clip(z, -60.0, 60.0)))
def head_of(kind, S):
"""同一个隐层输出,接三种不同的头。"""
if kind == "reg":
return S
if kind == "bin":
return sigmoid(S)
return softmax(S)
def dhead(kind, P, T):
"""损失对这层输入的导数,三种头各不相同。"""
if kind == "reg":
return 2.0 * (P - T) / T.size
if kind == "bin":
return (P - T) / len(T)
return (P - T) / len(T)
def back(p, X, T, kind):
A, Z = fwd(p, X)
n = len(p["W"])
d = dhead(kind, head_of(kind, A[-1]), T)
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:
d = (d @ p["W"][i].T) * (Z[i - 1] > 0.0)
return {"W": gW, "b": gb}
def train(p, X, T, kind, lr=0.1, epochs=400, 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 = back(p, X[j], T[j], kind)
for i in range(len(p["W"])):
p["W"][i] -= lr * g["W"][i]
p["b"][i] -= lr * g["b"][i]
return p
def out(p, X, kind):
return head_of(kind, fwd(p, X)[0][-1])
X, y = spiral(120, 3, seed=1)
print('[1] 同一个隐层,接三个不同的头')
print(' 数据:三条螺旋线上共 %d 个点' % len(y))
print(' 三个任务的输入完全一样,隐层结构也完全一样,只有最后一层和损失不同')
print('')
# 任务一:回归,预测这条点到原点的距离
r = np.sqrt((X ** 2).sum(axis=1))[:, None] / 4.0
p = train(init([2, 16, 1], seed=1), X, r, "reg", lr=0.05, epochs=400)
e = float(np.mean((out(p, X, "reg") - r) ** 2))
print('[2] 回归:输出 1 个数,用平方损失')
print(' 目标是每个点到原点的距离,输出层直接给出数值')
print(' 训练 %d 轮后,均方误差 %.6f' % (400, e))
print('')
# 任务二:二分类,判断这个点在第一类还是其他
y2 = (y == 0).astype(float)[:, None]
p = train(init([2, 16, 1], seed=1), X, y2, "bin", lr=0.1, epochs=400)
a = float(np.mean((out(p, X, "bin") > 0.5) == (y2 > 0.5)))
print('[3] 二分类:输出 1 个概率,用 sigmoid 把它压到 0 和 1 之间')
print(' 训练 %d 轮后,准确率 %.4f' % (400, a))
print('')
# 任务三:多分类,判断属于哪一条螺旋
T = np.zeros((len(y), 3))
T[np.arange(len(y)), y] = 1.0
p = train(init([2, 16, 3], seed=1), X, T, "ce", lr=0.1, epochs=400)
a3 = float(np.mean(np.argmax(out(p, X, "ce"), axis=1) == y))
print('[4] 多分类:输出 3 个分数,用 softmax 变成三个加起来为 1 的概率')
print(' 训练 %d 轮后,准确率 %.4f' % (400, a3))
print('')
print('[5] 三种任务,差别只在最后一层')
print(' 任务 输出个数 最后一层 损失')
print(' 回归 1 不激活 平方误差')
print(' 二分类 1 sigmoid 交叉熵')
print(' 多分类 类别数 softmax 交叉熵')
print(' 前面的层一模一样。这也是本书的组织方式:骨架讲一次,各种变体各讲一次')
print('')
print('[6] 全书八章各自管什么')
for line in (
' 第 1 章 绪论:这些东西是怎么一步步走到今天的',
' 第 2 章 神经网络:前向、反向、梯度下降,全书的地基',
' 第 3 章 卷积神经网络:给图像换一套更省参数的结构',
' 第 4 章 受限玻尔兹曼机:无监督的能量模型,第一次「预训练」',
' 第 5 章 自编码器:让网络学压缩,也学会了特征',
' 第 6 章 提高泛化能力:过拟合怎么防',
' 第 7 章 深度学习工具:把上面这些装进框架',
' 第 8 章 现在和未来:它已经用在哪,还差什么',
):
print(line)
实跑输出:
--------------------------------------------------------------------
本书结构:同一个隐层,接三个不同的头
--------------------------------------------------------------------
[1] 同一个隐层,接三个不同的头
数据:三条螺旋线上共 360 个点
三个任务的输入完全一样,隐层结构也完全一样,只有最后一层和损失不同
[2] 回归:输出 1 个数,用平方损失
目标是每个点到原点的距离,输出层直接给出数值
训练 400 轮后,均方误差 0.000168
[3] 二分类:输出 1 个概率,用 sigmoid 把它压到 0 和 1 之间
训练 400 轮后,准确率 1.0000
[4] 多分类:输出 3 个分数,用 softmax 变成三个加起来为 1 的概率
训练 400 轮后,准确率 1.0000
[5] 三种任务,差别只在最后一层
任务 输出个数 最后一层 损失
回归 1 不激活 平方误差
二分类 1 sigmoid 交叉熵
多分类 类别数 softmax 交叉熵
前面的层一模一样。这也是本书的组织方式:骨架讲一次,各种变体各讲一次
[6] 全书八章各自管什么
第 1 章 绪论:这些东西是怎么一步步走到今天的
第 2 章 神经网络:前向、反向、梯度下降,全书的地基
第 3 章 卷积神经网络:给图像换一套更省参数的结构
第 4 章 受限玻尔兹曼机:无监督的能量模型,第一次「预训练」
第 5 章 自编码器:让网络学压缩,也学会了特征
第 6 章 提高泛化能力:过拟合怎么防
第 7 章 深度学习工具:把上面这些装进框架
第 8 章 现在和未来:它已经用在哪,还差什么
第一段那句「输入完全一样,隐层结构也完全一样」是全篇的立论。
如果三个任务的网络结构不同,就说明不了「骨架可以共用」。
第二段回归的均方误差 0.000168 看着很小,但要注意目标值也被缩放了(除以 4)。
不缩放的话数值会大得多,学习率也得跟着改——这是实践中很常见的一步预处理。
第三、四段都是 1.0000,因为三条螺旋在这个设定下确实容易分。
这两个 1.0000 的价值不在于「准确率高」,而在于「同一层特征两边都能用」。
第五段那张表可以背下来。
实际做项目时,先判断任务属于哪一类,然后直接照着表配最后一层和损失,能省掉大量试错。
代码里 head_of 和 dhead 是成对的两个函数。
前向用什么变换,反向就要用对应的导数——这是自己实现网络时最容易错的地方。
写错一个,训练仍然会跑,只是效果莫名其妙地差。
第六段那八行是本书的目录,但换了个说法:不是说「第几章讲什么」,
而是说「每章在整体里干什么活」。
读技术书时先建立这个视角,比按顺序啃有效得多。
30 秒实验
- 把回归的目标从「到原点的距离」换成「角度的正弦」,重跑。 均方误差会变差一些,因为正弦在跨越正负 pi 的地方不连续。 这说明「回归」这个头不是万能的,目标本身的连续性会限制效果。
- 把二分类的隐层从 16 改成 2,重跑。准确率会掉。 同一份数据上,回归任务可能还凑合,分类任务先垮——不同任务对容量的要求不一样。
- 把多分类的损失从交叉熵换成平方误差(
dhead里改成2*(P-T)/T.size),重跑。 收敛会明显变慢。 这就是「softmax 配平方误差」的梯度消失问题,亲手跑一次比看公式印象深。
常见误区
- 「不同任务要用完全不同的网络结构」。 本节三个任务的隐层完全一样,只是最后一层不同。 回归、二分类、多分类共用同一套特征表示,这在实践中很常见(叫多任务学习的基础)。
- 「softmax 之后用平方误差也行,反正都是损失」。 能跑,但学得慢。 因为平方误差配 softmax 时,梯度里多一个 softmax 导数,在「很确信但错得离谱」时接近零。 交叉熵配 softmax 时这个因子恰好约掉。
- 「第 4、5 章讲的东西已经过时,可以跳过」。 方法本身确实不常用了,但思路没有:用重构误差做无监督训练、逐层预训练、 能量模型——这些在今天的自监督学习和生成模型里还在用。 而且 2006 年正是靠它们,深层网络才第一次训得动。
- 「用框架就行,不用懂原理」。 跑通一个例子确实不用懂。 但模型不收敛、准确率上不去、换个数据集就崩的时候,能救你的只有「知道每一步在算什么」。
自测三问(点开对答案)
1. 回归、二分类、多分类的最后一层分别是什么? —— 回归不激活,直接输出实数;二分类用 sigmoid 压到 0 和 1 之间; 多分类用 softmax 变成一组加起来为 1 的概率。损失上回归用平方误差,两种分类都用交叉熵。 2. 为什么分类任务要用交叉熵而不是平方误差? —— 因为平方误差配 softmax 时,输出层梯度里会多乘 softmax 的导数, 在「很确信但错得离谱」时这个导数接近零,导致错得越狠学得越慢。 交叉熵配 softmax 时两者导数恰好约掉,梯度直接是「预测减目标」。 3. 第 2 章为什么是唯一必须逐字读的一章? —— 因为后面每章都在它上面加东西,但没有一章换掉前向、反向、更新这三步。 第 3 章换层的连接方式,第 4、5 章换训练目标,第 6 章换正则手段,骨架始终是第 2 章那一套。地图看完了,从下一章开始正式动工。 第一个要拆开的是最基础的那个单元:一个神经元怎么把若干输入变成一个输出,它的权重又该怎么定。 这个问题从 1943 年问到今天,答案换过好几次。
- ☐ (本章完,下一节 2.1 神经网络的历史)