目录

目录

Stanford-CS224N

目录

每个不同的向量代表一个单词,例如:

motel = [0 0 0 0 0 0 1]  
hotel = [0 0 0 0 0 1 0]

这个方案的缺点很明显: 没法判断每个向量之间的相似性,虽然上面motel和hotel代表的向量是正交的,但它们之间相互有关联;其次这些one-bot vector的维度会非常大,因为词汇表中往往有几十万的单词。

基于单词的上下文获得它的含义,通过学习各个单词的含义得到更加密集的词向量。

expect = [
          0.286,
          0.792
          ...
         ]

重要假设:文本中里的最近的词语相似度越高

  • CBOW: 用中心词预测上下文词
  • skip-gram: 用上下文词预测中心词

上下文词的数量取决于窗口大小,中心词是每次遍历的词语。实战中除了用上下文来预测中心词,还需要用到负样本词,也就是非上下文词,具体用多少或者是否采用,取决于语料库大小。

对于每一个中心词,我们给其他词出现的概率定义为:

P(wotherwcenter) P(w_{other} \mid w_{center})

假设j是窗口大小,对于每个中心词,它上下文词语出现的概率即为:

P(wcenter+jwcenter) P(w_{center+j} \mid w_{center})

我们希望P(上下文词∣中心词) 越高,这样就说明我们预测越准确。

对于单个中心词的似然函数是:

Lt=cjc,j0P(wt+jwt) L_t = \prod_{-c \le j \le c, j \ne 0} P(w_{t+j} \mid w_t)

假设玩一个预测游戏,中心词是 “猫”,窗口内上下文词有两个:[“在”, “睡觉”]。模型预测:“在”出现的概率 = 0.8,“睡觉”出现的概率 = 0.9,同时猜对两个词的概率就是:0.8×0.9=0.72,乘起来,就是“两个词都预测对的概率”。

整个语料库的似然函数/目标损失函数就是:

L=t=1Tcjc,j0P(wt+jwt) L = \prod_{t=1}^{T} \prod_{-c \le j \le c, j \ne 0} P(w_{t+j} \mid w_t)

一般使用两个技巧来优化 Loss:

  1. 一般都是最小化而不是最大化,所以对Loss取负数,之后采用梯度下降
  2. 采用取对数减小数字大小,方便计算

最后的目标损失函数即为:

J=1Tt=1Tcjc,j0logP(wt+jwt) J = -\frac{1}{T} \sum_{t=1}^{T} \sum_{-c \le j \le c, j \ne 0} \log P(w_{t+j} \mid w_t)

给定中心词预测上下文词的公式如下:

P(wowi)=exp(vwoTvwi)w=1Vexp(vwTvwi) P(w_o \mid w_i) = \frac{\exp(v_{w_o}'^T v_{w_i})}{\sum_{w=1}^{V} \exp(v_w'^T v_{w_i})}
  1. 通过中心词和上下文词的内积,计算他们之间的相似度
  2. 通过exp取指数,它保证输出是正数。概率需要是正数
  3. 通过归一化使得它是0-1的小数,也就是说P(wowi)=该词在上下文i中的得分所有词在上下文i中的得分 P(w_o \mid w_i) = \frac{\text{该词在上下文i中的得分}}{ \text{所有词在上下文i中的得分}}

那前面说的中心词和上下文词的向量是如何得到的呢?一开始模型会为这些词随机一个向量,然后在梯度下降的过程中优化这些向量。


Word2Vec的目标函数/损失函数与深度学习中回归问题的损失函数有所不同。

  • 回归问题通常是用对已知值和预测值的差进行拟合
  • Word2Vec只是要求模型“预测的概率分布”尽量让真实上下文词的概率高

Word2Vec 的思想是“最大化概率”,它不直接比较预测值和真实值,而是要求模型“预测的概率分布”尽量让真实上下文词的概率高。

即:

\[ max\prod{P(真实上下文词∣中心词)} \]

回归问题是希望"预测值-真实值"尽可能小,而Word2Vec是希望概率尽可能大(取负数之后尽可能小),也可以看做是一个Loss Function。

θjnew:=θjoldηθJ(θ) \theta^{new}_j := \theta^{old}_j - \eta \, \nabla_{\theta} J(\theta)

传统梯度下降算法的缺点在于:

  1. 计算成本大:J(θ)=1Ni=1NL(xi,yi;θ)J(\theta) = \frac{1}{N} \sum_{i=1}^{N} L(x_i, y_i; \theta),这意味着每一次计算梯度都需要全部样本参与计算,开销很大
  2. 容易陷入局部最小值

仅仅在全部数据集中选取一个很小的子集,例如16或32个数据,用这些数据充当完整的数据集来计算损失函数和优化梯度

在深度学习中出现噪声经常会有更好的效果

Skip-Gram用的是Softmax概率:

P(wowi)=exp(vwoTvwi)w=1Vexp(vwTvwi) P(w_o \mid w_i) = \frac{\exp(v_{w_o}'^T v_{w_i})}{\sum_{w=1}^{V} \exp(v_w'^T v_{w_i})}

也就是说会计算整个Corpus的分母,计算量非常大。


负采样的思想是,不再计算整个词表的概率,而是单单判断正样本(上下文)和负样本(随机采样的非上下文词)

对于一个中心词wiw_i和一个上下文词wow_o以及负样本集K,目标函数是:

J(wi,wo)=logσ(vwovwi)k=1Klogσ(vwkvwi) J(w_i, w_o) = - \log \sigma(v_{w_o}' \cdot v_{w_i}) - \sum_{k=1}^{K} \log \sigma(-v_{w_k}' \cdot v_{w_i})

回顾之前提到的构造词向量的方法:one-hot编码和word2vec思想,还有一种更简单的表示思路。让相邻的词的向量表示相似,直接统计哪些词是经常一起出现的。下面图片就是CS224N课程中举的例子,根据三个句子的语料库构造的共现矩阵。

这样的表示明显优于one-hot表示,因为它的每一维都有含义——共现次数,因此这样的向量表示可以求词语之间的相似度。
但是这样表示还有有一些问题:

  1. 维度=词汇量大小,还是太大了
  2. 还是太过于稀疏,在做下游任务的时候依然不够方便。

对于第一个问题可以采用SVD奇异值分解的方法,它可以将任意矩阵分解为三个矩阵的乘积。为了减少尺度同时尽量保存有效信息,保留对角矩阵的最大的k个值,并将矩阵U,V 的相应的行列保留。

GloVe=Global+Vector,它的核心理念是:如果两个词在语料库中经常出现在相似的上下文中,它们的词向量就应该相似。

但不同于 Word2Vec 的「预测式」学习,GloVe 是一个「统计式」模型,直接利用共现矩阵的全局统计信息。

人为的定义共现概率:

P(ji)=Pij=XijXi P(j|i)=P_{ij}=\frac{X_{ij}}{X_i}
  • XijX_{ij}是词j出现在词i上下文的总次数
  • Xi=kXikX_i=\sum_kX_{ik},也就是词i的上下文总数

举个例子,Corpus中有下面两个句子,并且我们需要计算P(I,like):

I like A
I like B

那我们可以得到共现矩阵:

I like A B
I 0 2 0 0
like 2 0 1 1
A 0 1 0 0
B 0 1 0 0

根据公式可以得到:

P(I,like)=XI,likeXI P(I,like)=\frac{X_{I,like}}{X_I}

从共现矩阵可以看到,XI,like=2X_{I,like}=2。假设窗口大小为1,那么XI=2X_I=2P(I,like)=1P(I,like)=1;假设窗口大小为2,那么P(I,like)=12P(I,like)=\frac{1}{2}


GloVe 的一个非常直观的出发点是:

某些词之间的意义差异可以通过共现概率比值体现。

P(applered)P(applegreen)=Pred,applePgreen,apple1 \frac{P(apple|red)}{P(apple|green)} = \frac{P_{red,apple}}{P_{green,apple}} \ge 1

那么就认为red比green相对apple更有意义.

于是GloVe希望学习到一个函数能够近似概率比值:

F(wi,wj,wk~)=PikPjk F(wi, wj, \tilde{w_k})=\frac{P_{ik}}{P_{jk}}

经过一系列数学推导(下文会计算),词向量的点乘可以近似于:

wiTwj+bi+bjlogXij w_i^Tw_j+b_i+b_j \approx \log{X_{ij}}

此时损失函数就变成了拟合这个关系,我们希望他们尽可能相等,也就是

wiTwj+bi+bjlogXij w_i^Tw_j+b_i+b_j - \log{X_{ij}}

尽可能等于0

\[ J=\sum_{i,j}(​{w_i^T​w_j​+b_i​+b_j​−\log{X_{ij}}​})^2 \]

得到目标函数/损失函数的表达式后的操作就很简单了,对式子求偏导进行梯度下降,优化随机生成的词向量wiw_iwjw_j


某些词(比如 “the”)出现太多,会让损失被它们主导。所以 GloVe 加了一个权重函数f(Xij)f(X_{ij})f(x)f(x) 越大,代表词的重要性越高。

\[ J=\sum_{i,j}f(X_{ij})(​{w_i^T​w_j​+b_i​+b_j​−\log{X_{ij}}​})^2 \]

与Word2Vec的目标函数相比,GloVe的损失函数更像回归问题


Glove 的作者认为,单词词向量空间是一个线性结构,例如 “man” - “women” 的差与 “king” - “queen” 的差很相近.

作者假设存在一个函数:

F(wiwj,wk)=PikPjk F(w_i-w_j,w_k)=\frac{P_{ik}}{P_{jk}}

并且假设F是一个点乘的计算即:

F(wiwj,wk)=wkT(wiwj)=logPikPjk=logPiklogPjk F(w_i-w_j,w_k)=w_k^T(w_i-w_j)=\log{\frac{P_{ik}}{P_{jk}}}=\log{P_{ik}}-\log{P_{jk}}

这个式子可以化简为:

wiTwkwjTwk=log(Pik)log(Pjk) w_i^T w_k - w_j^T w_k = \log(P_{ik}) - \log(P_{jk})

从中可以看到存在这样的关系:

wiTwklog(Pik)=logXikXi=logXiklog(Xi) w_i^T w_k \approx \log(P_{ik}) = \log{\frac{X_{ik}}{X_i}}=\log{X_{ik}-\log(X_i)}

即:

wiTwk+log(Xi)logXik w_i^T w_k + \log(X_i) \approx \log{X_{ik}}

我们可以抽象出一般的形式,bib_ibk~\tilde{b_k}是偏置,吸收了log(Xi)\log(X_i)和其他常数:

wiTwj+bi+bj~logXij w_i^Tw_j + b_i + \tilde{b_j} \approx \log{X_{ij}}

值得注意的是,在 GloVe 中,每个词都有两个向量:

词向量 wiw_i — 表示这个词本身

上下文向量 wj~\tilde{w_j} — 表示当这个词出现在别的词的上下文中时的“角色”

并不是每次出现的上下文词直接拿句子里的其他词的向量,而是每个词都有一个固定的上下文向量,用来参与 共现对的预测。

最后训练结束词向量一般直接用wiw_i或者取wiw_iwi~\tilde{w_i}的平均值

x(Wx+b)=W \frac{\partial}{\partial{\textbf{x}}}(\textbf{Wx+b})=\textbf{W} b(Wx+b)=I \frac{\partial}{\partial{\textbf{b}}}(\textbf{Wx+b})=\textbf{I} u(uTh)=hT \frac{\partial}{\partial{\textbf{u}}}(\textbf{u}^Th)=\textbf{h}^T \[ \textbf{h}=f(\textbf{z}) \\ h_i=f(z_i) 且 \textbf{h} ,\textbf{z} \in R^n \]

可以得到:

(hz)ij=hizj=f(zi)zj={f(zi),  if  i=j0,  if  otherwisehz=(f(z1)00f(zn))=diag(f(z)) (\frac{\partial{h}}{\partial{z}})_{ij}=\frac{\partial{h_i}}{\partial{z_j}}=\frac{\partial{f(z_i)}}{\partial{z_j}}= \begin{cases} f'(z_i),\;if\;i=j\\ 0,\;if\;otherwise \end{cases} \\ \frac{\partial h}{\partial z} = \begin{pmatrix} f'(z_1) & & 0 \\ & \ddots & \\ 0 & & f'(z_n) \end{pmatrix} = \operatorname{diag}(f'(z))

  • 前向传播就是单纯的计算
  • 反向传播是根据梯度进行学习

单输入单输出的情况下,下游梯度就是局部梯度与上游梯度的乘积。

sz=hz×sh \frac{\partial{s}}{\partial{z}}=\frac{\partial{h}}{\partial{z}} \times \frac{\partial{s}}{\partial{h}}

多输入的情况下仍然遵循链式法则。


如图是一种错误的计算反向传播的方式,如果依次计算sW\frac{\partial{s}}{\partial{W}}sb\frac{\partial{s}}{\partial{b}},那么会有一部分计算是重复的,就导致反向传播的效率下降。正确的方式应该是先计算公共部分,然后再计算单独的部分,这可以通过拓扑排序来实现。

class multiplyGate():
    def forward(x, y):
        self.x = x
        self.y = y
        return x*y
    def backward(dz):
        dx = self.y * dz  # dz/dx * dL/dz
        dy = self.x * dz
        return [dx, dy]

传统的传统句法(如短语结构语法)认为句子是由短语组成的层级结构。以I saw a cat under the table为例子

  • table是名词
  • the table 组成名词短语
  • under 是介词
  • under the table 组成介词短语
  • a cat under the table 又组成一个名词短语…..

依存语法的出发点不同,它提出了一个非常重要的假设:

句子的结构由词与词之间的依存关系(dependency)决定。

以 “I like you” 举例:

like -> I    (nsubj)
like -> you  (obj)
  • 一般动词都是一个句子的核心
  • I 依赖于 like,依赖关系是主语(nsubj)
  • you 依赖于 like, 依赖关系是宾语(obj)

Dependency Parsing = 给句子中的每个词,确定它依赖于哪个词,也就是预测词之间的依存关系。

当我们预测句子中词与词的依存关系时,模型应该考虑哪些信息来源?

  1. Bilexical affinities: 两个具体词之间的亲和度
  2. Dependency distance: 依存关系之间的距离
  3. Intervening material: 依存关系通常不会跨越动词或标点等强结构边界
  4. Valency of heads: 一个中心词一般有规定数量和方向的依存词,例如happy通常只修饰一个名词

为了保证结果合法,依存分析通常要满足一些约束:

  1. Only one word is dependent of ROOT 整个句子只有一个核心动词(一个 root)
  2. No cycles (A→B, B→A) 不允许形成环,否则依存树不成立
  3. Dependencies form a tree 所有依存边连通、无环,每个节点只有一个入边

箭头是否可以交叉?

  • Projective tree(投射句法):所有依存边都不交叉 → 典型的英语句子结构
  • Non-projective tree(非投射句法):有交叉依存 → 常见于自由语序语言(如德语、俄语、中文)
  • 动态规划
  • 图算法
  • 约束满足法
  • 转移系统:维护一个 Stack + Buffer。由于变成一个线性的计算过程,所以时间复杂度也是线性的,相较于其他两种方法低很多。

转移系统(Transition-based Dependency Parsing) 是依存句法分析中最直观、工程上最常用的一类算法。它维护了两个数据结构:Stack和Buffer,并且只进行三个操作:Shift,Left-Arc,Right-Arc。

以句子"I eat apples"为例:

1.Initial
Stack = [ROOT]
Buffer = [I, eat, apples]
Arcs = []

2.Shift
Stack = [ROOT, I]
Buffer = [eat, apples]
Arcs = []

3.Shift
Stack = [ROOT, I, eat]
Buffer = [apples]
Arcs = []

4.Left-Arc
Stack = [ROOT, eat]
Buffer = [apples]
Arcs = [(eat, I)]

5.Shift
Stack = [ROOT, eat, apples]
Buffer = []
Arcs = [(eat, I)]

6.Right-Arc
Stack = [ROOT, eat]
Buffer = []
Arcs = [(eat, I), (eat, apples)]

7.Right-Arc
Stack = []
Buffer = []
Arcs = [(eat, I), (eat, apples), (ROOT, eat)]
依存树:
ROOT
 └── eat
      ├── I
      └── apples

从中可以看出,对于深度学习模型它需要做的就是判断每次需要进行哪个操作,三选一。

在深度学习中训练模型需要有一个损失函数,根据梯度进行优化,也就是说我们需要一个指标来评价预测的依存关系的好与坏。

