My notes and records during learing CS224N(NLP)

Lecture 1: Word Vectors

词义->上下文

对人而言,理解语言是一种简单的事情,但是计算机不行,只有把词元编码成向量才能被计算和理解,于是要探究如何编码词元

最原始的表示方式:one-hot编码:
给出一个共计有$R$个词元的词表$V$,可以把每一个词元变成一个长度为$R$的向量$v$,假定这个次元的位置是0,那么可以表示成$[1,0,...,0]$

显而易见,这不是一种”良好的”编码方式,因为随着词表$V$的扩大,向量长度也会无限制增大,另一方面,每一个向量之间是正交的,所以无法表示出词与词语义上的关联
这种编码方式带来的”稀疏和高维”正是计算机理解语言不希望看到的
于是转向词元在语句中的位置,一个词与其他词之间的关系可以通过上下文来描述,顺着这个思路,引出Word2vec

Word2vec

词元可以分为两类,一类是中心词,一类是上下文,它们类别的界定是由”正在考虑哪个词”决定的
e.x.p. : … problems turn into banking crises
先考虑into这个词,考虑它他就是中心词,周围的problems,turn这些就是context,我们另$w_t$表示中心词into,则可以用$w_{t+1}$表示problems

接下来我们限制一个中心词的上下文窗口$m$,也就是对于一个中心词$w_t$,只考虑从$[w_{t-m},w_{t+m}]$区间的词元向量,并计算似然概率:
$$ L(\theta)=\prod_{t=1}^{T}\prod_{\substack{-m\le j\le m\\\\j\ne 0}} P(w_{t+j}\mid w_t;\theta) $$
这个公式指的就是对于句子中的每一个中心词,都计算上下文窗口为$m$的区域的似然概率

下一步将其转换为便于计算机处理的加法形式,也就是取对数:
$$ J(\theta) = -\frac{1}{T}logL(\theta) = -\frac{1}{T}\sum_{t=1}^{T}\sum_{-m\le j\le m}^{}logP(w_{t+j}|w_t,\theta) $$
确定了优化目标,还有一个问题没解决,就是怎么计算这个$P(w_{t+j}|w_t,\theta)$的值,在解决之前,先给出两个定义:
$v_w$:$w$是中心向量时的表示
$u_w$:$w$是上下文向量时的表示

对于中心向量$c$和上下文向量$o$:
$$ P(o|c) = softmax(u_o^Tv_c) $$
即计算中心词向量和上下文向量的点积,用于评估而二者语义距离
这里我详细讲一下,点积的定义是$$ a\cdot b = |a| |b|cos\theta $$
那么语义上越近的向量,余弦值越大,而越是无关的向量之间的余弦值越小甚至是负数
但是如果用点积直接表示这个距离,不会有$a,b$模长的影响吗?实际上是不会的,因为模长包含着另外的自由度的信息(后面细究),所以可以用其值直接对应距离

传统机器学习中,常用的优化方式梯度下降,但是你真有钱和算力对着数量极大的词表一点一点GD吗?显然不行,下面来介绍随机梯度下降($SGD$):
每次只采样一个小的batch用于更新梯度,这也是现代dl中的常用思想

Training trick

负采样

我们考虑一个问题,在上文提到的计算$P(c|o)$的地方,实际上我们采用了一个$softmax$的归一化操作,这会导致机器去计算一个中心词的所有context的概率,带来非常大的计算开销

这时考虑将这个多分类->二分类,也就是负采样,具体来说,把最小化损失函数$L$的目标换成训练模型二分类的能力,给定一个词元对$(o,c)$,$o$有可能是真实上下文也可能是噪声,打个比方,(deep,research)是一个真实的词对,而(deep,you)就是一个假的词对,负采样目标就是训练出模型的判别能力,损失函数表达式如下:
$$ J = -log\sigma(u_o^Tv_c) - \sum_{k\in K}^{}-log\sigma(-u_o^Tv_c) $$
即最大化正确词对,最小化虚假词对概率

