神经网络入门:从感知机到反向传播

1 分钟阅读
·

没有机器学习背景的工程师补大模型原理的第一站:从拟合一条直线讲起,走通损失函数、梯度下降和反向传播,并用 numpy 手写一个两层网络。

本文是「零基础学大模型原理」系列的第 1 篇。这个系列记录一个没有任何机器学习背景的工程师,在 ChatGPT 发布之后从零补原理的过程。

  • 神经网络入门:从感知机到反向传播(本文)

2022 年 11 月 30 日 ChatGPT 上线,之后两周我的推特时间线几乎全是它的截图。12 月中旬从推上得知它背后是 GPT-3.5,就把 GPT-3 的论文翻出来想搞懂原理,结果摘要之后基本读不动:预训练、上下文学习、few-shot,每个词都查得到解释,连起来不知道这个系统是怎么来的。硬啃论文走不通,只能退回来,从最基础的「神经网络到底怎么训练」开始补。这篇是补课的第一份笔记,读它不需要前置,能看懂几行 Python 就够。

训练中的模型参数

介绍大模型的文章常说「模型在海量文本上训练」。对有工程背景但没有机器学习背景的人来说,这句话里的每个词都不够具体:模型的「参数」是什么?「训练」如何调整这些参数,按什么标准调整?「损失」又怎么算?

这一周看完 3Blue1Brown 神经网络系列的前四集和 Michael Nielsen 在线书的前两章,把这条链路走通了。训练是一个数值优化循环:先定义一个函数,把模型当前的误差算成一个数(损失),再算这个数对每个参数的变化率(梯度),然后让参数沿使损失减小的方向移动一小步,重复多次。反向传播按链式法则从后往前计算这些变化率,避免重复计算。

这篇笔记按我当时弄懂的顺序写:先拟合一条直线,再讲感知机和激活函数,然后是损失函数、梯度下降,最后用 numpy 手写一个两层网络,把反向传播的每一步落到实处。

从拟合一条直线开始

最能建立直觉的例子是线性回归。假设手里有一堆房价和面积的数据点,想找个模型预测房价。最简单的模型是一条直线:

这里的 就是这个模型的全部「参数」。训练这个模型就是调整这两个数,让直线在已有数据上的预测误差尽量小。这个只有两个参数的模型已经包含神经网络训练的基本要素:参数、预测值 和误差;还需要一个自动调整参数的方法。

要把调整参数写成程序,需要一个把误差算成数值的函数,以及一个确定每个参数更新方向和步长的方法。前者叫损失函数,后者叫梯度下降。深度学习在这两项计算上扩展了模型结构和参数规模。

感知机和激活函数

感知机是经典的神经元模型:把若干输入各自乘权重后相加,超过某个阈值就输出 1,否则输出 0。它等价于用一个线性决策边界将输入空间中的点分为两类。

单个感知机只能表示线性可分的分类问题。教科书里的经典反例是 XOR(异或)问题,输入是 (0,0)、(0,1)、(1,0)、(1,1) 四个点,要求 (0,0) 和 (1,1) 输出 0,另外两个输出 1。在平面上这四个点找不到一条直线把两类分开,所以单个感知机学不会 XOR。多层网络可先用一层神经元生成中间表示,再由后一层组合这些结果,形成更复杂的决策边界。

要用梯度法训练多层网络,每个神经元通常使用可导的激活函数,而不是阶跃函数。sigmoid 是其中一种:

sigmoid 的输出随参数连续变化,网络对参数也连续可导,因此可以计算梯度。对于定义在紧致集合上的连续函数,在满足激活函数等条件时,单隐层网络只要神经元足够多就可以任意逼近它。这是通用近似定理;我当时只接受了结论,没看证明。

损失函数

回到房价的例子。「预测得不好」要变成一个数,常用的写法是均方误差(MSE):把所有样本上「预测值减真实值」的平方加起来取平均:

平方会消除误差的符号,并让较大的误差产生按平方增长的损失;这个函数也光滑可导。训练要最小化这个数

到这里,「训练」的定义就完整了: 是所有参数的函数,调参数就是在这个函数上寻找较小的值。分类任务里常用另一个损失函数叫交叉熵,适用于「输出是概率分布」的场景,我当时只知道它存在,没有细看,先记下来。

梯度下降如何更新参数

是参数的函数,那每个参数该往哪边调?答案是这个点上的偏导数。以 为例, 的含义是: 增大一点点, 会变多少。这个值为正,说明增大 会让损失变大,更新时就减小 ;为负时则相反。更新规则是:

是学习率,控制每一步走多远。梯度指向局部最陡的上升方向,取负号得到局部最陡的下降方向。学习率太大时,参数可能跨过较低点并在两侧震荡;太小时,需要更多步才可能收敛。对于非凸网络,梯度下降也不保证找到全局最小值。工程上调参很大一部分就是和这个数较劲。

这个图景里每个参数都对应一个要算的偏导。房价模型只有两个参数,手算就行;真实网络的参数以百万计,逐个数值求导不现实。反向传播解决的就是这个计算量问题。