Gold:                  Parsed:
1  2 She     nsubj     1  2 She     nsubj
2  0 saw     root      2  0 saw     root
3  5 the     det       3  4 the     det
4  5 video   nn        4  5 video   nsubj
5  2 lecture obj       5  2 lecture ccomp
  1. UAS \( UAS=\frac{正确预测的head词数}{总词数}=\frac{4}{5}=80\% \)
  2. LAS \( LAS=\frac{head和label都预测正式的词数}{总词数}=\frac{2}{5}=40\% \)

在用神经网络对依存分析的每一步操作进行预测的时候,需要当前状态的一个输入值,这个输入的向量应该怎么得到呢?

很显然我们目前有Stack和Buffer还有Arcs三个状态,但是如果把全部的信息一股脑的输入给神经网络就冗余了,它只需要其中的几个关键信息:

  1. Stack栈顶和次栈顶的元素: s1, s2
  2. Buffer最前面马上要被处理的词: b1
  3. 在Arcs中s1, s2, b1的左右孩子

需要Arcs中元素对的原因是:假如一个词已经拥有了主语就不可能再有一个主语了


知道了模型输入需要的全部信息,接下来就要考虑怎么把它传输给模型。

对于每个被选中的词(如 s1、s2、b1 及其子节点),我们可以取它的词向量(word embedding),同时拼接其他特征的向量,如:

  • 词性(POS)嵌入
  • 依存关系标签嵌入(对已建立的弧)
  • 是否是根节点、是否已有父节点等布尔特征

最终,这些向量会被拼接成一个大的状态表示向量:

\[ x=[emb(s1​),emb(s2​),emb(b1​),emb(children of s1​),...] \]
  1. LM可以通过一句话的前n个词,计算出下一个词是某个词的概率。
P(xt+1xt,...,x1) P(x^{t+1}|x^{t},...,x^1)
  1. LM可以计算出某句话出现的概率
P(x1,...,xt)=P(x1)P(x2x1)... P(x^1,...,x^{t})=P(x^1) \cdot P(x^2|x^1) \cdot ...

N-gram 指的是一堆连续的词,而 N 指的是这一堆词的个数。一个 N-gram 的 LM 就可以根据前 N-1 个词,预测出第 N 个词的概率。

对于一个N-gram的LM,我们需要做一个假设:某个词出现的概率只由其前N-1个词决定。

假如我们有一个 3-gram 的 LM,要根据“今天天气怎么样”来预测下一个词,那么只能使用“怎么样”来预测,而不看之前的字。

P(xtxt1,...,t1)=P(xtxt1,...,xxN+1)=xt,xt1,...,ttN+1P(xt1,...,xtN+1) P(x^t|x^{t-1},...,t^1) \\ =P(x^t|x^{t-1},...,x^{x-N+1}) \\ =\frac{x^t,x^{t-1},...,t^{t-N+1}}{P(x^{t-1},...,x^{t-N+1})}
  • 等式1就是根据之前的假设得到的
  • 等式2是通过条件概率计算得到的
  • 概率用语料库中出现的频率来估计

稀疏性问题

  1. 当分子在语料中不存在的时候,可以采用一个很小的数补上,例如0.25
  2. 当分母在语料中不存在的时候,我们可以采用 backoff 的策略,统计(N-2)-gram的个数。

存储问题

虽然这种基于计数的LM的很简单,但是我们必须穷举出预料中所有可能的N-gram,并逐一去计数、保存,N一旦大起来的话,模型的大小就会陡增。


对于文本生成来说,这种基于计数的LM则有很大问题了。这里拿CS224N的PPT上的一个例子来说明:

RNN 的设计思路如下:

ht=tanh(Wxxt+Whht1+bh) h_t=tanh(Wx \cdot x_t + W_h \cdot h_{t-1} + b_h)
  1. 传入文本的 one-hot 编码
  2. 在嵌入层中取出每个词对应的 embedding
  3. 开始循环处理:首先初始化一个矩阵 w0w_0,每次循环用一个矩阵wiw_i和词向量相乘,再与上一个矩阵相加得到新矩阵,循环往复。
  4. 最后进行 softmax

但是 RNN 的缺点也很明显:

  1. 循环的次数和文本长度有关,效率低
  2. 长文本难以捕捉相互关系

训练过程

我们把序列作为输入,输入到RNN网络中,每一步都可以得到一个输出,这个输出即为当前步的下一个词的概率分布。我们使用这个概率分布可以和真实的概率分布计算一个损失。明确了损失函数,我们就很容易去训练了。

以上图为例,首先输入the,得到下一个词的概率分布,与students计算损失。之后输入第二字词students与正确的opened计算损失。最后将损失相加计算平均损失。

在大训练文本上通常会固定大小切割一次,不会考虑语言细节。并且这个好处在于可以组合成为矩阵方便输入。

RNN 在进行反向传播,计算第 j 步的损失对前面的一步梯度的时候,需要运用链式法则:

Jjhi=Jjhjhjhi=Jjhjhjhj1hi+1hi=Jjhji<tjhtht1 \begin{aligned} \frac{\partial J^j}{\partial h^i} &= \frac{\partial J^j}{\partial h^j} \cdot \frac{\partial h^j}{\partial h^i} \\ &= \frac{\partial J^j}{\partial h^j} \cdot \frac{\partial h^j}{\partial h^{j-1}} \cdot \ldots \cdot \frac{\partial h^{i+1}}{\partial h^i} \\ &= \frac{\partial J^j}{\partial h^j} \cdot \prod_{i<t\le j} \frac{\partial h^t}{\partial h^{t-1}} \end{aligned}

根据 RNN 的公式ht=tanh(Wxxt+Whht1+bh)h_t=tanh(Wx \cdot x_t + W_h \cdot h_{t-1} + b_h),将激活函数忽略可以得到,hth_tht1h_{t-1}的偏导就是 W,所以我们可以近似得到:

Jjhi=JjhjWji \frac{\partial{J^j}}{\partial{h^i}} = \frac{\partial{J^j}}{\partial{h^j}} \cdot W^{j-i}

可以看出,当 W 很小或者很大,同时 i 和 j 相差很远的时候,由于公式里有一个指数运算,这个梯度就会出现异常,变得超大或者超小,也就是所谓的“梯度消失/梯度爆炸”问题。

梯度爆炸的解决办法很暴力很简单,就是当梯度超过一个阈值时候,将它裁剪成阈值大小:

LSTM 在 RNN 的基础上很好的解决了长距离详细传递的问题,它引入了 Cell State 和三个门 Forget Gate, Input Gate 和 Output Gate 来传输记忆和决定哪些记忆是需要的,哪些不需要。

  • 遗忘门:根据ht1h^{t-1}xtx^t判断 Cell State 哪一些需要遗忘
  • 输入门:根据ht1h^{t-1}xtx^t判断需要向 Cell State 传入哪些当前信息
  • 输出门:根据ht1h^{t-1}xtx^t判断需要从 Cell State 中输出哪些信息

以 Forget Gate 举例:

ft=σ(wfht1+Ufxt+bf) f^t=\sigma(w_fh^{t-1}+U_fx^t+b_f)

sigmoid 激活函数会将计算结果隐射到 0-1 的区间,然后与 ct1c^{t-1}相乘。值越接近于 1,历史记忆就保留;相反值趋于 0,历史记忆就遗忘。

为什么 LSTM 相对于 RNN 能够记忆更长的记忆?

我们回顾一下 RNN 的公式:

ht=tanh(Wxxt+Whht1+bh) h_t=tanh(Wx \cdot x_t + W_h \cdot h_{t-1} + b_h)

由于参数矩阵是固定的,所以进行反向传播时候,梯度要么会非常大要么会非常小。

但是对于 LSTM,它的三个门控机制可以选择每次保留 or 遗忘记忆,使得历史记忆可以长期保存。举一个极端的例子,遗忘门总是为 1,输入门总是为 0,那么历史记忆就能一直在 Cell State 上流通。

实际上,LSTM 不光是解决了长距离依赖的问题,它的各种门,使得模型的学习潜力大大提升,各种门的开闭的组合,让模型可以学习出自然语言中各种复杂的关系。比如遗忘门的使用,可以让模型学习出什么时候该把历史的信息给忘掉,这样就可以让模型在特点的时候排除干扰。

BLEU 是机器翻译中最经典的自动评价指标之一,用来衡量模型生成的译文(candidate)和 人工参考译文(reference)之间的相似度。

BLEU 的核心思想: N-gram(词序列)有多少和参考译文匹配。匹配越多,得分越高。

BLEU=BP×exp(n=1..Nwnlogpn) BLEU = BP \times \exp(\sum_{n=1..N}{w_n * \log{p_n}})
符号 含义
pnp_n n-gram 精确率 precision
wnw_n 权重,一般 BLEU-4 时为 1/4
BP 长度惩罚

计算过程

candidate: the cat the cat on the mat
reference: the cat is on the mat

1. Step 1: unigram precision

word cand ref clipped
the 4 2 2
cat 2 1 1
on 1 1 1
mat 1 1 1
  • total clipped = 5
  • total candidate unigram = 8
  • unigram precision = 5/8

Step 2:bigram precision

word cand ref clipped
the cat 2 1 1
cat the 1 0 0
cat on 1 0 0
on the 1 1 1
the mat 1 1 1
  • total clipped = 3
  • total candidate bigram = 6
  • bigram precision = 3/6

同理计算 p3 和 p4。

Step 3:长度惩罚

len(candidate) > len(reference) 所以没有惩罚,BP=1

Step 4:综合 BLEU

BLEU=1×exp((logp1+logp2+logp3+logp4)/4) BLEU = 1 \times \exp((\log{p_1} + \log{p_2} + \log{p_3} + \log{p_4})/4)

在这个例子中,假如翻译的句子 candidate=the,那么它在 1-gram 中就能得到很高的分数,避免预测过短的句子就会采用惩罚机制:当预测长度>参考长度则不惩罚,BP=1;当预测长度<参考长度,BP=exp(1 - r/c)


机器翻译很怕高阶 n-gram 全不匹配

举个例子:

candidate: the the the the the the
reference: the cat is on the table

对于 2-gram 有:

candidate bigram: "the the", "the the", ...
reference bigram: "the cat", "cat is", ...

总 clipped=0, 总 can bigram=5, 得到 p(2)=0。
计算 BLEU 分数时:geo_mean=exp(14(log(p1)+log(p2)+log(p3)+log(p4)))geo\_mean = exp(\frac{1}{4}( \log(p_1) + \log(p_2) + \log(p_3) + \log(p_4)))

由于 log(0) 等于负无穷,所以取对数之后 BLEU 分数为 0。

  • STS^T 是 Decoder 在当前时间步的隐藏状态 dec_hidden
  • hih_i 是 Encoder 的第 i 个 token 对应的隐藏状态 enc_hidden_i
  • sThis^Th_i 就能得到 Decoder 当前 token 和 Encoder 第 i 个 token 的注意力分数,对 Encoder 的每一个注意力分数进行 softmax 就能得到注意力权重

这个就是最早提出的 Bahdanau 注意力,但是这个算法的问题在于:hih_i 这个向量包含了完整信息,也就是有太多不需要的信息了。

乘法注意力的解决方法就是:在两个向量之间乘一个矩阵,这个矩阵可以学习隐藏状态哪一部分是有用的

乘法注意力的问题在于,当隐藏状态长度很大时,中间矩阵的参数量就会非常大。解决办法是把大方阵拆为两个低秩的矩阵,它们有一样的效果。

从单条文本来看,矩阵 x=Ewtx=Ew_t 形状为 [SeqLen, EmbeddingSize]。

1. 用权重矩阵 Q,K,V 转换词向量

qi=Qxiki=Kxivi=Vxi \begin{align} q_i &= Qx_i \\ k_i &= Kx_i \\ v_i &= Vx_i \end{align}

为了提高计算效率,可以把 Q,K,V 合并为一个大矩阵和 x 相乘,然后再把各个部分取出来,类似 BiLSTM 中四个门控计算合并到一个 [4*H, B] 的大矩阵,然后再通过 .chunk 分开。

2. 用输入 x 去查询其他 token 的 Key

eij=qiTkjαij=exp(eij)jexp(eij) \begin{align} e_{ij} &= q_i^Tk_j \\ \alpha_{ij} &= \frac{\exp(e_{ij})}{\sum_{j'}{\exp(e_{ij'})}} \end{align}

用词向量的 Query 去查询其他词向量的 Key 得到它们的相似度,具体数学运算就是向量的点积,最后对每个 token 的相似度做z softmax 就能得到相似权重。

我们把 eije_{ij} 拆开来看:

eij=qiTkj=xiTQTKxi e_{ij} = q_i^Tk_j = x_i^TQ^TKx_i

Q:为什么不把 QTKQ^TK 直接用一个矩阵表示呢,还有乘两次这么麻烦?
A:因为提前把它们合并成一个矩阵,那么这个矩阵的大小也会变成 [SeqLen, SeqLen] 意味着矩阵大小依赖于输入序列的长度,参数矩阵可能会变得非常大。


Q:为什么采用点积计算两个向量的相似度?不采用余弦相似度其他的?


Q:这种拆解矩阵的低秩表示会不会对最终结果有影响? A:实验证明模型并不需要高秩的注意力矩阵,低秩反而是归纳偏置,有助于泛化。

3. 计算 x 的输出

将每个 token 的权重乘上它对应的 Value 得到加权和就是最后 x 的输出,这个输出值代表 x 在句子中的上下文含义:

oi=iαijvj o_i = \sum_i{\alpha_{ij}v_j}

自注意力机制和 RNN 或者 LSTM 不同,RNN 是从左到右或者右到左来更新隐藏状态矩阵的,而自注意力机制各个 token 之间不相互依赖,可以进行并行操作,但带来的缺点就是 模型不知道 token 之间的先后关系,例如: I love you 和 You love me 在它看来是一样的。

解决方案是:

正余弦位置编码

PE(pos,2i)=sin(pos100002i/dmodel)PE(pos,2i+1)=cos(pos100002i/dmodel) \begin{align} PE(pos, 2i) &= \sin(\frac{pos}{10000^{2i/d_{model}}}) \\ PE(pos, 2i+1) &= \cos(\frac{pos}{10000^{2i/d_{model}}}) \end{align}
  • 优点: 不需要学习,可以泛化到更长的序列,例如模型只训练到 512 长度,推理时给 4096 → 仍可用
  • 缺点:表达能力有限

可学习位置编码

定义一个和 x 大小相同的可训练矩阵,类似:

self.position_embedding = nn.Embedding(seqlen, embedding_size)

直接把 x 和 position_embedding 叠加,让模型训练学习位置信息。缺点在于无法extrapolation,训练 max_len=512,推理时来个 2048 没 embedding 只能报错。

可以在 self-attention 层计算各个 token 注意力分数之后通过一个前馈网络 MLP:

mi=MLP(outputi)=W2ReLU(W1outputi+b1)+b2 \begin{align} m_i &= MLP(output_i) \\ &= W_2 * ReLU(W_1 output_i + b_1) + b_2 \end{align}

以 “I love you” 生成 “我爱你” 为例, 在 Encoder 上生成 “爱” 的时候,我们只能看到之前的 “<sta>";在生成 “爱” 的时候只能看到之前的 “<sta>我”,之后的 token 对当前 token 来说应该是 invisible 的。但是之前的自注意力机制中计算注意力分数,我们直接整个矩阵相乘了,也就是说任意 token 都能窥视之后的信息,解决办法就是利用掩码:

eij={qiTkj,j<=i,j>i e_{ij}= \begin{cases} q_i^Tk_j, & j <= i \\ -\infty, & j > i \end{cases}

这样经过 softmax 之后,掩码位置都会变成 0,和 Value 相乘就不会影响模型。

多头注意力的出现是为了解决单头注意力机制的表达能力瓶颈。

人处理语言时会同时从多个角度去看的:

  • 一个角度看语法结构(主语-谓语关系)
  • 一个角度看语义相似度
  • 一个角度看指代消解
  • 一个角度看世界常识

单头必须把这所有线索压缩到一个 dkd_k 维的空间里去学太勉强了,容易学偏或者学模糊。

多头注意力就是把 dkd_k(比如 512)维分成 h=8 份,每份 64 维,让 8 个空间各自专心学一种关注模式,例如:

  • Head 1:专门学语法结构(比如主语更关注谓语)
  • Head 2:专门学短距离依赖(相邻词关注多一些)
  • Head 3:专门学长距离依赖(句首和句尾关注)
  • Head 4:专门学指代关系
  • Head 5:专门学情感极性

每个 head 都在一个低维子空间(64维)里独立学习一种“关注策略”,互不干扰。最后再把 8 个 head 的结果拼接起来,经过一个线性层融合,就相当于模型同时从 8 个不同角度理解了这个句子。

注意力机制存在一个问题就是:当模型规模变大之后,向量的点积也会随之变得很大


数学证明

首先我们要明确 Softmax 是一个 n → n 的函数,所以它的梯度不是一个数而是一个雅可比矩阵:

Jij=yixj J_{ij}=\frac{\partial{y_i}}{\partial{x_j}}

根据链式法则有:

Lxj=i=1nLyiyixj \frac{\partial{L}}{\partial{x_j}}=\sum_{i=1}^n{\frac{\partial{L}}{\partial{y_i}}\frac{\partial{y_i}}{\partial{x_j}}}

给定输入向量:

x=(x1,x2,x3,) \mathbf{x} = (x_1,x_2,x_3,\dots)

softmax 之后得到输出的第 i 个分量为:

yi=softmax(xi)=exik=1nexk=exiS \begin{align} \mathbf{y_i}&=softmax({\mathbf{x}}_i)=\frac{e^{x_i}}{\sum_{k=1}^n{e^{x_k}}} \\ &=\frac{e^{x_i}}{S} \end{align}

求偏导得到:

yixi=exiSexiexiS2=exiS(1exiS)=yi(1yi)yixj=exiexjS2=yiyj \begin{align} \frac{\partial{\mathbf{y_i}}}{\partial{\mathbf{x_i}}}&=\frac{e^{x_i}S-e^{x_i}e^{x_i}}{S^2}\\ &=\frac{e^{x_i}}{S}(1-\frac{e^{x_i}}{S})\\ &=y_i(1-y_i) \\ \frac{\partial{\mathbf{y_i}}}{\partial{\mathbf{x_j}}}&=-\frac{e^{x_i}e^{x_j}}{S^2} \\ &=-y_iy_j \end{align}

当 softmax 的输入某个 xix_i 特别大的时候,经过 softmax 就会变得接近 one-hot 编码,这时候最大项的梯度 pi(1pi)=0p_i(1-p_i)=0 ,其他项梯度 pipj=0p_ip_j=0,最后累加起来就是 0 了。


解决办法:缩放点积注意力

Attention(Q,K,V)=softmax(QKTdk)V Attention(\mathbf{Q},\mathbf{K},\mathbf{V})=softmax(\frac{\mathbf{QK}^T}{\sqrt{d_k}})V

QKTQK^T 除以 dk\sqrt{d_k},能把它的方差重新拉回到一个合理的范围(大约接近 1)。

梯度回传时候,链式计算可能会乘非常多的偏导。如果之间的偏导都是小于 1 的数,就有可能导致最后梯度接近于 0;反之梯度有可能非常大。梯度爆炸比较好解决,超过一定值的时候就把它裁剪掉。ResNet 的残差连接和 111*1 卷积,可以解决梯度消失这个问题。

Out(x)=f(x)+x Out(x)=f(x)+x

残差连接解决的是退化问题:

从数学上看,假设一个浅层网络(比如 10 层)已经能很好地拟合一个函数 H(x),我们希望再把网络加深 40 层得到更好的性能。假如我们让加深 40 层后的网络性能保持不变,那么这最少 40 词的非线性嵌套做的事就是学会一个恒等映射,这样准确率才不会下降。但是作为一个深层结构参数间高度耦合的网络,它很难学会恒等映射

H(x)=x H(x) = x

因为你要让几十层参数全部精确地抵消成“什么都不干”太难了,优化器几乎做不到。所以我们退而求其次,希望能学会这样一个函数:

F(x)=H(x)x F(x) = H(x) - x

这样让模型学会把输出变成全 0,比学会把输出精确等于输入 x 容易很多。

先明确一点,Lay Normalization 是在最后一个维度进行归一化。换句话说,在 Transformer 中 Lay Norm 是对每个 token 对应的隐藏状态向量 hih_i 进行归一化,它是不依赖于 Batch Size 或者 Sequence Size 的。

μi=xi1+xi22σi2=(xi1μi)2+(xi2μi)22x^ij=xijμiσi2+ϵ \begin{align} \mu_i &= \frac{x_{i1} + x_{i2}}{2} \\ \sigma_i^2 &= \frac{(x_{i1}-\mu_i)^2 + (x_{i2}-\mu_i)^2}{2} \\ \hat{x}_{ij} &= \frac{x_{ij}-\mu_i}{\sqrt{\sigma_i^2 + \epsilon}} \end{align}

Attention 机制在归一化阶段希望“先让每个 token 自己内部整洁”,再去交流。


Q:为什么需要对 feature 维度进行归一化 A:feature 数值差太大 → 在线性层中被放大或压缩

假设你有输入特征向量:x=[1000,  0.1]x = [1000,\; 0.1],经过一个线性层:y=Wxy = Wx。其中

W=[w1w2w3w4] \begin{array}{c} W = \begin{bmatrix} w_1 & w_2 \\ w_3 & w_4\end{bmatrix} \end{array}

计算:

y1=w11000+w20.1 y_1 = w_1\cdot1000 + w_2\cdot0.1

不管权重 w2w_2 多努力,最终贡献都会被 1000w11000 * w_1 盖掉。

class Transformer(nn.Module):
    def __init__(self):
        super(Transformer, self).__init__()
        self.encoder = Encoder().cuda()
        self.decoder = Decoder().cuda()
        self.projection = nn.Linear(d_model, tgt_vocab_size, bias=False).cuda()
    def forward(self, enc_inputs, dec_inputs):
        '''
        enc_inputs: [batch_size, src_len]
        dec_inputs: [batch_size, tgt_len]
        '''
        enc_outputs, enc_self_attns = self.encoder(enc_inputs)
        dec_outputs, dec_self_attns, dec_enc_attns = self.decoder(dec_inputs, enc_inputs, enc_outputs)
        dec_logits = self.projection(dec_outputs) # dec_logits: [batch_size, tgt_len, tgt_vocab_size]
        return dec_logits.view(-1, dec_logits.size(-1)), enc_self_attns, dec_self_attns, dec_enc_attns

整体来说 Transformer 包含 Encoder、Decoder 和投影三部分,Encoder 负责学习输入,Deocder 负责生成输出,最后把输出向量投影到期望的空间。

注意!!!

因为 PyTorch 的交叉熵损失要求输入形状如下:

input:    [N, C]
target:   [N]

其中:

  • N = 样本数量
  • C = 类别数量 = vocab_size

而 Transformer 解码器输出的 logits 是:[B, T, V],所以需要展平到二维。

class Encoder(nn.Module):
    """
    - Word Embedding
    - Position Embedding
    - MultiEncoderLayer
    """    
    def __init__(self, vocal_size: int,  d_model: int,  layer_nums: int,  max_len: int,  hidden_size: int, n_heads: int = 8, p: float = 0.1):
        super(Encoder, self).__init__()
        self.word_embedding = nn.Embedding(vocal_size, d_model)
        self.pos_embedding = PositionEmbedding(max_len, d_model, p)
        self.layers = nn.ModuleList([EncoderLayer(d_model, n_heads, hidden_size, p) for _ in range(n_layers)])
        
        
    def forward(self, x):
        attns = []
        enc_masks = padding_mask(x, x)
        enc_embed = self.word_embedding(x)
        enc_embed = self.pos_embedding(enc_embed)
        
        enc_output = enc_embed
        for layer in self.layers:
            enc_output, enc_attn = layer(enc_output, enc_masks)
            attns.append(enc_attn)

        return enc_output, attns

Encoder 包含 Word Embedding、Position Embedding 和多头注意力层,矩阵形状变化如下:

  1. 输入为 batch_size 行文本,文本长度固定为 seq_len:[batch_size, seq_len]
  2. 通过词嵌入之后得到: [batch_size, seq_len, d_model]
  3. Position Embedding 大小和输入相同直接叠加: [batch_size, seq_len, d_model]
  4. 多头注意力机制不改变矩阵形状,最后还是 [batch_size, seq_len, d_model]

严格来说 Encoder 和 Decoder 返回注意力权重 attn 没有什么用,不过它可以让我们查看模型在翻译时关注了源句子的哪些词。

词嵌入就是简单的使用了 torch.nn.Embedding 这个自学习矩阵,它的形状是 [src_vocal_size, d_modal],根据输入文本 token 的索引就能从中取出对应的词向量。

class PositionalEncoding(nn.Module):
    def __init__(self, d_model, dropout=0.1, max_len=5000):
        super(PositionalEncoding, self).__init__()
        self.dropout = nn.Dropout(p=dropout)

        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0).transpose(0, 1)
        self.register_buffer('pe', pe)

    def forward(self, x):
        x = x + self.pe[:x.size(0), :]
        return self.dropout(x)

正余弦编码的公式是:

PE(pos,2i)=sin(pos100002i/dmodel)PE(pos,2i+1)=cos(pos100002i/dmodel) \begin{align} PE(pos, 2i) &= \sin(\frac{pos}{10000^{2i/d_{model}}}) \\ PE(pos, 2i+1) &= \cos(\frac{pos}{10000^{2i/d_{model}}}) \end{align}

通过数学性质:

\[ \exp(a) = e^a,\text{而}a^b = \exp(b \ln a) \]

所以有:

pos100002i/dmodel=pos×exp2idmodelln10000 \frac{pos}{10000^{2i/d_{model}}}=pos \times \exp{-\frac{2i}{d_{model}}\ln{10000}}

为什么要把 position 从 [max_len] 扩展到 [max_len, 1]?

从公式来看,我们要做的事就是把每条句子的奇数和偶数位置的值替换掉,通过 pytorch 的切片操作 tensor[:, 0::2] 就可以用一个 [seq_len, d_model/2] 形状的矩阵替换掉偶数位置的值。同时利用 pytorch 的广播机制,把 position 扩展到 [seq_len, 1] 和 div_term [d_model/2] 相乘就能得到 [seq_len, d_model/2] 的新矩阵。

PS: 我感觉这个计算方法真的很诡异,div_term 的维度是 [d_model/2, ],我们可以看做是一个长为 d_model/2 的行向量。然后和 position 主元素相乘之后,广播到 [seq_len, d_model/2] ,这时候可以看做 d_model/2 的行向量进行了一个转置变长 d_model/2 的列向量,然后水平方向扩展为 [seq_len, d_model/2] 的矩阵。

用一个实际数字例子看广播计算

假设 position(5×1):

[[0],
 [1],
 [2],
 [3],
 [4]]

div_term(视为 1×3):

[ a  b  c ]

计算 position * div_term:

[[0*a, 0*b, 0*c],
 [1*a, 1*b, 1*c],
 [2*a, 2*b, 2*c],
 [3*a, 3*b, 3*c],
 [4*a, 4*b, 4*c]]

最后前向计算中也是利用到了广播机制,x 的形状是 [seq_len, batch_size, d_model], pe 的形状是 [seq_len, 1, d_model], 保持 x 的形状不变。

Pytorch 的广播机制为:如果两个张量维度不一致,则在较短张量前面补 1。如果维度一致,则从右往左对比,如果两个维度一致或者有一个为 1 则可以广播。例如 [1, 2, 3] 和 [3, 2, 1],从右往左:有一个 1,两个都是 2,有一个 1,最后广播为 [3, 2, 3], 每个维度是两个 shape 各自的最大值。

class EncoderLayer(nn.Module):
    
    def __init__(
        self,
        d_model: int,
        n_heads: int,
        hidden_size: int,
        dropout: float = 0.1,
        pre_norm: bool = True
    ):
        super(EncoderLayer, self).__init__()
        self.mha = MultiHeadAttention(d_model, n_heads, dropout)
        self.ff = FeedForwardNet(d_model, hidden_size, dropout)
        
        self.attn_norm = nn.LayerNorm(d_model)
        self.ff_norm = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(p=dropout)
        
        self.pre_norm = pre_norm
    
    def forward(self, x, attn_masks=None):
        
        if self.pre_norm:
            # Pre-LN: norm -> sublayer -> dropout -> residual
            norm_x = self.attn_norm(x)
            context, attn = self.mha(norm_x, norm_x, norm_x, attn_masks)
            x = self.dropout(context) + x
            
            norm_x = self.ff_norm(x)
            ff_out = self.ff(norm_x)
            x = self.dropout(ff_out) + x
            
            return x, attn
        else:
            # Post-LN: sublayer -> dropout -> residual -> norm
            context, attn = self.mha(x, x, x, attn_mask)
            x = self.attn_norm(x + self.dropout(context))

            ff_out = self.ff(x)
            x = self.ff_norm(x + self.dropout(ff_out))
            return x, attn
  • pre_norm 模型一般比 post_norm 更稳定,易于训练。这在深层Transformer中尤为明显。
  • post_norm 在较浅层时效果尚可,但模型越深,梯度消失或爆炸问题可能更严重。
class MultiHeadAttention(nn.Module):
    
    def __init__(self, d_model: int, n_heads: int, p: float = 0.1):
        super(MultiHeadAttention, self).__init__()
        assert d_model % n_heads == 0, "d_model must be divisible by n_heads"
        self.n_heads = n_heads
        self.d_model = d_model
        self.d_k = d_model // n_heads
        

        self.W_Q = nn.Linear(d_model, n_heads * d_k)
        self.W_K = nn.Linear(d_model, n_heads * d_k)
        self.W_V = nn.Linear(d_model, n_heads * d_k)
        self.fc = nn.Linear(n_heads * d_k, d_model)
        
        # self.norm = nn.LayerNorm() Laynorm 统一放在 EncoderLayer
        self.attn_dropout = nn.Dropout(p)
        self.proj_dropout = nn.Dropout(p)
        
    def forward(self, q, k, v, masks = None):
        batch_size, seq_len, _ = q.size()
        Q = self.W_Q(q).view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
        K = self.W_K(k).view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
        V = self.W_V(v).view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
        
        scores = torch.matmul(Q, K.transpose(-1, -2)) / np.sqrt(self.d_k)
        
        if mask:
            scores = scores.masked_fill(mask, float('-inf'))
        
        
        attn = nn.Softmax(scores, dim=-1)
        attn = self.attn_dropout(attn)
        
        
        context = torch.matmul(attn, V).transpose(1, 2)
        context = context.reshape(batch_size, seq_len, self.n_heads * self.d_k)
        context = self.fc(context)
        context = self.proj_dropout(context)
        
        # return self.norm(context + q, -1), attn
        return context, attn

前馈计算中的形状变化

  1. 输入的 q,k,v 形状都是三维矩阵 [batch_size, seq_len, d_model]
  2. 经过自学习矩阵 W_Q,W_K,W_V 变化之后得到 Q,K,V [batch_size, seq_len, n_heads*d_k]

其实形状没变,因为 Transformer 论文中规定 dmodel=n_heads×dkd_{model}=n\_heads \times d_k

  1. 将 n_heads 从矩阵第三维拉出来 [batch_size, seq_len, n_heads, d_k]
  2. 我们拉出 n_heads 是为了利用矩阵的形状同时计算多个注意力头,所以把 “头” 的维度拉到前面 [batch_size, n_heads, seq_len, d_k]
  3. 根据公式 softmax(QKTdk)Vsoftmax({\frac{QK^T}{\sqrt{d_k}}})V 计算得到 [batch_size, n_heads, seq_len, d_k]
  4. 最后将形状变回去 [batch_size, seq_len, n_heads, d_k] -> [batch_size, seq_len, n_heads * d_k] -> [batch_size, seq_len, d_model]

对形状变化还是不理解的可以考虑一下朴素版本通过 for 循环实现多头注意力,每个注意力头得到 [batch_size, seq_len, d_k] 然后 n_heads 个头的结果拼接在一起得到 [batch_size, seq_len, n_heads * d_k]


view 和 shape 都是对矩阵形状做变化,有什么区别?

  • view 只能对连续存储的张量进行形状变化,例如 self.W_Q(input_Q) 得到的张量通常是连续的。
  • reshape 可以对任意张量进行形状变化,但是性能不如 view。Q,K,V 通过 transpose 转置后不能保证在内存中连续,所以用 reshape 安全。

为啥注意力机制计算结束还要加一个全连接?

多头注意力机制最早出现在 Transformer 模型中(论文《Attention is All You Need》),它的定义明确包含了最后的线性变换层:

MultiHead(Q, K, V) = Concat(head_1, head_2, ..., head_h) * W_O

它的好处是前面的拼接只是机械地将每个头的输出放在一起,维度虽然匹配,信息却没有经过进一步的处理,并且拼接是无参数的,而 Linear 层引入了可学习的权重 W_o,让模型能够根据任务动态调整每个头的贡献。