再对这个词表进行采样,当然这个方法也有着很大的缺陷,那就是每次更新的时候只会涉及到中心词和它的上下文窗口中的词,最终更新的向量少,导致了梯度的高维和稀疏性,这对training来讲是灾难性的
于是提出了一种共性矩阵的方法,还是假设一个共有$v$个词元的词表$V$,维护一个$R^{v \times v}$的矩阵,其中$X_{ij}$表示第$i$个词和第$j$个词出现在一条上下文中的次数
但是到这里还远远不够,这个矩阵还是高维且稀疏的,跟前面提到的本质相同,最巧妙的在于对矩阵应用奇异值分解:
$$ X=U \Sigma V^T $$
计算出奇异值矩阵之后,选定一个低于$v$的维度$k$,然后取出前$k$行作为新的低秩矩阵的分解矩阵,即:
$$ E=U_kV_k $$
得到的这个E就是符合我们预期的低维的、稠密的矩阵,也就是最后编码后的词矩阵

GloVe

wait to update…

Lecture 2: neural nets

矩阵求导

在本章最开始,我们约定矩阵求导的时候方向遵照雅可比原则,即对于一个$x \in R^n,y\in R^m$,$\frac{\partial y}{\partial x} \in R^{m \times n}$,如果对后面的求导的转置有问题,先想想这里
给定一个有$n$个输入和一个输出的函数$f(\textbf{x})$,其表达式为:$$ f(\textbf{x}) = f(x_1,x_2,...,x_n) $$
那么对函数$f(\textbf{x})$求导,有:
$$ \frac{\partial f}{\partial \textbf{x}} = [\frac{\partial f}{\partial x_1},\frac{\partial f}{\partial x_2},...,\frac{\partial f}{\partial x_n}] $$
接下来我们推广到一个具有$m$个输出和$n$个输入的函数:
$$ \mathbf {f(x)} = [f_1(x_1,x_2,...,x_n),...,f_m(x_1,x_2,...,x_n)] $$
此时$f$对$x$的梯度就是一个$m \times n$的矩阵:
$$ \frac{\partial \mathbf{f}}{\partial \mathbf{x}} = \begin{bmatrix} \frac{\partial f_1}{\partial x_1} & \cdots & \frac{\partial f_1}{\partial x_n} \vdots & \ddots & \vdots \frac{\partial f_m}{\partial x_1} & \cdots & \frac{\partial f_m}{\partial x_n} \end{bmatrix} $$
也就是说,对于$(\frac{\partial f}{\partial x})_{ij} = \frac{\partial f_i}{\partial x_j} $

模型定义

