📗 第1章 绪论
📄 一句话概括

500 张 8 乘 8 的数字图,每张都随机平移过一两个像素。 原始像素加线性分类 0.8600,一层隐层 0.9733。 中间那行最值得看:我按常识设计的手工特征只有 0.7600,比直接用像素还差。 特征工程想对了有用,想错了有害,而且事前不一定看得出来。 深度学习省掉的正是这一步:不用猜哪个特征有用,让训练去找。

📝 本节和前面几节的关系

上一节说「加一层就能做到」,但没说「为什么非加不可」。 如果传统方法也能做到九成准确率,多那么多参数图什么? 答案在流程上。 传统机器学习的流程是:人先想出一组特征,再把特征喂给分类器。 特征设计得好,简单分类器就能出好结果;设计得差,再强的分类器也救不回来。 深度学习的流程是:直接把原始数据喂进去,让模型自己造特征。 这不是「精度高一点」的改进,是把流程里最难、最靠人的那一步换掉了。

一、把数据造得「需要想一下」

合成 10 个数字的 8 乘 8 灰度图,七段数码管的样子,每类 50 张。 关键是每张都随机平移一两个像素。 为什么要平移?因为真实的图像任务里,同一个物体几乎不会总出现在同一个位置。 不平移的话,模型只要记住「第 3 行第 5 列有亮点就是 0」,根本不用理解形状。 一旦平移,这种死记硬背就失效了。 这份数据正好卡在中间难度:不难到做不动,也不简单到看不出差别。

二、三种做法摆在一起

做法 输入维数 测试准确率
原始像素 + 线性分类 64 0.8600
手工特征 + 线性分类 16 0.7600
原始像素 + 一层隐层 64 0.9733
原始像素 + 两层隐层 64 0.9667
第一行 0.8600:把 64 个像素直接当特征,用线性分类器。
它居然不差,因为数字的笔画位置本身有规律。
第二行 0.7600:我设计了一组手工特征,反而更差。
我当时的想法是「把图按行求和、按列求和,这样平移就不影响了」。
实测证明这个想法是错的:行和对上下平移同样敏感——数字整体下移一行,每行的和就整体错位了。
第三行 0.9733:网络直接从 64 个原始像素出发,自己造特征。
没有人为干预,比我的手设计的高出 11 个百分点。

三、手工特征那一行为什么会失手

这不是「手工特征没用」,是「我这一组手工特征没用」。 真正对平移不敏感的特征是存在的:比如对整幅图做二维傅里叶变换取模, 或者算梯度方向直方图(HOG)。 它们在真实的手写数字识别里效果很好,用了很多年。 问题是:你怎么知道该设计哪一种? 得先想清楚「这个任务里什么不变、什么重要」,然后把这个想法翻译成可计算的量。 这一步没有任何通用方法,全靠领域知识和反复试。 而且试错了看不出来——就像我这次,不跑一遍根本不知道行和会失效。 深度学习把这个循环交给了训练过程。 它不需要人想「什么是平移不变的」,它只需要一个足够大的模型和一个优化目标。

四、代价是什么

省了人工,代价一点不少。

维度 传统方法 深度学习
数据量 几百条也许就够 通常要成千上万条
算力 CPU 几秒 GPU 几小时到几天
可解释性 特征有名有姓 中间表示难以命名
调试 哪一步错了好定位 端到端,错在哪不好找
所以「为什么是深度学习」这个问题,答案不是「因为它更好」。
而是:当数据足够多、任务足够复杂、人又说不清该看什么特征时,它更划算。
反过来说,数据只有几百条、或者特征本来就很明确时,传统方法往往更快更稳。
这一节的数据只有 500 张,其实已经偏小了。
在这么小的数据上网络还能赢,是因为任务是合成的、规律很干净。
真实场景里,小数据上深度学习经常输给精心设计的传统方法。

五、动手 Demo:为什么是深度学习:人设计特征与机器自己学特征

这段代码造带随机平移的数字图,然后比较四种做法:原始像素加线性、手工特征加线性、 原始像素加一层隐层、加两层隐层。 重点看两处。第二段四行 0.8600、0.7600、0.9733、0.9667;第三段对「手工特征为什么失手」的交代。

# -*- coding: utf-8 -*-
import numpy as np