为什么要把张量拆成四维并调整维度顺序呢?

在单头自注意力中,输入形状为 [B,L,H][B, L, H]
我们可以直接计算注意力得分:

QK: [B,L,H]×[B,H,L]=[B,L,L] Q K^\top :\ [B, L, H] \times [B, H, L] = [B, L, L]

对于多头注意力,我们首先将输入从 [B,L,H][B, L, H] 映射为 [B,L,n_heads×dk][B, L, n\_\text{heads} \times d_k]。这表示第三个维度已经包含了所有头的投影结果。

接着,我们把这一维拆分成两维:

[B,L,n_heads,dk] [B, L, n\_\text{heads}, d_k]

再通过 permute 调整维度顺序:

[B,n_heads,L,dk] [B, n\_\text{heads}, L, d_k]

此时可以将其理解为:每个 batch 里有 n_headsn\_\text{heads} 个独立的注意力头,每个头对应一个 [L,dk][L, d_k] 的投影空间,相当于单头时的[L,H][L, H] 但维度较小(dkd_k​)。


四维矩阵如何进行乘法的?

在本科的线性代数课上,我们学过了二维矩阵的乘法 [a,b]×[b,c]=[a,c][a, b] \times [b, c] = [a, c],那四维矩阵呢?

对于超过二维的张量,torch.matmul 会把张量的前几个维度(称为“批次维度”)看作独立的批次,对每个批次单独执行二维矩阵乘法。最后两维被视为矩阵的行和列,进行标准的矩阵乘法。

  • 假设两个张量 A 和 B:
    • A 的形状:[batch_dim1, batch_dim2, m, n]。
    • B 的形状:[batch_dim1, batch_dim2, n, p]。
  • 前面的批次维度 [batch_dim1, batch_dim2] 必须匹配。
  • 最后两维 [m, n] 和 [n, p]按二维矩阵乘法规则计算。
  • 结果形状:[batch_dim1, batch_dim2, m, p]。
class FeedForwardNet(nn.Module):
    
    def __init__(self, d_model: int, hidden_size: int, p: float = 0.1):
        super(FeedForwardNet, self).__init__()
        self.fc1 = nn.Linear(d_model, hidden_size)
        self.fc2 = nn.Linear(hidden_size, d_model)
        self.relu = nn.ReLU()
        # self.norm = nn.LayerNorm()
        self.dropout = nn.Dropout(p)
        
    def forward(self, x):
        output = self.fc1(x)
        output = self.relu(output)
        output = self.fc2(output)
        output = self.dropout(output)
        # return self.norm(x + output, -1)
        return output

Transformer Decoder 和 Encoder 稍有不同,它也包含 Word Embedding 和 Position Embedding,不过 DecoderLayer 每一层中是 MaskedMultiHeadAttention 和 CrossMultiHeadAttention。

class Decoder(nn.Module):
    
    def __init__(
        self,
        vocal_size: int,
        d_model: int,
        max_len: int,
        layer_nums: int,
        hidden_size: int,
        p: float = 0.1
    ):
        super(Decoder, self).__init__()
        self.word_embedding = nn.Embedding(vocal_size, d_model)
        self.position_embedding = PositionEmbedding(max_len, d_model, p)
        self.layers = nn.ModuleList([DecoderLayer(d_model, n_heads, hidden_size, p) for _ in range(layer_nums)])
        self.attn_norm = nn.LayerNorm(d_model)
        
    def forward(self, x, enc_x, enc_context):
        self_attns, cross_attns = [], []
        batch_size, seq_len = x.size()
        dec_embed = self.word_embedding(x)
        dec_embed = self.position_embedding(dec_embed)
        
        dec_padding_mask = padding_mask(x, x)  # [batch_size, seq_len, seq_len]
        cross_mask = padding_mask(enc_x, x)
        
        # 对 dec_masks 应用 Future Mask
        future_mask = torch.triu(torch.ones(seq_len, seq_len, dtype=torch.bool), diagonal=1)
        future_mask = future_mask.unsqueeze(0).expand(batch_size, seq_len, seq_len)
        self_mask = dec_padding_mask.bool() | future_mask
        
        
        dec_output = dec_embed
        for layer in self.layers:
            context, dec_self_attn, dec_cross_attn = layer(dec_output, enc_context, self_mask, cross_mask)
            self_attns.append(dec_self_attn)
            cross_attns.append(dec_cross_attn)
        
        return context, self_attns, cross_attns

Mask 掩码分为两种,一种是使 Sequence 固定长度用于掩盖空位的掩码,另一种是在 Transformer Decoder 中用于掩盖未来 token 的掩码。

Padding Mask

在 Transformer Encoder 的多头注意力机制中,我们用每个 token 的 Query 去查询其他 token 的 Key,得到了每个 token 在 源输入中的注意力权重 [seq_len, seq_len],其中第一个维度代表有多少 token,第二个维度代表每个 token 对其他 token 的注意力权重,所以我们应该应用 Mask 在第二个维度。在 Decoder 的交叉多头注意力机制中,我们用 Decoder 的每一个 token 去查询 Encoder 的 token,也就是说这时候产生的注意力权重形状是 [enc_len, dec_len]。

def padding_mask(enc_seq: torch.Tensor, dec_seq: torch.Tensor, pad_idx: int = 0):
    batch_size, enc_len = enc_seq.size()
    batch_size, dec_len = dec_seq.size()
    
    mask = dec_seq.detach().eq(pad_idx).unsqueeze(1)  # [batch_size, 1, dec_len]
    return mask.expand(batch_size, enc_len, dec_len)

Future Mask

Future Mask 的原理前文介绍过了,主要就是为了使 Decoder 不能窥视后续文本。我们仍然是在注意力权重矩阵中应用 Mask,它的形状是 [batch_size, seq_len, seq_len],对这个矩阵的上三角部分掩盖即可。

    future_masks = torch.triu(torch.ones(seq_len, seq_len, dtype=torch.bool), diagonal=1)
    future_masks = future_masks.unsqueeze(0).expand(batch_size, seq_len, seq_len)
    dec_masks = dec_masks | future_masks

我们生成一个上三角全是 True, 主对角和下三角为 False 的矩阵,然后和 Padding Mask 相与。

class DecoderLayer(nn.Module):
    
    def __init__(
        self,
        d_model: int,
        n_heads: int,
        hidden_size: int,
        dropout: float = 0.1,
    ):
        super(DecoderLayer, self).__init__()
        self.self_mha = MultiHeadAttention(d_model, n_heads, dropout)
        self.cross_mha = MultiHeadAttention(d_model, n_heads, dropout)
        self.ff = FeedForwardNet(d_model, hidden_size, dropout)
        
        self.cross_norm = nn.LayerNorm(d_model)
        self.self_norm = nn.LayerNorm(d_model)
        self.ff_norm = nn.LayerNorm(d_model)
    
        self.dropout = nn.Dropout(p=dropout)
    
    def forward(self, x, enc_output, self_mask, cross_mask):
        norm_x = self.self_norm(x)
        self_context, dec_self_attn = self.self_mha(norm_x, norm_x, norm_x, self_mask)
        x = self.dropout(self_context) + x
        
        norm_x = self.cross_norm(x)
        cross_context, dec_cross_attn = self.cross_mha(norm_x, enc_output, enc_output, cross_mask)
        x = self.dropout(cross_context) + x       
        
        norm_x = self.ff_norm(x)
        ff_output = self.ff(norm_x)
        
        return self.dropout(ff_output) + x, dec_self_attn, dec_cross_attn

DecoderLayer 依次应用掩码多头注意力,交叉多头注意力和前馈神经网络,为了让 CrossMultiHeadAttention 可以服用多头注意力的代码,我们只需要把输入 x 区分为 input_q, input_k, input_v。用掩码多头注意力得到的 Query 去查询 Encoder 输出的 Key就行了。

在过去的实践中,我们处理文本会先进行分词,然后把一个个 token 转换为其对应的 idx 索引,在用它去进行词嵌入。但这样的做法有一个问题,在测试集中我们遇到没见过的词语就把他用 <UNK> 来替换,这样会丢失大量的信息。

现代 NLP 模型不会直接把完整单词当作基本单位,而是把单词拆成若干 subwords(子词)来表示。

  • 出现非常频繁的词(如 hat、learn)会被当作 完整的词 放进词表,少见词、奇怪词、新造词会被拆成多个子词
  • 如果是特别奇怪的词、根本没见过、字母组合很怪,模型可能把每个字符都拆开。

  • 常用词:例如 hat, learn 等词语,Embedding 会完整学习不拆分。
  • 变体:例如 taaaaast,会把词语拆为常见的 Subword 和常用词的组合 taa##aaa##sty。
  • 拼写错误:laern,模型没见过于是拆分为 la##ern,模型没见过这个错词,所以分成多个子词。
  • 新词 / 自创词:Transformerify 拆为 Transformer## ify。

这些拆分后的 Subword 会得到多个 Embedding,我们可以取最后一个 Embedding,对 Embedding 进行平均,或者用 RNN 等模型对其进行学习。

Pretraining 干的事情就是给模型安排一个非常困难的任务,迫使它在数据中尽可能的学习。

  • Encoder Only:几乎只能用 MLM / 替换 token 检测等,因为它没有生成能力。
  • Decoder Only:几乎只能用 next-token prediction(因为它看不到未来),或者说 TeachForcing。
  • Encoder-Decoder:传统的带对齐的翻译(supervised),Next-token prediction(纯自回归),Span corruption(T5 式)

Bert 就是 Encoder Only Pretaining 的典型代表,它的训练方式包括:

  • 随机遮掉 15% 的 Subword
  • 随机替换 15% 的 Subword
  • 随机选择 15% 的 Subword 不替换,但还是让它预测
  • 判断两个句子是否相邻

对于不同的任务会选择不同的预训练方法:例如 Bert 更适合为文档选择一个 Tag,它不适合进行文本生成的任务。


一个 Misunderstanding

经典的 Transformer(Vaswani et al., 2017 那篇 “Attention is All You Need”)本身不是一种,而是一个“积木盒子”。 它同时提供了三种可拼装的部件:

  1. Transformer Encoder 块(双向注意力,允许看未来)
  2. Transformer Decoder 块(带 masked self-attention,只能看过去)
  3. Cross-attention(Decoder 看 Encoder 的输出)

根据你怎么拼这三个积木,就自然得到了刚才 PPT 里的三种预训练架构:

你拿 Transformer 的哪些部分 拼出来的是哪种架构 经典代表模型 预训练方式
只用 Encoder 块堆 6/12 层 → 纯 Encoder BERT、RoBERTa、DeBERTa MLM(遮词填空)
Encoder + Decoder 都用 → Encoder-Decoder T5、BART、Flan-T5、UL2 Span Corruption 等
只用 Decoder 块堆很多层 → 纯 Decoder(GPT式) GPT-1/2/3/4、LLaMA、Grok、PaLM、Gemma、Qwen、Mistral、DeepSeek Next-token prediction(下一词预测)

Prefix Tuning 是一种参数高效微调 (PEFT) 技术。它保持预训练语言模型(如 GPT, BERT)的参数完全冻结,只在Transformer的每一层输入前添加一小段可训练的连续向量(即 Prefix)。

以 Decoder Only 的模型进行参数微调为例,我们会初始化一部分可训练参数 prefix_k 和 prefix_v。假如 Decoder 每一层的 Q, K, V 形状为 [batch_size, seq_len, d_model],那么 prefix_k 和 prefix_v 的形状就是 [batch_size, prefix_len, d_model]。我们将 prefix_k 和 K 矩阵在第 2 个维度进行拼接,得到新的 K 和 V,形状为 [batch_size, prefix_len+seq_len, d_model]。

## 在 modeling_llama.py 里,你会看到类似的代码(伪代码)
def forward(...):
    # 正常计算
    key = self.k_proj(hidden_states)      # W_K * x
    value = self.v_proj(hidden_states)    # W_V * x

    # ↓↓↓ 关键:Prefix-Tuning 的注入点 ↓↓↓
    if self.config.peft_type == "PREFIX_TUNING":
        past_key_value = self.get_prompt(batch_size)   # 就是 P_K^l, P_V^l
        key = torch.cat([past_key_value[0], key], dim=2)   # cat 在 seq_len 维度
        value = torch.cat([past_key_value[1], value], dim=2)

    # 后面的 attention 计算完全不变
    attn_weights = torch.matmul(query, key.transpose(-1, -2)) / sqrt(...)

为什么输入形状不变,K 和 V 形状变了不影响呢?

shape(softmax(QKTdk)V)=[batch_size,prefix_len,d_model]×[batch_size,d_model,prefix_len+seq_len]×[batch_size,prefix_len+seq_len,d_model]=[batch_size,prefix_len,d_model] \begin{align} shape(softmax(\frac{QK^T}{\sqrt{d_k}})V) &= [batch\_size, prefix\_len, d\_model] \times [batch\_size, d\_model, prefix\_len+seq\_len] \times [batch\_size, prefix\_len+seq\_len, d\_model]\\ &= [batch\_size, prefix\_len, d\_model] \end{align}

Span Corruption 的方法是随机把原文里连续的几段文字(span)整段抠掉,换成一个特殊的 mask token,然后让模型把这些被抠掉的原文原封不动地生成回来。

举个例子,我们有一段文本:我星期天要去参加比赛,然后会对他随机扣掉一段 span:

  • Span Corruption 后输入:我 <extra_id_0> 要去参加 <extra_id_1>
  • 目标输出:<extra_id_0> 星期天 <extra_id_1> 比赛

之后损失函数就是一个标准的自回归训练:在 Encoder 输入 Span Corruption 后文本,在 Decoder 中每一歩都把真实的上一个 token 喂给模型,将预测的 logtis 和目标输出求交叉熵损失。

我们用一个极小的词表:

0: <pad>
1: 我
2: 星期天      (把“星期天”当 1 token)
3: 要
4: 去
5: 参加
6: 比赛
7: 。
8: <extra_id_0>
9: <extra_id_1>
10: <extra_id_2>
11: <s>   (decoder start)
12: </s>  (decoder end)

于是就有:

raw_input_ids = [1, 2, 3, 4, 5, 6, 7]
encoder_input_ids = [1, 8, 3, 4, 5, 9, 7]
## 对应: 我 <extra_id_0> 要 去 参加 <extra_id_1> 。
decoder_input_ids = [11, 8, 2, 9, 6, 10]   
## [<s>, <extra_id_0>, 星期天, <extra_id_1>, 比赛, <extra_id_2>]
decoder_target_ids = [8, 2, 9, 6, 10, 12]  
## 预测目标(要与 logits 对齐)

它的好处与 MLM 和 Next-token Prediction 相比在于:

  • 它既逼模型理解上下文(像 BERT)
  • 又逼模型学会生成长序列(像 GPT)

10.1 Post-training

Post-training 位于预训练之后,目的是为了让模型 更懂任务、更听指令、更符合人类意图

Post-training 包含3个步骤:

  1. Finetune
  2. RLHF
  3. DPO

其中 Finetune 又包含 Instruction Finetuning、LoRA、Task Finetuning 等。

过去的训练方式是用大量数据在预训练上,用少量数据对特定任务进行微调。但是后面发现用大量数据在不同任务上进行后训练,然后整合到一个 UX 中。这些指令包含不同的任务,例如 Q&A,翻译,生成,推理等。

但是指令微调的局限性也很明显:

  • IF 属于 SFT 也就是 Supervised Finetuning,它的数据收集成本非常高。
  • 对于开放性的问题,没有正确的答案:例如翻译任务,将 good 翻译为"一般"和"差"代价一样,但是明显翻译为"差"错的更多

RLHF 基于人类反馈的强化学习是 post-training 的一种方法,它的思路是通过人类的反馈来优化模型。但是这个想法存在几个问题:

  • 人力成本很昂贵:解决方法很简单,就是通过机器学习的方法训练一个模型,可以预测人们更倾向于哪一个答案。
  • 反馈很难量化:例如对于生成任务,很难对结果进行打分,解决方法就是生成多个结果,然后对其进行排序,rank instead of score

强化学习的基本思路:

  1. Agent 根据状态 State 做出行为 Action
  2. Action 进而对环境产生影响,状态更新并且基于 Reward Model 给予 Agent 奖励 Reward
  3. Agent 根据奖励和新的状态做出新的行为

我们谈到了奖励值 Reward  ,它表示环境进入状态 State 下的即时奖励。但如果只考虑即时奖励,目光似乎太短浅了:当下的状态和动作会影响到未来的状态和动作,进而影响到未来的整体收益。所以,一种更好的设计方式是:t 时刻状态 s 的总收益 = 身处状态 s 能带来的即时收益 + 从状态 s 出发后能带来的未来收益。 写成表达式就是:

Vt=Rt+γVt+1 V_t=R_t+\gamma V_{t+1}
  • VtV_t 指 t 时刻之后的全部收益
  • RtR_t 指 t 时刻即时收益
  • Vt+1V_{t+1} 指 t+1 时候之后的全部收益
  • γ\gamma 是折扣因子,它决定了我们在多大程度上考虑将“未来收益”纳入“当下收益

  • 我们先喂给模型一个 prompt,期望它能产出符合人类喜好的 response
  • 在 t 时刻,模型根据上文,产出一个token,这个token即对应着强化学习中的动作,我们记为 AtA_t。因此不难理解,在NLP语境下,强化学习任务的动作空间就对应着词表。
  • 在 t 时刻,模型产出 token AtA_t 对应着的即时收益为 RtR_t,总收益为 VtV_t。此刻,模型的状态变为 St+1S_{t+1},也就是从“上文”变成“上文 + 新产出的token”
  • 在NLP语境下,智能体是语言模型本身,环境则对应着它产出的语料

  • Actor Model:演员模型,这就是我们想要训练的目标语言模型
  • Critic Model:评论家模型,它的作用是预估总收益 VtV_t
  • Reward Model:奖励模型,它的作用是计算即时收益 RtR_t
  • Reference Model:参考模型,它的作用是在RLHF阶段给语言模型增加一些“约束”,防止语言模型训歪

其中 Actor Model 和 Critical Model 需要在 RLHF 过程中参与训练,Reward Model 和 Reference Model 两个模型需要冻结参数。

Actor Model 一般用SFT阶段产出的SFT模型来对它做初始化。策略是,先喂给 Actor 一条 prompt (这里假设batch_size = 1,所以是 1 条 prompt),让它生成对应的 response。然后,我们再将“prompt + response"送入我们的“奖励-loss”计算。

RLHF 中存在 Reward Hacking 这个概念,指的是模型知道了评分标准而去直接学习投机取巧的方法而不是学习知识。解决方案就是通过 Reference Model 来衡量预训练得到的模型和 RLHF 微调之后模型的差异,约束 policy 不要离正常语言太远。

  • 对 Actor 模型,我们喂给它一个 prompt,它正常输出对应的 response。那么 response 中每一个 token 肯定有它对应的 log_prob 概率分布呀,我们把这样的结果记为 log_probs
  • 对 Ref 模型,我们把 Actor 生成的"prompt + response"喂给它,那么它同样能给出每个 token 的 log_prob 结果,我们记其为ref_log_probs
  • 那么这两个模型的输出分布相似度就可以用 ref_log_probs - log_probs 来衡量,我们在加入 KL 散度作为惩罚项就可以避免偏离 Ref 太远。
Reward=RewardβKL Reward'=Reward - \beta * KL

Ref 模型一般是将 SFT 模型直接复制后冻结参数。


KL 散度在 RLHF 中具体如何计算:

  1. 将 prompt 输入 actor 得到 response
  2. 将 “prompt+response” 输出 actor 得到每个 token 的概率分布 act_log_probs
  3. 将 “prompt+response” 输出 ref 得到每个 token 的概率分布 ref_log_probs
  4. 最后 KL 散度就是对每个 token 概率差的对数求和。
KL=1nresponselog(prob_ref(token))log(prob_act(token)) KL = \frac{1}{n}\sum_{response}{\log{(prob\_ref(token))-\log{(prob\_act(token))}}}

KL 散度的标准定义应该是,对于单个 token 的 KL 散度是要对 vocab 上每一个 token都求概率和加权求和。但是在 RLHF 实际实现中,token 级 KL 只针对 Actor 实际生成出来的 response[t] token,计算 log p_actor(response[t]) − log p_ref(response[t])

举个例子:

  • prompt=“i like”
  • 输入到 actor 得到 response “you very much”
  • 将 “i like you very much” 输入 actor 求概率分布,这里用的是 Teacher-Forcing
    • 先前向记录第一个 token 的概率分布,将 input=prompt 经过 forward 就能得到 response_0 的概率分布。
    • input=prompt+r0,经过前向计算得到 response_1 的概率分布
  • 类似的将 “i like you very much” 输入到 ref 得到概率分布
位置 t 要预测的 token Reference Model 的概率分布 p_ref(.) Actor(当前 policy)的概率分布 p_actor(.)
1 you p_ref(you)=0.40, i=0.25, like=0.10, … p_actor(you)=0.75, I=0.05, like=0.12, …
2 very p_ref(very)=0.35, … p_actor(very)=0.60, …
3 much p_ref(much)=0.70, … p_actor(much)=0.85, …
4 <eos> p_ref(<eos>)=0.90, … p_actor(<eos>)=0.92, …

对每个 response token t,计算单 token 的 KL 散度

位置 t 要预测的 token KL_t
1 you ≈log(0.75)-log(0.4)
2 very
3 much
4 <eos>

Q:训练Actor模型我能理解,但我还是不明白,为什么要单独训练一个 Critic 模型用于预测收益呢? A:这是因为,当我们在前文讨论总收益(即时 + 未来)时,我们是站在上帝视角的,也就是这个 VtV_t 就是客观存在的、真正的总收益。但是我们在训练模型时,就没有这个上帝视角加成了,也就是在 t 时刻,我们给不出客观存在的总收益 VtV_t,我们只能训练一个模型去预测它。

先来看一个直观的 loss 设计方式:

actor_loss=VtlogP(AtSt) actor\_loss = -\sum V_{t}log P(A_{t}|S_{t})
  • P(AtSt)P(A_{t}|S_{t}) 是在状态 S 的情况下执行 AtA_{t} 的概率
  • VtV_t 是对应的预期未来收益

假如 Vt>0V_t>0 那么损失函数就倾向于提高执行 AtA_{t} 的概率,反之减少概率。但是这个方法存在一个问题:只要预期收益是正的,模型就拼命提高这个回答出现的概率,因此引入了 Advantage 这个概念。

假设是在迷宫游戏中以到达出口为目的,直接到达终点的 Vt=10V_t=10,绕一圈到达出口也可以胜利但是 Vt=5V_t=5,两者的预期收益都为正数也就是说都会提高执行他们的概率,但实际上我们需要提高的只有第一个方法。换句话说"这会导致策略误判,把差动作也当成好动作优化”。

所以我们定义优势为:

Advt=Rt+γVt+1Vt Adv_{t} = R_{t} + \gamma * V_{t+1} - V_{t}

新的 Actor Loss 为:

actor_loss=AdvtlogP(AtSt) actor\_loss = -\sum Adv_{t}log P(A_{t}|S_{t})

前面还记得我们提到了 Reference Model,它的作用是为了约束模型的更新,它具体用在 Reward 奖励函数中,遵循「鼓励高人类偏好(RM 奖励)+ 抑制策略偏离(KL 惩罚)」。

最简单的实现,只需要计算一次序列的 KL 散度:

Rt=r(s,a)γKL R_t=r(s,a)-\gamma KL

简单代码实现如下:

ref_model = actor_model = sft_model
def compute_rewards(prompts, responses)
	with torch.no_grad():
        rm_rewards = rm_model(**tokenized).logits.squeeze(-1)  # [batch_size]
	kls = []
	for p, r in zip(prompts, responses):
		input_text = f"### 人类:{p}\n### 助手:{r}"
		# 处理为 token
		actor_logits = actor_model(input_ids=input_ids, attention_mask=attention_mask).logits[:, :-1, :]
        actor_probs = F.softmax(actor_logits, dim=-1)
        # 这里 input_ids 的起始位置用 prompt 长度更好
		actor_log_probs = torch.log(actor_probs.gather(2, input_ids[:, 1:].unsqueeze(-1)).squeeze(-1)).sum(dim=-1)
		with torch.no_grad():
            ref_logits = ref_model(input_ids=input_ids, attention_mask=attention_mask).logits[:, :-1, :]
            ref_probs = F.softmax(ref_logits, dim=-1)
            ref_log_probs = torch.log(ref_probs.gather(2, input_ids[:, 1:].unsqueeze(-1)).squeeze(-1)).sum(dim=-1)
        
        # KL 散度(平均到每个 token)
        kl = (actor_log_probs - ref_log_probs) / (input_ids.shape[1] - 1)
        kls.append(kl)
	    
    beta = 0.1  # KL 权重(可根据训练情况调整)
    total_rewards = rm_rewards - beta * kls  # 最终奖励 = 原始奖励 - KL 惩罚
    return total_rewards, rm_rewards, kls
  • 首先计算 RM,注意需要冻结参数
  • 之后计算每一个 Q&A 的 KL 散度:
    • 先计算 Actor Model 的对数概率,具体上 actor_model.logits 返回的是一个 [batch_size, seq_len, vocal_size] 的矩阵,代表每个 token 选择的概率,通过 softmax 求概率分布之后通过 gather 函数取出正真实 token 对应的概率,形状为 [batch_size, seq_len, 1],squeeze 到 [batch_size, seq_len] 再求对数和, 形状就变成了 [batch_size, ],这就对应前面公式里的 responselog(prob_act(token))\sum_{response}{\log{(prob\_act(token))}}
    • Ref Model 的计算同 Actor Model,就是要冻结参数。
    • 两个对数求差之后求平均就是这个 sequence 的 KL 散度了。

logits[:, seq_len-1, :] 预测的是 第 seq_len+1 个 token —— 但原输入序列只有 seq_len 个 token,第 seq_len+1 个 token 是 “未存在的、需要生成的 token”,在当前场景(计算已生成序列的对数概率)中,这个位置的预测是 无用的


deepspeed-chat 的 RLHF 实践中,对 RtR_t 做了另一种设计:

$$\begin{array}{c} R_t = \begin{cases}

  • \text{kl_ctl} \cdot \log \frac{P(A_t|S_t)}{P_\text{ref}(A_t|S_t)}, & t \neq T \

  • \text{kl_ctl} \cdot \log \frac{P(A_t|S_t)}{P_\text{ref}(A_t|S_t)} + R_t, & t = T \end{cases} \end{array}$$

  • tTt \neq T 时,我们更加关心 Actor 是否有在 Ref 的约束下生产 token  

  • t=Tt=T 时,我们不仅关心 Actor 是否遵从了 Ref 的约束,也关心真正的即时收益

def compute_rewards(self, prompts, log_probs, ref_log_probs, reward_score, action_mask):
        kl_divergence_estimate = -self.kl_ctl * (log_probs - ref_log_probs)
        rewards = kl_divergence_estimate
        # ---------------------------------------------------------------------------------------------------
        # response开始的位置
        # (因为我们对prompt做过padding处理,因此batch中每个prompt长度一致,也就意味着每个response开始的位置一致)
        # (所以这里start是不加s的,只是一个int)
        # ---------------------------------------------------------------------------------------------------
        start = prompts.shape[1] - 1
        # ---------------------------------------------------------------------------------------------------
        # response结束的位置
        # (因为一个batch中,每个response的长度不一样,所以response的结束位置也不一样)
        # (所以这里end是加s的,ends的尺寸是(batch_size,)
        # ---------------------------------------------------------------------------------------------------
        ends = start + action_mask[:, start:].sum(1) + 1
        reward_clip = torch.clamp(reward_score, -self.clip_reward_value,
                                  self.clip_reward_value)
        batch_size = log_probs.shape[0]
        for j in range(batch_size):
            rewards[j, ends[j]] += reward_clip[j]

        return rewards

同样可以把最后一个时刻的即时奖励替换为每个 token 即时奖励的均值


  • 第一步,我们准备一个 batch 的 prompts
  • 第二步,我们将这个 batch 的 prompts 喂给 Actor 模型,让它生成对应的 responses
  • 第三步,我们把 prompt+responses 喂给我们的 Critic/Reward/Reference 模型,让它生成用于计算 actor/critic loss 的数据
  • 第四步,我们根据这些经验,实际计算出 actor/critic loss,然后更新 Actor 和 Critic 模型,最终得到的 Actor 模型就是 RLHF 之后微调过的最终模型

从图例中可以看到 PPO 采用的是 batch_prompts,因为训练不可能是 生成一个样本 → 立刻更新模型 → 再生成一个样本 → 更新……,而是 一大批样本生成完 → 再训练很多步,这就导致了一个问题:当我们用旧的模型生成了一堆 prompt-response 并且得到了对应的 advantage,然后求 Actor Loss 对 Actor Model 更新了很多次,举个例子:

  • 准备了 batch_size 个 prompt 喂给 Actor Model,相对于后面来说,现在的 Actor Model 就是旧的,它生成了结果 responses。假设某个 responses[k] 中的某个 token 为 hello 且 P(hello)=0.6
  • 经过 epochs 轮训练,得到了新的 Actor Model,这时候如果再把之前的 prompts 喂给他得到的 responses 就不一样了
  • 这时候我们需要求这一轮的 actor_loss=AdvtlogP(AtSt)actor\_loss = -\sum Adv_{t}log P(A_{t}|S_{t}) ,此时我们用的还是旧模型得到的 response,但是训练后的新模型每个 token 的概率就不同了,此时 P(hello)=0.1,模型认为概率这么低还选中了,那更要提高它的概率,于是把 hello 这个不喜欢的 token 概率又提高了。

解决方案就是(涉及数学问题不会了,反正就是这个):

actor_loss=min(AdvtP(AtSt)Pold(AtSt),Advtclip(P(AtSt)Pold(AtSt),0.8,1.2)) actor\_loss = -min(Adv_{t} *\frac{P(A_{t} | S_{t})}{P_{old}(A_{t} | S_{t})}, Adv_{t} * clip(\frac{P(A_{t} | S_{t})}{P_{old}(A_{t} | S_{t})}, 0.8, 1.2))
def actor_loss_fn(self, logprobs, old_logprobs, advantages, mask):
        """
        logprobs: 实时计算的,response部分的prob(只有这个是随着actor实时更新而改变的)
        old_logprobs:老策略中,response部分的prob (这个是固定的,不随actor实时更新而改变)
        advantages: 老策略中,response部分每个token对应的优势(这个是固定的,不随actor实时更新而改变)
        mask:老策略中,response部分对应的mask情况这个是固定的,不随actor实时更新而改变)
        self.cliprange: 默认值是0.2
        """
        log_ratio = (logprobs - old_logprobs) * mask
        ratio = torch.exp(log_ratio)
        pg_loss1 = -advantages * ratio
        pg_loss2 = -advantages * torch.clamp(ratio, 1.0 - self.cliprange, 1.0 + self.cliprange)
        # 最后是取每个非mask的response token的平均loss作为最终loss
        pg_loss = torch.sum(torch.max(pg_loss1, pg_loss2) * mask) / mask.sum() 
        return pg_loss
LVF=E[(Vθ(st)Rttarget)2]Rttarget=k=tTrk \begin{align} L^{VF} &= \mathbb{E}\Big[(V_\theta(s_t) - R_t^{\text{target}})^2\Big] \\ R_t^{target}&=\sum_{k=t}^{T}r_k \end{align}

Critical Model 的目的是预测未来收益,所以 Critical Loss 的设计也很简单了,就是求未来预期收益和未来实际收益的 MSE。


这里又有一个问题了:既然我们可以得到未来实际收益,那么我们还需要 Crtical Model 预测未来收益做什么?查了白天还是不懂。。

与传统 RLHF 相比,DPO 的核心创新在于:直接利用人类标注的 “哪个回答更好” 的偏好数据来优化模型,而不是先训练一个奖励模型再用强化学习优化

DPO 需要的训练数据格式非常简单:三元组 (prompt, chosen, rejected),即:

{
  "prompt": "解释量子计算",
  "chosen": "量子计算利用量子比特可以同时处于多个状态的特性,实现信息的并行处理,使某些问题的解决速度呈指数级提升",
  "rejected": "量子计算是一种涉及原子和粒子的复杂技术"
}

常规的 SFT 训练都是希望能最大化 log(P(yx))\log(P(y|x)) 也就是最大化选中 chosen answer 的概率,但是 DPO 认为还需要正确答案比作物答案选择的概率大,DPO 最大化的是:

loss=logσ(log(P(ychosenx))log(P(yrejectx))) loss = -\log\sigma(\log(P(y^{chosen}|x))-\log(P(y^{reject}|x)))