接下来我们会推导神经网络传播过程的梯度推导,首先我们先明确函数指代:
$$ J(\theta) = \sigma(s) = \frac{1}{1+e^{-s}} \\ \mathbf{s = u^T h}\\ \mathbf{h} = f(\mathbf{z}) \\ \mathbf{z} = \mathbf{W} \mathbf{x} + \mathbf{b} $$
其中$f$是一个激活函数,可以是ReLU/tanh这些激活函数,然后$\mathbf{z}$是由$\mathbf{x}$线性变换而来
$s$是对于输入$\mathbf{x}$的打分值,然后$\mathbf{u}$是输入层的权重表示
然后我们来看$\frac{\partial \mathbf{h}}{\partial \mathbf{z}}$的计算,这两个向量都是长度为$n$的列向量,求导结果是一个$n \times n$的矩阵:
$$ (\frac{\partial h}{\partial z})_{ij} = \frac{\partial h_i}{\partial z_j} $$
显然根据偏导数的求导法则,当且仅当$i=j$的时候有:
$$ \frac{\partial f_i}{\partial z_j} = f'(z_i) $$
而其它情况下导数值全部为0,即:
$$ \frac{\partial \mathbf{f}}{\partial \mathbf{z}} = \begin{bmatrix} f'(z_1) & \cdots & 0\\ \vdots & \ddots & \vdots\\ 0 & \cdots & f'(z_n) \end{bmatrix} = diag(\mathbf{f}'(z)) $$
接下来我们看这样一个求导:$$ \frac{\partial \mathbf{(Wx+b)}}{\partial \mathbf{x}} = \mathbf {W} $$
我们进行一个拆分:
$$ \frac{\partial \mathbf{(Wx + b)}}{\partial \mathbf{x}} = \frac{\partial \mathbf{(Wx)}}{\partial \mathbf{x}} + \frac{\partial \mathbf{b}}{\partial \mathbf{x}} $$
显然这个$\mathbf{b}$与$\mathbf{x}$是没有关系的,所以第二项的导数值为0,所以它的值只由第一项决定,下面只需要看一看这个东西的值就行了:
我们设矩阵$$ \mathbf{W}=\begin{bmatrix} w_{11} & \cdots & w_{1n} \\ \vdots & \ddots & \vdots \\ w_{n1} & \cdots & w_{nn} \end{bmatrix} $$
计算$\mathbf{Wx}$的值
$$ \mathbf{Wx} = \begin{bmatrix} w_{11} & \cdots & w_{1n} \vdots & \ddots & \vdots w_{n1} & \cdots & w_{nn} \end{bmatrix} \begin{bmatrix} x_1 \vdots x_n \end{bmatrix} = \begin{bmatrix} w_{11}x_1+w_{12}x_2+...+w_{1n}x_n \vdots w_{n1}x_1+w_{n2}x_2+...+w_{nn}x_n \end{bmatrix} $$
对于$\frac{\partial \mathbf{Wx}}{\partial \mathbf{x}}$每一项的计算,直接看:
$$ (\frac{\partial \mathbf{Wx}}{\partial \mathbf{x}})_{ij} = \frac{\partial (\mathbf{Wx})_i}{\partial (\mathbf{x})_j} = w_{ij} $$
(这是显然的,可以通过随便验证一个位置赖搞清楚)
至此我们就得到了上面的结论,即$\frac{\partial \mathbf{(Wx+b)}}{\partial \mathbf{x}} = \mathbf {W}$
与上文同理,我们还能够得到一个结论:
$$ \frac{\partial \mathbf{(Wx+b)}}{\partial \mathbf{b}} = \mathbf{I} $$
即线性变换后的结果对bias项求导结果为恒等矩阵$\mathbf{I}$
相似的,还能得到一个结论:
$$ \frac{\partial \mathbf{(u^Th)}}{\partial \mathbf{u}} = \mathbf{h^T} $$
其实还需要求一个导数,这也是我一开始完全搞不懂的点,就是$\frac{\partial \mathbf{z}}{\partial \mathbf{W}}$是多少,先说结论,值为$\mathbf{x^T}$,下面给推导:
根据本节就开始的原则,这应该是一个$n \times n \times n$的矩阵,但是用了优化方法变到二维了
$$ \mathbf{z_k} = \sum_{t=1}^{n} \mathbf{W_{kt} x_t + b_k} \frac{\partial \mathbf{z_k}}{\partial \mathbf{W_{ij}}} = \mathbf{x_j} (当且仅当k=i时) $$
这是显然的,所以说对于$k \in [1,n]$,都有:
$$ \frac{\partial \mathbf{z_k}}{\partial \mathbf{W_{ij}}} = \begin{bmatrix} 0 & 0 & \cdots & 0 \vdots & \vdots & \ddots & \vdots x_1 & x_2 & \cdots & x_n \vdots & \vdots & \ddots & \vdots 0 & 0 & \cdots & 0 \end{bmatrix} (此时只有第k行是x^T,其余全是0) $$
所以我们认为它的值就是$\mathbf{x^T}$

Lecture 3 : rnnlm

Language modeling:给定一个长为$t$的序列$x_1,x_2,...,x_t$,预测出下一个token $x_{t+1}$,其中$x_{t+1}$可以是词表$V$中任意元素

中间讲了一个n-gram的方法,感觉没啥意思,跳过了,直接看RNN

RNN

依旧是一个Language modeling,在给定前$t$个tokens之后,预测第$t+1$的token的流程如下:
对每一个token做编码(one-hot或其他):
$$ \mathbf{x_t} \in R^{|V|} $$
词嵌入:
$$ \mathbf{e_t} = \mathbf{E x_t} $$
计算隐藏层,并给定0号隐层的记号为$h_0$:
$$ h_t = \sigma(\mathbf{W_h h_{t-1}+W_e e_t + b_1}) $$
计算概率分数$y$:
$$ y = softmax(\mathbf{U h_t + b_2}) $$
直接来分析这种做法的问题,然后为后面的Transformer结构铺垫:

  • 递归式的计算梯度非常耗时和计算资源
  • 在lm模型中,第$t+1$个token的测试质量依赖于前$t$个的损失函数$J^t(\theta)$计算,显然的是,这是一个非常长的链式法则(原因藏在$h_t$的表达式当中)
  • 上一点会带来梯度爆炸/消失的问题
    梯度裁剪(clip):比如说我们采取SGD的更新方法,提前设定好一个阈值$|g|$,如果某一步的梯度$|g_t|$大于这个阈值,则在学习率更新之前进行裁剪:

    $$ |g_t| = \frac{|g_t|}{|g|} $$

Lecture 4:transformers

注意力机制

核心的思想就是在decoder解码的时候加入一个类似于提示的东西,这个权重用于衡量下一个decode出来的token与前面的token有多大的关系

self-attention : key, value, query

对于$[w_1,w_2,...,w_n] \in R^{|V|}$有:
$$ \mathbf{x_i} = \mathbf{E}w_i \in R^d $$
接下来给定三个矩阵(qkv),分别是:
$$ Q,K,V \in R^{d \times d} $$
并记:
$$ q_i = Qx_i \\ k_i = Kx_i \\ v_i = Vx_i $$
计算每一个token对之间的相似度并用softmax函数归一化:
$$ e_{ij} = q_i^Tk_j\\ \alpha_{ij} = \frac{exp(e_{ij})}{\sum_{j'}^{}exp(e_{ij'})} $$
最后加入v矩阵计算权重
$$ o_i = \sum_{j}^{}\alpha_{ij}v_j $$
至此我们得到了原始词$w_i$的表达向量,即$o_i \in R^{d}$

