GPT

论文首先指出一个问题:NLP领域中,有标号的数据量太少,难以训练出有效的模型。

然后给出解决思路:先在没有标号的数据上训练一个预训练模型,再在子任务上用有标号的数据微调。

难点:

  • 目标函数如何选取:自监督

  • 如何找到一种能有效应用于不同子任务的表示

预训练

用窗口内的k个词元去预测下一个词元,要使模型输出与原文章相同的概率最大,即最大化以下似然函数: 其中θ是基于transformer解码器的模型:

GPT模型图

与bert不同之处在于,GPT的注意力层带有掩码,训练时是用前k个词元预测下一个词元,而BERT模型的注意力层没有掩码,预测一个词元时能看到上下文信息。

微调

给定序列x_1, x2, ... , x_m,对应标签为y,也就是我们要根据序列x去预测y的概率。我们把序列放入GPT模型中,拿到x_m对应的输出(x_m一般是构造序列时添加的Extract词元),乘以一个输出层并做softmax即得到y的概率,即: h的下标l表示是第l层,即最后一层的输出,上标m表示是x_m对应的输出。

目标函数: 微调时,同时使用了两个目标函数:

  • 在序列中预测下一个词
  • 用完整的序列预测标号

论文中给出了四类常见应用场景的微调。

GPT微调任务

Classification

在文本前添加Start词元,在文本后添加Extract词元,用Extract词元对应的输出向量过一个全连接层,例如共有10种分类,则全连接层输出大小为10。

Entailment

给出两段文本,做一个三分类问题(支持/反对/既不支持也不分对)

将两段文本串成一个序列输入模型。

Similarity

判断两段文本是否相似,因为GPT具有先后顺序,而相似是相互的,因此需要构造两个序列。两个序列的输出相加进入全连接层。

Multiple Choice

做多选题,需要将每个选项分别与题干构造序列,Linear层的输出大小为1,表示该答案正确的置信度,选择置信度最大的序列。

实现细节

  • 使用BPE的词元化方式,字典大小为4000
  • n_model = 768,layer = 12, n_heads = 12
  • 采用可学习的位置编码,位置编码长度为3072
  • 激活函数为GLUE

与bert的差异:

  • GPT预训练直接使用自然文本,没有使用[CLS]、[SEP]等词元
  • bert能捕捉上下文信息,而GPT只能单向捕捉信息
  • GPT采用BPE,bert采用WordPiece
  • bert增加了段编码,而GPT只有位置编码

总结而言,GPT的思路是用大量自然文本做预训练,再用带标号的文本针对下游任务做微调,解决的是分类任务。这篇文章诞生于bert之前,二者的区别在于GPT训练时无法看到后面的数据,而bert可以看到上下文。

GPT2

GPT2训练文本达到百万级,参数量达到15亿

文章提到,NLP传统训练方式是用一个数据集训练一个任务,进而引出多任务学习(Multitask Learning),即只用一个数据集,但构造多个损失函数来达到能在多个任务使用的效果。这种方式虽然很早就提出了,但当时却不是很流行。当时主流的预训练+微调的模式仍需要针对下游任务用有标号的数据进行微调。

GPT2强调了zero-shot的设定,即只需要训练一个模型,不做微调就可以直接应用于各个下游任务。

模型直接由自然文本训练,由于没有微调环节,因此下游任务的输入必须与预训练的文本一样,而不能添加没有见过的符号(如Start、Extract)。

论文使用了prompt的方法(但作者并没有直接提出这个概念),例如要让模型做机器翻译任务,可以构造输入:

  • translate to french, english text, french text

GPT2的训练采用的是来自Reddit的有一定质量的文本数据(只爬取至少有三个karma的),一共40GB文本。

实现细节

  • GPT2采用pre-norm,即将layer normalization放到每个sub-block之前,并在最后一个self-attention后再增加一个layer normalization。

总结而言,GPT2相较于GPT除了规模的提升,更重要的是GPT2能直接运用于下游任务。虽然GPT2在很多任务上和SOTA仍有差距,但其具有强大的通用性,并且论证了模型性能还将随着规模提升。

GPT3

GPT2思路极具新意,但在实际任务中的表现却很一般。在zero-shot表现不佳的情况下,GPT3采用了few-shot,即将模型应用于不同任务时,给出几个样例供模型参考。

GPT3模型有175B的参数。

论文分别用few-shot、one-shot、zero-shot对不同规模的模型进行评估,结果显示当模型规模达到百亿以上时,效果有了显著的提升。

fine-tuning、zero-shot、one-shot、few-shot的区别:

  • fine-tuning:用具有一定规模的样本微调模型,会改变模型参数
  • zero-shot:只给出任务描述,要求模型能预测出答案。例如:“Translate English to Chinese: cheese => ”要求模型能回答出cheese对应的中文。
  • one-shot:除了任务描述外,还给出一个样例供模型参考,例如:“Translate English to Chinese: cheese => 奶酪, biscuit => ”要求模型能回答出biscuit对应的中文。
  • few-shot:与one-shot类似,给出多个样例。

作者提出了“In-context learning”的概念,即样本不用来训练模型的参数,而是作为样例和问题一起提供给模型,比如few-shot。

GPT2为了提高数据质量,采用了来自reddit的数据,但是GPT3需要更大量级的数据,因此只能继续采用Common Crawl数据集。Common Crawl数据较脏,因此作者对其进行了过滤:

  • 将Common Crawl的数据作为低质量的样本(认为该数据集中大部分样本质量都较低),reddit上karma大于3的帖子作为高质量样本,训练一个二分类器,然后对Common Crawl的数据进行分类,过滤掉低质量的数据。
  • 去除掉数据集中的重复文章。(使用LSH算法检索相似文章)
  • 加入一些其他的高质量数据集。

实现细节

  • GPT3使用了稀疏的自注意力(locally banded sparse attention)。sparse attention计算注意力时,只关注距离不超过K以及距离为K, 2K, 3K, ...的token,其余token注意力都设为0。