Q:最大化 log(P(ycorrectx))log(P(yrejectx))\log(P(y^{correct}|x))-\log(P(y^{reject}|x)) 很好理解,就是希望选择 correct 的概率大,选择 reject 的概率小,但是为什么还要在前面加一个 sigmoid 呢? A:因为模型要学习的是 P(ychosen>yrejectedx)P(y^{chosen} > y^{rejected} | x),概率比“分数差”更好表达学习目标。如果直接用两个 P 相减,那么它是一个无界的分数差,没有统一尺度,用 sigmoid 之后就可以把它映射到 0-1 的区间。

同时和 RLHF 一样为了不让模型训练跑偏,还要引入 KL 散度:

loss=logσ(βlogπθ(ychosenx)πref(ychosenx)βlogπθ(yrejectx)πref(yrejectx)) loss = -\log\sigma(\beta \log\frac{\pi_\theta(y^{chosen}\mid x)}{\pi_{\text{ref}}(y^{chosen}\mid x)}-\beta \log\frac{\pi_\theta(y^{reject}\mid x)}{\pi_{\text{ref}}(y^{reject}\mid x)})

这里的 πθ(ychosenx)\pi_\theta(y^{chosen}\mid x) 就是 Teacher-Forcing 对 chosen 中的每一个 token 求联合概率密度 chosenP(tokenprompt)\prod_{chosen}P(token \mid prompt)

介绍混合精度训练之前先回顾计算机组成原理的两个知识点:

浮点数在计算机上面是以 “sign + exp + digits” 的格式存储的,exp 的大小决定了浮点数范围,digits 的大小决定了浮点数的精度。


在训练大型 DNN 的时候,如果采用 FP32 很可能遇到 CUDA 内存溢出的问题,这时候可以尝试把参数的精度从 FP32 调成 FP16,但是这个方法也存在问题:

  1. 参数精度下降导致模型性能下降(实际上精度损失影响很小)
  2. 如果参数小于 FP16 的表示范围就会变成 0,大于就会变成 NAN。

第二个问题是最严重的,NVIDIA 博客中的一幅图表示在 FP16 中这些梯度接近一半都会直接设为 0。


一个朴素的想法就是:

  1. 拷贝模型的 FP32 参数为 Master Weight 保持不动
  2. 将 FP32 的参数转为 FP16 用于前向计算和梯度计算
  3. 将梯度转为 FP32,用来更新 Master Weight
  4. 循环 1-3 步

这个想法没有解决根本问题,Forward 和 Backward 确实采用 FP16 减少内存占用了,但是精度变小还是可能导致梯度变成 0。