位置编码

只有注意力机制不够,因为attention只描述了token之间的关系和联系,但是具体在一个文本当中,词的位置也是至关重要的,所以在最终的词嵌入时,还需要加入位置编码

正余弦位置编码:对于奇偶数的位置分别使用正弦和余弦来编码:
$$ PE(pos,2i) = sin(\frac{pos}{10000^{\frac{2i}{d}}})\\ PE(pos,2i+1) = cos(\frac{pos}{10000^{\frac{2i}{d}}}) $$

非线性层

实际上到现在为止,self-attention还是局限于一个加权平均的过程,并没有所谓的非线性,这一步我们来解决这个问题:
加前馈神经网络FFN,表达式为:
$$ m_i = MLP(o_i) = \mathbf{W_2} ReLU(\mathbf{W_1}o_i + b_1) + b_2 $$
就是线性层+激活函数+线性层,这是一个很经典的设计

掩码

我们还需要考虑在实际情况中,预测第$t+1$个token的时候,只有前$t$个token是可见的,而后面的所有token都看不到,这就需要掩码遮住后面的token,也就是在注意力中让后面的项消失权重

还记得在self-attention中计算token对相似度这一步吗,在这里我们加入掩码,也就是对于$i$之后的所有token的相似度都变成$- \infty$:
$$ e_{ij} = \begin{cases} q_i^Tk_j ,& j \le i \\ -\infty , & j > i \end{cases} $$

总结一下,到这里讲的所有内容可以汇总成一张图片

矩阵表达的self-attention

我们接下来看矩阵形式的自注意力计算:
令$\mathbf{X} = [x_1;x_2;...;x_n] \in R^{n \times d}$
注意到$XQ,XK,XV \in R^{n \times d}$
最终输出的结果的表达式为:
$$ output = softmax(XQ(XK)^T)XV \in R^{n \times d} $$