def digits(n_per=50, size=8, seed=3, noise=0.12, shift=1):
    """合成七段数码管的 8x8 灰度图,整幅随机平移。"""
    SEG = {0: "abcdef", 1: "bc", 2: "abged", 3: "abgcd", 4: "fgbc",
           5: "afgcd", 6: "afgecd", 7: "abc", 8: "abcdefg", 9: "abcdfg"}
    POS = {"a": (1, 2, 5), "b": (2, 5, 3), "c": (5, 5, 3), "d": (7, 2, 4),
           "e": (5, 1, 3), "f": (2, 1, 3), "g": (4, 2, 4)}
    rg = np.random.default_rng(seed)
    X, Y = [], []
    for k in sorted(SEG):
        for _ in range(n_per):
            img = np.zeros((size, size))
            for sg in SEG[k]:
                r, c0, ln = POS[sg]
                if sg in ("b", "c"):
                    img[r:r + ln, c0] = 1.0
                else:
                    img[r, c0:c0 + ln] = 1.0
            dr = int(rg.integers(-shift, shift + 1))
            dc = int(rg.integers(-shift, shift + 1))
            img = np.roll(np.roll(img, dr, axis=0), dc, axis=1)
            X.append((img + rg.normal(0.0, noise, (size, size))).ravel())
            Y.append(k)
    X = np.array(X)
    Y = np.array(Y)
    idx = rg.permutation(len(Y))
    return X[idx], Y[idx]


def hand(X):
    """手工特征:把图按行求和、按列求和,各得 8 个数。"""
    I = X.reshape(-1, 8, 8)
    return np.hstack([I.sum(axis=2), I.sum(axis=1)])