问题:

目标:

方法:

  • 人为制作数据集(问题+回答),对模型做SFT(有监督微调),但是人工写回答成本太高

  • 用模型生成9个答案,并人工对这些答案进行排序,使用排序数据来训练奖励模型(6B)

奖励模型会给好的回答打高分,给差的回答打低分

模型输出后过一个输出大小为1的线性层,获得分数

奖励模型的损失函数: y_w和y_l是K个回答中的一对,其中y_w的排序比y_l高,分别算出两个回答的奖励分数,σ是sigmoid函数,最小化loss即最大化y_w和y_l的奖励分数差。

  • 强化学习RL

使用PPO算法

使用SFT模型的参数初始化RL模型。

每轮用RL模型生成新的回答,并计算奖励分数(目标是最大化奖励分数),调整模型,再用新的模型生成回答,如此循环。(与传统方法不同在于,传统训练过程中数据标签不会发生改变,而这里y是不断调整的)

数据来源

首先由人工写一些问答数据,用这些数据训练模型,然后将模型投入试用,并收集用户问题进行再次训练

  • 数据集分为三份:

①SFT dataset(13K):人工标注回答

②RM dataset(33K):模型生成多个回答并人工排序

③PPO dataset(31K):使用Reward Model生成回答

自回归(autoregressive)语言模型,如GPT,采用从左向右单向解码的方式,适用于自然语言生成(NLG)任务。非自回归(non-autoregressive)语言模型,如BERT,每个时刻的输出都可以充分利用双向信息,适用于自然语言理解(NLU)任务,但是在NLG上表现不佳。BERT采用transformer的编码器结构,GPT采用transformer的解码器结构,而BART和T5都采用了transformer的原始结构。

预训练

BART的预训练任务是带噪声的输入还原。BART共采用了两个预训练任务。

  • Text Infilling。mask文本中30%的字符,每处mask掉span长度的字符,span长度服从λ = 3的泊松分布。例如对于序列ABCDE,添加噪声后可能变成A_B_E,其中AB之间插入了一个span长度为0的mask,CD也替换成了mask。将加噪后的文本作为解码器输入,要求模型还原出文本。
  • Sentence Permutation。根据标点符号将句子顺序打乱,并要求模型将句子顺序复原。

除此之外作者还尝试了Token Masking、Token Deletion、Document Rotation等方法。经过对比实验Text Infilling更有助于模型效果提升,而Sentence permutation虽然提升不大,但作者假设模型规模提升后这个任务会有用。

BART

T5使用两种任务,分为无监督和有监督。其中无监督任务也是Span级别的mask,不过输出不需要还原整句,只需要输出mask掉的tokens就可以,总共mask15%字符。有监督任务提升不大,这里不展开说明。

微调

BART的微调方式如下图:

  • 左边是分类任务的微调方式,输入将会同时送入Encoder和Decoder,最终使用最后一个输出为文本表示。

  • 右边是翻译任务的微调方式,由于翻译任务的词表可能和模型词表不同,所以这里使用一个新的小型Encoder替换BART中的Embedding。

BART微调

T5将分类任务和生成任务都视为生成式任务:

BART微调

效果比较

对于理解任务,将两篇论文中实验结果整理为下表:

NLU效果比对

对于生成任务,两个模型都在CNN/DailyMail上进行了实验

NLG效果比对

综合比较来看,BART稍微好一些,尤其是在理解任务上。不过由于T5发布的模型比较大,参数量最多达到了11B,所以在GLUE和SuperGLUE上长期霸榜。

其他细节

位置编码

Transformers使用Position Encoding,使用sinusoidal函数

BERT和BART都换成了可学习的绝对位置嵌入

T5改成了相对位置嵌入(relative position embeddings)

激活函数

Transformer最开始使用ReLU,BERT和GPT都使用GELU,BART也同样采用GELU,不过T5还是使用了最初的ReLU。

模型大小

BART-large:12encoder, 12decoder, 1024hidden

T5-base:12encoder, 12decoder, 768 hidden, 220M parameters(2x bert-base)

T5-large: 24encoder, 24decoder, 1024hidden, 770M parameters

T5-large的模型大小是BART-large的两倍。

今天在做基于chatglm3的聊天机器人部署时,遇到以下问题:

  • 阿里云上无法生成本地可用的地址,gradio构建的界面排版错乱、无法使用。(如下图,对话无法发出)
gradio生成界面

解决方案是直接生成外部访问链接,具体如下:

  • 将launch()的share参数改为True后运行,此时应该会因缺少frpc内网穿透插件而报错:

    链接生成失败
  • 根据报错中提供的地址下载文件。这里使用windowns下载可能出错,需要进行以下设置:

    • 打开注册表,定位到**HKEY_LOCAL_MACHINE*,将ScanWithAntiVirus 属性的值修改为1。
    • 重启电脑。
  • 下载文件并重命名为frpc_linux_amd64_v0.2

  • 根据报错中的提示将文件上传至阿里云。

  • 重新运行代码,即可生成可用链接。

题目描述

给你一个整数数组 nums 和一个整数 k ,请你统计并返回 该数组中和为 k 的子数组的个数

子数组是数组中元素的连续非空序列。

示例 1:

1
2
输入:nums = [1,1,1], k = 2
输出:2

示例 2:

1
2
输入:nums = [1,2,3], k = 3
输出:2

法一:暴力

直接枚举所有子数组的和,统计所有和中等于K的个数,时间复杂度为O(N^2),空间复杂度为O(N)。此方法是最容易想到也是最容易实现的,在此不赘述。

法二:前缀和+哈希

前缀和是求子数组和的有效方法。具体而言,是用pre[i]表示从nums[0]到nums[i]的和,那么我们就能通过pre[j]-pre[i-1]直接计算出nums[i]到nums[j]的和。