假设有:

  • 模型:单参数 w(用 FP32 存储主参数,避免累积误差),初始值 w=0.0000001
  • 输入 x=1.0,真实标签 y=0.000000103(故意让预测值和真实值接近,制造微小梯度);
  • 损失函数:L = 0.5*(w*x - y)^2(MSE 加 0.5 是为了导数简洁);
  • 真实梯度:g_true = (w*x - y)*x(;

那么计算梯度 g=(wxy)x=(0.00000011000000103)0.0000001=3e8g=(wx-y)*x=(0.0000001*1-000000103)*0.0000001=-3e-8,超过 FP16 的表示范围,会直接表示为 0,就没法更新模型了。

所以新的想法就是,在计算梯度之前给他乘一个缩放因子,这样计算出来就在 FP16 的表示范围只能,然后变成 FP32 之后再除以缩放因子变回去:

  1. 拷贝模型的 FP32 参数为 Master Weight 保持不动
  2. 将 FP32 的参数转为 FP16 用于前向计算
  3. 乘上缩放因子,然后计算梯度
  4. 将梯度转为 FP32,除以缩放因子,再用来更新 Master Weight
  5. 循环 1-4 步
for epoch in epochs:
	for inp, tgt in data:
		optimizer.zero_grad()
		
		with auto_cast(device="cuda", dtype=torch.float16):
			out = model(inp)
			loss = loss_fn(out, tgt)
		
		scaler(loss).backward()
		scaler.step(optimizer)
		scaler.update()

还有一种解决方法就是用 BF16,BF16 也是以 2 字节存储,但是它将 digits 的长度减少让位给 exp,也就是说它牺牲了精度提高了表示范围。

PEFT 是 Parameters Efficiently Finetune,参数高效微调,指的是只更新参数集的一部分。

LoRA 的实现思路就是用低秩矩阵表示增量矩阵,类似自注意力机制里面的 Q、K、V。

假设存在线性层 y=Wxy=Wx 并且 WRm×nW \in R^{m \times n} ,那么可以将其分解为 Bm×rB \in m \times rAr×nA \in r \times n

W=BA W = BA

因此我们可以冻结原模型参数不变,仅微调(训练)低秩矩阵的参数:

Y=Wx+BAx×alpha Y = Wx + BAx \times alpha

alpha 的取值一般为 1,它取决于是否需要大幅度改变模型:如果需要新学习的知识原模型没见过可以设为大于 1 的值。

实验证明将 LoRA 应用于 Q、V 矩阵效果最好。


SVD 分解:

任意矩阵 ΔW 都可以写成:

ΔW=UΣVT \Delta W = U\Sigma V^T
  • Un×nU \in n \times n
  • Vm×nV \in m \times n
  • Σ\Sigma 奇异值对角矩阵

通过低秩近似,我们可以得到:

ΔWUrΣrVrT \Delta W \approx U_r \Sigma_r V_r^T

和 LoRA 公式对比就有:

BAUrΣrVrT BA \longleftrightarrow U_r \Sigma_r V_r^T

所以 LoRA 的核心思路是:利用 “SVD 说明低秩近似存在” 这个事实,用 SGD 直接在“低秩空间”里找最优解。

Q:既然可以通过 SVD 分解得到低秩矩阵为什么还需要用 B 和 A 去学习呢? A:因为还得通过 Fulltune 得到 ΔW\Delta W 然后再分解,虽然参数占用减少了,但是计算量还增加了。

class LoRALinear(nn.Module):
    
    def __init__(
        self, 
        base_layer: nn.Linear,
        r: int,
        alpha: float,
        dropout: float
    ):
        self.rank = r
        self.in_features = base_layer.in_features
        self.out_features = base_layer.out_features
        # 注意数学公式中 x 都是列向量
        # 但是这里 x.shape = (bsize, len, in_features)
        # 所以 y=x*(BA)^T BA.shape = (out_feature, in_feature)
        if r > 0:
            self.lora_B = nn.Parameter(torch.zeros(self.out_features, r))
            self.lora_A = nn.Parameter(torch.zeros(r, self.in_features))
            self.scaling = alpha / r
            
            # 初始化
            nn.init.kaiming_normal_(self.lora_A, a=0.001)
        
        self.dropout = nn.Dropout(dropout) if r > 0 else nn.Identity()
        
        # freeze parameters
        self.base_layer.weight.requires_grad = False
        self.base_layer.bias.requires_grad = False
        
    
    def forward(self, x):
        output = self.base_layer(x)
        
        if self.rank > 0:
            output = output + self.scaling * (x @ (self.lora_B @ self.lora_A).T)
            
        return self.dropout(output)

LoRA 的初始化

从代码中可以看到 LoRA 对 A、B 矩阵进行初始化时,对 A 才用 kaiming 初始化,对 B 矩阵直接初始化为 0。

  • 让LoRA的更新 ΔW=BA\Delta W=BA 接近于 0 矩阵,从而不破坏预训练权重的行为。如果 B 初始化为 0 → ΔW=0×A\Delta W=0 \times A 矩阵, 训练一开始,LoRA相当于没起作用,模型行为和原始预训练模型完全一致,非常稳定。但是如果 A 也初始化为 0 → 永远都是0,梯度更新不了,所以A必须有随机初始值,让梯度能正常回传到 B。
  • 在前向传播中,低秩更新实际走的路径是:x → A → (scale) → B

两种 LoRA 方式

  1. merge:训练完,把 ΔW=BA\Delta W=BA 直接加到原始权重 W 上。
  2. adapter:训练完后仍然把A、B矩阵单独保存,推理时先加载大模型,再动态把 LoRA adapter 加载进来。

Write a method to work out the distinct words (word types) that occur in the corpus.

You can use for loops to process the input corpus (a list of list of strings), but try using Python list comprehensions (which are generally faster). In particular, this may be useful to flatten a list of lists. If you’re not familiar with Python list comprehensions in general, here’s more information.

Your returned corpus_words should be sorted. You can use python’s sorted function for this.

You may find it useful to use Python sets to remove duplicate words.

def distinct_words(corpus):
    """ Determine a list of distinct words for the corpus.
        Params:
            corpus (list of list of strings): corpus of documents
        Return:
            corpus_words (list of strings): sorted list of distinct words across the corpus
            n_corpus_words (integer): number of distinct words across the corpus
    """
    corpus_words = []
    n_corpus_words = -1
    
    # ------------------
    # Write your implementation here.
    corpus_words = [item for sublist in corpus for item in sublist]
    corpus_words = sorted(list(set(corpus_words)))
    n_corpus_words = len(corpus_words)
    # ------------------

    return corpus_words, n_corpus_words

Write a method that constructs a co-occurrence matrix for a certain window-size nn (with a default of 4), considering words nn before and nn after the word in the center of the window. Here, we start to use numpy (np) to represent vectors, matrices, and tensors. If you’re not familiar with NumPy, there’s a NumPy tutorial in the second half of this cs231n Python NumPy tutorial.

def compute_co_occurrence_matrix(corpus, window_size=4):
    """ Compute co-occurrence matrix for the given corpus and window_size (default of 4).
    
        Note: Each word in a document should be at the center of a window. Words near edges will have a smaller
              number of co-occurring words.
              
              For example, if we take the document "<START> All that glitters is not gold <END>" with window size of 4,
              "All" will co-occur with "<START>", "that", "glitters", "is", and "not".
    
        Params:
            corpus (list of list of strings): corpus of documents
            window_size (int): size of context window
        Return:
            M (a symmetric numpy matrix of shape (number of unique words in the corpus , number of unique words in the corpus)): 
                Co-occurence matrix of word counts. 
                The ordering of the words in the rows/columns should be the same as the ordering of the words given by the distinct_words function.
            word2ind (dict): dictionary that maps word to index (i.e. row/column number) for matrix M.
    """
    words, n_words = distinct_words(corpus)
    M = None
    word2ind = {}
    
    # ------------------
    # Write your implementation here.
    M = np.zeros((n_words, n_words))
    for index, word in enumerate(words):
        word2ind[word] = index
    for sentence in corpus:
        for sta, word in enumerate(sentence):
            for i in range(-window_size, window_size + 1):
                idx = min(max(sta+i, 0), len(sentence) - 1)
                if idx == sta: continue
                M[word2ind[word], word2ind[sentence[idx]]] += 1
    # ------------------
    return M, word2ind

Construct a method that performs dimensionality reduction on the matrix to produce k-dimensional embeddings. Use SVD to take the top k components and produce a new matrix of k-dimensional embeddings.

Note: All of numpy, scipy, and scikit-learn (sklearn) provide some implementation of SVD, but only scipy and sklearn provide an implementation of Truncated SVD, and only sklearn provides an efficient randomized algorithm for calculating large-scale Truncated SVD. So please use sklearn.decomposition.TruncatedSVD.

sklearn-svd使用方法

from sklearn.decomposition import TruncatedSVD
import numpy as np

## 一个示例矩阵(比如共现矩阵)
A = np.array([
    [1, 1, 0],
    [0, 1, 1],
    [1, 0, 1]
])

## 保留 2 个奇异值
svd = TruncatedSVD(n_components=2)
A_reduced = svd.fit_transform(A)

print("UΣ(降维结果):")
print(A_reduced)
print("\n奇异值 Σ:")
print(svd.singular_values_)
print("\n右奇异向量 V^T:")
print(svd.components_)

Here you will write a function to plot a set of 2D vectors in 2D space. For graphs, we will use Matplotlib (plt).

For this example, you may find it useful to adapt this code. In the future, a good way to make a plot is to look at the Matplotlib gallery, find a plot that looks somewhat like what you want, and adapt the code they give.

def plot_embeddings(M_reduced, word2ind, words):
    """ Plot in a scatterplot the embeddings of the words specified in the list "words".
        NOTE: do not plot all the words listed in M_reduced / word2ind.
        Include a label next to each point.
        
        Params:
            M_reduced (numpy matrix of shape (number of unique words in the corpus , 2)): matrix of 2-dimensioal word embeddings
            word2ind (dict): dictionary that maps word to indices for matrix M
            words (list of strings): words whose embeddings we want to visualize
    """

    # -------- primary ----------
    for word in words:
        idx = word2ind[word]
        x = M_reduced[idx][0]
        y = M_reduced[idx][1]
        plt.scatter(x, y, marker='x', color='red')
        plt.text(x, y, word, fontsize=9)
    plt.show()
    # -------- better ----------
    x_coords = M_reduced[:, 0]
    y_coords = M_reduced[:, 1]
    for word in words:
        x = x_coords[word2ind[word]]
        y = y_coords[word2ind[word]]
        plt.scatter(x, y, marker='x', color='red')
        plt.text(x, y, word, fontsize=9)
    plt.show()

Run the cell below to plot the 2D GloVe embeddings for ['movie', 'book', 'mysterious', 'story', 'fascinating', 'good', 'interesting', 'large', 'massive', 'huge'].

Now that we have word vectors, we need a way to quantify the similarity between individual words, according to these vectors. One such metric is cosine-similarity. We will be using this to find words that are “close” and “far” from one another.

We can think of n-dimensional vectors as points in n-dimensional space. If we take this perspective L1 and L2 Distances help quantify the amount of space “we must travel” to get between these two points. Another approach is to examine the angle between two vectors. From trigonometry we know that:

Instead of computing the actual angle, we can leave the similarity in terms of similarity=cos(Θ)similarity = cos(\Theta). Formally the Cosine Similarity ss between two vectors pp and qq is defined as:

s=pqpq, where s[1,1] s = \frac{p \cdot q}{||p|| ||q||}, \textrm{ where } s \in [-1, 1]

用python实现余弦相似度计算如下:

similarity = np.dot(p, q) / (np.linalg.norm(p) * np.linalg.norm(q))

When considering Cosine Similarity, it’s often more convenient to think of Cosine Distance, which is simply 1 - Cosine Similarity.

Find three words (w1,w2,w3)(w_1,w_2,w_3) where w1w_1 and w2w_2 are synonyms and w1w_1 and w3w_3 are antonyms, but Cosine Distance (w1,w3)<(w_1,w_3) < Cosine Distance (w1,w2)(w_1,w_2).

As an example, w1w_1=“happy” is closer to w3w_3=“sad” than to w2w_2=“cheerful”. Please find a different example that satisfies the above. Once you have found your example, please give a possible explanation for why this counter-intuitive result may have happened.

You should use the the wv_from_bin.distance(w1, w2) function here in order to compute the cosine distance between two words. Please see the GenSim documentation for further assistance.

导致同义词比反义词的余弦距离更大的原因可能为,在某个语料库下,两个反义词一起出现的频率很高。

  1. 数据预处理:会得到三个数据集以及一个 Parser,在依存分析实验中 Parser 统筹管理转移系统中的全部资源,包括 Stack, Buffer, Arcs 还有一个深度学习的 model。
  2. 训练过程:train 函数会进行 n 个 batch 的训练,保存 UAS 最大的一个模型。
  3. 使用刚刚保存的最好模型对 test 数据集进行处理
if __name__ == "__main__":
    debug = args.debug

    assert (torch.__version__.split(".") >= ["1", "0", "0"]), "Please install torch version >= 1.0.0"

    print(80 * "=")
    print("INITIALIZING")
    print(80 * "=")
    parser, embeddings, train_data, dev_data, test_data = load_and_preprocess_data(debug)

    start = time.time()
    model = ParserModel(embeddings)
    parser.model = model
    print("took {:.2f} seconds\n".format(time.time() - start))

    print(80 * "=")
    print("TRAINING")
    print(80 * "=")
    output_dir = "results/{:%Y%m%d_%H%M%S}/".format(datetime.now())
    output_path = output_dir + "model.weights"

    if not os.path.exists(output_dir):
        os.makedirs(output_dir)

    train(parser, train_data, dev_data, output_path, batch_size=1024, n_epochs=10, lr=0.0005)

    if not debug:
        print(80 * "=")
        print("TESTING")
        print(80 * "=")
        print("Restoring the best model weights found on the dev set")
        parser.model.load_state_dict(torch.load(output_path))
        print("Final evaluation on test set",)
        parser.model.eval()
        UAS, dependencies = parser.parse(test_data)
        print("- test UAS: {:.2f}".format(UAS * 100.0))
        print("Done!")

训练数据集 be like:

1	In	_	ADP	IN	_	5	case	_	_
2	an	_	DET	DT	_	5	det	_	_
3	Oct.	_	PROPN	NNP	_	5	compound	_	_
4	19	_	NUM	CD	_	5	nummod	_	_
5	review	_	NOUN	NN	_	45	nmod	_	_
6	of	_	ADP	IN	_	9	case	_	_
7	``	_	PUNCT	``	_	9	punct	_	_
8	The	_	DET	DT	_	9	det	_	_

这个文件是一个 CoNLL-U(或类似 CoNLL 格式) 的依存句法分析标注文件,每一段对应一个句子,“In an Oct. 19 review of ‘The Misanthrope’ at Chicago’s Goodman Theatre …”

  • read_conll 函数会负责读取这些文件,并且返回list[{'word': [], 'pos': [], 'head': [], 'label': []}]格式的数据,每个字典对应一句话
  • 构建 Parser 核心类,里面封装了依存分析的整个系统。
  • 读取预训练的词向量
  • 构建初始化 [token_num, 50] 大小的词向量矩阵
  • 将数据集中的向量变成 one-hot 编码
  • create_instances() 从语料中生成“当前状态 → 正确动作”的训练样本

已经有预训练的词向量,为什么还要随机生成? 因为预训练的词向量库不一定能囊括全部的词汇,所以需要初始化一个词向量矩阵,然后用预训练的替换

已经有词向量了,为什么还需要把 word 和 pos 等转为 one-hot 编码呢?这样不是丢失了信息吗? TODO

PyTorch 框架下,要实现自己的神经网络可以继承 torch.nn.Module 类,它考研自动把 nn.Parameter 和子模块中的参数收集起来,便于优化器访问 model.parameters()。

class CustomizedNet(nn.Module):
    def __init__(self, in_dim, hidden_dim, out_dim):
        super().__init__()

    def forward(self, x):
        return x

embedding_lookup

前面提到在数据预处理的时候,训练集的数据中 word,pos 等向量会转为 one-hot 编码,所以在进入神经网络进行训练时候,需要根据 idx 得到对应的向量表示。

在依存句法分析(Dependency Parsing)中,每一步的输入状态可以用一些“特征单词”来表示,比如:

  • 栈顶的词(stack top)
  • 缓冲区前几个词(buffer front)
  • 它们的子节点(left/right children)

这些单词的索引被拼成一个固定长度的列表,比如:w = [23, 14, 7, 65, 99, ..., 8], 模型输入的 train_set 维度为 [batch_size, n_features]。

    def embedding_lookup(self, w):
        x = self.embeddings[w]
        x = x.view(x.size(0), -1)

        return x

这里用到了 PyTorch 中张量索引的技巧,如果用一个张量 a 当另一个张量 b 的索引,那么 a 中的每个元素 i 会被替换为 b 中第一个维度的第 i 个元素,例如:

self.embeddings = torch.tensor([
    [1, 1, 1],   # 词 0
    [2, 2, 2],   # 词 1
    [3, 3, 3],   # 词 2
    [4, 4, 4],   # 词 3
])
w = torch.tensor([[0, 2], [1, 3]])
=>
self.embeddings[w] = 
[
    [[1, 1, 1], [3, 3, 3]],
    [[2, 2, 2], [4, 4, 4]]
]

可以计算得到,经过 lookup 查表操作之后,train_set 的维度从 [batch_size, n_features] 变成 [batch_size, n_features, embed_size]。

由于神经网络的输入必须是一维的 feature 向量,所以需要对 train_set 进行展平操作,从 [batch_size, n_features, embed_size] 降维到 [batch_size, n_features * embed_size]


init

    def __init__(self, embeddings, n_features=36, hidden_size=200, n_classes=3,         dropout_prob=0.5):
        super(ParserModel, self).__init__()
        self.n_features = n_features
        self.n_classes = n_classes
        self.dropout_prob = dropout_prob
        self.embed_size = embeddings.shape[1]
        self.hidden_size = hidden_size
        self.embeddings = nn.Parameter(torch.tensor(embeddings))

        # declare
        self.embed_to_hidden_weight = nn.Parameter(torch.empty(self.embed_size * self.n_features))
        self.embed_to_hidden_bias = nn.Parameter(torch.empty(hidden_size))
        
        # initialize
        nn.init.xavier_uniform_(self.embed_to_hidden_weight)
        nn.init.uniform_(self.embed_to_hidden_bias)
        
        # dropout layer
        self.dropout = nn.Dropout(p=self.dropout_prob)
        
        # declare
        self.hidden_to_logits_weight = nn.Parameter(torch.empty(self.hidden_size, self.n_classes))
        self.hidden_to_logits_bias = nn.Parameter(torch.empty(self.n_classes))

前馈层是线性计算,我们手动定义的 Weight 和 Bias 与 nn.Linear 有着相同作用:

y=Wx+b y=Wx+b

输入 x 的维度为 [batch_size, self.embed_size * self.n_features], 所以 W 维度为 [self.embed_size * self.n_features, hidden_size], 乘积的维度为 [self.batch_size, hidden_size], 所以 b 的维度是 [hidden_size,],后续计算同理。

为什么 b 的维度是 [hidden_size] 或者说 [1, hidden_size]

首先我们来分析一下y=xWy=xW得到的矩阵,他的形状是 [batch_size, hidden_size],这代表有 batch_size 行,每一行宽度是 hidden_size。所以如果我们想给函数加一个偏置项,应该是给每一行加上去,偏置向量的形状应该是 [1, hidden_size]。但是矩阵和向量是如何相加的呢?PyTorch(或 NumPy)的广播规则是:只要两个张量在末尾维度上能匹配,就可以自动扩展前面的维度,也就是说:广播时,b 会被自动扩展为 [1, hidden_size] → [batch_size, hidden_size]。所以在这个语境下,它行为上更像是一个“行向量”。


forward

    def forward(self, w):
        logits = None
        x = self.embedding_lookup(w)
        logits = nn.ReLU(torch.matmul(x, self.embed_to_hidden_weight) + self.embed_to_hidden_bias)
        logits = self.dropout(logits)
        logits = nn.ReLU(torch.matmul(logits, self.hidden_to_logits_weight) + self.hidden_to_logits_bias)
        return logits

为什么在 forward 前馈计算中是 x 乘以 W 呢?

这主要是源于 PyTorch 和数学计算的差异:在数学中我们通常规定 x 是一个长度为 n 的列向量,但是在 PyTorch 中输入 x 几乎总是一批行向量。所以在 PyTorch 代码中,一般是y=xWy=xW或者y=xWTy=xW^T

dropout 一定要放在两个线性变换之间吗?隐藏层的输出是模型学习到的特征表征。对这些表征做 dropout,迫使下一层的权重依赖更加广泛、鲁棒的特征组合,从而降低过拟合。

  1. Adam Optimizer 需要模型全部神经元当参数
  2. 损失函数采用交叉熵损失
  3. 训练 n_epochs 个轮次,然后取最高分保存模型。
def train(parser, train_data, dev_data, output_path, batch_size=1024, n_epochs=10, lr=0.0005):
    best_dev_uas = 0
    params = parser.model.parameters()
    optimizer = optim.Adam(params, lr=0.0001)
    loss_func = nn.CrossEntropyLoss()

    for epoch in range(n_epochs):
        print(f"Epoch {epoch} out of {n_epochs}")
        dev_uas = epoch_train(parser, train_data, lr)
        print(f"Epoch {epoch} scored {dev_uas} UAS")
        if dev_uas > best_dev_uas:
            torch.save(parser.model.state_dict(), output_path)
    print(f"train completed with best dev uas of {best_dev_uas}")
  1. 在训练之前调用model.train(),dropout 屏蔽一部分神经元
  2. 每次取 batch_size 个数据训练
  3. 每次训练的套路都是比较固定了,梯度清零-预测-计算 loss-梯度回传-更新梯度
def train_for_epoch(parser, train_data, dev_data, optimizer, loss_func, batch_size):
    parser.model.train()
    n_minibatches = math.ceil(len(train_data) / batch_size)
    total_loss = 0
    with tqdm(total=(n_minibatches)) as prog:
        for i, (train_x, train_y) in enumerate(minibatches(train_data, batch_size)):
            # ---------- 一套连招 -----------
            optimizer.zero_grad()
            logits = parser.model(train_x)
            loss = loss_func(logits, train_y)
            loss.backward()
            optimizer.step()
            total_loss += loss.item()
            prog.update(1)
    print(f"Average Train Loss: {total_loss / n_minibatchs}")

    parser.model.eval()
    dev_uas, _ = parser.parse(dev_data)
    print("- dev UAS: {:.2f}".format(dev_UAS * 100.0))
    return dev_UAS

上面代码中最重要的部分就是利用 batch data 进行训练的部分,接下来详细分析一下。

  1. 首先为什么需要model.train()

启用训练模式之后,神经网络会采用 Dropout 以一定概率(例如 p=0.5)随机“屏蔽”一部分神经元输出,这么做是为了让模型不要太依赖某些特征,防止过拟合。

yi={0xi1p y_i= \begin{cases} 0\\ \frac{x_i}{1-p} \end{cases}
  1. 为什么需要optimizer.zero_grad()手动清空梯度?

PyTorch 中进行反向传播之后计算梯度并不是简单的赋值 (=) 而是选择了梯度累积 (+=),这里用一个代码举例:

"""
假设 y=w*x, 并且需要训练的 w 为 2
"""

w = torch.tensor([1.0], requires_grad=True)
optimizer = torch.optim.SGD([w], lr=0.1)

def loss_func(pred_y, train_y):
    """
    损失函数为 loss = (y1 - y) ^ 2 = (w * x - y) ^ 2
    求导后为 ∂loss/∂w = 2(w * x - y) * x
    """
    return (pred_y - train_y) ** 2

train_x1, train_x2 = torch.tensor([1.0]), torch.tensor([2.0])
train_y1, train_y2 = torch.tensor([2.0]), torch.tensor([4.0])

pred_y1 = w * train_x1
loss1 = loss_func(pred_y1, train_y1)
loss1.backward()

"""
w=1, train_x1=1, pred_y1=1
∂loss/∂w=2*(1-2)*1=-2
所以 w 的梯度为-2
"""

print(f"第一次推导之后 w 的梯度为{w.grad}")

pred_y2 = w * train_x2
loss2 = loss_func(pred_y2, train_y2)
loss2.backward()

"""
w=1, train_x2=2, pred_y1=4
∂loss/∂w=2*(2-4)*2=-8
所以 w 的梯度为-8
"""
print(f"第二次推导之后 w 的梯度为{w.grad}")

代码运行后会得到 w 的梯度是-10 而不是-8,因为采用了梯度累积(2)+(8)=10(-2) + (-8) = -10,这也就解释了为什么需要在每一次反向传播之前将梯度清零。

为什么 PyTorch 采用梯度累积呢?

  1. 解决显存不足:梯度累积训练(Gradient Accumulation)

当模型或批次较大(如大模型、高分辨率图像)时,显存可能无法容纳一个完整的大批次数据(例如想使用 atch_size=32,但显存只支持 batch_size=8)。此时可以:

  • 将大批次拆分为多个小批次(如 4 个 batch_size=8);
  • 每个小批次计算梯度后不更新参数,而是累积梯度(backward() 自动+=);
  • 累积 4 次后,用总梯度(等价于 batch_size=32 的梯度)更新一次参数。

这样既避免了显存溢出,又等价于使用大批次训练(保持梯度统计特性一致)。

  1. 多损失函数场景:合并不同损失的梯度
## 多任务损失
loss_cls = cross_entropy(pred_cls, label_cls)  # 分类损失
loss_reg = mse_loss(pred_reg, label_reg)      # 回归损失

## 分别反向传播,梯度自动累积(+=)
loss_cls.backward(retain_graph=True)  # retain_graph 保留计算图,供下一次 backward
loss_reg.backward()

## 用总梯度(cls 梯度 + reg 梯度)更新参数
optimizer.step()
optimizer.zero_grad()

如果 backward() 是 “赋值”,则第二个损失的梯度会覆盖第一个,导致只能用单一损失的梯度更新,无法实现多损失的联合优化。

Bahdanau 注意力机制的思想就是在 Decoder 的每一个时刻,用当前 token 和 Encoder 的每一个 token 计算注意力分数,判断 Encoder 的哪一个 token 和当前 token 关系最大。

在早期的 seq2seq 结构中只包含最简单的 Encoder 和 Decoder 两部分。训练时如果让 Decoder 完全靠自己前一步的预测输出当下一步输入,容易出错 → 错误不断累积,训练非常难收敛。为了解决这个问题,Teacher-forcing 的解决办法是直接把答案告诉模型。

假设正在翻译 “I love you” -> “我爱你”:

  • t=0,输入句首 token,将预测的第一个 token 和答案求损失
  • t=1,输入 “我”,将预测的第二个 token 求损失
  • t=2,输入 “我爱”, 将预测的第三个 token 求损失

这个方法的好处在于可以避免一步错步步错的现象,但是坏处就是这个方法在训练集有很好的表现,但是在测试集和验证集上仍然可能看到之前错误的预测。

NMT 模型做的事情就是实现 seq2seq,它有一个 Encoder (双向 LSTM)+一个 Decoder (单向 LSTM 和注意力机制)实现。待翻译的文本首先会通过 CNN 卷积层,有助于提取上下文 Token 之间的信息。然后进入双向 LSTM,输出每时刻的隐藏状态,Last_hidden 和 Last_cell (后面会成为 Decoder 的初始向量)。Decoder 阶段的输入是真实的翻译文本也就是答案), Decoder 根据答案+隐藏状态+注意力机制预测下一个 token 是什么,最后损失则为每一损失的平均值。

编码器的作用是通过双向 LSTM 解析原文本,根据原文本的语义信息生成向量提供给解码器生成目标文本。

  • 输入到 NMT 模型的文本形状为 [B, L],这里的 L 指的是训练集每条评论不同的长度。
  • 经过处理,这些张量会统一到相同的长度得到原文本张量 [SL, B],这里的 SL指的是原文本最大长度。

Q:为什么输入张量的形状是 [SL, B],通常不都是 [B, SL] 吗? A:因为 PyTorch 中,LSTM 模型默认 batch_first=False,也就是要求输入格式为 [L, B, embedding_dim],而训练数据通常是 [B, L],所以必须转成 [L, B]。假如设置了 batch_first=True那么就可以沿用之前的形状 [B, L]。

  • NMT 内部维护着两个可训练的 Embedding,分别对应原文本和目标文本。
  • 对输入张量进行词嵌入之后,形状从 [SL, B] 变成 [SL, B, E]
  • 由于要对张量进行卷积操作,需要先变形为 [B, E, SL] 再变回 [SL, B, E]
  • 为了避免带 padding 的输入影响模型训练效果,通过 pack_padded_sequence 得到处理过的张量放进 LSTM 模型,他可以只处理有效数据,节省计算,并且让 RNN 层只看真实部分,自动忽略 padding。
  • 双向 LSTM 模型会返回三个张量enc_hiddens, (last_hidden, last_cell)
    • enc_hiddens 是双向 LSTM 每个时刻两个方向的隐藏状态,需要通过 pad_packed_sequence 转为 Tensor,形状为 [SL, B, 2H],最后转为 [B, SL, 2H]
    • last_hidden 和 last_cell 是双向 LSTM 最后输出的两个状态张量,形状为 [2, B, H]
  • 由于 Encoder 是双向 LSTM,forward LSTM 和 backward LSTM 会分别返回一个隐藏状态向量和细胞向量,所以形状是 [2, B, H]。但是 Decoder 是一个单向 LSTM,所以它的隐藏状态向量和细胞向量形状为 [B, H]。解决办法就是把2个向量在第二个维度拼接为 [B, 2*H]。

Q:为什么要进行卷积? A:因为词向量是逐词的,每个 token 对应的 embedding 只包含它自己的信息,不同 token 组合起来有不同的含义。再词嵌入和 LSTM 之间加入卷积层,可以让模型更好的学习输入文本的语义信息。

解码器也采用了 LSTM 模型,但目的不像 Encode 部分一样是为了得到隐藏状态向量,所以没有用 nn.LSTM 而用了 nn.LSTMCell,通过手动 for 循环一步步计算注意力分数。

  • 由于 enc_hiddens 的形状是 [B, SL, 2*H],所以需要对它做一次线性变换变成 [B, SL, H]
  • 对目标文本进行词嵌入之后得到 Y,形状也是 [TL, B, E]。
  • 沿着 Y 的第一维遍历 Y,能得到每个时刻向量 y_t,形状为[B, E]。
  • o_prev 是 Encoder 上一个时间步的隐藏状态,或者可以说是综合考量注意力分数和正确答案得到的隐藏状态,形状也是 [B, H]
  • 每一个时间步 Decode 做的事情包括:
    • 将 y_t 和 o_prev 裁剪为 [B, E+H] 输入到 Decode 的单向 LSTM,得到 dec_hidden, dec_cell, 形状为 [B, H]
    • 通过矩阵乘法,将 enc_hiddens_proj [B, SL, H] 和 dec_hidden.unsequeeze(2) [B, H, 1] 相乘,得到注意力分数 [B, SL, 1] 再变形为 [B, SL],其中第二维每一个元素就代表每个 token 和当前的匹配度。
    • 由于通过 padding 将文本长度补齐到 SL,所以需要把 padding 部分的 score 降到负无穷,不会影响上下文。
    • Softmax 得到注意力权重 alpha_t,表示在生成当前目标词时,源句 token 对结果的重要性。
    • 矩阵乘法,将 alpha_t.unsqueeze(1) [B, 1, SL] 和 enc_hiddens [B, SL, 2H] 相乘,得到注意力分数 [B, 1, 2H] 再变形为 [B, 2H]。
    • 最后将 decoder 的隐藏状态张量和 上下文向量 a_t 拼接得到 U_t [B, 3H],经过线性变换最后得到 [B, H] 的张量,也就是下一个时间步的 o_prev

上一步得到注意力分数只是告诉模型“在源句的每个位置上,我该关注多少?”。它只是权重,不能直接用于生成词或更新 decoder。而 a_t 它是一个真正的向量表示,包含你“关注的位置合成出来的语义与上下文信息”。decoder 需要 这个语义向量 来决定下一步要输出什么词。

最后 decoder 会返回一个 [SL, B, H] 的张量,翻译或者说单词预测也是一个多分类问题,所以还需要通过线性变换将 [SL, B, H] 变为 [SL, B, E] 再做 softmax,其中 [a, b, c] 代表第 b 个句子中第 a 个 token 是 vocal[c] 的可能性。

机器翻译的本质给定源句子“我爱你”,让模型学会输出目标句子“I love you”。或者说我们希望模型学会条件概率分布: \(p(y | x) = p(I love you | 我爱你)\),概率越大越好。假设我们有 N 个平行句对训练样本,那么希望找到一组超参数 θ,让所有训练样本出现的联合概率最大:

θ=argmaxθi=1Np(yixi;θ) \theta^{*} = argmax_{\theta}\prod_{i=1}^{N}p(y^{i}|x^{i};\theta)

为了方便计算对其取对数:

θ=argmaxθi=1Nlogp(yixi;θ) \theta^{*} = argmax_{\theta}\sum_{i=1}^{N}\log{p(y^{i}|x^{i};\theta)}

因为求最小值比求最大值容易,所以取负数:

θ=argminθi=1Nlogp(yixi;θ) \theta^{*} = argmin_{\theta}\sum_{i=1}^{N}-\log{p(y^{i}|x^{i};\theta)}

对于单个句子而言是如何计算概率的呢?

根据概率公式:

p(yx)=p(y1<s>,x)×p(y2<s>,y1,x)×=t=1Tlogp(yty<t,x) p(y|x)=p(y_1|<s>,x) \times p(y_2|<s>,y_1,x) \times \dots = \sum_{t=1}^{T}\log{p(y_t|y_{<t},x)}

NMT 是一个多分类问题,采用 softmax 得到概率分布:

p(yt=ky<k,x)=exp(logitk)kexp(logiyk) p(y_t=k|y_{<k},x)=\frac{exp(logit_k)}{\sum_{k'}{exp(logiy_{k'})}}