def init_mlp(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 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 np.maximum(z, 0.0)
        A.append(h)
    return (A, Z) if keep else h


def softmax(S):
    S = S - S.max(axis=1, keepdims=True)
    E = np.exp(S)
    return E / E.sum(axis=1, keepdims=True)


def backward(p, X, T):
    A, Z = forward(p, X, keep=True)
    n = len(p["W"])
    d = (softmax(A[-1]) - T) / 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:
            d = (d @ p["W"][i].T) * (Z[i - 1] > 0.0)
    return {"W": gW, "b": gb}


def train(p, X, T, lr=0.1, epochs=150, 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(np.argmax(forward(p, X), axis=1) == y))


X, y = digits(50, shift=1)
rg = np.random.default_rng(9)
idx = rg.permutation(len(y))
k = int(len(y) * 0.7)
tr, te = idx[:k], idx[k:]
Xtr, ytr, Xte, yte = X[tr], y[tr], X[te], y[te]
Ttr = np.zeros((len(ytr), 10))
Ttr[np.arange(len(ytr)), ytr] = 1.0

print('[1] 500 张 8 乘 8 的数字图,每张都随机平移了一两个像素')
print('  训练 %d 张,测试 %d 张,共 10 个类' % (len(ytr), len(yte)))
print('  平移是关键:同一个数字可能出现在画面的任何位置')
print('')

print('[2] 三种做法,同一个任务')
print('  做法                     输入维数   测试准确率')

p = train(init_mlp([64, 10], seed=1), Xtr, Ttr, lr=0.1, epochs=150)
print('  原始像素 + 线性分类          64      %.4f' % acc(p, Xte, yte))

Ftr, Fte = hand(Xtr), hand(Xte)
p = train(init_mlp([16, 10], seed=1), Ftr, Ttr, lr=0.1, epochs=300)
print('  手工特征 + 线性分类          16      %.4f' % acc(p, Fte, yte))

p = train(init_mlp([64, 32, 10], seed=1), Xtr, Ttr, lr=0.1, epochs=150)
print('  原始像素 + 一层隐层          64      %.4f' % acc(p, Xte, yte))

p = train(init_mlp([64, 64, 32, 10], seed=1), Xtr, Ttr, lr=0.1, epochs=150)
print('  原始像素 + 两层隐层          64      %.4f' % acc(p, Xte, yte))

print('')
print('[3] 手工特征那一行值得停一下')
print('  我按常识把图按行求和、按列求和,想借此躲开平移')
print('  结果反而比直接用像素还差')
print('  因为「行和」对上下平移同样敏感,这个特征根本没抓住不变性')
print('  特征工程是门手艺:想对了有用,想错了有害,而且事前不一定看得出来')
print('')

print('[4] 网络不需要我猜哪个特征有用')
print('  它从原始像素出发,靠训练自己把 64 个数揉成 32 个、再揉成 10 个')
print('  每一层的输出就是它自己造出来的特征')
print('  深度学习这个名字里,「深度」指的就是这一层层造特征的过程')

实跑输出:

--------------------------------------------------------------------
为什么是深度学习:人设计特征与机器自己学特征
--------------------------------------------------------------------
[1] 500 张 8 乘 8 的数字图,每张都随机平移了一两个像素
  训练 350 张,测试 150 张,共 10 个类
  平移是关键:同一个数字可能出现在画面的任何位置

[2] 三种做法,同一个任务
  做法                     输入维数   测试准确率
  原始像素 + 线性分类          64      0.8600
  手工特征 + 线性分类          16      0.7600
  原始像素 + 一层隐层          64      0.9733
  原始像素 + 两层隐层          64      0.9667

[3] 手工特征那一行值得停一下
  我按常识把图按行求和、按列求和,想借此躲开平移
  结果反而比直接用像素还差
  因为「行和」对上下平移同样敏感,这个特征根本没抓住不变性
  特征工程是门手艺:想对了有用,想错了有害,而且事前不一定看得出来

[4] 网络不需要我猜哪个特征有用
  它从原始像素出发,靠训练自己把 64 个数揉成 32 个、再揉成 10 个
  每一层的输出就是它自己造出来的特征
  深度学习这个名字里,「深度」指的就是这一层层造特征的过程

第一段那句「每张都随机平移一两个像素」是整节的胜负手。 不平移的话四种做法全都能到 1.00,什么都看不出来。 造数据时「难度卡在哪」比「数据量多大」更重要。 第二段第一行 0.8600 说明原始像素本身已经含有不少信息。 8 乘 8 太小,笔画位置几乎就等于形状,所以线性分类器也能凑合。 换成 28 乘 28,这行会掉得厉害。 第二段第二行的 0.7600 是全篇最诚实的一处。 它没有配合「手工特征不如自动特征」的叙事,而是直接说明我这次设计失败了。 留着这个数比改数据凑一个好看的结论更有用。 第三行 0.9733 和第四行 0.9667 几乎一样,第二层没帮上忙。 和 1.1 节一样:容量够用之后,再加就是浪费。 区别在于这次是在图像上,本来以为需要更深的层——实测并不需要。 代码里 hand 那两行求和就是全部的手工特征设计。 它短得让人以为随便就能设计好,这恰恰是错觉——真正有效的手工特征(比如 HOG)背后是很多篇论文。

30 秒实验

  1. 把 digits 的 shift 从 1 改成 0,重跑。四种做法大概都会接近 1.00。 这说明「特征重要不重要」取决于任务——没有平移,像素位置本身就是最好的特征。
  2. 把 hand 改成先对每幅图做二维傅里叶变换取模,再展平,重跑。 这是真正平移不变的特征,准确率会明显超过 0.7600。 亲手验证一次「想对了确实有用」,比读十遍「特征工程很重要」管用。
  3. 把 n_per 从 50 改成 10(每类只有 10 张),重跑。网络那两行会掉得比线性还厉害。 数据一少,参数多的模型就先撑不住了。

常见误区

  1. 「手工特征一定不如自动学的特征」。 本节手工特征确实更差(0.7600 对 0.9733),但那是因为设计错了。 在数据量小、任务明确的场景里,精心设计的特征经常赢过神经网络。 深度学习的优势是「不用设计」,不是「设计不如不设计」。
  2. 「特征加得越多越好」。 本节加了两组特征,维数从 64 降到 16,信息也丢了,结果更差。 特征不是越多越好,是「越接近任务真正需要的那几个量」越好。
  3. 「深度学习精度更高,所以任何场合都该用它」。 代价是数据、算力、可解释性、调试难度。 数据只有几百条、特征本来就很清楚时,传统方法往往更划算。
  4. 「手工特征已经过时了」。 在图像、语音这类「原始信号」,深度学习确实接管了。 但在表格数据、风控、推荐这些特征本来就结构化的场景,特征工程仍然是主力。
自测三问(点开对答案) 1. 为什么要在数据里加随机平移? —— 因为不平移的话,模型只要记住「某个位置有亮点就是某个数字」就能满分,根本不需要理解形状。 加上平移后,这种死记硬背失效,才逼出「学表示」和「设计特征」的差别。 2. 本节的手工特征为什么比原始像素还差? —— 因为它想靠「按行求和、按列求和」躲开平移,但行和对上下平移同样敏感——数字整体下移一行, 每行的和就整体错位了。这组特征没有抓住真正的不变性,反而丢掉了像素里的空间信息。 3. 既然深度学习更准,为什么不是所有场合都该用它? —— 因为代价不小:需要更多数据、更多算力,可解释性差,出错不好定位。 数据只有几百条或特征本来就很清楚时,传统方法更快更稳。选型要看「人能不能说清该看什么特征」。

说了这么多「层」和「特征」,该把最小的一套机制拆开看一遍了。 一个网络到底怎么把输入变成输出,误差又怎么一层层变回梯度? 下一节用一个 13 个参数的小网络,把每一行算式都印出来。

相关笔记