但是,如果只用前缀和的方法,我们仍需要枚举每个子数组的和,时间复杂度依然是O(N^2)。

事实上,当我们将nums数组转化为pre数组后,我们的问题就成为了“两数之差”问题,即“给定数组pre和目标值k,要求统计序号对(i, j)的个数,使得pre[j]-pre[i]==k,其中i<j。”这与经典问题“两数之和“(leetcode1)十分相似。我们只需要扫描一遍pre数组,枚举数组中的每个数x,查找在x前x-k出现过的次数。这个查找过程可以使用哈希方便地完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int subarraySum(vector<int>& nums, int k) {
int n = nums.size(),ans = 0;
vector<int> pre(n,0);
map<int,int> mp;
pre[0] = nums[0];
for (int i = 1; i<n; i++){
pre[i] = pre[i-1] + nums[i]; // 计算前缀和,其中pre[i]表示nums[0]到nums[i]的和
}
mp[0]=1; // mp[0]=1,即当pre[i]==k时,ans也要++
for (int i = 0; i < n; i++) {
ans += mp[pre[i]-k]; // mp[pre[i]-k]即表示在i之前有多少j,使得pre[j]=pre[i]-k,也即pre[i]-pre[j]=k
mp[pre[i]]++; // 在枚举完pre[i]后,再将其存入map,可保证map中都是扫描过的元素
}
return ans;
}

题目描述

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回 滑动窗口中的最大值

示例 1:

1
2
3
4
5
6
7
8
9
10
11
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7

示例 2:

1
2
输入:nums = [1], k = 1
输出:[1]

法一 暴力

可以直接扫描每个窗口,找到每个窗口的最大值,时间复杂度为0(K*N),空间复杂度为O(1),此方法易于实现,不多赘述。

在这种方法中,我们每次只往窗口中添加一个数、从窗口中删除一个数,却需要对窗口中所有数重新进行一遍比较,其中必然存在很大的优化空间。

法二 优先队列

频繁地对窗口中的数进行微小的更新,并维护一个最大值,这很容易让我们想到堆。我们可以维护一个大根堆,当窗口右边界从i滑动到i+1,我们就向堆中添加元素nums[i+1],并删除元素nums[i-k+1],然后输出堆顶元素。

但是,要从堆中查找到一个指定元素并删除并不容易。事实上,我们也不需要每次滑动窗口就马上将窗口外的元素删除,我们只关心窗口中最大的值,因此我们只需要在取出最大值时判断其是否在窗口内就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
priority_queue<pair<int,int>> stack; // 堆中数据类型为一个整型对(nums[i],i),其中i用来判断元素是否在窗口内
vector<int> ans;
for (int i = 0; i < k - 1; i++) {
stack.emplace(nums[i], i); // 将前k-1个元素压入堆中
}
for (int i = k-1; i < nums.size(); i++) { //从第k个元素开始,每将一个元素压入堆中,就取出一次窗口内的最大值
stack.emplace(nums[i], i);
while (stack.top().second <= i - k) stack.pop(); // 如果堆顶元素在窗口外,则移出堆顶元素,直到堆顶在窗口内
ans.push_back(stack.top().first); // 此时堆顶元素即滑动窗口中的最大值
}
return ans;
}

每个元素都需要入堆一次,最坏情况下始终没有元素出堆,时间复杂度为O(NlogN),空间复杂度为O(N)。

法三 单调队列

这个方法更加巧妙,一开始并没有想到。

在这个方法中,我们维护一个双端队列,用来存窗口中的元素。每扫描到一个元素nums[i],我们进行如下处理:

  • 从队尾删除比nums[i]小的元素。在nums[i]被移出窗口前,比nums[i]小的元素不可能成为窗口内的最大值,而队列里已存在的元素必然先于nums[i]进入窗口,也将先于nums[i]移出窗口。因此队列中比nums[i]小的元素在被移出窗口前不可能成为最大的数了。
  • 从队首删除窗口外的元素。我们总是在队尾插入元素,因此队首的元素必然是最先进入队列的,也会是最先被移出窗口的。因此我们只需要从窗口移除元素,就可以保证队列内的元素总是在窗口内。

用以上方式维护队列,得到的队列必然满足:

  • 队列中的元素总是单调递减。因为根据第一条规则,队列中若存在q[i]<q[i+1],那么在q[i+1]入队时,q[i]就应该被删除了。
  • 队列中的元素必然在窗口中。
  • 窗口中最大的元素必然在队列中。

因此,队列中最大的元素,也即窗口中最大的最大元素,必然在队列的队首。我们每次维护完队列,只需要输出队首元素就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> q;
vector<int> ans;
for (int i = 0; i < nums.size(); i
if (!q.empty() && i - q.front() >= k) q.pop_front(); // 队首元素在窗口外则出队
if (q.empty() || nums[i] < nums[q.back()]) q.push_back(i); // 如果nums[i]比队尾元素小则直接入队
else{ // 否则将队尾小于nums[i]的元素删除后再入队
while (!q.empty() && nums[i] >= nums[q.back()]){
q.pop_back();
}
q.push_back(i);
}
if (i > k - 2) ans.push_back(nums[q.front()]); // 从nums[k-1]开始窗口成形,每移动一次窗口都要获取一个最大值
}
return ans;

}

这种方法每个元素入队一次且最多出队一次,时间复杂度只有O(N)。

transformer是目前主流的NLP架构,为了对其有更深入的了解花了几天时间对其底层代码进行复现。这是学深度学习以来代码量和思维量最大的一次实践了,过程中处理张量格式真的痛苦,主体架构搭起来不难但是很多小细节太折磨了。最后代码跑通的时候真的爽。

先附上attention is all yoou need一文中transformer的架构图,写代码的时候多看看这张图有助于理清逻辑,写完这张图也深深印在脑子里了。

transformer

数据构建