多头注意力

在语义上,我们希望注意力能够注意到很多维度的信息,而不仅仅是笼统的用一个”大的”注意力一概表示,于是引入了多头注意力的方法:
实际上,与self-attention不同之处仅仅是$QKV$矩阵被分成了$h$个头:
$$ Q,K,V \in R^{d \times d} \to Q_l,K_l,K_l \in R^{d \times \frac{d}{h}} $$
这里需要联想到矩阵乘法的一个性质:

这个性质用矩阵计算很好推,只需要想着有这么个东西就行

相应的,输出的向量变成了:
$$ output = [o_1,o_2,...,o_h]\\ o_i = softmax(XQ_l(XK_l)^T)XV_l\\ o_i \in R^{n \times \frac{d}{h}} $$

计算性质

多头注意力引入后,我们要采取一种transpose+reshape的方法来优化计算:
$$ reshape : XQ,XK,XV \in R^{n \times d} \to R^{n \times h \times \frac{d}{h}}\\ transpose:R^{n \times h \times \frac{d}{h}} \to R^{h \times n \times \frac{d}{h}} $$

剩下的参考这张图就行,想的时候就分开想$(XQ)_i,(XK)_i,(XV)_i$的维度和矩阵乘法就行

点积约束

首先回顾attention的公式:
$$ o_i = softmax(XQ_i(XK_i)^T)XV_i $$
对于softmax这个函数,有这样一个性质,首先假设一组分数分布是$softmax[x_1,x_2,...,x_n]$,如果同时给序列中的数放大$k$倍($k>1$),这个分布会趋于锐化,因为softmax会把分布中的值送到指数运算中,造成了模型倾向生成的token的概率非常大,甚至会接近one-hot的情况,这不是我们希望的,所以在softmax中加入约束项:
$$ o_i = softmax(\frac{XQ_i(XK_i)^T}{\sqrt{d_k}})XV_i $$
我们记这里$d_k = \frac{d}{h}$,这样就会让这个分布变得稳定

残差连接

一个普通的情况是:x经过某一层,学得一个layer(x),即:
$$ x \to layer \to layer(x) $$
残差连接的思想是只学x经过这一层的时候学到了什么,就是变化量:
$$ x \to layer \to x + layer(x) $$
这样的好处是在回传的时候,由于有一个恒等的部分对应梯度是1,这样比较好学,即使这一层学不到什么东西,由于只是差值,所以影响不是很大

Layer normalization

加速模型训练的方法
下面给出归一化的流程,给定$x \in R^d$,有:
$$ \mu = \frac{1}{d} \sum_{i=1}^{d}x_i \\ \sigma = \frac{1}{d} \sum_{i=1}^{d} (x_i - \mu)^2 $$
然后定义两个可学习的参数$\beta , \gamma$,后续用于训练
$$ output = \gamma \times \frac{x - \mu}{\sqrt{\sigma + \epsilon}} + \beta $$

作业实现记录(codes only)

hw3 : Transformer

详细的

MLP forward

简单的多层感知机模型,两个线性层中间套一个激活函数就行

def forward(self,x):
    x = self.fc1(x)
    x = self.gelu(x)
    x = self.fc2(x)
    return x

CausalAttention forward

实现一个最基本的多头注意力机制,写这个东西的时候注意从输入到输出的维度没有变化,只是带着的语义信息变了
整个流程如下:先计算出q / k / v矩阵,然后缩放加掩码mask,过softmax函数算分,然后乘v矩阵

def forward(self,x);
    B,T,C = x.size()
    q = self.w_q(x).reshape(B,T,self.n_heads,self.d_attention).transpose(1,2)
    k = self.w_k(x).reshape(B,T,self.n_heads,self.d_attention).transpose(1,2)
    v = self.w_v(x).reshape(B,T,self.n_heads,self.d_attention).transpoes(1,2)
    logits = q @ k.transpose(-2,-1) / math.sqrt(self.d_attention)
    mask = self.causal_mask[:, :, :T, :T]
    scores = logits.masked_fill(mask == 0, float("-inf"))
    scores = torch.softmax(scores,dim = -1)
    output = scores @ v 
    output = output.transpose(1,2).reshape(B,T,C)
    output = self.w_o(output)
    return output

