Ray's Blog

计算机学习与实践笔记:Linux、网络与深度学习,从原理出发。Ray's notes on computing — Linux, networking, and deep learning, from first principles.

(六) 神经网络

兑现承诺的时刻

欢迎回到零基础深度学习之旅。这个系列从一条直线出发:我们先理解了优化机制(一),实现了梯度下降(二),构建了张量(三),装配了四种优化器(四),最后亲手写出了自动微分引擎(五)。每一篇的结尾我都在暗示同一个方向:用这套自己搭的机器训练一个真正的神经网络。

今天就是这一天。本文结束时,我们的引擎会在经典的鸢尾花(iris)数据集上训练出一个能正确分类的神经网络,不借助 PyTorch,不借助 NumPy,就用前五篇文章写下的那几百行纯 Python。

从直线到网络:为什么需要非线性

我们的模型库存目前有:直线、二次函数、平面。它们有个共同点:模型的形状是已知的。数据像抛物线,我们就选二次函数。但真实问题里没人告诉你数据长什么形状。

神经网络的野心是:用一个通用的形状去逼近任意形状。它的配方只有两样东西:

  1. 线性层:$y = Wx + b$,也就是我们的老朋友平面模型,只不过 W 从一个向量升级成矩阵,一次算出多个输出;
  2. 非线性激活:怎么样把线性函数变成非线性函数呢?很简单只要在线性层的结果上增加一个非线性函数。这个函数被称为激活函数

第二样是灵魂。如果只叠线性层,两层线性还是线性($W_2(W_1x + b_1) + b_2$ 展开后仍是 $Wx+b$ 的形式),叠一百层也只是一条更贵的直线。非线性函数打破了这一点,让层与层的组合能弯曲出任意形状。

ReLU:最朴素的非线性

深度学习里最常用的激活函数的简单得让人怀疑:

\[\mathrm{ReLU}(x) = \max(0, x)\]

负数归零,正数不动。就这样。

有趣的是它的梯度怎么进我们的自动微分系统。ReLU 在 $x>0$ 时导数是 1(梯度原样通过),$x<0$ 时导数是 0(梯度被拦截)。用(五)的 link 风格写出来:

def rectify_0(d):
    r = get_r(d)
    result = max(0.0, r)

    if not is_dual(d):
        return result

    def link(_, g, grad_tape):
        grad = g if r > 0.0 else 0.0   # 正区间放行,负区间归零
        k = get_k(d)
        return k(d, grad, grad_tape)

    return Dual(result, link)

rectify = ext1(rectify_0, 0)   # 用(三)的 ext1 扩展到任意阶张量

注意一个细节:max 这种带分支的函数也能求导,只要在当前这个点上分支是确定的,梯度规则就是确定的。自动微分是在具体数值上工作的,不需要全局的数学表达式。这是它和符号微分的本质区别,(五)里埋的这个伏笔在这里派上了用场。

线性层与网络的组合

单层的定义直接复用已有零件:矩阵乘法用(三)的 star2_1,加法用 tadd:

def linear(t):
    return lambda theta: tadd(star2_1(theta[0], t), theta[1])  # Wx + b

def relu(t):
    return lambda theta: rectify(linear(t)(theta))

多层网络就是层的嵌套。参数约定:每层占用 $theta$ 里的两个位置($W$ 和 $b$),递归时消耗掉自己的部分,把剩下的传给后面的层:

def k_relu(k):
    def network(t):
        def layer_fn(theta):
            if k == 0:
                return t
            first_output = relu(t)(theta[:2])         # 本层用前两个参数
            return k_relu(k - 1)(first_output)(theta[2:])  # 其余传给后面
        return layer_fn
    return network

和(五)的函数式风格一脉相承:网络不是一个对象,而是一个由函数组合出来的函数。

初始化:为什么不能从零开始

前几篇我们习惯把参数初始化为 0。对神经网络,这个习惯是致命的。

原因是对称性:如果同一层的所有神经元起点相同,它们收到的梯度也相同,更新后依然相同,无论训练多久,一层里的 n 个神经元实际上只是同一个神经元的 n 份复制。必须用随机初始化打破对称。

但随机也不能乱来。方差太大,信号逐层放大到爆炸;太小,逐层衰减到消失。对 ReLU 网络,常用的方案是 He 初始化:权重从均值 $0$、方差 $2/n$ 的正态分布中抽取(n 是该层的输入个数)。直觉是:方差 $1/n$ 恰好让输出的尺度和输入持平,而 ReLU 会砍掉一半的值,所以再乘 2 补偿回来。偏置则照常初始化为 0,因为它不参与对称性问题。