为了过程的完备性,这里简要交代一下如何将数据集转化为模型需要的格式。这一部分实际与transformer的架构关系不大,可以先行跳过。

采用一个中英翻译数据集,并将其导入成以下格式:

1
2
3
4
5
6
sentences = [
# enc_input dec_input dec_output
['我 有 一 个 好 朋 友 P', 'S i have a good friend . P', 'i have a good friend . E P'],
['我 有 零 个 女 朋 友 P', 'S i have zero girl friend . P', 'i have zero girl friend . E P']
...
]

其中中文数据在各字之间插入空格,并将长度不足的部分用“P”填充,作为编码器的输入。英文数据起始处插入“S”词元,长度不足部分用“P”填充,作为解码器输入。英文数据句末插入“E”词元,长度不足部分用“P”填充,作为解码器输出。以上以长度为8举例。

随后分别生成中英文词库src_vocab和sgt_vocab,格式为{词元: 序号},为了方便,这里中文直接按字分词,英文直接按单词分词。

接下来定义一个make_data函数,用于使用词库映射将单词序列转化为数字序列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def make_data(sentences):
"""把单词序列转换为数字序列"""
enc_inputs, dec_inputs, dec_outputs = [], [], []
for i in range(len(sentences)):
enc_input = [[src_vocab[n] for n in sentences[i][0].split()]] # [[1, 2, 3, 4, 5, 6, 7, 0],[1, 2, 8, 4, 9, 6, 7, 0]]
dec_input = [[tgt_vocab[n] for n in sentences[i][1].split()]] # [[ 8, 1, 2, 3, 4, 5, 10],[ 8, 1, 2, 6, 7, 5, 10]]
dec_output = [[tgt_vocab[n] for n in sentences[i][2].split()]] # [[ 1, 2, 3, 4, 5, 10, 9],[ 1, 2, 6, 7, 5, 10, 9]]

enc_inputs.extend(enc_input)
dec_inputs.extend(dec_input)
dec_outputs.extend(dec_output)

return torch.LongTensor(enc_inputs), torch.LongTensor(dec_inputs), torch.LongTensor(dec_outputs)

enc_inputs, dec_inputs, dec_outputs = make_data(sentences)

接下来定义一个数据集,并生成数据迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyDataset(Data.Dataset): 
"""自定义DataLoader"""
def __init__(self, enc_inputs, dec_inputs, dec_outputs):
super(MyDataset, self).__init__()
self.enc_inputs = enc_inputs
self.dec_inputs = dec_inputs
self.dec_outputs = dec_outputs

def __len__(self):
return self.enc_inputs.shape[0]

def __getitem__(self,idx):
return enc_inputs[idx],dec_inputs[idx],dec_outputs[idx]

loader = Data.DataLoader(MyDataset(enc_inputs, dec_inputs, dec_outputs), 16, shuffle = True)

以上就是数据构建的过程,主要完成了以下工作:

  • 将中英文翻译数据集导入,并添加起始符、结束符、填充符,转化为transformer需要的输入输出格式;
  • 将文本数据词元化,并转化为数字序列enc_inputs, dec_inputs, dec_outputs;
  • 将enc_inputs, dec_inputs, dec_outputs装入数据迭代器;

Transformer模型

下面是主干部分,这一部分将从底层实现transformer的位置编码、多头注意力、FFN等机制,并将其组合成encoder和decoder,搭建起一个完整的transformer模型。

首先需要定义模型参数。这里沿用原论文的参数。

1
2
3
4
5
6
# Transformer Parameters
d_model = 512 # Embedding Size(token embedding和position编码的维度)
d_ff = 2048 # FeedForward dimension (两次线性层中的隐藏层 512->2048->512,线性层是用来做特征提取的),当然最后会再接一个projection层
d_k = d_v = 64 # dimension of K(=Q), V(Q和K的维度需要相同,这里为了方便让K=V)
n_layers = 6 # number of Encoder of Decoder Layer(Block的个数)
n_heads = 8 # number of heads in Multi-Head Attention(有几套头)

多头注意力

transformer采用了注意力机制,注意力机制可简单理解为如下:

  • 存储着许多key-value对;
  • 每次使用一个query查询,将query与每个key的关系作为权重对对应的value进行加权平均,得到该query对应的输出;

通过以上过程,query对应的输出根据和key的关系不同程度地捕获了关于所有value的信息。注意力分数有多种计算方式,在此不一一展开。

transformer的编码器和解码器内部采用的是自注意力,即每个向量同时作为query、key、value。而在编码器和解码器交接的位置,将编码器的输出作为key和value,将解码器的输入作为query。

此外,在解码器的自注意力层会遮蔽未预测的词元,即在对序列中的一个元素输出时,不应该考虑该元素之后的元素。

transformer还引入了多头注意力,即query、key、value会同时进入多个头,每个头都独立地计算注意力,抽取不同的特征,然后将每个头的输出合并得到最终输出。

mask矩阵

mask矩阵是一个01矩阵,将我们希望遮蔽的位置设为true,不希望遮蔽的部分设为false。在注意力计算时,会将mask矩阵为true的位置设置为一个很小的负数,在经过softmax后这些数就会变成机器0,以实现对这些值的屏蔽。

首先需要实现填充词元的mask,因为这一部分我们并不希望注意到。

1
2
3
4
5
6
7
8
def get_attn_pad_mask(seq_q, seq_k):
"""
用于mask掉pad词元
"""
batch_size, len_q = seq_q.size()
batch_size, len_k = seq_k.size()
attn_pad_mask = seq_k.data.eq(0).unsqueeze(1) # (batch_size,1,len_k)词元序号为0(pad)的设为true,其余为false
return attn_pad_mask.expand(batch_size, len_q, len_k) # 扩充第1个维度,以便seq_q中每个词元都能并行计算

除此之外,解码器中我们还需要一个mask矩阵以对未来的信息进行遮蔽,因为我们每次预测出一个词元,因此这个矩阵实际上是一个上三角矩阵。