注意一些细节上的东西,开多头之后相当于每个头都有T个token,然后每个token被简化到了d_attention维,所以过完注意力层之后需要转换维度,也就是transpose()函数,发生的具体变化是:(B,T,C) -> (B,T,nh,nd) -> (B,nh,T,nd),再矩阵相乘(先对齐维度)得到T个token之间的关联度矩阵,过掩码后一起softmax得到注意力分数,乘上v矩阵完成维度还原,最后一个线性层收尾
$$ output = Linear(softmax(\frac{QK^T}{\sqrt{d_{attention}}} + mask)V) $$

至此我们完成了最重要的注意力部分,后面要把它作为module去拼接到网络中去

Decoder Block

非常容易的一步,只需要做pre-LN和拼接模块就行

def forward(self,x):
    x = x + self.attention(self.pre_layer_norm(x))
    x = x + self.mlp(self.post_layer_norm(x))
    return x

注意现在的架构中99.9%采用pre-LN的方式,这样即使学不到什么东西也可以进行梯度回传,保留大部分之前的内容

Transformer

ok至此我们写完了所有的组件了,开始拼接

回顾transformer架构,一段文本是怎么变成一个具有语义信息的token序列的呢?

传入的x是tokenizer后的向量,shape是(B,T),然后做词嵌入和位置编码,接着进入n个decoder block中学习潜在的语义关系,最后经由一个归一化层和一个线性层得到一个序列,这个序列包含的就是输入的序列中的T个token和词表vocab中的关联性分数,后面用于生成

def forward(self,x):
    B,T = x.shape()
    pos = torch.arange(T,device = x.device)
    input = self.embeddings(x) + self.position_embedding(pos)
    for block in elf.backbone:
        input = block(input)
    logits = self.final_layer_norm(input)
    logits = self.lm_head(logits)
    return logits

generate

解决了网络架构的问题之后,我们来看next-token-predict的时候是怎么做的

经过transformer网络得到的是一个shape为(B,T,vocab_size)的tensor,这代表了每一个seq中每一处token和整个词表中的关联的token之间的关联度,既然我们要做的是next-token predict,那么只需要取出整个seq中的最后一个token对应的词表的打分,然后argmax找下表就完事了

@torch.no_grad()
def generate(
    self,
    x: Int[Tensor, "batch_size seq_len"],
    num_new_tokens: int,
) -> Int[Tensor, "batch_size seq_len+num_new_tokens"]:

    # TODO, complete
    for _ in range(num_new_tokens):
        logits = self(x)
        logits = logits[:,-1,:]
        next_token= torch.argmax(logits,dim = -1, keepdim = True)
        x = torch.cat([x,next_token] , dim = -1)
    return x

get_batch_loss

这个是train loop中的一个关键点,如何将交叉熵作为损失函数实现出来,逻辑就是先取出前T-1个token然后送入网络预测词表,然后再取出后T-1个token作为答案算交叉熵

def get_loss_on_batch(
    self,
    input_ids: Int[Tensor, "batch_size seq_len"], 
) -> Float[Tensor, ""]:
    B , T = input_ids.shape()
    inputs = input_ids[: , : -1]
    logits = self(inputs)
    target = input_ids[: , 1 : ]
    logits = logits.reshape(-1,self.vocab_size)
    targets = targets.reshape(-1)
    return F.cross_entropy(logits,targets)

在实现过程中有一些小的细节,就是这个cross_entropy函数要求的是一个一维的tensor和一个二维的tensor进入计算,所以我们需要展平(也就是代码中的reshape(-1)自动推导出第一维)

至此,我们完成了a3的全部代码!彻底理解了tarnsformer的运作流程!

a4由于需要花自己的钱买API KEY,故直接放弃不做了…


Give it time, let it pass.