困惑度就是对平均负对数损失做指数:

ppl=elosslen(token)=e1Ni=1NlogP(yi) ppl=e^{\frac{loss}{len(token)}}=e^{-\frac{1}{N}\sum_{i=1}^{N}\log{P(y_i)}}

早停机制可以有效的避免模型进行无效训练或者陷入过拟合:

  1. 如果当前困惑度最低,那么 patience 清零并且保存模型
  2. 否则 patience++
  3. 如果 patience 达到上限则触发 trial,学习率下降并且从上一个 checkpoint 重新加载模型和优化器,当trial 到达上限则早停退出。

BLEU 是常用的“自动评翻译好不好”的指标,步骤是:

  1. 算“n-gram 精度”(比如 1-gram 是单个词的匹配度,2-gram 是两个词连起来的匹配度,比如“adequate resources”在参考翻译里有没有);
  2. 算“ brevity penalty(简短惩罚)”:如果模型翻译太短(比如参考句 10 个词,模型只翻 5 个),会扣分项;
  3. 把精度、惩罚结合起来,算最终 BLEU 分(0-1 之间,越高越好)。

beam search 是“让模型生成更好翻译”的方法,训练时会记录不同迭代次数的翻译结果(比如第 200 次、第 3000 次迭代),要做两件事:

  1. 看翻译质量有没有随迭代提升(比如第 200 次翻得不通顺,第 3000 次接近参考翻译);
  2. 分析同一迭代下,beam search 生成的多个候选翻译(比如 10 个候选)有什么差别(比如有的候选多了个“the”,有的少了个“and”)。

任务(a) 要求阅读 mingpt-demo/play_char.ipynb 代码

GPT 用的位置编码不是正余弦函数,而是自训练的参数矩阵:

def __init__(self, ...):
	self.pos_emb = nn.Parameter(torch.zeros(1, config.block_size, config.n_embd))


def forward(self, idx, targets):
	position_embeddings = self.pos_emb[:, :t, :]
	x = self.drop(token_embeddings + position_embeddings)

之前提到过这个的好处是表达能力比单纯的正余弦更强。

每一个文本输入都是用同一个 Position Embedding,所以矩阵的 size(0)=1,然后通过张量广播就好了。

之前我们通过 nn.Linear 或者 nn.Embedding 定义的模块,PyTorch 会对他们的参数进行默认 Kaiming 初始化。这里 GPT 手动对参数进行了初始化如下:

model.apply(init_function)

apply 方法要求传入一个形参为 module 的函数,它会遍历模型的所有子模块,然后用 init_function 方法对它进行处理。

def _init_weights(self, module):
    if isinstance(module, (nn.Linear, nn.Embedding)):
        module.weight.data.normal_(mean=0.0, std=0.02)
        if isinstance(module, nn.Linear) and module.bias is not None:
            module.bias.data.zero_()
    elif isinstance(module, nn.LayerNorm):
        module.bias.data.zero_()
        module.weight.data.fill_(1.0)

对于 nn.Linear 和 nn.Embedding,GPT 将参数初始化为均值 0,标准差 0.02 的正态分布,线性层的偏置初始化为 0,LayerNorm 的缩放因子设为 1。

GPT 的优化器用的是 AdamW,它采用了 Weight Decay 来防止模型过拟合,这其实也就是 L2 正则化。

losstotal=loss+λW2 \text{loss}_{total} = \text{loss} + \lambda \|W\|^2
  • λ\lambda 是超参数,Weight Decay 的系数
  • W2|W\|^2 是全部参数的平方和

在损失函数中加入参数的平方和,就可以在反向传播的时候让模型趋向于减小参数,从而降低模型复杂度,增强泛化能力。

参数类型 为什么不加 / 要加 weight decay 实际影响
Linear weight 参数量巨大,是模型表达能力的主要来源,容易过拟合 → 必须加 weight decay 正则化 强烈推荐加
bias 只有一维(每个神经元一个),参数量极少,过拟合风险几乎为 0;加了反而会干扰梯度信号(bias 只需要平移) 坚决不加
LayerNorm γ, β LayerNorm 参数本身就是做归一化的,强制让它们变小会破坏归一化效果(尤其是 γ 是 scale 参数,加 decay 会让模型倾向于输出更小的方差,效果变差) 坚决不加
Embedding 争议最大。早期 GPT-2 不加,后来 LLaMA1/2、Qwen、DeepSeek 等很多模型也给 Embedding 加了小的 weight decay(0.01~0.1),发现能稍微提升泛化。目前两种做法都有 可加可不加
optim_groups = [
    {"params": [param_dict[pn] for pn in sorted(list(decay))], "weight_decay": train_config.weight_decay},
    {"params": [param_dict[pn] for pn in sorted(list(no_decay))], "weight_decay": 0.0},
]
optimizer = torch.optim.AdamW(optim_groups, lr=train_config.learning_rate, betas=train_config.betas)

可以用 nn.Sequential 直接组合多个模块,不用自己再继承 nn.Module 然后写 forward 。

self.mlp = nn.Sequential(
    nn.Linear(config.n_embd, 4 * config.n_embd),
    nn.GELU(),
    nn.Linear(4 * config.n_embd, config.n_embd),
    nn.Dropout(config.resid_pdrop),
)

Assignment 4 的最终目的是完成一个模型,可以读取一系列的 Q&A(某人的出生地点) 然后回答问题,例如:

  • input: Where was Bryan Dubreuiel born?
  • output: Atlanta

模型的训练方式很有意思,它并非是将 Question 输入然后输出 Birthplace,而是将整个 Q&A 输出进去,然后输出答案的定位。也就是说这个任务不是生成答案,而是 “定位答案”。

x: Where was Khatchig Mouradian born?⁇Lebanon⁇□□□□
y: □□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□⁇Lebanon⁇□□□□□

如果 y 不加 PAD,而变成 Lebanon ,那模型会被训练成生成问题后直接生成答案,模型就会学到:

“Where was… born?” → Lebanon

它就成了文本生成模型,而不是抽取模型。但这个任务是抽取式 QA,希望模型学会:在 x 的上下文里定位答案的位置、不是生成答案,因此 y 前面的位置全部用 PAD。

if args.variant == 'vanilla':
    # TODO: [part c] Make some model here
    from models import GPT
    model = GPT(mconf).to(device)
    
    
elif args.function == 'finetune':
    assert args.writing_params_path is not None
    assert args.finetune_corpus_path is not None
    # [part c]
    ### YOUR CODE HERE ###
    from trainer import Trainer, TrainerConfig
    tconf = TrainerConfig(
        max_epochs=75,
        batch_size=256,
        learning_rate=args.finetune_lr,
        lr_decay=True,
        warmup_tokens=512*20,
        final_tokens=200*len(pretrain_dataset)*block_size,
        num_workers=4,
        writer=writer
    )
    text = open(args.finetune_corpus_path, 'r').read()
    train_dataset = dataset.NameDataset(pretrain_dataset, text)
    trainer = Trainer(model, train_dataset, None, tconf)
    trainer.train()
    torch.save(model.state_dict(), args.writing_params_path)

运行 scripts/run_vanilla_no_pretraining.bat 就能看到刚刚微调但没有预训练的模型,在数据集上面的表现了。最终的结果是正确率 1.8%。

实验还让我们每个问题都预测 London 将结果和之前的对比:

def main():
    accuracy = 0.0

    # Compute accuracy in the range [0.0, 100.0]
    predictions = ["London" for _ in range(2000)]
    total, correct = utils.evaluate_places(r"/root/cs224n/birth_places_train.tsv", predictions)
    accuracy = correct / total

    return accuracy

输出的准确率是 4.55 %。

Span Corrutpion 跨度破坏,是一种预训练的方法,之前在 Lecture 9 介绍过。但是之前介绍的 Span Corruption 是 Encoder-Decoder 架构的,并且模型参数/训练数据量大,所以这次的 Span Corruption 有所不同。

简单来说,以 “I want to be an engineer” 这个句子为例:

  1. 截取 document 前面一个部分,平均长度为 block_size 的 7/8,最短为 4。
  2. 将 document 分为 prefix + masked + suffix 三部分,prefix 和 masked 两部分平均长度为 document 的 1/4。
  3. 将 document 重新组装为 prefix + mask_char + suffix + mask_char + masked + pad_char,长度为 block_size + 1。

最后得到形如 “I want??an engineer?? to be □□□□□□□□□□□□” 的句子。由于 GPT 是一个 Decoder Only 的结构,所以是自回归训练,还需要调整为 x, y = document[:-1], document[1:]


我在看 CharCorruption 的时候很奇怪,为什么处理文本处理的这么奇怪。我们先回顾一下 T5 Span Corruption 是怎么处理的:

假设有文本 “What are you doing Jennie?",会随机选择几个 span 进行 mask,得到

  • input: What “masked” doing “masked”
  • output: “masked” are you “masked” Jennie?

思考之后我才发现这是因为 Decoder-Only 架构的局限性,它没法像 Encoder-Decoder 结构一样可以把 Mask 里面的信息在 Encoder 中告诉模型,只能把 Mask 的部分拼接在后面。T5(encoder-decoder)可以肆无忌惮地把一段文字直接删掉,替换成 Mask ,因为 Encoder 已经把原文全看过了,上下文信息已经编码进隐藏状态了,Decoder 只管从 sentinel token 开始生成被删掉的内容就行。但 GPT(decoder-only) 模型是因果自回归,它每一步只能看到之前的 token。如果你在中间直接把一段删掉换成 ⁇,模型在预测时根本不知道被删掉的内容长什么样,等到后面想让它复原时,信息已经彻底丢失了。

def __getitem__(self, idx):
        # TODO [part e]: see spec above
        document = self.data[idx]
        document = document[:(tsize := random.randint(4, int(self.block_size*7/8)))]
        prefix = document[:(psize := random.randint(1, int(tsize/4)))]
        masked_document = document[psize:psize+(msize := random.randint(1, int(tsize/4)))]
        suffix = document[psize + msize:]
        output = prefix + self.MASK_CHAR + suffix + self.MASK_CHAR + masked_document
        output += self.PAD_CHAR * (self.block_size + 1 - len(output))
        x, y = output[:-1], output[1:]
        return torch.tensor([self.stoi[c] for c in x], dtype=torch.long), torch.tensor([self.stoi[c] for c in y], dtype=torch.long)

之前的实验可以看到,模型不进行预训练直接微调后的结果非常糟糕,所以 (f) 任务就要求先进行预训练。

在这里再明确一下: 预训练和微调都是 train,只是数据、任务目标、训练方式不同

  • 预训练:预训练任务是 CharCorruption,训练方法是自回归
  • 微调:预训练任务是给出完整 Q&A 然后定位到答案,训练方法也是自回归

所以预训练的代码就很简单了:

if args.function == 'pretrain':
    assert args.writing_params_path is not None
    # TODO [part f]:
    from trainer import Trainer, TrainerConfig
    tconf = TrainerConfig(
        max_epochs=650,
        batch_size=128,
        learning_rate=args.pretrain_lr,
        lr_decay=True,
        warmup_tokens=512*20,
        final_tokens=650*len(pretrain_dataset)*block_size,
        num_workers=4,
        writer=writer,
    )
    trainer = Trainer(model, train_dataset, test_dataset)
    trainer.train()
    torch.save(model.state_dict(), args.writing_params_path)

实验结束在测试集上正确率是 5%,没有达到作业要求。把 CharCorruption 里面 prefix 和 mask 长度改为句子的 1/2 之后正确率达到 15% 了。

RoPE 的具体实现参考文章 RoPE

def precompute_rotary_emb(dim, max_positions):
    rope_cache = None
    # TODO: [part g]
    # theta.shape = [dim/2, ]
    theta = 1.0 / (10000 ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
    positions = torch.arange(max_positions)
    freqs = torch.outer(positions, theta)
    cos, sin = torch.cos(freqs), torch.sin(freqs)
    rope_cache = torch.stack([cos, sin], dim=-1)
    return rope_cache
def apply_rotary_emb(x, rope_cache):
    """Apply the RoPE to the input tensor x."""
    rotated_x = None
    ### YOUR CODE HERE ###
    _, _, seq_len, dim = x.shape
    rot = torch.view_as_complex(rope_cache[:seq_len, ...]) 
    
    rotated_x = x.view(*x.shape[:-1], dim // 2, 2)
    rotated_x = torch.view_as_complex(rotated_x)
    rotated_x = rotated_x * rot
    rotated_x = torch.view_as_real(rotated_x).reshape_as(x)
    
    ### END YOUR CODE ###
    return rotated_x

最后 MHA 里面把 Query 和 Key 进行旋转就好

def forward(self, x):
	B, T, C = x.size()
	# calculate query, key, values for all heads in batch and move head forward to be the batch dim
    k = self.key(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
    q = self.query(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
    v = self.value(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
    if self.rope:
	    q = apply_rotary_emb(q, self.rope_cache)
        k = apply_rotary_emb(k, self.rope_cache)
    # ...   

相关内容