1
2
3
4
5
6
7
8
def get_attn_subsequence_mask(seq):
"""
用于解码器中msk掉未预测出的词元
"""
attn_shape = [seq.size(0), seq.size(1), seq.size(1)] # attn_shape: [batch_size, tgt_len, tgt_len]
subsequence_mask = np.triu(np.ones(attn_shape), k=1) # 生成一个上三角矩阵
subsequence_mask = torch.from_numpy(subsequence_mask).byte()
return subsequence_mask

缩放点积注意力

transformer中采用的是缩放点积注意力的计算方式,计算公式如下:

  • 为保证无论向量长度如何点积的方差都为1,因此要除以sqrt(d_k);

缩放点积注意力的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ScaledDotProductAttention(nn.Module):
"""
缩放点积注意力
"""
def __init__(self):
super(ScaledDotProductAttention, self).__init__()
def forward(self, Q, K, V, attn_mask):
"""
Q: [batch_size, n_heads, len_q, d_k]
K: [batch_size, n_heads, len_k, d_k]
V: [batch_size, n_heads, len_v(=len_k), d_v]
attn_mask: [batch_size, n_heads, seq_len, seq_len]
"""
scores = torch.matmul(Q, K.transpose(-1, -2)) / np.sqrt(d_k) # scores : [batch_size, n_heads, len_q, len_k]
attn = nn.Softmax(dim=-1)(scores) # 对最后一个维度(v)做softmax
# scores : [batch_size, n_heads, len_q, len_k] * V: [batch_size, n_heads, len_v(=len_k), d_v]
context = torch.matmul(attn, V) # context: [batch_size, n_heads, len_q, d_v]
# context:[[z1,z2,...],[...]]向量, attn注意力稀疏矩阵(用于可视化的)
return context, attn

多头注意力

有了掩码的实现和注意力分数的计算,我们就可以实现多头注意力机制了。如前所述,多头注意力会使用多个注意力头抽取不同特征,再将输入合并,如下图所示:

多头注意力

除此之外,transformer中还用到了残差连接和层归一化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class MultiHeadAttention(nn.Module):
"""这个Attention类可以实现:
Encoder的Self-Attention
Decoder的Masked Self-Attention
Encoder-Decoder的Attention
输入:seq_len x d_model
输出:seq_len x d_model
"""
def __init__(self):
super(MultiHeadAttention, self).__init__()
self.W_Q = nn.Linear(d_model, n_heads*d_k, bias=False) # 事实上d_modle=n_heads*d_k,d_k和d_q必须相等才能点积
self.W_K = nn.Linear(d_model, n_heads*d_k, bias=False)
self.W_V = nn.Linear(d_model, n_heads*d_v, bias=False)
self.fc = nn.Linear(n_heads*d_v, d_model, bias=False)

def forward(self, input_Q, input_K, input_V, attn_mask):
"""
input_Q: [batch_size, len_q, d_model]
input_K: [batch_size, len_k, d_model]
input_V: [batch_size, len_v(=len_k), d_model]
attn_mask: [batch_size, seq_len, seq_len]
"""
residual, batch_size = input_Q, input_Q.size(0)

# Q: [batch_size, n_heads, len_q, d_k]
Q = self.W_Q(input_Q).view(batch_size, -1, n_heads, d_k).transpose(1,2)
K = self.W_K(input_K).view(batch_size, -1, n_heads, d_k).transpose(1,2)
V = self.W_V(input_V).view(batch_size, -1, n_heads, d_v).transpose(1,2)

# 因为是多头,所以mask矩阵要扩充成4维的
# attn_mask: [batch_size, seq_len, seq_len] -> [batch_size, n_heads, seq_len, seq_len]
attn_mask = attn_mask.unsqueeze(1).repeat(1, n_heads, 1, 1)
context, attn = ScaledDotProductAttention()(Q, K, V, attn_mask)
# 下面将不同头的输出向量拼接在一起
# context: [batch_size, n_heads, len_q, d_v] -> [batch_size, len_q, n_heads * d_v]
context = context.transpose(1, 2).reshape(batch_size, -1, n_heads * d_v)
output = self.fc(context) # [batch_size, len_q, d_model]
return nn.LayerNorm(d_model).to(device)(output + residual), attn # 由于nn.LayerNorm未在init中定义,需要移到gpu

位置编码

根RNN、CNN不同,自注意力没有记录位置信息,因此需要将位置信息加入输入中。transformer构建了一个位置编码矩阵P: 假设输入序列为X,那么将会把X+P输入模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class PositionalEncoding(nn.Module):
"""
生成位置编码矩阵
"""
def __init__(self, d_modle, dropout=0.01, max_len=5000):
super(PositionalEncoding,self).__init__()
self.dropout = nn.Dropout(p=dropout)

pe = torch.zeros(max_len, d_modle)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) # 生成一个max_len*1的张量
div_term = torch.exp(torch.arange(0, d_modle, 2).float()*(-math.log(10000.0)/d_modle))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0).transpose(0,1) # (max_len, d_modle)→(max_len, 1, d_modle)
self.register_buffer('pe',pe) # 将pe张量注册为buffer,这样在模型推断时就不会被当做参数来优化了

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

FFN层

FFN,即基于位置的前馈神经网络(Positionwise Feed Forward Net)是transformer中的又一重要子层,FFN层本质上是全连接层,可以理解为是两个核窗口为1的一维卷积层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class PoswiseFeedForwardNet(nn.Module):
"""
基于位置的前馈神经网络
"""
def __init__(self):
super(PoswiseFeedForwardNet, self).__init__()
self.layers = nn.Sequential(nn.Linear(d_model, d_ff, bias=False),
nn.ReLU(),
nn.Linear(d_ff, d_model, bias=False))

def forward(self, inputs):
"""
inputs: [batch_size, seq_len, d_model]
"""
residual = inputs
outputs = self.layers(inputs)
return nn.LayerNorm(d_model).to(device)(outputs + residual)