反向传播如何计算梯度

先说我当时的一个误解。一开始我以为反向传播是梯度下降之外的另一种训练算法,看完 Nielsen 第二章才明白:反向传播本身不更新参数,它只负责把「损失对每个参数的偏导」高效地算出来。真正决定参数怎么动的,还是上一节那条更新规则。

网络的计算是一层套一层的复合函数:输入乘权重、过激活、再乘下一层权重、再过激活,最后和真实值比较得到损失。复合函数求导用链式法则。前面各层的导数式子共享大量相同的中间因子。逐个参数计算导数会重复计算这些因子;从损失端向前计算时,每过一层只需乘上该层的局部导数,并将结果传给前一层。这样可复用已计算的中间结果。

拿一个具体的两层网络过一遍。结构是:2 维输入,4 个隐层神经元(sigmoid 激活),1 个输出。前向计算是:

损失用 MSE。对包含 个样本的批次,损失对输出的导数是 。将它乘上 sigmoid 的导数,得到 的梯度 ;有了 的梯度就是 。再将 传回隐层,并乘隐层的 sigmoid 导数得到 ,进而得到 的梯度。每一步都使用链式法则计算本层参数的导数。

下面照这个流程写了完整实现,在 XOR 数据集上训练,只依赖 numpy,可以直接运行:

import numpy as np

np.random.seed(42)

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

def sigmoid_grad(a):        # a = sigmoid(z),导数可用 a 表示:a * (1 - a)
    return a * (1 - a)

# XOR:单个感知机学不会的四个点
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
Y = np.array([[0], [1], [1], [0]], dtype=float)

W1 = np.random.randn(2, 4) * 0.5   # 输入层 -> 隐层
b1 = np.zeros((1, 4))
W2 = np.random.randn(4, 1) * 0.5   # 隐层 -> 输出
b2 = np.zeros((1, 1))

lr = 1.0
for epoch in range(5000):
    # 前向:算出预测,记下每层的中间结果
    z1 = X @ W1 + b1
    a1 = sigmoid(z1)
    z2 = a1 @ W2 + b2
    a2 = sigmoid(z2)
    loss = np.mean((a2 - Y) ** 2)

    # 反向:从损失出发,逐层把误差记账回每个参数
    n = X.shape[0]
    dz2 = 2 * (a2 - Y) * sigmoid_grad(a2) / n
    dW2 = a1.T @ dz2
    db2 = dz2.sum(axis=0, keepdims=True)
    dz1 = (dz2 @ W2.T) * sigmoid_grad(a1)
    dW1 = X.T @ dz1
    db1 = dz1.sum(axis=0, keepdims=True)

    # 梯度下降:所有参数沿负梯度挪一小步
    W2 -= lr * dW2; b2 -= lr * db2
    W1 -= lr * dW1; b1 -= lr * db1

    if epoch % 1000 == 0:
        print(f"epoch {epoch:5d}  loss {loss:.4f}")

跑起来的输出大致是:

epoch     0  loss 0.2557
epoch  1000  loss 0.2033
epoch  2000  loss 0.0052
epoch  3000  loss 0.0017
epoch  4000  loss 0.0010

loss 从 0.25 降到 0.001,四个点的预测值约是 0.03、0.97、0.97、0.03,XOR 被学会了。这个示例使用 1 个包含 4 个 sigmoid 神经元的隐层,因此能表示 XOR 的非线性边界。

损失曲面上的梯度下降路径与两层网络的反向传播记账

前向时每层的中间结果(z1、a1)必须保留,反向时直接取用,这些缓存避免了重复计算。sigmoid 的导数可以写成 ,所以代码里 sigmoid_grad 直接接收激活值,不用再算一次 exp。参数需要随机初始化,不能全部从零开始:全零初始化会让同一隐层的所有神经元保持相同的参数和梯度,学到完全一样的特征,网络等价于每层只保留一个神经元。这一条是我踩了坑之后才查明白的。

当时还没搞懂的问题

如实记录当时的疑问,留给后面。

第一,通用近似定理说的是「存在这样一组参数」,但梯度下降凭什么能找到它,我当时只有「高维空间的损失面大部分是平缓的坡、少有真正的深坑」这种二手直觉,没有判断力。第二,为什么网络加深效果会变好,3Blue1Brown 和 Nielsen 都只给了「特征逐层抽象」的定性说法,我接受了这个说法,但知道它不算解释。第三,这个 40 行的手写版本和 PyTorch 的 autograd 之间隔着什么,我当时还没碰框架,只知道框架能替你做反向传播,不知道自动微分具体怎么实现。

至于 ChatGPT 和 GPT-3,离这一层还很远。现在搞清楚的是「参数怎么被数据一点点调出来」这件事本身;词怎么变成数字、句子顺序怎么处理、Transformer 是什么,都是接下来要补的课。此刻我甚至不知道合理的学习顺序是什么,只能读到哪算哪。

本篇参考的资料


496 字 · 47 段落
ximing

Written by ximingFollow onGitHub

相关文章