【草稿】从反向传播到小语言模型
参考的是 Andrej Karpathy 的经典课程 Neural Networks: Zero to Hero 视频。
反向传播
反向传播是经典的底层机制,作者提供的仓库在 Micrograd.
我们考虑可以良好分层的树。先从源节点到汇节点(记作 $L$)将数据值正向传播(就是计算);然后考虑反向传播,在每个叶节点加一个字段 .grad 表示 $L$ 关于它的偏导,对 $e = \mathrm{op}(b_1, \dots, b_n)$ 只需:
$$\frac{\partial L}{\partial b_i} = \frac{\partial L}{\partial e} \cdot \frac{\partial e}{\partial b_i}$$
如果变量被重复使用,则梯度叠加即可。
=
+=
+=
=
return
=
+= *
+= *
=
return
在反向传播时需要进行拓扑排序:
=
=
= 1.0
在 PyTorch 中,也存在对应的功能:
=
...
=
假若我们希望让 $L$ 尽量大,则应该让源节点沿着梯度走某个微小的步长(a.data += a.grad * step),调整完所有源节点后,再做一次正向传播,如此循环。
现在考虑更现实的模型。考虑神经元,一个神经元接受 $x_1, \dots, x_n$,通过权重和偏置项(bias)计算 $\sum w_i x_i + b$,再将此值喂给一个激活函数,使之非线性。激活函数通常是压缩函数,如:
| 名称 | 效果 |
|---|---|
| Sigmoid | $1 / (1 + e^{-x})$ |
| Leaky ReLU | $\max(0.1x, x)$ |
| tanh | $\tanh(x)$ |
| Maxout | $\max(w_1^\top x + b_1, w_2^\top x + b_2)$ |
| ReLU | $\max(0, x)$ |
| ELU | $\begin{cases} x & x \geq 0 \cr \alpha (e^x-1) & x < 0 \end{cases}$ |
=
=
=
return
return +
=
=
return
return
# 多层感知机
= +
=
=
return
return
一个回归分析的例子,使用最小二乘法给出损失函数:
# forward pass
=
=
# backward pass
= 0.0
# update
+= -0.05 *
在实际应用中,数据集规模达到百万级别时,通常随机选取一个子集,称为批次(batch),只对它作计算。
有时使用 L2 正则化技术去增强泛化能力。
- 使用 Residual Connection,使得更深的层数不会变劣
- 选择 ReLU
- 进行好的初始化
- 使用 dropout,即每一层大量神经元时每次临时隐藏一半神经元
- 使用 weight decay,如加入 L2 正则
- 依据 batch size 调整 learning rate
- 使用 warm up 及 learning rate decay
Bigram
作者提供的仓库在 Makemore,是一个把字符视作基本元素,生成人名的模型。
最简单的方法是 bigram 模型。用 <S> 标记开头,用 <E> 标记结尾,统计所有的二元组在所有的词中的出现次数;在生成时,从 <S> 开始,每次按概率生成下一个字符,直到 <E>. 不过这样的效果很差。
为了衡量模型的优质程度,我们使用似然(likelihood),定义为整个概率表格中所有概率的乘积。为了方便计算我们使用其对数的负,再除以 $n$,这个值越高模型表现越差。我们也可以对一个指定的词计算这个值。
有时一个二元组从未出现过,计算时对数会变为负无穷。为了让模型平滑一些,我们将所有的计数增加 $1$.
现在用基于梯度的学习方法实现 bigram 的效果。我们准备 27 个接受 27 个输入的神经元,对一个单词的 $m$ 个二元组,将它们 one-hot 编码(编码为长 27 的数组,除一处是 1 外均为 0),然后前向传播得到概率:
# "Emma"
# xs = tensor([0, 5, 13, 13, 1])
# ys = tensor([5, 13, 13, 1, 0])
# 初始化神经元的权重
=
= # 正态分布 N(0, 1)
# one-hot 编码
=
= @ # 矩阵乘法 (m, 27) @ (27, 27) => (m, 27) 此结果表示对数计数值
=
= /
# 最后两行合称 `softmax`
计算损失函数:
=
=
= - # negative log likelihood
=
=
# 向量化地写为
# loss = -probs[torch.argane(5), ys].log().mean()
现在就可以仿照上一节做反向传播。
= None
+= -0.1 *
改为考虑所有单词的所有二元组,然后增大 learning rate. 最终的效果不会好于 bigram,因为没有引入额外的信息;但这种方法更灵活,可以将它扩展到利用 $n$-元组的结果。
在这种方法中,等效于之前方法中平滑处理的是:尽量让权重 W 趋于零,进行这种引导的就是正则化方法。
= ... + 0.01 *
多层感知机
如果用朴素的方法扩展到 $n$-元组,则需要的存储空间会相当巨大。
作者参考的论文是 Bengio et al. 2003,该论文中关注的是词(我们继续考虑字符),为 17000 个词中的每一个分配到一个 30 维的向量。建模方法则和之前是类似的:

这里中间的那层称为隐藏层,其大小称为超参数,可任意设定。
= 27
= 3 # 用于预测下一字符的字符数
= 10 # 嵌入的维数
= 200 # 隐藏层的神经元个数
= 32 # 批次大小
=
=
=
=
=
=
=
# minibatch construct
=
# forward pass
= # (batch_size, block_size, n_embd)
= # (batch_size, n_hidden)
# tensor 实际上是用一维数组存储的,所以直接 `view` 即可
# 参数也可改为 `-1` 表示自动推断
= @ +
=
= @ + # (batch_size, vocab_size)
# counts = logits.exp()
# prob = counts / counts.sum(1, keepdims=True)
# loss = -prob[torch.arange(batch_size), Y].log().mean()
=
# 过大的正数 `exp()` 后会变成 `nan`,此函数内部会减去最大的正数
# 或者用 `prob = F.softmax(logits, dim=1)`
# backward pass
= None
# update
+= -0.1 *
为了确定合适的 learning rate,可以选取 -1 ~ -0.001 中的值(10**torch.linspace(-3, 0, 1000)),计算效果然后绘图查看不稳定性。
训练到一定程度后可以降低 learning rate. 经过数千个循环后 loss 将低于 bigram 模型。但这个值低并不总是好的,可能发生过拟合。为了防止过拟合,我们会把数据分为训练集(~80%)、开放/验证集(~10%,用于调整超参数,如隐藏层大小、嵌入层大小、正则化强度)、测试集(~10%)三份。
在生成时:
=
=
=
=
= @ +
=
=
= +
break
我们稍微测试一下,会发现最开始的 loss 相当大,因此考虑初始化时让 W1 是原本初始化的 0.2 倍,b1、W2 是原本的 0.01 倍,b2 为零。我们希望避免这样的情况:
以 ReLU 为例,如果一个神经元不会被任何一组数据激活(始终落在 $< 0$ 区域),则在训练时梯度会被归零,那么这个神经元的权重与偏置根本不会被改变,从满足这个条件开始可以视为这个神经元“已死”。对 tanh 等压缩函数,情况也类似(分布在 $\pm 1$ 附近)。
在较深、较复杂的网络中,不良的初始化可能导致根本无法训练。
那如何调这个参数呢?我们希望让 hpreact 大致是 $\mathcal N(0, 1)$ 的高斯分布。可以参考 Delving Deep into Rectifiers 论文。在 PyTorch 中可以调用 torch.nn.init 中的函数。参考以下表格:
| nonlinearity | gain |
|---|---|
| Linear / Identity | 1 |
| Conv{1,2,3}D | 1 |
| Sigmoid | 1 |
| Tanh | $5/3$ |
| ReLU | $\sqrt{2}$ |
| Leaky Relu | $\sqrt{2 / (1 + \text{negative\textunderscore slope}^2)}$ |
| SELU | $3/4$ |
$$\text{std} = \frac{\text{gain}}{\sqrt{\text{fan\textunderscore mode}}} \tag{Kaiming}$$
这里 fan_mode 我们取 fan_in,指一个层的输入节点数量。
= * /
之后的技术,如 residue connections、使用 normalization layers、更先进的优化器,使得初始化可以不需要特别精细。考虑 Batch Normalization:直接将 hpreact 归一化,这是可微的操作。
= /
但是我们希望允许在非初始化阶段这个分布可以调整,因此额外引入矩阵,并将它们作为可训练的参数:
# bngain = torch.ones((1, n_hidden))
# bnbias = torch.zeros((1, n_hidden))
= * ... +
由于批次是随机选择的,logits 及 h 会抖动。也许这会被认为是某种缺陷,但实际上它起到的是积极效果,产生类似正则化的效果。
因为人们不希望前向传播的过程中 batch 的数据耦合起来,人们尝试弃用 batch normalization,转而采用其它归一化技术。
现在讨论这个问题:训练时使用 batch normalization,那我们在使用时如何对单个样本作预测呢?
# calibrate the batch norm at the end of training
=
=
= @ +
=
=