def init_shape(shape):
    if len(shape) == 1:                    # 偏置
        return zero_tensor(shape)
    input_length = shape[1]                # 权重:He 初始化
    return random_tensor(0.0, 2.0 / input_length, shape)

实战:分类鸢尾花

鸢尾花数据集:150 朵花,每朵 4 个测量值(花萼/花瓣的长宽),三个品种。我们取每类前 30 个共 90 个样本,品种用 one-hot 编码(setosa → [1,0,0],以此类推)。

网络结构 4 → 6 → 3:输入 4 个特征,隐藏层 6 个神经元,输出 3 个数对应三个品种,预测取 argmax。损失函数依然是老朋友 l2_loss,把 one-hot 目标当作回归目标。优化器用(四)的 Adam,小批量采样用(四)的 sampling_obj。引擎的任何部分都不需要为神经网络做修改——网络只是一个碰巧比较复杂的 target 函数。

shapes = network_shape_list([6, 3], input_length=4)  # [(6,4),(6,),(3,6),(3,)]
theta = init_theta(shapes)
objective = sampling_obj(l2_loss(model), xs, ys, batch_size=8)
history = adam_gd(objective, theta, AdamConfig(lr=0.01, revs=1000,
                                               decay=0.9, momentum=0.85))

实测结果(纯 Python,1000 步训练约 5 秒):三个随机种子下,训练集准确率分别为 76.7%、86.7%、84.4%,平均约 83%。对一个没有任何库依赖、完全从系列文章里长出来的引擎来说,我对这个结果相当满意。

一次真实的翻车:输出层的 ReLU

上面的网络有个容易忽略的细节:最后一层是线性的,没有过 ReLU。我最初的版本对每一层(包括输出层)都做了 ReLU,结果训练完全失败。这次调试值得完整记录,因为它是这个系列里最”深度学习式”的一课。

症状很诡异:无论换什么随机种子,损失都恰好停在 90.0,一动不动。

调试的转机是注意到 90 这个数字太整齐了,它恰好等于样本数。每个 one-hot 目标的模长是 1,90 个样本的 L2 损失恰好是 90,意味着网络的输出全是零

为什么全零?拟合 [1,0,0] 这样的目标,输出层必须能输出接近 0 的值,也就需要负的预激活值;但输出层的 ReLU 把所有负值裁剪成 0,而 ReLU 在负区间的梯度也是 0,参数收不到任何梯度信号,网络”死”在了初始状态附近。这就是所谓的 dying ReLU 问题,只不过发生在最不该放 ReLU 的地方。

更有欺骗性的是准确率显示 33.3%,看起来”至少学到点什么”,其实是假象:全零向量的 argmax 恒为 0,即永远预测第一类,恰好蒙对了 30 个 setosa。

网络变体 最终损失(3 种子平均) 训练准确率
输出层过 ReLU 90.0000(纹丝不动) 0.333(假象)
输出层线性 30.59 0.826

一句话教训:隐藏层用激活函数制造非线性,输出层的形式由任务决定——回归到实数就用线性输出。

完整代码仓库

系列写到第六篇,代码分散在各篇文章里已经不便使用。现在它们被整理成了一个可以直接运行的仓库:tiny-learner,一个零依赖,clone 下来就能跑,包含本文的完整鸢尾花示例、三个可互换的自动微分引擎和一套测试。测试直接锚定系列文章中的手算例子(比如(五)里手推的 $\sin(x^2+3x)$ 在 $x=2$ 处梯度 ≈ −5.8735)。

总结

回顾本文的构件清单:

  1. ReLU——一个 max 函数如何进入自动微分系统;
  2. 线性层与 k 层组合——网络是函数的组合,不是新概念;
  3. He 初始化——为什么不能全零,为什么方差是 2/n;

从(一)的一条直线,到今天一个能分类真实数据的神经网络,这个系列最初的目标——”构建一个简易深度学习框架”,这个任务已经完成了。相信有了这些知识,以后在新闻里看到’某模型有多少参数’,听到’模型训练’‘神经网络’‘反向传播’这些词,你都能明白它们到底在说什么。有了基础知识也可以进一步学习,从这里出发到现在如火如荼的大语言模型还有非常多要学习的。好在网上也能找到很多很好的资料,比如Andrej Karpathy的Zero to Hero系列。我自己也还在学习中,将来有更多的收获了可能会继续写博客。感谢你的阅读,再见。