Encoder

我们已经有了搭建transformer的必要组件,接下来只需按一开始的模型图进行搭建。首先我们需要一个encoder块,它由多头注意力层和FFN层组成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class EncoderLayer(nn.Module):
"""
一个encoder块
"""
def __init__(self):
super(EncoderLayer, self).__init__()
self.self_attn = MultiHeadAttention()
self.ffn = PoswiseFeedForwardNet()

def forward(self, inputs, mask):
"""
inputs: [batch_size, src_len, d_model]
mask: [batch_size, src_len, src_len] mask矩阵(pad mask or sequence mask)
"""
outputs, attn = self.self_attn(inputs,inputs,inputs,mask)
outputs = self.ffn(outputs)
return outputs,attn

有了一个encoder块后,我们需要对多个块进行堆叠组成编码器。在进入编码器前,还需要对词元进行embedding并加入位置信息,我们也在这里一并实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Encoder(nn.Module):
def __init__(self):
super(Encoder, self).__init__()
self.embedding = nn.Embedding(src_vocab_size, d_model)
self.pos_emb = PositionalEncoding(d_model)
self.layers = nn.ModuleList([EncoderLayer() for _ in range(0, n_layers)])

def forward(self, inputs):
"""
enc_inputs: [batch_size, src_len]
"""
enc_outputs = self.embedding(inputs) # # [batch_size, src_len, d_model]
enc_outputs = self.pos_emb(enc_outputs.transpose(0, 1)).transpose(0, 1) # [batch_size, src_len, d_model]
attn_mask = get_attn_pad_mask(enc_inputs, enc_inputs) # [batch_size, src_len, src_len]
enc_self_attns = []
for layer in self.layers:
# enc_outputs: [batch_size, src_len, d_model], enc_self_attn: [batch_size, n_heads, src_len, src_len]
enc_outputs, enc_self_attn = layer(enc_outputs, attn_mask)
enc_self_attns.append(enc_self_attn)
return enc_outputs, enc_self_attns

Decoder

与编码器类似,我们也可以实现解码器。解码器出了自注意力和FFN之外,还需要一个以编码器输出为key和value、以解码器输入为query的多头注意力层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class DecoderLayer(nn.Module):
def __init__(self):
super(DecoderLayer, self).__init__()
self.dec_self_attn = MultiHeadAttention()
self.dec_enc_attn = MultiHeadAttention()
self.pos_ffn = PoswiseFeedForwardNet()

def forward(self, dec_inputs, enc_outputs, dec_self_attn_mask, dec_enc_attn_mask):
"""
dec_inputs: [batch_size, tgt_len, d_model]
enc_outputs: [batch_size, src_len, d_model]
dec_self_attn_mask: [batch_size, tgt_len, tgt_len]
dec_enc_attn_mask: [batch_size, tgt_len, src_len]
"""
# dec_outputs: [batch_size, tgt_len, d_model], dec_self_attn: [batch_size, n_heads, tgt_len, tgt_len]
dec_outputs, dec_self_attn = self.dec_self_attn(dec_inputs, dec_inputs, dec_inputs,
dec_self_attn_mask) # 这里的Q,K,V全是Decoder自己的输入
# dec_outputs: [batch_size, tgt_len, d_model], dec_enc_attn: [batch_size, h_heads, tgt_len, src_len]
dec_outputs, dec_enc_attn = self.dec_enc_attn(dec_outputs, enc_outputs, enc_outputs,
dec_enc_attn_mask) # Attention层的Q(来自decoder) 和 K,V(来自encoder)
dec_outputs = self.pos_ffn(dec_outputs) # [batch_size, tgt_len, d_model]
return dec_outputs, dec_self_attn, dec_enc_attn # dec_self_attn, dec_enc_attn这两个是为了可视化的

对多个块进行堆叠构成解码器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Decoder(nn.Module):

def __init__(self):
super(Decoder, self).__init__()
self.tgt_emb = nn.Embedding(tgt_vocab_size, d_model)
self.pos_emb = PositionalEncoding(d_model)
self.layers = nn.ModuleList([DecoderLayer() for _ in range(n_layers)])

def forward(self, dec_inputs, enc_inputs, enc_outputs):
"""
dec_inputs: [batch_size, tgt_len]
enc_inputs: [batch_size, src_len]
enc_outputs: [batch_size, src_len, d_model]
"""
dec_outputs = self.tgt_emb(dec_inputs) # [batch_size, tgt_len, d_model]
dec_outputs = self.pos_emb(dec_outputs.transpose(0, 1)).transpose(0, 1).to(device) # [batch_size, tgt_len, d_model]
# pad mask矩阵
dec_self_attn_pad_mask = get_attn_pad_mask(dec_inputs, dec_inputs).to(device) # [batch_size, tgt_len, tgt_len]
# mask未预测的信息
dec_self_attn_subsequence_mask = get_attn_subsequence_mask(dec_inputs).to(
device) # [batch_size, tgt_len, tgt_len]

# Decoder中把两种mask矩阵相加
dec_self_attn_mask = torch.gt((dec_self_attn_pad_mask + dec_self_attn_subsequence_mask), 0).to(device) # [batch_size, tgt_len, tgt_len]
dec_enc_attn_mask = get_attn_pad_mask(dec_inputs, enc_inputs) # [batc_size, tgt_len, src_len]
dec_self_attns, dec_enc_attns = [], []
for layer in self.layers:
# dec_outputs: [batch_size, tgt_len, d_model], dec_self_attn: [batch_size, n_heads, tgt_len, tgt_len], dec_enc_attn: [batch_size, h_heads, tgt_len, src_len]
dec_outputs, dec_self_attn, dec_enc_attn = layer(dec_outputs, enc_outputs, dec_self_attn_mask, dec_enc_attn_mask)
dec_self_attns.append(dec_self_attn)
dec_enc_attns.append(dec_enc_attn)
# dec_outputs: [batch_size, tgt_len, d_model]
return dec_outputs, dec_self_attns, dec_enc_attns

组装

有了编码器和解码器,我们可以用它们搭建起最终的transformer了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Transformer(nn.Module):
def __init__(self):
super(Transformer, self).__init__()
self.encoder = Encoder().to(device)
self.decoder = Decoder().to(device)
self.projection = nn.Linear(d_model, tgt_vocab_size, bias=False).to(device)

def forward(self, enc_inputs, dec_inputs):
"""
enc_inputs: [batch_size, src_len]
dec_inputs: [batch_size, tgt_len]
"""
# enc_outputs: [batch_size, src_len, d_model], enc_self_attns: [n_layers, batch_size, n_heads, src_len, src_len]
enc_outputs, enc_self_attns = self.encoder(enc_inputs)
# dec_outputs: [batch_size, tgt_len, d_model], dec_self_attns: [n_layers, batch_size, n_heads, tgt_len, tgt_len], dec_enc_attn: [n_layers, batch_size, tgt_len, src_len]
dec_outputs, dec_self_attns, dec_enc_attns = self.decoder(dec_inputs, enc_inputs, enc_outputs)
# dec_outputs: [batch_size, tgt_len, d_model] -> dec_logits: [batch_size, tgt_len, tgt_vocab_size]
dec_logits = self.projection(dec_outputs)
return dec_logits.view(-1, dec_logits.size(-1)), enc_self_attns, dec_self_attns, dec_enc_attns

训练

损失函数使用交叉熵损失,优化器使用SGD,对模型进行训练。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
model = Transformer().to(device)
# ignore_index=0,因为 "pad" 这个单词的索引为 0,这样设置以后,就不会计算 "pad" 的损失
criterion = nn.CrossEntropyLoss(ignore_index=0)
optimizer = optim.SGD(model.parameters(), lr=1e-3, momentum=0.99)

for epoch in range(epochs):
for enc_inputs, dec_inputs, dec_outputs in loader:
"""
enc_inputs: [batch_size, src_len]
dec_inputs: [batch_size, tgt_len]
dec_outputs: [batch_size, tgt_len]
"""
enc_inputs, dec_inputs, dec_outputs = enc_inputs.to(device), dec_inputs.to(device), dec_outputs.to(device)
# outputs: [batch_size * tgt_len, tgt_vocab_size]
outputs, enc_self_attns, dec_self_attns, dec_enc_attns = model(enc_inputs, dec_inputs)
loss = criterion(outputs, dec_outputs.view(-1)) # dec_outputs.view(-1):[batch_size * tgt_len * tgt_vocab_size]
print('Epoch:', '%04d' % (epoch + 1), 'loss =', '{:.6f}'.format(loss))

optimizer.zero_grad()
loss.backward()
optimizer.step()

问题发生在写leetcode994腐烂的橘子的时候。这道题只要朴素的广搜就能A,我第一次提交的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
int orangesRotting(vector<vector<int>>& grid) {
queue<vector<int>> q;
int dir[4][2]={{1,0},{-1,0},{0,1},{0,-1}}; // 方向数组,分别指向上下左右四个方向
int m=grid.size(),n=grid[0].size(),cnt=0,ans;
// 已腐烂的橘子入队
for (int i=0;i<m;i++){
for (int j=0;j<n;j++){
if (grid[i][j]==2){
vector<int> a={i,j,0}; // (x坐标,y坐标,腐烂时间)
q.push(a);
}
if (grid[i][j]==1) cnt++; //新鲜橘子个数
}
}
// 广度优先搜索
while (!q.empty()){
vector<int> a=q.front(); //出队
q.pop();
// 传染邻格的橘子
for (int i=0;i<4;i++){
int x=a[0]+dir[i][0],y=a[1]+dir[i][1];
if (x>=0&&x<m&&y>=0&&y<n&&grid[x][y]==1){
cnt--; //新鲜橘子减少
vector<int> temp={x,y,a[2]+1};
q.push(temp); // 刚腐烂的橘子入队
grid[x][y]=2;
}
}
ans=q.front()[2]; //最后一个橘子出队前,ans将更新为其腐烂时间
}
if (cnt==0) return ans; // 不存在新鲜橘子
else return -1;
}

然后出现了以下报错:

1
Line 1037: Char 9: runtime error: reference binding to misaligned address 0xbebebebebebebec6 for type 'int', which requires 4 byte alignment (stl_vector.h)

经过检查,发现是“ans=q.front()[2];”这行代码未进行判空,导致在队列为空时引用了front()函数而产生错误。

将改行代码修改为“if (!q.empty()) ans=q.front()[2];”后问题解决。

这是一道简单的回溯题,正常dfs就能A,但是我做的时候内存爆了,从而发现了一个以前一直没发现的问题。

之前我只有需要对实参进行修改才会在定义函数时使用引用传递。这道题因为不用对字母表进行修改,所以我一开始直接传值:

1
bool dfs(vector<vector<char>> a,string word,int n,int x,int y)  // a为字母表,n为当前匹配长度,x、y为当前字母坐标

然后内存爆了。因为回溯题的数组一般都比较小,就算把整个数组直接传进去空间复杂度也才乘100左右,以前直接这样传内存一直没有爆过。以后递归的时候不能再这样传参了,太占空间了。

下面附上此题代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bool visit[16][16]={};  // 记录各字母是否被访问过
bool dfs(vector<vector<char>> &a,string word,int n,int x,int y){
if (x>=a.size()||x<0||y>=a[0].size()||y<0) return false; // 坐标越界
if (visit[x][y]) return false; // 已经用过的字母不能再次使用
if (a[x][y]!=word[n]) return false; // 字母不匹配
if (n==word.length()-1) return true; // 匹配成功
visit[x][y]=1;
// 分别对上下左右四个方向进行搜索
bool ans=dfs(a,word,n+1,x+1,y)||dfs(a,word,n+1,x-1,y)||dfs(a,word,n+1,x,y+1)||dfs(a,word,n+1,x,y-1);
visit[x][y]=0;
return ans;
}
bool exist(vector<vector<char>>& board, string word) {
//分别以每个点为起点进行搜索
for (int i=0;i<board.size();i++){
for (int j=0;j<board[0].size();j++){
if (dfs(board,word,0,i,j)) return true;
}
}
return false;
}

这样就A了,就没有再剪枝了。。

序列问题在机器学习和统计建模中指的是那些需要考虑数据元素之间顺序或时间依赖关系的问题。这些问题通常涉及到一连串的输入或者输出,这些输入或输出之间的关联不是独立的,而是具有某种前后关联性、时序特征或结构化特性。

例如:

  • 序列预测问题:给定一个时间序列数据(如股票价格、气温变化等),目标是基于过去的数据点预测未来的数据点。

  • 序列标注问题(也称为序列标记):在自然语言处理(NLP)中,这是一个常见的任务类型,比如词性标注(POS tagging)、命名实体识别(NER)或情感分析,其中每个单词或字符都会被分配一个标签,且标签间的分配依赖于上下文序列信息。

  • 隐马尔可夫模型(HMM)中的最优状态序列问题:给定一系列观察值和一个HMM模型,找到最可能产生这些观察值的状态序列。

  • 语音识别:将连续的音频信号转换为对应的文本序列,其中每个音素或词的识别都依赖于前面已识别的部分。

  • DNA序列分析:在生物信息学中,分析DNA或蛋白质序列,寻找特定的模式或功能区域,这里的序列元素是核苷酸或氨基酸,并且它们的性质往往与位置相关联。

总的来说,序列问题的关键特征在于它要求模型能够理解并利用数据内部的时间结构或顺序关系来做出决策或进行预测。

RNN

循环神经网络(RNN)是一种特殊的神经网络结构,它专为处理序列数据而设计。在传统的前馈神经网络(Feedforward Neural Networks)中,信息仅从输入层经过隐藏层流向输出层,而在RNN中,引入了循环机制,使得当前时刻的隐藏层状态不仅取决于当前时刻的输入,还依赖于上一时刻或之前所有时刻隐藏层的状态。这就意味着RNN具有记忆功能,能够捕捉到数据中的时间依赖性或者顺序特征。

形式上,RNN的一个单元可以表示为: 其中h_t是是在时间步t的隐藏层状态,X_t是时间步t的输入,o_t为时间步t的输出,v、c为权重矩阵。

RNN模型图

由于其递归特性,RNN在很多领域有广泛应用,尤其是在自然语言处理(NLP)任务中,如文本分类、情感分析、机器翻译和语音识别等;此外,在时间序列预测、视频动作识别和音乐生成等领域也有出色表现。

更复杂的RNN变体包括长短期记忆网络(LSTM)和门控循环单元(GRU),它们通过引入额外的“门”机制来改进基础RNN,以更好地解决长期依赖问题,即随着序列长度增加,较远过去的信息不容易被有效捕获的问题。

GRU

使用RNN时,矩阵连续乘积可能导致梯度消失或梯度爆炸的问题。解决这类问题最早的方法是LSTM,而门控循环单元GRU是LSTM的简化变体,因此先介绍GRU。

门控循环单元与普通的循环神经网络之间的关键区别在于: 前者支持隐状态的门控。 这意味着模型有专门的机制来确定应该何时更新隐状态, 以及应该何时重置隐状态。

GRU模型图

重置门和更新门

GRU使用了重置门更新门

  • 重置门:控制“可能还想记住”的过去状态的数量,有助于捕获序列中的短期依赖关系。

  • 更新门:控制新状态中有多少个是旧状态的副本,有助于捕获序列中的长期依赖关系。

两个门的输入是由当前时间步的输入和前一时间步的隐状态给出,输出是由使用sigmoid激活函数的两个全连接层给出。数学表达如下: 其中R为重置门,Z为更新门,X为输入,H为隐状态,W为权重参数,b为偏置参数。sigmoid函数将输入值转换到(0,1)区间。

隐状态

在RNN中,我们使用H来表示隐状态,而在GRU中,我们又引入候选隐状态,为做区分,我们将H所表示的称为常规隐状态。

候选隐状态是一个基于部分重置的历史信息和当前输入计算出的新状态。数学表示为: 其中⊙为Hadamard积(按元素乘积)运算符。使用重置门R来控制以往状态的影响,当R接近1时,便退化成普通的RNN。

有了候选隐状态后,我们结合更新门确定新的隐状态: 容易看出,当更新门Z接近1时,模型倾向于保留旧状态;相反,当Z接近0时, 新的隐状态就会接近候选隐状态。

LSTM

长短期记忆网络LSTM虽然出现得比GRU早得多,却比GRU更加复杂。

LSTM模型图

LSTM使用了输入门、忘记门、输出门三个门: 并引入了候选记忆元 候选记忆元的计算与上面描述的三个门的计算类似, 但是使用tanh函数作为激活函数。

我们用C来表示记忆元,输入门I控制采用多少来自候选记忆元的新数据,遗忘门F控制保留多少过去记忆元的内容: 如果遗忘门始终为1且输入门始终为0, 则过去的记忆元将随时间被保存并传递到当前时间步。 引入这种设计是为了缓解梯度消失问题, 并更好地捕获序列中的长距离依赖关系。

最后,我们使用记忆元结合输出门得到隐状态: 只要输出门接近1,我们就能够有效地将所有记忆信息传递给预测部分, 而对于输出门接近0,我们只保留记忆元内的所有信息,而不需要更新隐状态。

LSTM能够在处理时间序列任务时有效地捕获并保持长期依赖关系,从而在语音识别、机器翻译、文本生成等诸多领域取得了显著效果。

0%