This commit is contained in:
logan_zou
2024-05-28 12:25:44 +08:00
parent 756334417a
commit dbced843e5
33 changed files with 3548 additions and 1 deletions

135
README.md
View File

@@ -1 +1,134 @@
# happy-llm
# Happy LLM
## 大纲
### 第一章 NLP 基础概念 志学
- 1.1 什么是 NLP
- 1.2 NLP 发展历程
- 1.3 NLP 任务
- 1.3.1 中文分词
- 1.3.2 子词切分
- 1.3.3 词性标注
- 1.3.4 文本分类
- 1.3.5 实体识别
- 1.3.6 关系抽取
- 1.3.7 文本摘要
- 1.3.8 机器翻译
- 1.3.9 自动问答
- 1.4 文本表示的发展历程
- 1.4.1 词向量
- 1.4.2 语言模型
- 1.4.3 Word2Vec
- 1.4.4 ELMo
### 第二章 基础架构-Transformer 雨衡
- 2.1 注意力机制
- 2.1.1 注意力机制详解
- 2.1.2 自注意力与多头注意力
- 2.1.3 注意力掩码与因果注意力
- 2.2 Encoder-Decoder
- 2.2.1 Seq2Seq 模型
- 2.2.2 Encoder
- 2.2.3 Decoder
- 2.3 Transformer
- 2.3.1 Transformer 结构总览
- 2.3.2 Tokenizer 与 Embedding 层
- 2.3.3 位置编码
- 2.3.4 Transformer 中的其他结构
### 第三章 预训练语言模型
- 3.1 Encoder-Only PLM 雨衡
- 3.1.1 BERT
- 1模型架构Encoder Only
- 2预训练任务
- 3针对下游任务微调
- 3.1.2 RoBERTa
- 3.1.3 ALBERT
- 3.1.4 XLNet
- 3.2 Encoder-Decoder PLM 志学
- 3.2.1 T5
- 1模型架构Encoder-Decoder
- 2预训练任务
- 3大一统思想
- 3.2.2 BART
- 3.3 Decoder-Only PLM
- 3.3.1 GPT
- 1模型架构Decoder Only
- 2预训练任务
- 3GPT 的发展历程
- 3.3.2 LLaMA
- 1模型架构优化
- 2预训练数据
- 3LLaMA1 到 LLaMA2
- 3.3.3 ChatGLM
- 1模型架构Prefix-Decoder
- 2预训练数据
- 3ChatGLM 的发展历程
- 3.3.4 BaiChuan
- 3.3.5 Qwen
- 3.3.6 Mistral
- 3.3.7 MiniCPM
- 3.3.8 Mixtral-8*7B
- 1模型架构MoE
- 2MoE 架构的核心优势
### 第四章 大语言模型 雨衡
- 4.1 什么是 LLM Done
- 4.1.1 LLM 的定义
- 4.1.2 LLM 的能力
- 4.1.3 LLM 的特点
- 4.2 训练 LLM 的三个阶段
- 4.2.1 Pretrain
- 4.2.2 SFT
- 4.2.3 RLHF
### 第五章 预训练一个 LLM 志学
- 5.1 模型架构-LLaMA
- 5.1.1 LLaMA Attention
- 5.1.2 LLaMA Decoder Layer
- 5.1.3 LLaMA MLP
- 5.1.4 LLaMA RMSNorm
- 5.1.5 A Whole LLaMA
- 5.2 预训练数据
- 5.2.1 预训练数据集
- 5.2.2 预训练数据处理
- 5.3 训练 Tokenizer
- 5.3.1 Word-based Tokenizer
- 5.3.2 Character-based Tokenzier
- 5.3.3 Subword Tokenizer
- 1BPE
- 2Word Piece
- 3Unigram
- 5.3.4 训练一个 Tokenizer
- 5.4 预训练
### 第六章 微调 LLM
- 6.1 微调数据
- 6.1.1 指令数据集
- 6.1.2 微调数据处理
- 6.2 SFT
- 6.3 微调其他 LLM 的通用流程
- 6.4 高效微调-LoRA
- 6.4.1 LoRA 原理(注:深入浅出 LoRA
- 6.4.2 实践 LoRA 微调
### 第七章 RLHF
- 7.1 RM 训练
- 7.2 PPO 训练
- 7.3 RLHF 的平替版本-DPO
### 第八章 LLM 应用
- 8.1 LLM 的评测
- 8.1.1 LLM 的评测方法
- 8.1.2 主流的评测榜单
- 8.1.3 特定的评测榜单
- 8.2 Prompt Engineering吴恩达课程
- 8.2.1 上下文学习
- 8.2.2 思维链
- 8.2.3 Prompt 的迭代优化
- 8.3 RAG (注:志学-TinyRAG
- 8.3.1 RAG 的基本原理
- 8.3.2 搭建一个 RAG 框架
- 8.4 Agent
- 8.4.1 Agent 的基本原理
- 8.4.2 搭建一个 Multi-Agent 框架

BIN
docs/.DS_Store vendored Normal file

Binary file not shown.

42
docs/README.md Normal file
View File

@@ -0,0 +1,42 @@
# 项目名称
这里写项目的各类介绍信息,例如:
- 项目背景、动机等简介内容
- 项目内容目录
- ....
## Roadmap
*注:说明当前项目的规划,并将每个任务通过 Issue 形式进行对外进行发布。*
## 参与贡献
- 如果你想参与到项目中来欢迎查看项目的 [Issue]() 查看没有被分配的任务。
- 如果你发现了一些问题,欢迎在 [Issue]() 中进行反馈🐛。
- 如果你对本项目感兴趣想要参与进来可以通过 [Discussion]() 进行交流💬。
如果你对 Datawhale 很感兴趣并想要发起一个新的项目,欢迎查看 [Datawhale 贡献指南](https://github.com/datawhalechina/DOPMC#%E4%B8%BA-datawhale-%E5%81%9A%E5%87%BA%E8%B4%A1%E7%8C%AE)。
## 贡献者名单
| 姓名 | 职责 | 简介 |
| :----| :---- | :---- |
| 小明 | 项目负责人 | 小明 |
| 小红 | 第1章贡献者 | 小明的朋友 |
| 小强 | 第2章贡献者 | 小明的朋友 |
*注:表头可自定义,但必须在名单中标明项目负责人*
## 关注我们
<div align=center>
<p>扫描下方二维码关注公众号Datawhale</p>
<img src="https://raw.githubusercontent.com/datawhalechina/pumpkin-book/master/res/qrcode.jpeg" width = "180" height = "180">
</div>
## LICENSE
<a rel="license" href="http://creativecommons.org/licenses/by-nc-sa/4.0/"><img alt="知识共享许可协议" style="border-width:0" src="https://img.shields.io/badge/license-CC%20BY--NC--SA%204.0-lightgrey" /></a><br />本作品采用<a rel="license" href="http://creativecommons.org/licenses/by-nc-sa/4.0/">知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议</a>进行许可。
*默认使用CC 4.0协议,也可根据自身项目情况选用其他协议*

8
docs/_sidebar.md Normal file
View File

@@ -0,0 +1,8 @@
- 第1章 [我是第1章的标题](chapter1/chapter1)
- 第2章 我是第2章的标题
- [2.1 我是2.1的标题](chapter2/chapter2_1)
- [2.2 我是2.2的标题](chapter2/chapter2_2)
- 第3章 我是第3章的标题
- 3.1 我是3.1的标题
- 3.1.1 [我是3.1.1的标题](chapter3/chapter3_1/chapter3_1_1)
- 3.1.2 [我是3.1.2的标题](chapter3/chapter3_1/chapter3_1_2)

View File

@@ -0,0 +1,236 @@
# NLP 基础概念
自然语言处理NLP作为人工智能领域的一个重要分支旨在使计算机能够理解和处理人类语言实现人机之间的自然交流。随着信息技术的飞速发展文本数据已成为我们日常生活中不可或缺的一部分NLP技术的进步为我们从海量文本中提取有用信息、理解语言的深层含义提供了强有力的工具。从早期的基于规则的方法到后来的统计学习方法再到当前深度学习技术的广泛应用NLP领域经历了多次技术革新文本表示作为NLP的核心技术之一其研究和进步对于提升自然语言处理系统的性能具有决定性的作用。
欢迎大家来到 NLP 基础概念的学习,本章节将为大家介绍 NLP 的基础概念,帮助大家更好地理解和回顾 NLP 的相关知识。
## 1.1 什么是 NLP
自然语言处理NLP是一种让计算机理解、解释和生成人类语言的技术。它是人工智能领域中一个极为活跃和重要的研究方向其核心任务是通过计算机程序来模拟人类对语言的认知和使用过程。NLP结合了计算机科学、人工智能、语言学和心理学等多个学科的知识和技术旨在打破人类语言和计算机语言之间的障碍实现无缝的交流与互动。
NLP技术使得计算机能够执行各种复杂的语言处理任务如中文分词、子词切分、词性标注、文本分类、实体识别、关系抽取、文本摘要、机器翻译、自动问答等。这些任务不仅要求计算机能够识别和处理语言的表层结构更重要的是理解语言背后的深层含义包括语义、语境、情感和文化等方面的复杂因素。
随着深度学习等现代技术的发展NLP已经取得了显著的进步。通过训练大量的数据深度学习模型能够学习到语言的复杂模式和结构从而在多个NLP任务上取得了接近甚至超越人类水平的性能。然而尽管如此NLP仍然面临着诸多挑战如处理歧义性、理解抽象概念、处理隐喻和讽刺等。研究人员正致力于通过更加先进的算法、更大规模的数据集和更精细的语言模型来解决这些问题以推动NLP技术不断向前发展。
## 1.2 NLP 发展历程
NLP的发展历程是从早期的规则基础方法到统计方法再到现在的机器学习和深度学习方法的演变过程。每一次技术变革都极大地推动了NLP技术的发展使其在机器翻译、情感分析、实体识别和文本摘要等任务上取得了显著成就。随着计算能力的不断增强和算法的不断优化NLP的未来将更加光明能够在更多领域发挥更加重要的作用。
### 早期探索1940年代 - 1960年代
NLP的早期探索始于二战后当时人们认识到从一种语言自动翻译到另一种语言的重要性。1950年艾伦·图灵提出了图灵测试。
> 他说,如果一台机器可以通过使用打字机成为对话的一部分,并且它完全模仿人类,没有明显的差异,那么机器可以被认为是能够思考的。
这是判断机器是否能够展现出与人类不可区分的智能行为的测试。这一时期,诺姆·乔姆斯基提出了生成语法理论,这对理解机器翻译的工作方式产生了重要影响。然而,这一时期的机器翻译系统非常简单,主要依赖字典查找和基本的词序规则来产生翻译,效果并不理想。
### 符号主义与统计方法1970年代 - 1990年代
1970年代以后NLP研究者开始探索新的领域包括逻辑基础的范式和自然语言理解。这一时期研究者分为符号主义或规则基础和统计方法两大阵营。符号主义研究者关注于形式语言和生成语法而统计方法的研究者更加关注于统计和概率方法。1980年代随着计算能力的提升和机器学习算法的引入NLP领域出现了革命性的变化统计模型开始取代复杂的“手写”规则。
### 机器学习与深度学习2000年代至今
2000年代以后随着深度学习技术的发展NLP领域取得了显著的进步。深度学习模型如循环神经网络RNN、长短时记忆网络LSTM和注意力机制等技术被广泛应用于NLP任务中取得了令人瞩目的成果。2013年Word2Vec模型的提出开创了词向量表示的新时代为NLP任务提供了更加有效的文本表示方法。2018年BERT模型的问世引领了预训练语言模型的新浪潮为NLP技术的发展带来了新的机遇和挑战。近年来基于Transformer的模型如GPT-3通过训练巨大的参数模型能够生成高质量的文本甚至在某些情况下与人类写作难以区分。
## 1.3 NLP 任务
在NLP的广阔研究领域中有几个核心任务构成了NLP领域的基础它们涵盖了从文本的基本处理到复杂的语义理解和生成的各个方面。这些任务包括但不限于中文分词、子词切分、词性标注、文本分类、实体识别、关系抽取、文本摘要、机器翻译以及自动问答系统的开发。每一项任务都有其特定的挑战和应用场景它们共同推动了语言技术的发展为处理和分析日益增长的文本数据提供了强大的工具。
### 1.3.1 中文分词
中文分词Chinese Word Segmentation, CWS是自然语言处理领域中的一个基础任务。在处理中文文本时由于中文语言的特点词与词之间没有像英文那样的明显分隔如空格所以无法直接通过空格来确定词的边界。因此中文分词成为了中文文本处理的首要步骤其目的是将连续的中文文本切分成有意义的词汇序列。
```
输入:今天天气真好,适合出去游玩.
输出:["今天", "天气", "真", "好", "", "适合", "出去", "游玩", "。"]
```
正确的分词结果对于后续的词性标注、实体识别、句法分析等任务至关重要。如果分词不准确,将直接影响到整个文本处理流程的效果。
### 1.3.2 子词切分
子词切分Subword Segmentation是自然语言处理NLP中的一种文本预处理技术旨在将词汇进一步分解为更小的单位即子词。这种方法特别适用于处理词汇稀疏问题即当遇到罕见词或未见过的新词时能够通过已知的子词单位来理解或生成这些词汇。子词切分在处理那些拼写复杂、合成词多的语言如德语或者在预训练语言模型如BERT、GPT系列中尤为重要。
子词切分的方法有很多种常见的有Byte Pair Encoding (BPE)、WordPiece、Unigram、SentencePiece等。这些方法的基本思想是将单词分解成更小的、频繁出现的片段这些片段可以是单个字符、字符组合或者词根和词缀。
```
输出unhappiness
不使用子词切分整个单词作为一个单位“unhappiness”
使用子词切分假设BPE算法单词被分割为“un”、“happi”、“ness”
```
在这个例子中通过子词切分“unhappiness”这个词被分解成了三个部分前缀“un”表示否定“happi”是“happy”的词根变体表示幸福“ness”是名词后缀表示状态。即使模型从未见过“unhappiness”这个完整的单词它也可以通过这些已知的子词来理解其大致意思为“不幸福的状态”。
### 1.3.3 词性标注
词性标注Part-of-Speech TaggingPOS Tagging是自然语言处理NLP中的一项基础任务它的目标是为文本中的每个单词分配一个词性标签如名词、动词、形容词等。这个过程通常基于预先定义的词性标签集如英语中的常见标签有名词NounN、动词VerbV、形容词AdjectiveAdj等。词性标注对于理解句子结构、进行句法分析、语义角色标注等高级NLP任务至关重要。通过词性标注计算机可以更好地理解文本的含义进而进行信息提取、情感分析、机器翻译等更复杂的处理。
假设我们有一个英文句子She is playing the guitar in the park.
词性标注的结果如下:
- She (代词PronounPRP)
- is (动词VerbVBZ)
- playing (动词的现在分词VerbVBG)
- the (限定词DeterminerDT)
- guitar (名词NounNN)
- in (介词PrepositionIN)
- the (限定词DeterminerDT)
- park (名词NounNN)
- . (标点Punctuation.)
词性标注通常依赖于机器学习模型如隐马尔可夫模型Hidden Markov ModelHMM、条件随机场Conditional Random FieldCRF或者基于深度学习的循环神经网络Recurrent Neural NetworkRNN和长短时记忆网络Long Short-Term MemoryLSTM等。这些模型通过学习大量的标注数据来预测新句子中每个单词的词性。
### 1.3.4 文本分类
文本分类Text Classification是自然语言处理NLP领域的一项核心任务涉及到将给定的文本自动分配到一个或多个预定义的类别中。这项技术广泛应用于各种场景包括但不限于情感分析、垃圾邮件检测、新闻分类、主题识别等。文本分类的关键在于理解文本的含义和上下文并基于此将文本映射到特定的类别。
假设有一个文本分类任务,目的是将新闻文章分类为“体育”、“政治”或“科技”三个类别之一。
```
文本“NBA季后赛将于下周开始湖人和勇士将在首轮对决。”
类别:“体育”
文本:“美国总统宣布将提高关税,引发国际贸易争端。”
类别:“政治”
文本:“苹果公司发布了新款 Macbook配备了最新的m3芯片。”
类别:“科技”
```
文本分类任务的成功关键在于选择合适的特征表示和分类算法,以及拥有高质量的训练数据。随着深度学习技术的发展,使用神经网络进行文本分类已经成为一种趋势,它们能够捕捉到文本数据中的复杂模式和语义信息,从而在许多任务中取得了显著的性能提升。
### 1.3.5 实体识别
实体识别Named Entity Recognition, NER也称为命名实体识别是自然语言处理NLP领域的一个关键任务旨在自动识别文本中具有特定意义的实体并将它们分类为预定义的类别如人名、地点、组织、日期、时间等。实体识别任务对于信息提取、知识图谱构建、问答系统、内容推荐等应用很重要它能够帮助系统理解文本中的关键元素及其属性。
假设有一个实体识别任务,目的是从文本中识别出人名、地名和组织名等实体。
```
输入李雷和韩梅梅是北京市海淀区的居民他们计划在2024年4月7日去上海旅行。
输出:[("李雷", "人名"), ("韩梅梅", "人名"), ("北京市海淀区", "地名"), ("2024年4月7日", "日期"), ("上海", "地名")]
```
通过实体识别任务我们不仅能识别出文本中的实体还能了解它们的类别为深入理解文本内容和上下文提供了重要信息。随着NLP技术的发展实体识别的精度和效率不断提高可以为各种NLP应用提供强大的支持。
### 1.3.6 关系抽取
关系抽取Relation Extraction是自然语言处理NLP中的一项关键任务它的目标是从文本中识别实体之间的语义关系。这些关系可以是因果关系、拥有关系、亲属关系、地理位置关系等关系抽取对于理解文本内容、构建知识图谱、提升机器理解语言的能力等方面具有重要意义。
假设我们有以下句子:
```
输入:比尔·盖茨是微软公司的创始人。
输出:[("比尔·盖茨", "创始人", "微软公司")]
```
在这个例子中,关系抽取任务的目标是从文本中识别出“比尔·盖茨”和“微软公司”之间的“创始人”关系。通过关系抽取,我们可以从文本中提取出有用的信息,帮助计算机更好地理解文本内容,为后续的知识图谱构建、问答系统等任务提供支持。
### 1.3.7 文本摘要
文本摘要Text Summarization是自然语言处理NLP中的一个重要任务目的是生成一段简洁准确的摘要来概括原文的主要内容。根据生成方式的不同文本摘要可以分为两大类抽取式摘要Extractive Summarization和生成式摘要Abstractive Summarization
- 抽取式摘要:抽取式摘要通过直接从原文中选取关键句子或短语来组成摘要。优点是摘要中的信息完全来自原文,因此准确性较高。然而,由于仅仅是原文中句子的拼接,有时候生成的摘要可能不够流畅。
- 生成式摘要与抽取式摘要不同生成式摘要不仅涉及选择文本片段还需要对这些片段进行重新组织和改写并生成新的内容。生成式摘要更具挑战性因为它需要理解文本的深层含义并能够以新的方式表达相同的信息。生成式摘要通常需要更复杂的模型如基于注意力机制的序列到序列模型Seq2Seq
假设我们有以下新闻报道:
```
2021年5月22日国家航天局宣布我国自主研发的火星探测器“天问一号”成功在火星表面着陆。此次任务的成功标志着我国在深空探测领域迈出了重要一步。“天问一号”搭载了多种科学仪器将在火星表面进行为期90个火星日的科学探测工作旨在研究火星地质结构、气候条件以及寻找生命存在的可能性。
```
抽取式摘要:
```
我国自主研发的火星探测器“天问一号”成功在火星表面着陆,标志着我国在深空探测领域迈出了重要一步。
```
生成式摘要:
```
“天问一号”探测器成功实现火星着陆,代表我国在宇宙探索中取得重大进展。
```
文本摘要任务在信息检索、新闻推送、报告生成等领域有着广泛的应用。通过自动摘要,用户可以快速获取文本的核心信息,节省阅读时间,提高信息处理效率。
### 1.3.8 机器翻译
机器翻译Machine Translation, MT是自然语言处理NLP领域的一项核心任务指使用计算机程序将一种自然语言源语言自动翻译成另一种自然语言目标语言的过程。机器翻译不仅涉及到词汇的直接转换更重要的是要准确传达源语言文本的语义、风格和文化背景等使得翻译结果在目标语言中自然、准确、流畅以便跨越语言障碍促进不同语言使用者之间的交流与理解。
假设我们有一句中文:“今天天气很好。”,我们想要将其翻译成英文。
```
源语言:今天天气很好。
目标语言The weather is very nice today.
```
在这个简单的例子中机器翻译能够准确地将中文句子转换成英文保持了原句的意义和结构。然而在处理更长、更复杂的文本时机器翻译面临的挑战也会相应增加。为了提高机器翻译的质量研究者不断探索新的方法和技术如基于神经网络的Seq2Seq模型、Transformer模型等这些模型能够学习到源语言和目标语言之间的复杂映射关系从而实现更加准确和流畅的翻译。
### 1.3.9 自动问答
自动问答Automatic Question Answering, QA是自然语言处理NLP领域中的一个高级任务旨在使计算机能够理解自然语言提出的问题并根据给定的数据源自动提供准确的答案。自动问答任务模拟了人类理解和回答问题的能力涵盖了从简单的事实查询到复杂的推理和解释。自动问答系统的构建涉及多个NLP子任务如信息检索、文本理解、知识表示和推理等。
自动问答大致可分为三类检索式问答Retrieval-based QA、知识库问答Knowledge-based QA和社区问答Community-based QA。检索式问答通过搜索引擎等方式从大量文本中检索答案知识库问答通过结构化的知识库来回答问题社区问答则依赖于用户生成的问答数据如问答社区、论坛等。
自动问答系统的开发和优化是一个持续的过程,随着技术的进步和算法的改进,这些系统在准确性、理解能力和应用范围上都有显著的提升。通过结合不同类型的数据源和技术方法,自动问答系统正变得越来越智能,越来越能够处理复杂和多样化的问题。
## 1.4 文本表示的发展历程
文本表示的目的是将人类语言的自然形式转化为计算机可以处理的形式,也就是将文本数据数字化,使计算机能够对文本进行有效的分析和处理。文本表示是自然语言处理领域中的一项基础性和必要性工作,它直接影响甚至决定着自然语言处理系统的质量和性能。
在自然语言处理中,文本表示涉及到将文本中的语言单位(如字、词、短语、句子等)以及它们之间的关系和结构信息转换为计算机能够理解和操作的形式,例如向量、矩阵或其他数据结构。这样的表示不仅需要保留足够的语义信息,以便于后续的自然语言处理任务,如文本分类、情感分析、机器翻译等,还需要考虑计算效率和存储效率。
文本表示的发展历程经历了多个阶段,从早期的基于规则的方法,到统计学习方法,再到当前的深度学习技术,文本表示技术不断演进,为自然语言处理的发展提供了强大的支持。
### 1.4.1 词向量
向量空间模型Vector Space Model, VSM是自然语言处理NLP领域中一个基础且强大的文本表示方法最早由哈佛大学Salton提出。向量空间模型通过将文本包括单词、句子、段落或整个文档转换为高维空间中的向量来实现文本的数学化表示。在这个模型中每个维度代表一个特征项例如字、词、词组或短语而向量中的每个元素值代表该特征项在文本中的权重这种权重通过特定的计算公式如词频TF、逆文档频率TF-IDF等来确定反映了特征项在文本中的重要程度。
VSM的应用极其广泛包括但不限于文本相似度计算、文本分类、信息检索等自然语言处理任务。它将复杂的文本数据转换为易于计算和分析的数学形式使得文本的相似度计算和模式识别成为可能。此外通过矩阵运算如特征值计算、奇异值分解singular value decomposition, SVD等方法可以优化文本向量表示进一步提升处理效率和效果。
然而VSM也存在很多问题。其中最主要的是数据稀疏性和维数灾难问题因为特征项数量庞大导致向量维度极高同时多数元素值为零。此外由于模型基于特征项之间的独立性假设忽略了文本中的结构信息如词序和上下文信息限制了模型的表现力。特征项的选择和权重计算方法的不足也是向量空间模型需要解决的问题。
为了解决这些问题研究者们对VSM的研究主要集中在两个方面一是改进特征表示方法如借助图方法、主题方法等进行关键词抽取二是改进和优化特征项权重的计算方法可以在现有方法的基础上进行融合计算或提出新的计算方法.
### 1.4.2 语言模型
N-gram模型是自然语言处理NLP领域中一种基于统计的语言模型广泛应用于语音识别、手写识别、拼写纠错、机器翻译和搜索引擎等众多任务。N-gram模型的核心思想是基于马尔可夫假设即一个词的出现概率仅依赖于它前面的N-1个词。这里的N代表连续出现单词的数量可以是任意正整数。例如当N=1时模型称为unigram仅考虑单个词的概率当N=2时称为bigram考虑前一个词来估计当前词的概率当N=3时称为trigram考虑前两个词来估计第三个词的概率以此类推N-gram。
N-gram模型通过条件概率链式规则来估计整个句子的概率。具体而言对于给定的一个句子模型会计算每个N-gram出现的条件概率并将这些概率相乘以得到整个句子的概率。例如对于句子"The quick brown fox"作为trigram模型我们会计算 $P("brown" | "The", "quick")$、$P("fox" | "quick", "brown")$等概率,并将它们相乘。
N-gram的优点是实现简单、容易理解在许多任务中效果不错。但当N较大时会出现数据稀疏性问题。模型的参数空间会急剧增大相同的N-gram序列出现的概率变得非常低导致模型无法有效学习模型泛化能力下降。此外N-gram模型忽略了词之间的范围依赖关系无法捕捉到句子中的复杂结构和语义信息。
尽管存在局限性N-gram模型由于其简单性和实用性在许多自然语言处理任务中仍然被广泛使用。在某些应用中结合N-gram模型和其他技术如深度学习模型可以获得更好的性能。
### 1.4.3 Word2Vec
Word2Vec是一种流行的词嵌入word embedding技术由Tomas Mikolov等人在2013年提出。它是一种基于神经网络NNLM的语言模型旨在通过学习词与词之间的上下文关系来生成词的密集向量表示。Word2Vec的核心思想是利用词在文本中的上下文信息来捕捉词之间的语义关系从而使得语义相似或相关的词在向量空间中距离较近。
Word2Vec模型主要有两种架构连续词袋模型CBOW(continuous bag of words)是根据目标词上下文中的词对应的词向量, 计算并输出目标词的向量表示Skip-Gram模型与CBOW模型相反, 是利用目标词的向量表示计算上下文中的词向量. 实践验证CBOW适用于小型数据集, 而Skip-Gram在大型语料中表现更好。
相比于传统的高维稀疏表示如one-hot编码Word2Vec生成的是低维通常几百维的密集向量有助于减少计算复杂度和存储需求。Word2Vec模型能够捕捉到词与词之间的语义关系比如”国王“和“王后”在向量空间中的位置会比较接近因为在大量文本中它们通常会出现在相似的上下文中。Word2Vec模型也可以很好的泛化到未见过的词因为它是基于上下文信息学习的而不是基于词典。但由于CBOW/Skip-Gram模型是基于局部上下文的无法捕捉到长距离的依赖关系缺乏整体的词与词之间的关系因此在一些复杂的语义任务上表现不佳。
### 1.4.4 ELMo
ELMoembeddings from language models实现了一词多义、静态词向量到动态词向量的跨越式转变。首先在大型语料库上训练语言模型得到词向量模型然后在特定任务上对模型进行微调得到更适合该任务的词向量ELMo首次将预训练思想引入到词向量的生成中使用双向LSTM结构能够捕捉到词汇的上下文信息生成更加丰富和准确的词向量表示。
ELMo采用典型的两阶段过程: 第1个阶段是利用语言模型进行预训练; 第2个阶段是在做特定任务时, 从预训练网络中提取对应单词的词向量作为新特征补充到下游任务中。基于RNN的LSTM模型训练时间长, 特征提取是ELMo模型优化和提升的关键。
ELMo模型的主要优势在于其能够捕捉到词汇的多义性和上下文信息生成的词向量更加丰富和准确适用于多种自然语言处理任务。然而ELMo模型也存在一些问题如模型复杂度高、训练时间长、计算资源消耗大等。
## 参考文献
- [Distributed  representations  of  words  and  phrases  and  their  compositionality.](https://arxiv.org/abs/1310.4546)
- [BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding](https://arxiv.org/abs/1810.04805)
- [Attention Is All You Need](https://arxiv.org/abs/1706.03762)
- [Combining IR and LDA Topic Modeling for Filtering Microblogs](https://doi.org/10.1016/j.procs.2017.08.166)
- [Deep contextualized word representations](https://arxiv.org/abs/1802.05365)
- [A vector space model for automatic indexing](https://dl.acm.org/doi/10.1145/361219.361220)
- [中文信息处理发展报告2016](https://cips-upload.bj.bcebos.com/cips2016.pdf)
- [自然语言处理中的文本表示研究](https://kns.cnki.net/kcms2/article/abstract?v=DFdco8SIy0LQDAMMFRamf5faFi2aiJvXDIJgnN-QlsWYgmnTcBjrKz4xYCV_yg5Jk_CcMOMjlaB5qd7rgvmfvxWwhJjCWFhx-_pNFBfsuAVn1biQUfVN47DzgOaAGjKP_dS-GP6aqFLBA90IRSycFg==&uniplatform=NZKPT&language=CHS)

View File

@@ -0,0 +1,377 @@
# 2.1 注意力机制
## 2.1.1 什么是注意力机制
随着 NLP 从统计机器学习向深度学习迈进,作为 NLP 核心问题的文本表示方法也逐渐从统计学习向深度学习迈进。
正如我们在第一章所介绍的,文本表示从最初的通过统计学习模型进行计算的向量空间模型、语言模型,
通过 Word2Vec 的单层神经网络进入到通过神经网络学习文本表示的时代。
但是,从 CVComputer Vision计算机视觉为起源发展起来的神经网络其核心架构有三种
- 全连接网络FNN即每一层的神经元都和上下两层的每一个神经元完全连接:
![全连接网络](./figures/1-0.png)
- 卷积神经网络CNN即训练参数量远小于全连接层的卷积层来进行特征提取和学习:
![卷积神经网络](./figures/1-1.png)
- 循环神经网络RNN能够使用历史信息作为输入、包含环和自重复的网络:
![循环神经网络](./figures/1-2.png)
由于 NLP 任务所需要处理的文本往往是序列,因此专用于处理序列、时序数据的 RNN 往往能够在 NLP 任务上取得最优的效果。
事实上在注意力机制横空出世之前RNN 以及 RNN 的衍生架构 LSTM 是 NLP 领域当之无愧的霸主。
例如,我们在第一章讲到过的开创了预训练思想的文本表示模型 ELMo就是使用的双向 LSTM 作为网络架构。
但 RNN 及 LSTM 虽然具有捕捉时序信息、适合序列生成的优点,却有两个难以弥补的缺陷:
1. 序列依序计算的模式能够很好地模拟时序信息,但限制了计算机并行计算的能力。
由于序列需要依次输入、依序计算GPU 并行计算的能力受到了极大限制,导致 RNN 为基础架构的模型虽然参数量不算特别大,
但计算时间成本却很高;
2. RNN 难以捕捉长序列的相关关系。在 RNN 架构中,距离越远的输入之间的关系就越难被捕捉,
同时 RNN 需要将整个序列读入内存依次计算,也限制了序列的长度。虽然 LSTM 中通过门机制对此进行了一定优化,
但对于较远距离相关关系的捕捉RNN 依旧是不如人意的。
针对这样的问题Vaswani 等学者参考了在 CV 领域被提出、被经常融入到 RNN 中使用的注意力机制
(注意,虽然注意力机制在 NLP 被发扬光大,但其确实是在 CV 领域被提出的),
创新性地搭建了完全由注意力机制构成的神经网络——Transformer也就是 LLM 的鼻祖及核心架构,
从而让注意力机制一跃成为深度学习最核心的架构之一。
那么究竟什么是注意力机制Attention
Attention 机制最先源于计算机视觉领域,其核心思想为当我们关注一张图片,我们往往无需看清楚全部内容而仅将注意力集中在重点部分即可。
而在自然语言处理领域,我们往往也可以通过将重点注意力集中在一个或几个 token从而取得更高效高质的计算效果。
Attention 机制有三个核心变量:**Query**(查询值)、**Key**(键值)和 **Value**(真值)。
我们可以通过一个案例来理解每一个变量所代表的含义。
例如,当我们有一篇新闻报道,我们想要找到这个报道的时间,那么,我们的 Query 可以是类似于“时间”、“日期”一类的向量
(为了便于理解,此处使用文本来表示,但其实际是稠密的向量),
Key 和 Value 会是整个文本。通过对 Query 和 Key 进行运算我们可以得到一个权重,
这个权重其实反映了从 Query 出发,对文本每一个 token 应该分布的注意力相对大小。
通过把权重和 Value 进行运算,得到的最后结果就是从 Query 出发计算整个文本注意力得到的结果。
具体而言Attention 机制的特点是通过计算 **Query** 与**Key**的相关性为真值加权求和,
从而拟合序列中每个词同其他词的相关关系。
## 2.1.2 深入理解注意力机制
刚刚我们说到,注意力机制有三个核心变量:查询值 Query键值 Key 和 真值 Value。
接下来我们以字典为例,逐步分析注意力机制的计算公式是如何得到的,从而帮助读者深入理解注意力机制。
首先,我们有这样一个字典:
```json
{
"apple":10,
"banana":5,
"chair":2
}
```
此时,字典的键就是注意力机制中的键值 Key而字典的值就是真值 Value。
字典支持我们进行精确的字符串匹配,例如,如果我们想要查找的值也就是查询值 Query 为“apple”
那么我们可以直接通过将 Query 与 Key 做匹配来得到对应的 Value。
但是,如果我们想要匹配的 Query 是一个包含多个 Key 的概念呢?
例如我们想要查找“fruit”此时我们应该将 apple 和 banana 都匹配到,但不能匹配到 chair。
因此,我们往往会选择将 Key 对应的 Value 进行组合得到最终的 Value。
例如,当我们的 Query 为“fruit”我们可以分别给三个 Key 赋予如下的权重:
```json
{
"apple":0.6,
"banana":0.4,
"chair":0
}
```
那么,我们最终查询到的值应该是:
$$value = 0.6 * 10 + 0.4 * 5 + 0 * 2 = 8$$
给不同 Key 所赋予的不同权重,就是我们所说的注意力分数,也就是为了查询到 Query我们应该赋予给每一个 Key 多少注意力。
但是,如何针对每一个 Query计算出对应的注意力分数呢
从直观上讲,我们可以认为 Key 与 Query 相关性越高,则其所应该赋予的注意力权重就越大。
但是,我们如何能够找到一个合理的、能够计算出正确的注意力分数的方法呢?
在第一章中,我们有提到词向量的概念。通过合理的训练拟合,词向量能够表征语义信息,
从而让语义相近的词在向量空间中距离更近,语义较远的词在向量空间中距离更远。
我们往往用欧式距离来衡量词向量的相似性,但我们同样也可以用点积来进行度量:
$$v·w = \sum_{i}v_iw_i$$
根据词向量的定义语义相似的两个词对应的词向量的点积应该大于0而语义不相似的词向量点积应该小于0。
那么,我们就可以用点积来计算词之间的相似度。假设我们的 Query 为“fruit”对应的词向量为 $q$
我们的 Key 对应的词向量为 $k = [v_{apple} v_{banana} v_{chair}]$,
则我们可以计算 Query 和每一个键的相似程度:
$$x = qK^T$$
此处的 K 即为将所有 Key 对应的词向量堆叠形成的矩阵。基于矩阵乘法的定义x 即为 q 与每一个 k 值的点积。
现在我们得到的 x 即反映了 Query 和每一个 Key 的相似程度,我们再通过一个 Softmax 层将其转化为和为 1 的权重:
$$softmax(x)_i = \frac{e^{xi}}{\sum_{j}e^{x_j}}$$
这样,得到的向量就能够反映 Query 和每一个 Key 的相似程度,同时又相加权重为 1也就是我们的注意力分数了。
最后,我们再将得到的注意力分数和值向量做对应乘积即可。根据上述过程,我们就可以得到注意力机制计算的基本公式:
$$attention(Q,K,V) = softmax(qK^T)v$$
不过,此时的值还是一个标量,同时,我们此次只查询了一个 Query。我们可以将值转化为维度为 $d_v$ 的向量,
同时一次性查询多个 Query同样将多个 Query 对应的词向量堆叠在一起形成矩阵 Q得到公式
$$attention(Q,K,V) = softmax(QK^T)V$$
目前,我们离标准的注意力公式还差最后一步。
在上一个公式中,如果 Q 和 K 对应的维度 $d_k$ 比较大,
softmax 放缩时就非常容易受影响,使不同值之间的差异较大,从而影响梯度的稳定性。
因此,我们要将 Q 和 K 乘积的结果做一个放缩:
$$attention(Q,K,V) = softmax(\frac{QK^T}{\sqrt{d_k}})V$$
这也就是注意力机制的核心计算公式了。
## 2.1.3 注意力机制的实现
基于上文,我们可以很简单地使用 Pytorch 来实现注意力机制的代码:
```python
'''注意力计算函数'''
def attention(query, key, value, dropout=None):
'''
args:
query: 查询值矩阵
key: 键值矩阵
value: 真值矩阵
'''
# 获取键向量的维度,键向量的维度和值向量的维度相同
d_k = query.size(-1)
# 计算Q与K的内积并除以根号dk
# transpose——相当于转置
scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
# Softmax
p_attn = scores.softmax(dim=-1)
if dropout is not None:
p_attn = dropout(p_attn)
# 采样
# 根据计算结果对value进行加权求和
return torch.matmul(p_attn, value), p_attn
```
注意,在上文代码中,我们假设输入的 q、k、v 是已经经过转化的词向量矩阵,也就是公式中的 Q、K、V。
我们仅需要通过上述几行代码,就可以实现核心的 attention 计算。
## 2.1.4 自注意力
根据上文的分析我们可以发现Attention 机制的本质是对两段序列的元素依次进行相似度计算,
寻找出一个序列的每个元素对另一个序列的每个元素的相关度,然后基于相关度进行加权,即分配注意力。
而这两段序列即是我们计算过程中 Q、K、V 的来源。
但是,在我们的实际应用中,我们往往只需要计算 Query 和 Key 之间的注意力结果,很少存在额外的真值 Value。
也就是说,我们其实只需要拟合两个文本序列。
​在经典的 Attention 机制中Q 往往来自于一个序列K 与 V 来自于另一个序列,都通过参数矩阵计算得到,从而可以拟合这两个序列之间的关系。
例如在 Transformer 的 Decoder 结构中Q 来自于 Encoder 的输出K 与 V 来自于 Decoder 的输入,
从而拟合了编码信息与历史信息之间的关系,便于综合这两种信息实现未来的预测。
​但在 Transformer 的 Encoder 结构中,使用的是 Attention 机制的变种 —— self-attention (自注意力)机制。
所谓自注意力,即是计算本身序列中每个元素都其他元素的注意力分布,
即在计算过程中Q、K、V 都由同一个输入通过不同的参数矩阵计算得到。
在 Encoder 中Q、K、V 分别是输入对参数矩阵 $W_q$、$W_k$、$W_v$ 做积得到,从而拟合输入语句中每一个 token 对其他所有 token 的关系。
通过自注意力机制,我们可以找到一段文本中每一个 token 与其他所有 token 的相关关系大小,从而建模文本之间的依赖关系。
在代码中的实现self-attention 机制其实是通过给 Q、K、V 的输入传入同一个参数实现的:
```python
# attention 为上文定义的注意力计算函数
attention(x, x, x)
```
## 2.1.5 掩码自注意力
掩码自注意力,即 Mask Self Attention是指使用注意力掩码的自注意力机制。
掩码的作用是遮蔽一些特定位置的 token模型在学习的过程中会忽略掉被遮蔽的 token。
使用注意力掩码的核心动机是让模型只能使用历史信息进行预测而不能看到未来信息。
使用注意力机制的 Transformer 模型也是通过类似于 n-gram 的语言模型任务来学习的,
也就是对一个文本序列,不断根据之前的 token 来预测下一个 token直到将整个文本序列补全。
例如,如果待学习的文本序列是 【BOS】I like you【EOS】那么模型会按如下顺序进行预测和学习
Step 1输入 【BOS】输出 I
Step 2输入 【BOS】I输出 like
Step 3输入 【BOS】I like输出 you
Step 4输入 【BOS】I like you输出 【EOS】
理论上来说,只要学习的语料足够多,通过上述的过程,模型可以学会任意一种文本序列的建模方式,也就是可以对任意的文本进行补全。
但是,我们可以发现,上述过程是一个串行的过程,也就是需要先完成 Step 1才能做 Step 2接下来逐步完成整个序列的补全。
我们在一开始就说过Transformer 相对于 RNN 的核心优势之一即在于其可以并行计算,具有更高的计算效率。
如果对于每一个训练语料,模型都需要串行完成上述过程才能完成学习,那么很明显没有做到并行计算,计算效率很低。
针对这个问题Transformer 就提出了掩码自注意力的方法。掩码自注意力会生成一串掩码,来遮蔽未来信息。
例如,我们待学习的文本序列仍然是 【BOS】I like you【EOS】我们使用的注意力掩码是【MASK】那么模型的输入为
<BOS> 【MASK】【MASK】【MASK】【MASK】
<BOS> I 【MASK】 【MASK】【MASK】
<BOS> I like 【MASK】【MASK】
<BOS> I like you 【MASK】
<BoS> I like you </EOS>
在每一行输入中,模型仍然是只看到前面的 token预测下一个 token。但是注意上述输入不再是串行的过程
而可以一起并行地输入到模型中,模型只需要每一个样本根据未被遮蔽的 token 来预测下一个 token 即可,从而实现了并行的语言模型。
观察上述的掩码,我们可以发现其实则是一个和文本序列等长的上三角矩阵。
我们可以简单地通过创建一个和输入同等长度的上三角矩阵作为注意力掩码,再使用掩码来遮蔽掉输入即可。
也就是说,当输入维度为 batch_size, seq_len, hidden_size我们的 Mask 矩阵维度一般为 (1, seq_len, seq_len)(通过广播实现同一个 batch 中不同样本的计算)。
在具体实现中,我们通过以下代码生成 Mask 矩阵:
```python
# 创建一个上三角矩阵,用于遮蔽未来信息。
# 先通过 full 函数创建一个 1 * seq_len * seq_len 的矩阵
mask = torch.full((1, args.max_seq_len, args.max_seq_len), float("-inf"))
# triu 函数的功能是创建一个上三角矩阵
mask = torch.triu(mask, diagonal=1)
```
生成的 Mask 矩阵会是一个上三角矩阵,上三角位置的元素均为 -inf其他位置的元素置为0。
在注意力计算时,我们会将计算得到的注意力分数与这个掩码做和,再进行 Softmax 操作:
```python
# 此处的 scores 为计算得到的注意力分数mask 为上文生成的掩码矩阵
scores = scores + mask[:, :seqlen, :seqlen]
scores = F.softmax(scores.float(), dim=-1).type_as(xq)
```
通过做求和,上三角区域(也就是应该被遮蔽的 token 对应的位置)的注意力分数结果都变成了 -inf而下三角区域的分数不变。
再做 Softmax 操作,-inf 的值在经过 Softmax 之后会被置为 0从而忽略了上三角区域计算的注意力分数从而实现了注意力遮蔽。
## 2.1.6 多头注意力
注意力机制可以实现并行化与长期依赖关系拟合,但一次注意力计算只能拟合一种相关关系,
单一的注意力机制很难全面拟合语句序列里的相关关系。
因此 Transformer 使用了 Multi-Head attention也就是多头注意力机制
即同时对一个语料进行多次注意力计算,每次注意力计算都能拟合不同的关系,将最后的多次结果拼接起来作为最后的输出,即可更全面深入地拟合语言信息。
在原论文中,作者也通过实验证实,多头注意力计算中,每个不同的注意力头能够拟合语句中的不同信息,如下图:
![Multihead Attention](./figures/1-3.jpeg)
​上层与下层分别是两个注意力头对同一段语句序列进行自注意力计算的结果,可以看到,
对于不同的注意力头,能够拟合不同层次的相关信息。
通过多个注意力头同时计算,能够更全面地拟合语句关系。
事实上,所谓的多头注意力机制其实就是将原始的输入序列进行多组的自注意力处理;
然后再将每一组得到的自注意力结果拼接起来,再通过一个线性层进行处理,得到最终的输出。
我们用公式可以表示为:
$$
\mathrm{MultiHead}(Q, K, V) = \mathrm{Concat}(\mathrm{head_1}, ...,
\mathrm{head_h})W^O \\
\text{where}~\mathrm{head_i} = \mathrm{Attention}(QW^Q_i, KW^K_i, VW^V_i)
$$
其最直观的代码实现并不复杂,即 n 个头就有 n 组3个参数矩阵每一组进行同样的注意力计算
但由于是不同的参数矩阵从而通过反向传播实现了不同的注意力结果,然后将 n 个结果拼接起来输出即可。
但上述实现时空复杂度均较高,我们可以通过矩阵运算巧妙地实现并行的多头计算,
其核心逻辑在于使用三个组合矩阵来代替了n个参数矩阵的组合也就是矩阵内积再拼接其实等同于拼接矩阵再内积。
具体实现可以参考下列代码:
```python
import torch.nn as nn
import torch
'''多头自注意力计算模块'''
class MultiHeadAttention(nn.Module):
def __init__(self, args: ModelArgs, is_causal=False):
# 构造函数
# args: 配置对象
super().__init__()
# 隐藏层维度必须是头数的整数倍,因为后面我们会将输入拆成头数个矩阵
assert args.n_embd % args.n_head == 0
# 模型并行处理大小默认为1。
model_parallel_size = 1
# 本地计算头数,等于总头数除以模型并行处理大小。
self.n_local_heads = args.n_heads // model_parallel_size
# 每个头的维度,等于模型维度除以头的总数。
self.head_dim = args.dim // args.n_heads
# Wq, Wk, Wv 参数矩阵,每个参数矩阵为 n_embd x n_embd
# 这里通过三个组合矩阵来代替了n个参数矩阵的组合其逻辑在于矩阵内积再拼接其实等同于拼接矩阵再内积
# 不理解的读者可以自行模拟一下每一个线性层其实相当于n个参数矩阵的拼接
self.wq = nn.Linear(args.dim, args.n_heads * self.head_dim, bias=False)
self.wk = nn.Linear(args.dim, args.n_heads * self.head_dim, bias=False)
self.wv = nn.Linear(args.dim, args.n_heads * self.head_dim, bias=False)
# 输出权重矩阵,维度为 n_embd x n_embdhead_dim = n_embeds / n_heads
self.wo = nn.Linear(args.n_heads * self.head_dim, args.dim, bias=False)
# 注意力的 dropout
self.attn_dropout = nn.Dropout(args.dropout)
# 残差连接的 dropout
self.resid_dropout = nn.Dropout(args.dropout)
# 创建一个上三角矩阵,用于遮蔽未来信息
# 注意因为是多头注意力Mask 矩阵比之前我们定义的多一个维度
if is_causal:
mask = torch.full((1, 1, args.max_seq_len, args.max_seq_len), float("-inf"))
mask = torch.triu(mask, diagonal=1)
# 注册为模型的缓冲区
self.register_buffer("mask", mask)
def forward(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor):
# 获取批次大小和序列长度,[batch_size, seq_len, dim]
bsz, seqlen, _ = q.shape
# 计算查询Q、键K、值V,输入通过参数矩阵层,维度为 (B, T, n_embed) x (n_embed, n_embed) -> (B, T, n_embed)
xq, xk, xv = self.wq(q), self.wk(k), self.wv(v)
# 将 Q、K、V 拆分成多头,维度为 (B, T, n_head, C // n_head),然后交换维度,变成 (B, n_head, T, C // n_head)
# 因为在注意力计算中我们是取了后两个维度参与计算
# 为什么要先按B*T*n_head*C//n_head展开再互换1、2维度而不是直接按注意力输入展开是因为view的展开方式是直接把输入全部排开
# 然后按要求构造,可以发现只有上述操作能够实现我们将每个头对应部分取出来的目标
xq = xq.view(bsz, seqlen, self.n_local_heads, self.head_dim)
xk = xk.view(bsz, seqlen, self.n_local_heads, self.head_dim)
xv = xv.view(bsz, seqlen, self.n_local_heads, self.head_dim)
xq = xq.transpose(1, 2)
xk = xk.transpose(1, 2)
xv = xv.transpose(1, 2)
# 注意力计算
# 计算 QK^T / sqrt(d_k),维度为 (B, nh, T, hs) x (B, nh, hs, T) -> (B, nh, T, T)
scores = torch.matmul(xq, xk.transpose(2, 3)) / math.sqrt(self.head_dim)
# 掩码自注意力必须有注意力掩码
if self.is_causal:
assert hasattr(self, 'mask')
# 这里截取到序列长度,因为有些序列可能比 max_seq_len 短
scores = scores + self.mask[:, :, :seqlen, :seqlen]
# 计算 softmax维度为 (B, nh, T, T)
scores = F.softmax(scores.float(), dim=-1).type_as(xq)
# 做 Dropout
scores = self.attn_dropout(scores)
# V * Score维度为(B, nh, T, T) x (B, nh, T, hs) -> (B, nh, T, hs)
output = torch.matmul(scores, xv)
# 恢复时间维度并合并头。
# 将多头的结果拼接起来, 先交换维度为 (B, T, n_head, C // n_head),再拼接成 (B, T, n_head * C // n_head)
# contiguous 函数用于重新开辟一块新内存存储因为Pytorch设置先transpose再view会报错
# 因为view直接基于底层存储得到然而transpose并不会改变底层存储因此需要额外存储
output = output.transpose(1, 2).contiguous().view(bsz, seqlen, -1)
# 最终投影回残差流。
output = self.wo(output)
output = self.resid_dropout(output)
return output
```
**参考文献**
1. [Attention is all you need](https://arxiv.org/abs/1706.03762)
2. [An Intuition for Attention](https://jaykmody.com/blog/attention-intuition/)

View File

@@ -0,0 +1,223 @@
# 2.2 Encoder-Decoder
在上一节,我们详细介绍了 Transformer 的核心——Attention 机制。在《Attention is All You Need》一文中作者通过仅使用 Attention 而抛弃传统的 RNN、CNN 架构搭建出 Transformer 模型,从而带来了 NLP 领域的大变革。在 Transformer 中,使用 Attention 机制的是其两个核心组件——Encoder编码器和 Decoder解码器。事实上后续基于 Transformer 架构而来的预训练语言模型基本都是对 Encoder-Decoder 部分进行改进来构建新的模型架构,例如只使用 Encoder 的 BERT、只使用 Decoder 的 GPT 等。
在本节中,我们将以上一节所介绍的 Attention 机制为基础,从 Transformer 所针对的 Seq2Seq 任务出发,解析 Transformer 的 Encoder-Decoder 结构。
## 2.2.1 Seq2Seq 模型
Seq2Seq即序列到序列是一种经典 NLP 任务。具体而言,是指模型输入的是一个自然语言序列 $input = (x_1, x_2, x_3...x_n)$,输出的是一个可能不等长的自然语言序列 $output = (y_1, y_2, y_3...y_m)$。事实上Seq2Seq 是 NLP 最经典的任务,几乎所有的 NLP 任务都可以视为 Seq2Seq 任务。例如文本分类任务,可以视为输出长度为 1 的目标序列(如在上式中 $m$ = 1词性标注任务可以视为输出与输入序列等长的目标序列如在上式中 $m$ = $n$)。
机器翻译任务即是一个经典的 Seq2Seq 任务例如我们的输入可能是“今天天气真好”输出是“Today is a good day.”。Transformer 是一个经典的 Seq2Seq 模型即模型的输入为文本序列输出为另一个文本序列。事实上Transformer 一开始正是应用在机器翻译任务上的。
对于 Seq2Seq 任务,一般的思路是对自然语言序列进行编码再解码。所谓编码,就是将输入的自然语言序列通过隐藏层编码成能够表征语义的向量(或矩阵),可以简单理解为更复杂的词向量表示。而解码,就是对输入的自然语言序列编码得到的向量或矩阵通过隐藏层输出,再解码成对应的自然语言目标序列。通过编码再解码,就可以实现 Seq2Seq 任务。
Transformer 中的 Encoder就是用于上述的编码过程Decoder 则用于上述的解码过程。Transformer 结构如下图:
![Encoder-Decoder](./figures/2-0.jpg)
Transformer 由 Encoder 和 Decoder 组成,每一个 EncoderDecoder又由 6个 EncoderDecoderLayer 组成。输入源序列会进入 Encoder 进行编码,到 Encoder Layer 的最顶层再将编码结果输出给 Decoder Layer 的每一层,通过 Decoder 解码后就可以得到输出目标序列了。
接下来,我们将首先介绍 Encoder 和 Decoder 内部传统神经网络的经典结构——全连接网络FNN、层归一化Layer Norm和残差连接Residual Connection然后进一步分析 Encoder 和 Decoder 的内部结构。
## 2.2.2 全连接网络
全连接网络Full Neural Network下简称 FNN也就是我们在上一节提过的每一层的神经元都和上下两层的每一个神经元完全连接的网络结构。每一个 Encoder Layer 都包含一个上文讲的注意力机制和一个全连接层。全连接层的实现是较为简单的:
```python
class MLP(nn.Module):
'''全连接层'''
def __init__(self, dim: int, hidden_dim: int, dropout: float):
super().__init__()
# 定义第一层线性变换,从输入维度到隐藏维度
self.w1 = nn.Linear(dim, hidden_dim, bias=False)
# 定义第二层线性变换,从隐藏维度到输入维度
self.w2 = nn.Linear(hidden_dim, dim, bias=False)
# 定义dropout层用于防止过拟合
self.dropout = nn.Dropout(dropout)
def forward(self, x):
# 前向传播函数
# 首先输入x通过第一层线性变换和RELU激活函数
# 然后结果乘以输入x通过第三层线性变换的结果
# 最后通过第二层线性变换和dropout层
return self.dropout(self.w2(F.relu(self.w1(x))))
```
注意Transformer 的全连接层是由两个线性层中间加一个 RELU 激活函数组成的,以及全连接层还加入了一个 Dropout 层来防止过拟合。
## 2.2.3 层归一化
层归一化,也就是 Layer Norm是深度学习中经典的归一化操作。神经网络主流的归一化一般有两种批归一化Batch Norm和层归一化Layer Norm
归一化核心是为了让不同层输入的取值范围或者分布能够比较一致。由于深度神经网络中每一层的输入都是上一层的输出,因此多层传递下,对网络中较高的层,之前的所有神经层的参数变化会导致其输入的分布发生较大的改变。也就是说,随着神经网络参数的更新,各层的输出分布是不相同的,且差异会随着网络深度的增大而增大。但是,需要预测的条件分布始终是相同的,从而也就造成了预测的误差。
因此,在深度神经网络中,往往需要归一化操作,将每一层的输入都归一化成标准正态分布。批归一化是指在一个 mini-batch 上进行归一化,相当于对一个 batch 对样本拆分出来一部分,首先计算样本的均值:
$$\mu_j = \frac{1}{m}\sum^{m}_{i=1}Z_j^{i}$$
其中,$Z_j^{i}$ 是样本 i 在第 j 个维度上的值m 就是 mini-batch 的大小。
再计算样本的方差:
$$\sigma^2 = \frac{1}{m}\sum^{m}_{i=1}(Z_j^i - \mu_j)^2$$
最后,对每个样本的值减去均值再除以标准差来将这一个 mini-batch 的样本的分布转化为标准正态分布:
$$\widetilde{Z_j} = \frac{Z_j - \mu_j}{\sqrt{\sigma^2 + \epsilon}}$$
此处加上 $\epsilon$ 这一极小量是为了避免分母为0。
但是,批归一化存在一些缺陷,例如:
- 当显存有限mini-batch 较小时Batch Norm 取的样本的均值和方差不能反映全局的统计分布信息,从而导致效果变差;
- 对于在时间维度展开的 RNN不同句子的同一分布大概率不同所以 Batch Norm 的归一化会失去意义;
- 在训练时Batch Norm 需要保存每个 step 的统计信息(均值和方差)。在测试时,由于变长句子的特性,测试集可能出现比训练集更长的句子,所以对于后面位置的 step是没有训练的统计量使用的
- 应用 Batch Norm每个 step 都需要去保存和计算 batch 统计量,耗时又耗力
因此出现了在深度神经网络中更常用、效果更好的层归一化Layer Norm。相较于 Batch Norm 在每一层统计所有样本的均值和方差Layer Norm 在每个样本上计算其所有层的均值和方差从而使每个样本的分布达到稳定。Layer Norm 的归一化方式其实和 Batch Norm 是完全一样的,只是统计统计量的维度不同。
基于上述进行归一化的公式,我们可以简单地实现一个 Layer Norm 层:
```python
class LayerNorm(nn.Module):
''' Layer Norm 层'''
def __init__(self, features, eps=1e-6):
super(LayerNorm, self).__init__()
# 线性矩阵做映射
self.a_2 = nn.Parameter(torch.ones(features))
self.b_2 = nn.Parameter(torch.zeros(features))
self.eps = eps
def forward(self, x):
# 在统计每个样本所有维度的值,求均值和方差
mean = x.mean(-1, keepdim=True) # mean: [bsz, max_len, 1]
std = x.std(-1, keepdim=True) # std: [bsz, max_len, 1]
# 注意这里也在最后一个维度发生了广播
return self.a_2 * (x - mean) / (std + self.eps) + self.b_2
```
注意,在我们上文实现的 Layer Norm 层中,有两个线性矩阵进行映射。
## 2.2.4 残差连接
由于 Transformer 模型结构较复杂、层数较深为了避免模型退化Transformer 采用了残差连接的思想来连接每一个子层。残差连接,即下一层的输入不仅是上一层的输出,还包括上一层的输入。残差连接允许最底层信息直接传到最高层,让高层专注于残差的学习。
​例如,在 Encoder 中,在第一个子层,输入进入多头自注意力层的同时会直接传递到该层的输出,然后该层的输出会与原输入相加,再进行标准化。在第二个子层也是一样。即:
$$
x = x + MultiHeadSelfAttention(LayerNorm(x))
$$
$$
output = x + FNN(LayerNorm(x))
$$
我们在代码实现中,通过在层的 forward 计算中加上原值来实现残差连接:
```python
# 注意力计算
h = x + self.attention.forward(self.attention_norm(x))
# 经过全连接网络
out = h + self.feed_forward.forward(self.fnn_norm(h))
```
在上文代码中self.attention_norm 和 self.fnn_norm 都是 LayerNorm 层self.attn 是注意力层,而 self.feed_forward 是全连接层。
## 2.2.5 Encoder
在实现上述组件之后,我们可以搭建起 Transformer 的 Encoder。Encoder 由 N 个 Encoder Layer 组成,每一个 Encoder Layer 包括一个注意力层和一个全连接层。因此,我们可以首先实现一个 Encoder Layer
```python
class EncoderLayer(nn.Module):
'''Encoder层'''
def __init__(self, config):
super().__init__()
# 一个 Layer 中有两个 LayerNorm分别在 Attention 之前和 MLP 之前
self.attention_norm = LayerNorm(args.n_embd)
# Encoder 不需要掩码,传入 is_causal=False
self.attention = MultiHeadAttention(args, is_causal=False)
self.fnn_norm = LayerNorm(args.n_embd)
self.feed_forward = MLP(args)
def forward(self, x):
# Layer Norm
x = self.attention_norm(x)
# 自注意力
h = x + self.attention.forward(x, x, x)
# 经过全连接网络
out = h + self.feed_forward.forward(self.fnn_norm(h))
return out
```
然后我们搭建一个 Encoder由 N 个 Encoder Layer 组成,在最后会加入一个 Layer Norm 实现规范化:
```python
class Encoder(nn.Module):
'''Encoder 块'''
def __init__(self, args):
super(Encoder, self).__init__()
# 一个 Encoder 由 N 个 Encoder Layer 组成
self.layers = nn.ModuleList([EncoderLayer(args) for _ in range(args.n_layer)])
self.norm = LayerNorm(args.n_embd)
def forward(self, x):
"分别通过 N 层 Encoder Layer"
for layer in self.layers:
x = layer(x)
return self.norm(x)
```
通过 Encoder 的输出,就是输入编码之后的结果。
## 2.2.6 Decoder
类似的,我们也可以先搭建 Decoder Layer再将 N 个 Decoder Layer 组装为 Decoder。但是和 Encoder 不同的是Decoder 由两个注意力层和一个全连接层组成。第一个注意力层是一个掩码自注意力层,即使用 Mask 的注意力计算,保证每一个 token 只能使用该 token 之前的注意力分数;第二个注意力层是一个多头注意力层,该层将使用第一个注意力层的输出作为 query使用 Encoder 的输出作为 key 和 value来计算注意力分数。最后再经过全连接层
```python
class DecoderLayer(nn.Module):
'''解码层'''
def __init__(self, args):
super().__init__()
# 一个 Layer 中有三个 LayerNorm分别在 Mask Attention 之前、Self Attention 之前和 MLP 之前
self.attention_norm_1 = LayerNorm(args.n_embd)
# Decoder 的第一个部分是 Mask Attention传入 is_causal=True
self.mask_attention = MultiHeadAttention(args, is_causal=True)
self.attention_norm_2 = LayerNorm(args.n_embd)
# Decoder 的第二个部分是 类似于 Encoder 的 Attention传入 is_causal=False
self.attention = MultiHeadAttention(args, is_causal=False)
self.ffn_norm = LayerNorm(args.n_embd)
# 第三个部分是 MLP
self.feed_forward = MLP(config)
def forward(self, x, enc_out):
# Layer Norm
x = self.attention_norm_1(x)
# 掩码自注意力
x = x + self.mask_attention.forward(x, x, x)
# 多头注意力
x = self.attention_norm_2(x)
h = x + self.attention.forward(x, enc_out, enc_out)
# 经过全连接网络
out = h + self.feed_forward.forward(self.fnn_norm(h))
return out
```
然后同样的,我们搭建一个 Decoder 块:
```python
class Decoder(nn.Module):
'''解码器'''
def __init__(self, args):
super(Decoder, self).__init__()
# 一个 Decoder 由 N 个 Decoder Layer 组成
self.layers = nn.ModuleList([DecoderLayer(args) for _ in range(args.n_layer)])
self.norm = LayerNorm(args.n_embd)
def forward(self, x, enc_out):
"Pass the input (and mask) through each layer in turn."
for layer in self.layers:
x = layer(x, enc_out)
return self.norm(x)
```
完成上述 Encoder、Decoder 的搭建,就完成了 Transformer 的核心部分,接下来将 Encoder、Decoder 拼接起来再加入 Embedding 层就可以搭建出完整的 Transformer 模型啦。

View File

@@ -0,0 +1,274 @@
# 2.3 搭建一个 Transformer
在前两章,我们分别深入剖析了 Attention 机制和 Transformer 的核心——Encoder、Decoder 结构,接下来,我们就可以基于上一章实现的组件,搭建起一个完整的 Transformer 模型。
## 2.3.1 Embeddng 层
正如我们在第一章所讲过的,在 NLP 任务中,我们往往需要将自然语言的输入转化为机器可以处理的向量。在深度学习中,承担这个任务的组件就是 Embedding 层。
Embedding 层其实是一个存储固定大小的词典的嵌入向量查找表。也就是说,在输入神经网络之前,我们往往会先让自然语言输入通过分词器 tokenizer分词器的作用是把自然语言输入切分成 token 并转化成一个固定的 index。例如如果我们将词表大小设为 4输入“我喜欢你”那么分词器可以将输入转化成
input: 我
output: 0
input: 喜欢
output: 1
input
output: 2
当然在实际情况下tokenizer 的工作会比这更复杂。例如,分词有多种不同的方式,可以切分成词、切分成子词、切分成字符等,而词表大小则往往高达数万数十万。此处我们不赘述 tokenizer 的详细情况,在后文会详细介绍大模型的 tokenizer 是如何运行和训练的。
因此Embedding 层的输入往往是一个形状为 batch_sizeseq_len1的矩阵第一个维度是一次批处理的数量第二个维度是自然语言序列的长度第三个维度则是 token 经过 tokenizer 转化成的 index 值。例如对上述输入Embedding 层的输入会是:
[[0,1,2]]
其 batch_size 为1seq_len 为3转化出来的 index 如上。
而 Embedding 内部其实是一个可训练的Vocab_sizeembedding_dim的权重矩阵词表里的每一个值都对应一行维度为 embedding_dim 的向量。对于输入的值会对应到这个词向量然后拼接成batch_sizeseq_lenembedding_dim的矩阵输出。
上述实现并不复杂,我们可以直接使用 torch 中的 Embedding 层:
```python
self.tok_embeddings = nn.Embedding(args.vocab_size, args.dim)
```
## 2.3.2 位置编码
Attention 机制可以实现良好的并行计算,但同时,其注意力计算的方式也导致序列中相对位置的丢失。在 RNN、LSTM 中,输入序列会沿着语句本身的顺序被依次递归处理,因此输入序列的顺序提供了极其重要的信息,这也和自然语言的本身特性非常吻合。
但从上文对 Attention 机制的分析我们可以发现,在 Attention 机制的计算过程中,对于序列中的每一个 token其他各个位置对其来说都是平等的即“我喜欢你”和“你喜欢我”在 Attention 机制看来是完全相同的,但无疑这是 Attention 机制存在的一个巨大问题。因此为使用序列顺序信息保留序列中的相对位置信息Transformer 采用了位置编码机制,该机制也在之后被多种模型沿用。
​位置编码,即根据序列中 token 的相对位置对其进行编码再将位置编码加入词向量编码中。位置编码的方式有很多Transformer 使用了正余弦函数来进行位置编码绝对位置编码Sinusoidal其编码方式为
$$
PE(pos, 2i) = sin(pos/10000^{2i/d_{model}})\\
PE(pos, 2i+1) = cos(pos/10000^{2i/d_{model}})
$$
上式中pos 为 token 在句子中的位置2i 和 2i+1 则是指示了 token 是奇数位置还是偶数位置,从上式中我们可以看出对于奇数位置的 token 和偶数位置的 tokenTransformer 采用了不同的函数进行编码。
我们以一个简单的例子来说明位置编码的计算过程:假如我们输入的是一个长度为 4 的句子"I like to code",我们可以得到下面的词向量矩阵$\rm x$,其中每一行代表的就是一个词向量,$\rm x_0=[0.1,0.2,0.3,0.4]$对应的就是“I”的词向量它的pos就是为0以此类推第二行代表的是“like”的词向量它的pos就是1
$$
\rm x = \begin{bmatrix} 0.1 & 0.2 & 0.3 & 0.4 \\ 0.2 & 0.3 & 0.4 & 0.5 \\ 0.3 & 0.4 & 0.5 & 0.6 \\ 0.4 & 0.5 & 0.6 & 0.7 \end{bmatrix}
$$
​则经过位置编码后的词向量为:
$$
\rm x_{PE} = \begin{bmatrix} 0.1 & 0.2 & 0.3 & 0.4 \\ 0.2 & 0.3 & 0.4 & 0.5 \\ 0.3 & 0.4 & 0.5 & 0.6 \\ 0.4 & 0.5 & 0.6 & 0.7 \end{bmatrix} + \begin{bmatrix} \sin(\frac{0}{10000^0}) & \cos(\frac{0}{10000^0}) & \sin(\frac{0}{10000^{2/4}}) & \cos(\frac{0}{10000^{2/4}}) \\ \sin(\frac{1}{10000^0}) & \cos(\frac{1}{10000^0}) & \sin(\frac{1}{10000^{2/4}}) & \cos(\frac{1}{10000^{2/4}}) \\ \sin(\frac{2}{10000^0}) & \cos(\frac{2}{10000^0}) & \sin(\frac{2}{10000^{2/4}}) & \cos(\frac{2}{10000^{2/4}}) \\ \sin(\frac{3}{10000^0}) & \cos(\frac{3}{10000^0}) & \sin(\frac{3}{10000^{2/4}}) & \cos(\frac{3}{10000^{2/4}}) \end{bmatrix} = \begin{bmatrix} 0.1 & 1.2 & 0.3 & 1.4 \\ 1.041 & 0.84 & 0.41 & 1.49 \\ 1.209 & -0.016 & 0.52 & 1.59 \\ 0.541 & -0.489 & 0.895 & 1.655 \end{bmatrix}
$$
我们可以使用如下的代码来获取上述例子的位置编码:
```python
import numpy as np
import matplotlib.pyplot as plt
def PositionEncoding(seq_len, d_model, n=10000):
P = np.zeros((seq_len, d_model))
for k in range(seq_len):
for i in np.arange(int(d_model/2)):
denominator = np.power(n, 2*i/d_model)
P[k, 2*i] = np.sin(k/denominator)
P[k, 2*i+1] = np.cos(k/denominator)
return P
P = PositionEncoding(seq_len=4, d_model=4, n=100)
print(P)
```
```python
[[ 0. 1. 0. 1. ]
[ 0.84147098 0.54030231 0.09983342 0.99500417]
[ 0.90929743 -0.41614684 0.19866933 0.98006658]
[ 0.14112001 -0.9899925 0.29552021 0.95533649]]
```
这样的位置编码主要有两个好处:
1. 使 PE 能够适应比训练集里面所有句子更长的句子,假设训练集里面最长的句子是有 20 个单词,突然来了一个长度为 21 的句子,则使用公式计算的方法可以计算出第 21 位的 Embedding。
2. 可以让模型容易地计算出相对位置,对于固定长度的间距 kPE(pos+k) 可以用 PE(pos) 计算得到。因为 Sin(A+B) = Sin(A)Cos(B) + Cos(A)Sin(B), Cos(A+B) = Cos(A)Cos(B) - Sin(A)Sin(B)。
我们也可以通过严谨的数学推导证明该编码方式的优越性。原始的 Transformer Embedding 可以表示为:
$$\begin{equation}f(\cdots,\boldsymbol{x}_m,\cdots,\boldsymbol{x}_n,\cdots)=f(\cdots,\boldsymbol{x}_n,\cdots,\boldsymbol{x}_m,\cdots)\end{equation}
$$
很明显,这样的函数是不具有不对称性的,也就是无法表征相对位置信息。我们想要得到这样一种编码方式:
$$\begin{equation}\tilde{f}(\cdots,\boldsymbol{x}_m,\cdots,\boldsymbol{x}_n,\cdots)=f(\cdots,\boldsymbol{x}_m + \boldsymbol{p}_m,\cdots,\boldsymbol{x}_n + \boldsymbol{p}_n,\cdots)\end{equation}
$$
这里加上的 $p_m$$p_n$ 就是位置编码。接下来我们将 $f(...,x_m+p_m,...,x_n+p_n)$ 在 m,n 两个位置上做泰勒展开:
$$\begin{equation}\tilde{f}\approx f + \boldsymbol{p}_m^{\top} \frac{\partial f}{\partial \boldsymbol{x}_m} + \boldsymbol{p}_n^{\top} \frac{\partial f}{\partial \boldsymbol{x}_n} + \frac{1}{2}\boldsymbol{p}_m^{\top} \frac{\partial^2 f}{\partial \boldsymbol{x}_m^2}\boldsymbol{p}_m + \frac{1}{2}\boldsymbol{p}_n^{\top} \frac{\partial^2 f}{\partial \boldsymbol{x}_n^2}\boldsymbol{p}_n + \underbrace{\boldsymbol{p}_m^{\top} \frac{\partial^2 f}{\partial \boldsymbol{x}_m \partial \boldsymbol{x}_n}\boldsymbol{p}_n}_{\boldsymbol{p}_m^{\top} \boldsymbol{\mathcal{H}} \boldsymbol{p}_n}\end{equation}$$
可以看到第1项与位置无关25项仅依赖单一位置第6项f 分别对 m、n 求偏导)与两个位置有关,所以我们希望第六项($p_m^THp_n$)表达相对位置信息,即求一个函数 g 使得
$$p_m^THp_n = g(m-n)$$
我们假设 $H$ 是一个单位矩阵,则:
$$p_m^THp_n = p_m^Tp_n = \langle\boldsymbol{p}_m, \boldsymbol{p}_n\rangle = g(m-n)$$
通过将向量 [x,y] 视为复数 x+yi基于复数的运算法则构建方程:
$$\begin{equation}\langle\boldsymbol{p}_m, \boldsymbol{p}_n\rangle = \text{Re}[\boldsymbol{p}_m \boldsymbol{p}_n^*]\end{equation}$$
再假设存在复数 $q_{m-n}$ 使得:
$$\begin{equation}\boldsymbol{p}_m \boldsymbol{p}_n^* = \boldsymbol{q}_{m-n}\end{equation}$$
使用复数的指数形式求解这个方程,得到二维情形下位置编码的解:
$$\begin{equation}\boldsymbol{p}_m = e^{\text{i}m\theta}\quad\Leftrightarrow\quad \boldsymbol{p}_m=\begin{pmatrix}\cos m\theta \\ \sin m\theta\end{pmatrix}\end{equation}$$
由于内积满足线性叠加性,所以更高维的偶数维位置编码,我们可以表示为多个二维位置编码的组合:
$$\begin{equation}\boldsymbol{p}_m = \begin{pmatrix}e^{\text{i}m\theta_0} \\ e^{\text{i}m\theta_1} \\ \vdots \\ e^{\text{i}m\theta_{d/2-1}}\end{pmatrix}\quad\Leftrightarrow\quad \boldsymbol{p}_m=\begin{pmatrix}\cos m\theta_0 \\ \sin m\theta_0 \\ \cos m\theta_1 \\ \sin m\theta_1 \\ \vdots \\ \cos m\theta_{d/2-1} \\ \sin m\theta_{d/2-1} \end{pmatrix}\end{equation}
$$
再取 $\theta_i = 10000^{-2i/d}$(该形式可以使得随着|mn|的增大⟨pm,pn⟩有着趋于零的趋势这一点可以通过对位置编码做积分来证明而 base 取为 10000 是实验结果),就得到了上文的编码方式。
当 $H$ 不是一个单位矩阵时,因为模型的 Embedding 层所形成的 d 维向量之间任意两个维度的相关性比较小,满足一定的解耦性,我们可以将其视作对角矩阵,那么使用上述编码:
$$\begin{equation}\boldsymbol{p}_m^{\top} \boldsymbol{\mathcal{H}} \boldsymbol{p}_n=\sum_{i=1}^{d/2} \boldsymbol{\mathcal{H}}_{2i,2i} \cos m\theta_i \cos n\theta_i + \boldsymbol{\mathcal{H}}_{2i+1,2i+1} \sin m\theta_i \sin n\theta_i\end{equation}
$$
通过积化和差:
$$\begin{equation}\sum_{i=1}^{d/2} \frac{1}{2}\left(\boldsymbol{\mathcal{H}}_{2i,2i} + \boldsymbol{\mathcal{H}}_{2i+1,2i+1}\right) \cos (m-n)\theta_i + \frac{1}{2}\left(\boldsymbol{\mathcal{H}}_{2i,2i} - \boldsymbol{\mathcal{H}}_{2i+1,2i+1}\right) \cos (m+n)\theta_i \end{equation}
$$
说明该编码仍然可以表示相对位置。
上述​编码结果示例如下:
![Positional Embedding](./figures/3-0.png)
基于上述原理,我们实现一个​位置编码层:
```python
class PositionalEncoding(nn.Module):
'''位置编码模块'''
def __init__(self, args):
super(PositionalEncoding, self).__init__()
# Dropout 层
self.dropout = nn.Dropout(p=args.dropout)
# block size 是序列的最大长度
pe = torch.zeros(args.block_size, args.n_embd)
position = torch.arange(0, args.block_size).unsqueeze(1)
# 计算 theta
div_term = torch.exp(
torch.arange(0, args.n_embd, 2) * -(math.log(10000.0) / args.n_embd)
)
# 分别计算 sin、cos 结果
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0)
self.register_buffer("pe", pe)
def forward(self, x):
# 将位置编码加到 Embedding 结果上
x = x + self.pe[:, : x.size(1)].requires_grad_(False)
return self.dropout(x)
```
## 2.3.3 一个完整的 Transformer
上述所有组件,再按照下图的 Tranfromer 结构拼接起来就是一个完整的 Transformer 模型啦:
![Transformer 结构](./figures/3-1.png)
如图,经过 tokenizer 映射后的输出先经过 Embedding 层和 Positional Embedding 层编码,然后进入上一节讲过的 N 个 Encoder 和 N 个 Decoder在 Transformer 原模型中N 取为6最后经过一个线性层和一个 Softmax 层就得到了最终输出。
基于之前所实现过的组件,我们实现完整的 Transformer 模型:
```python
class Transformer(nn.Module):
'''整体模型'''
def __init__(self, args):
super().__init__()
# 必须输入词表大小和 block size
assert args.vocab_size is not None
assert args.block_size is not None
self.args = args
self.transformer = nn.ModuleDict(dict(
wte = nn.Embedding(args.vocab_size, args.n_embd),
wpe = PositionalEncoding(args),
drop = nn.Dropout(args.dropout),
encoder = Encoder(args),
decoder = Decoder(args),
))
# 最后的线性层,输入是 n_embd输出是词表大小
self.lm_head = nn.Linear(args.n_embd, args.vocab_size, bias=False)
# 初始化所有的权重
self.apply(self._init_weights)
# 查看所有参数的数量
print("number of parameters: %.2fM" % (self.get_num_params()/1e6,))
'''统计所有参数的数量'''
def get_num_params(self, non_embedding=False):
# non_embedding: 是否统计 embedding 的参数
n_params = sum(p.numel() for p in self.parameters())
# 如果不统计 embedding 的参数,就减去
if non_embedding:
n_params -= self.transformer.wpe.weight.numel()
return n_params
'''初始化权重'''
def _init_weights(self, module):
# 线性层和 Embedding 层初始化为正则分布
if isinstance(module, nn.Linear):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
torch.nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
'''前向计算函数'''
def forward(self, idx, targets=None):
# 输入为 idx维度为 (batch size, sequence length, 1)targets 为目标序列,用于计算 loss
device = idx.device
b, t = idx.size()
assert t <= self.config.block_size, f"不能计算该序列,该序列长度为 {t}, 最大序列长度只有 {self.config.block_size}"
# 通过 self.transformer
# 首先将输入 idx 通过 Embedding 层,得到维度为 (batch size, sequence length, n_embd)
print("idx",idx.size())
# 通过 Embedding 层
tok_emb = self.transformer.wte(idx)
print("tok_emb",tok_emb.size())
# 然后通过位置编码
pos_emb = self.transformer.wpe(tok_emb)
# 再进行 Dropout
x = self.transformer.drop(pos_emb)
# 然后通过 Encoder
print("x after wpe:",x.size())
enc_out = self.transformer.encoder(x)
print("enc_out:",enc_out.size())
# 再通过 Decoder
x = self.transformer.decoder(x, enc_out)
print("x after decoder:",x.size())
if targets is not None:
# 训练阶段,如果我们给了 targets就计算 loss
# 先通过最后的 Linear 层,得到维度为 (batch size, sequence length, vocab size)
logits = self.lm_head(x)
# 再跟 targets 计算交叉熵
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1)
else:
# 推理阶段,我们只需要 logitsloss 为 None
# 取 -1 是只取序列中的最后一个作为输出
logits = self.lm_head(x[:, [-1], :]) # note: using list [-1] to preserve the time dim
loss = None
return logits, loss
```
注意,上述代码除去搭建了整个 Transformer 结构外,我们还额外实现了三个函数:
- get_num_params用于统计模型的参数量
- _init_weights用于对模型所有参数进行随机初始化
- forward前向计算函数
另外,在前向计算函数中,我们对模型使用 pytorch 的交叉熵函数来计算损失,对于不同的损失函数,读者可以查阅 Pytorch 的官方文档,此处就不再赘述了。
经过上述步骤,我们就可以从零“手搓”一个完整的、可计算的 Transformer 模型。限于本书主要聚焦在 LLM在本章我们就不再详细讲述如何训练 Transformer 模型了;在后文中,我们将类似地从零“手搓”一个 LLaMA 模型,并手把手带大家训练一个属于自己的 Tiny LLaMA。

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

113
docs/chapter3/3.1 Encoder-Only.md Executable file
View File

@@ -0,0 +1,113 @@
# 3.1 Encoder-only PLM
在上一章,我们详细讲解了给 NLP 领域带来巨大变革的 Attention 机制以及使用 Attention 机制搭建的模型 TransformerNLP 模型的里程碑式转变也就自此而始。在上文对 Transformer 的讲解中我们可以看到Transformer 主要由 Encoder、Decoder 两个部分组成,两个部分分别具有不一样的结构和输入输出。
针对 Encoder、Decoder 的特点,引入 ELMo 的预训练思路,开始出现不同的、对 Transformer 进行优化的思路。例如Google 仅选择了 Encoder 层,通过将 Encoder 层进行堆叠,再提出不同的预训练任务 MLMMask Language Model掩码语言模型打造了一统 NLUNatural Language Understanding自然语言理解任务的代表模型——BERT。而 OpenAI 则选择了 Decoder 层,使用原有的 LM语言模型任务通过不断增加模型参数和预训练语料打造了在 NLGNatural Language Generation自然语言生成任务上优势明显的 GPT 系列模型,也是现今大火的 LLM 的基座模型。当然,还有一种思路是同时保留 Encoder 与 Decoder打造预训练的 Transformer 模型,例如由 Google 发布的 T5模型。
在本章中,我们将以 Encoder-Only、Encoder-Decoder、Decoder-Only 的顺序来依次介绍 Transformer 时代的各个主流预训练模型,分别介绍三种核心的模型架构、每种主流模型选择的预训练任务及其独特优势,这也是目前所有主流 LLM 的模型基础。
## 3.1.1 BERT
BERT全名为 Bidirectional Encoder Representations from Transformers是由 Google 团队在 2018年发布的预训练语言模型。该模型发布于论文《BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding》实现了包括 GLUE、MultiNLI 等七个自然语言处理评测任务的 the-state-of-art最优分数堪称里程碑式的成果。自 BERT 推出以来,预训练+微调的模式开始成为自然语言处理任务的主流,不仅 BERT 自身在不断更新迭代提升模型性能,也出现了如 MacBERT、BART 等基于 BERT 进行优化提升的模型。可以说BERT 是自然语言处理的一个阶段性成果,标志着各种自然语言处理任务的重大进展以及预训练模型的统治地位建立,一直到 LLM 的诞生NLP 领域的主导地位才从 BERT 系模型进行迁移。即使在 LLM 时代,要深入理解 LLM 与 NLPBERT 也是无法绕过的一环。
### 1思想沿承
BERT 是一个统一了多种思想的预训练模型。其所沿承的核心思想包括:
- Transformer 架构。正如我们在上一章所介绍的,在 2017年发表的《Attention is All You Need》论文提出了完全使用 Attention 机制而抛弃 RNN、LSTM 结构的 Transformer 模型带来了新的模型架构。BERT 正沿承了 Transformer 的思想,在 Transformer 的模型基座上进行优化,通过将 Encoder 结构进行堆叠,扩大模型参数,打造了在 NLU 任务上独居天分的模型架构;
- 预训练+微调范式。同样在 2018年ELMo 的诞生标志着预训练+微调范式的诞生。ELMo 模型基于双向 LSTM 架构,在训练数据上基于语言模型进行预训练,再针对下游任务进行微调,表现出了更加优越的性能,将 NLP 领域导向预训练+微调的研究思路。而 BERT 也采用了该范式,并通过将模型架构调整为 Transformer引入更适合文本理解、能捕捉深层双向语义关系的预训练任务 MLM将预训练-微调范式推向了高潮。
接下来,我们将从模型架构、预训练任务以及下游任务微调三个方面深入剖析 BERT分析 BERT 的核心思路及优势,帮助大家理解 BERT 为何能够具备远超之前模型的性能,也从而更加深刻地理解 LLM 如何能够战胜 BERT 揭开新时代的大幕。
### 2模型架构——Encoder Only
BERT 的模型架构是取了 Transformer 的 Encoder 部分堆叠而成,其主要结构如下图:
![](./figures/1-0.png)
BERT 是针对于 NLU 任务打造的预训练模型,其输入一般是文本序列,而输出一般是 Label例如情感分类的积极、消极 Label。但是正如 Transformer 是一个 Seq2Seq 模型,使用 Encoder 堆叠而成的 BERT 本质上也是一个 Seq2Seq 模型,只是没有加入对特定任务的 Decoder因此为适配各种 NLU 任务,在模型的最顶层加入了一个分类头 prediction_heads用于将多维度的隐藏状态通过线性层转换到分类维度例如如果一共有两个类别prediction_heads 输出的就是两维向量)。
模型整体既是由 Embedding、Encoder 加上 prediction_heads 组成:
![](./figures/1-1.png)
输入的文本序列会首先通过 tokenizer 转化成 input_ids基本每一个模型在 tokenizer 的操作都类似,可以参考 Transformer 的 tokenizer 机制,后文不再赘述),然后进入 Embedding 层转化为特定维度的 hidden_states再经过 Encoder 块。Encoder 块中是对叠起来的 N 层 Encoder LayerBERT 有两种规模的模型,分别是 base 版本12层 Encoder Layer768 的隐藏层维度,总参数量 110Mlarge 版本24层 Encoder Layer1024 的隐藏层维度,总参数量 340M。通过Encoder 编码之后的最顶层 hidden_states 最后经过 prediction_heads 就得到了最后的类别概率,经过 Softmax 计算就可以计算出模型预测的类别。
prediction_heads 其实就是线性层加上激活函数,一般而言,最后一个线性层的输出维度和任务的类别数相等:
![](./figures/1-5.png)
而每一层 Encoder Layer 都是和 Transformer 中的 Encoder Layer 结构类似的层:
![](./figures/1-2.png)
如图,已经通过 Embedding 层映射的 hidden_states 进入核心的 attention 机制,然后通过残差连接的机制和原输入相加,再经过一层 Intermediate 层得到最终输出。Intermediate 层是 BERT 的特殊称呼,其实就是一个线性层加上激活函数:
![](./figures/1-3.png)
注意BERT 所使用的激活函数是 GELU 函数,全名为高斯误差线性单元激活函数,这也是自 BERT 才开始被普遍关注的激活函数。GELU 的计算方式为:
$$GELU(x) = 0.5x(1 + tanh(\sqrt{\frac{2}{\pi}})(x + 0.044715x^3))$$
GELU 的核心思路为将随机正则的思想引入激活函数,通过输入自身的概率分布,来决定抛弃还是保留自身的神经元。关于 GELU 的原理与核心思路,此处不再赘述,有兴趣的读者可以自行学习。
BERT 的 Attention 机制和 Transformer 中 Encoder 的 Self-Attention 机制几乎完全一致,但是 BERT 将相对位置编码融合在了 Attention 机制中,将相对位置编码同样视为可训练的权重参数:
![](./figures/1-4.png)
如图BERT 的 Attention 计算过程和 Transformer 的唯一差异在于,在完成注意力分数的计算之后,先通过 Position Embedding 层来融入相对位置信息。这里的 Position Embedding 层,其实就是一层线性矩阵。通过可训练的参数来拟合相对位置,相对而言比 Transformer 使用的绝对位置编码 Sinusoidal 能够拟合更丰富的相对位置信息,但是,这样也增加了不少模型参数,同时完全无法处理超过模型训练长度的输入(例如,对 BERT 而言能处理的最大上下文长度是 512 个 token
可以看出BERT 的模型架构既是建立在 Transformer 的 Encoder 之上的,这也是为什么说 BERT 沿承了 Transformer 的思想。
### 3预训练任务——MLM + NSP
相较于基本沿承 Transformer 的模型架构BERT 更大的创新点在于其提出的两个新的预训练任务上——MLM 和 NSPNext Sentence Prediction。预训练-微调范式的核心优势在于,通过将预训练和微调分离,完成一次预训练的模型可以仅通过微调应用在几乎所有下游任务上,只要微调的成本较低,即使预训练成本是之前的数倍甚至数十倍,模型仍然有更大的应用价值。因此,可以进一步扩大模型参数和预训练数据量,使用海量的预训练语料来让模型拟合潜在语义与底层知识,从而让模型通过长时间、大规模的预训练获得强大的语言理解和生成能力。
因此,预训练数据的核心要求即是需要极大的数据规模(数亿 token。毫无疑问通过人工标注产出的全监督数据很难达到这个规模。因此预训练数据一定是从无监督的语料中获取。这也是为什么传统的预训练任务都是 LM 的原因——LM 使用上文预测下文的方式可以直接应用到任何文本中,对于任意文本,我们只需要将下文遮蔽将上文输入模型要求其预测就可以实现 LM 训练,因此互联网上所有文本语料都可以被用于预训练。
但是LM 预训练任务的一大缺陷在于,其直接拟合从左到右的语义关系,但忽略了双向的语义关系。虽然 Transformer 中通过位置编码表征了文本序列中的位置信息但这和直接拟合双向语义关系还是有本质区别。例如BiLSTM双向 LSTM 模型)在语义表征上就往往优于 LSTM 模型,就是因为 BiLSTM 通过双向的 LSTM 拟合了双向语义关系。因此,有没有一种预训练任务,能够既利用海量无监督语料,又能够训练模型拟合双向语义关系的能力?
基于这一思想Jacob 等学者提出了 MLM也就是掩码语言模型作为新的预训练任务。相较于模拟人类写作的 LMMLM 模拟的是“完形填空”。MLM 的思路也很简单,在一个文本序列中随机遮蔽部分 token然后将所有未被遮蔽的 token 输入模型,要求模型根据输入预测被遮蔽的 token。例如输入和输出可以是
输入I <MASK> you because you are <MASK>
输出:<MASK> - love; <MASK> - wonderful
由于模型可以利用被遮蔽的 token 的上文和下文一起理解语义来预测被遮蔽的 token因此通过这样的任务模型可以拟合双向语义也就能够更好地实现文本的理解。同样MLM 任务无需对文本进行任何人为的标注只需要对文本进行随机遮蔽即可因此也可以利用互联网所有文本语料实现预训练。例如BERT 的预训练就使用了足足 3300M 单词的语料。
不过MLM 也存在其固有缺陷。LM 任务模拟了人自然创作的过程,其训练和下游任务是完全一致的,也就是说,训练时是根据上文预测下文,下游任务微调和推理时也同样如此。但是 MLM 不同,在下游任务微调和推理时,其实是不存在我们人工加入的 \<MASK\> 的,我们会直接通过原文本得到对应的隐藏状态再根据下游任务进入分类器或其他组件。预训练和微调的不一致,会极大程度影响模型在下游任务微调的性能。针对这一问题,作者对 MLM 的策略进行了改进。
在具体进行 MLM 训练时,会随机选择训练语料中 15% 的 token 用于遮蔽。但是这 15% 的 token 并非全部被遮蔽为 \<MASK\>,而是有 80% 的概率被遮蔽10% 的概率被替换为任意一个 token还有 10% 的概率保持不变。其中 10% 保持不变就是为了消除预训练和微调的不一致,而 10% 的随机替换核心意义在于迫使模型保持对上下文信息的学习。因为如果全部遮蔽的话,模型仅需要处理被遮蔽的位置,从而仅学习要预测的 token 而丢失了对上下文的学习。通过引入部分随机 token模型无法确定需要预测的 token从而被迫保持每一个 token 的上下文表征分布,从而具备了对句子的特征表示能力。且由于随机 token 的概率很低,其并不会影响模型实质的语言理解能力。
除去 MLMBERT 还提出了另外一个预训练任务——NSP即下一个句子预测。NSP 的核心思想是针对句级的 NLU 任务,例如问答匹配、自然语言推理等。问答匹配是指,输入一个问题和若干个回答,要求模型找出问题的真正回答;自然语言推理是指,输入一个前提和一个推理,判断推理是否是符合前提的。这样的任务都需要模型在句级去拟合关系,判断两个句子之间的关系,而不仅是 MLM 在 token 级拟合的语义关系。因此BERT 提出了 NSP 任务来训练模型在句级的语义关系拟合。
NSP 任务的核心思路是要求模型判断一个句对的两个句子是否是连续的上下文。例如,输入和输入可以是:
输入:
Sentence AI love you.
Sentence B: Because you are wonderful.
输出:
1是连续上下文
输入:
Sentence AI love you.
Sentence B: Because today's dinner is so nice.
输出:
0不是连续上下文
通过要求模型判断句对关系,从而迫使模型拟合句子之间的关系,来适配句级的 NLU 任务。同样,由于 NSP 的正样本可以从无监督语料中随机抽取任意连续的句子,而负样本可以对句子打乱后随机抽取(只需要保证不要抽取到原本就连续的句子就行),因此也可以具有几乎无限量的训练数据。
在具体预训练时BERT 使用了 800M 的 BooksCorpus 语料和 2500M 的英文维基百科语料90% 的数据使用 128 的上下文长度训练,剩余 10% 的数据使用 512 作为上下文长度进行预训练,总共约训练了 3.3B token。可以看到相比于传统的非预训练模型其训练的数据量有指数级增长。当然更海量的训练数据需要更大成本的算力BERT 的 base 版本和 large 版本分别使用了 16块 TPU 和 64块 TPU 训练了 4天才完成。
### 4下游任务微调
作为 NLP 领域里程碑式的成果BERT 的一个重大意义就是正式确立了预训练-微调的两阶段思想,即在海量无监督语料上进行预训练来获得通用的文本理解与生成能力,再在对应的下游任务上进行微调。该种思想的一个重点在于,预训练得到的强大能力能否通过低成本的微调快速迁移到对应的下游任务上。
针对这一点BERT 设计了更通用的输入和输出层来适配多任务下的迁移学习。对每一个输入的文本序列BERT 会在其首部加入一个特殊 token /<CLS/>。在后续编码中,该 token 代表的即是整句的状态,也就是句级的语义表征。在进行 NSP 预训练时,就使用了该 token 对应的特征向量来作为最后分类器的输入。
在完成预训练后,针对每一个下游任务,只需要使用一定量的全监督人工标注数据,对预训练的 BERT 在该任务上进行微调即可。所谓微调,其实和训练时更新模型参数的策略一致,只不过在特定的任务、更少的训练数据、更小的 batch_size 上进行训练,更新参数的幅度更小。对于绝大部分下游任务,都可以直接使用 BERT 的输出。例如,对于文本分类任务,可以直接修改模型结构中的 prediction_heads 最后的分类头即可。对于序列标注等任务,可以集成 BERT 多层的隐含层向量再输出最后的标注结果。对于文本生成任务,也同样可以取 Encoder 的输出直接解码得到最终生成结果。因此BERT 可以非常高效地应用于多种 NLP 任务。
BERT 一经提出,直接在 NLP 11个赛道上取得 SOTA 效果,成为 NLU 方向上当之无愧的霸主,后续若干在 NLU 任务上取得更好效果的模型都是在 BERT 基础上改进得到的。直至 LLM 时代BERT 也仍然能在很多标注数据丰富的 NLU 任务上达到最优效果事实上对于某些特定、训练数据丰富且强调高吞吐的任务BERT 比 LLM 更具有可用性。
**参考资料**
1. [BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding](https://arxiv.org/abs/1810.04805)

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

BIN
docs/chapter3/figures/1-1.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,169 @@
# 4.1 什么是 LLM
在前三章,我们从 NLP 的定义与主要任务出发,介绍了引发 NLP 领域重大变革的核心思想——注意力机制与 Transformer 架构。
随着 Transformer 架构的横空出世NLP 领域逐步进入预训练-微调范式,
以 Transformer 为基础的、通过预训练获得强大文本表示能力的预训练语言模型
层出不穷,将 NLP 的各种经典任务都推进到了一个新的高度。
随着2022年底 ChatGPT 再一次刷新 NLP 的能力上限LLM大模型开始接替传统的 PLM 成为 NLP 的主流方向,
基于 LLM 的全新研究范式也正在刷新被 BERT 发扬光大的预训练-微调范式NLP 由此迎来又一次翻天覆地的变化。
从2022年底至今LLM 能力上限不断刷新,通用基座大模型数量指数级上升,基于 LLM 的概念、应用也是日新月异,预示着大模型时代的到来。
在第三章,我们从模型架构的角度出发,分别分析了 Encoder-Only、Encoder-Decoder 和 Decoder-Only 三种架构下的经典模型及其训练过程。
这些模型有的是 LLM 时代之前堪称时代主角的里程碑(如 BERT有的则是 LLM 时代的舞台主角,是 AGI通用人工智能 的有力竞争者。
那么,究竟什么是 LLMLLM 和传统的 PLM 的核心差异在哪里,又是什么令研究者们对 LLM 抱有如此高的热情与期待呢?
在本章中,我们将结合上文的模型架构讲解,深入分析 LLM 的定义、特点及其能力,为读者揭示 LLM 与传统深度学习模型的核心差异,
并在此基础上,展示 LLM 的实际三阶段训练过程,帮助读者从概念上厘清 LLM 是如何获得这样的独特能力的,
从而为进一步实践 LLM 完整训练提供理论基础。
## 4.1.1 LLM 的定义
LLM即 Large Language Model中文名为大语言模型或大型语言模型
是一种相较传统语言模型参数量更多、在更大规模语料上进行预训练的语言模型。
在第一章中我们已经介绍了语言模型LM的概念即通过预测下一个 token 任务来训练的 NLP 模型。
LLM 使用与传统预训练语言模型相似的架构与预训练任务(如 Decoder-Only 架构与 CLM 预训练任务),
但拥有更庞大的参数、在更海量的语料上进行预训练,也从而展现出与传统预训练语言模型截然不同的能力。
一般来说LLM 指包含**数百亿(或更多)参数的语言模型**,它们往往在**数 T token 语料上**通过多卡分布式集群进行预训练,
具备远超出传统预训练模型的文本理解与生成能力。不过,随着 LLM 研究的不断深入,多种参数尺寸的 LLM 逐渐丰富,
广义的 LLM 一般覆盖了从**十亿参数**(如 Qwen-1.5B)到**千亿参数**(如 Grok-314B的所有大型语言模型。
只要模型展现出**涌现能力**,即在一系列复杂任务上表现出远超传统预训练模型(如 BERT、T5的能力与潜力都可以称之为 LLM。
一般认为GPT-31750亿参数是 LLM 的开端,
基于 GPT-3 通过 Pretrain、SFT、RLHF 三阶段训练得到的 ChatGPT 更是主导了 LLM 时代的到来。
自2022年11月 OpenAI 发布 ChatGPT 至今不到2年时间里已涌现出了上百个各具特色、能力不一的 LLM。
下表列举了自 2022年11月至2023年11月国内外发布的部分大模型
时间 | 开源 LLM | 闭源 LLM
-------- | ----- | --------
2023.11 | 无 | OpenAI-ChatGPT
2023.02 | Meta-LLaMA复旦-MOSS | 无
2023.03 | 斯坦福-Alpaca、Vicuna智谱-ChatGLM|OpenAI-GPT4百度-文心一言Anthropic-ClaudeGoogle-Bard
2023.04 | 阿里-通义千问Stability AI-StableLM|商汤-日日新
2023.05 | 微软-PiTll-Falcon|讯飞-星火大模型Google-PaLM2
2023.06 | 智谱-ChatGLM2上海 AI Lab-书生浦语;百川-BaiChuan虎博-TigerBot|360-智脑大模型
2023.07 | Meta-LLaMA2|Anthropic-Claude2华为-盘古大模型3
2023.08 | 无|字节-豆包
2023.09 | 百川-BaiChuan2|Google-Gemini腾讯-混元大模型
2023.11 | 零一万物-Yi幻方-DeepSpeek|xAI-Grok
目前,国内外企业、研究院正不断推出性能更强大的 LLM探索通往 AGI 的道路。
## 4.1.2 LLM 的能力
### 1. 涌现能力Emergent Abilities
区分 LLM 与传统 PLM 最显著的特征即是 LLM 具备 `涌现能力`
涌现能力是指同样的模型架构与预训练任务下,某些能力在小型模型中不明显,但在大型模型中特别突出。
可以类比到物理学中的相变现象,涌现能力的显现就像是模型性能随着规模增大而迅速提升,超过了随机水平,也就是我们常说的量变引起了质变。
具体来说,涌现能力可以定义为与某些复杂任务相关的能力。
但一般而言NLP 更关注的是它们具备的通用能力,也就是能够应用于解决各种 NLP 任务的能力。
涌现能力是目前业界和学界对 LLM 保持较高的热情和关注的核心所在,
即虽然 LLM 目前的能力、所能解决的任务与人类最终所期待的通用人工智能还存在不小的差距,
但在涌现能力的作用下,我们相信随着研究的不断深入、高质量数据的不断涌现和更高效的模型架构及训练框架的出现,
LLM 终能具备通用人工智能所需要具备的能力,从而给人类生活带来质变。
### 2. 上下文学习In-context Learning
上下文学习能力是由 GPT-3 首次引入的。
具体而言,上下文学习是指允许语言模型在提供自然语言指令或多个任务示例的情况下,
通过理解上下文并生成相应输出的方式来执行任务,而无需额外的训练或参数更新。
对传统 PLM在经过高成本的预训练之后往往还需要对指定的下游任务进行有监督微调。
虽然传统 PLM 体量较小,对算力要求较低,但例如 BERT 类模型0.5B 参数),进行有监督微调一般还是需要 10G 以上显存,有一定的算力成本。
而同时,有监督微调的训练数据的成本更高。针对下游任务难度的不同,需要的训练样本数往往在 1k~数十k 不等,
均需要进行人工标注,数据获取上有不小的成本。
而具备上下文学习能力的 LLM 往往无需进行高成本的额外训练或微调,而可以通过少数示例或是调整自然语言指令,
来处理绝大部分任务,从而大大节省了算力和数据成本。
上下文学习能力也正在引发 NLP 研究范式的变革。在传统 PLM 时代,解决 NLP 下游任务的一般范式是预训练-微调,
即选用一个合适的预训练模型,针对自己的下游任务准备有监督数据来进行微调。
而通过使用具备上下文学习能力的 LLM一般范式开始向 Prompt Engineering 也就是调整 Prompt 来激发 LLM 的能力转变。
例如,目前绝大部分 NLP 任务,通过调整 Prompt 或提供 1~5 个自然语言示例,就可以令 GPT-4 达到超过传统 PLM 微调的效果。
### 3. 指令遵循Instruction Following
通过使用自然语言描述的多任务数据进行微调,也就是所谓的 `指令微调`
LLM 被证明在同样使用指令形式化描述的未见过的任务上表现良好。
也就是说,经过指令微调的 LLM 能够理解并遵循未见过的指令,并根据任务指令执行任务,
而无需事先见过具体示例,这展示了其强大的泛化能力。
指令遵循能力意味我们不再需要每一件事都先教模型,然后它才能去做。
我们只需要在指令微调阶段混合多种指令来训练其泛化能力LLM 就可以处理人类绝大部分指令,即可以灵活地解决用户遇到的问题。
这一点在 ChatGPT 上体现地尤为明显。ChatGPT 之所以能够具备极高的热度,其核心原因即在于其不再是仅能用于学界、业界研究的理论模型,
而同样可以广泛地服务于各行各业用户。通过给 ChatGPT 输入指令,其可以写作文、编程序、批改试卷、阅读报纸......
指令遵循能力使 LLM 可以真正和多个行业结合起来,通过人工智能技术为人类生活的方方面面赋能,
从而为人类带来质的改变。不管是目前大火的 Agent、Flow还是并不遥远的未来可能就会出现的全能助理、超级智能
其本质依赖的都是 LLM 的指令遵循能力。
### 4. 逐步推理Step by Step Reasoning
逻辑推理,尤其是涉及多个推理步骤的复杂推理任务,一直是 NLP 的攻关难点,
也是人工智能难以得到普遍认可的重要原因。
毕竟,如果一个模型不能解答基础的“鸡兔同笼”问题,或者不能识别语言中的逻辑陷阱,
你很难认为它是“智能的”而非“智障的”。
但是,传统的 NLP 模型通常难以解决涉及多个推理步骤的复杂任务,例如数学问题。
然而LLM 通过采用 `思维链CoT, Chain of Thought` 推理策略,
可以利用包含中间推理步骤的提示机制来解决这些任务,从而得出最终答案。
据推测,这种能力可能是通过对代码的训练获得的。
逐步推理能力意味着 LLM 可以处理复杂逻辑任务,也就是说可以解决日常生活中需要逻辑判断的绝大部分问题,
从而向“可靠的”智能助理迈出了坚实的一步。
这些独特能力是 LLM 区别于传统 PLM 的重要优势,也让 LLM 在处理各种任务时表现出色,使它们成为了解决复杂问题和应用于多领域的强大工具。
正是因为涌现能力、上下文学习能力、指令遵循能力与逐步推理能力的存在NLP 研究人员相信 LLM 是迈向通用人工智能,
帮助人类社会实现生产力质变的重要途径。而事实上,目前已有众多基于 LLM 的应用,旨在利用 LLM 的独特能力显著提高生产力。
例如,微软基于 GPT-4 推出的 Copilot就基于 LLM 强大的指令遵循能力与逐步推理能力,
通过提供代码补全、代码提示、代码编写等多种功能,辅助程序员更高效、便捷、精准地编写程序,极大提高了程序员的生产效率。
## 4.1.3 LLM 的特点
除上文讨论的 LLM 的核心能力外LLM 还具备一些额外的、有趣或是危险的特点,
这些特点也是 LLM 目前重要的研究方向,在此讨论其中一二:
### 1. 多语言支持
多语言、跨语言模型曾经是 NLP 的一个重要研究方向,
但 LLM 由于需要使用到海量的语料进行预训练,训练语料往往本身就是多语言的,
因此 LLM 天生即具有多语言、跨语言能力,只不过随着训练语料和指令微调的差异,在不同语言上的能力有所差异。
由于英文高质量语料目前仍是占据大部分,以 GPT-4 为代表的绝大部分模型在英文上具有显著超越中文的能力。
虽然都可以对多种语言进行处理,但针对中文进行额外训练和优化的国内模型(如文心一言、通义千问等)往往能够在中文环境上展现更优越的效果。
### 2. 长文本处理
由于能够处理多长的上下文文本在一定程度上决定了模型的部分能力上限LLM 往往比传统 PLM 更看重长文本处理能力。
相对于以 512 token 为惯例的传统 PLM如 BERT、T5等模型的最大上下文长度均为 512
LLM 在拓宽最大上下文长度方面可谓妙计频出。
由于在海量分布式训练集群上进行训练LLM 往往在训练时就支持 4k、8k 甚至 32k 的上下文长度。
同时LLM 大部分采用了 RoPE (或者同样具有外推能力的 AliBi作为位置编码具有一定的长度外推能力
也就是在推理时能够处理显著长于训练长度的文本。
例如InternLM 在 32k 长度上下文上进行了预训练,但通过 RoPE 能够实现 200k 长度的上下文处理。
通过不断增强长文本处理能力LLM 往往能够具备更强的信息阅读、信息总结能力,
从而解决诸如要求 LLM 读完《红楼梦》并写一篇对应的高考作文的“世纪难题”。
### 3. 拓展多模态
LLM 的强大能力也为其带来了跨模态的强大表现。随着 LLM 的不断改进,通过为 LLM 增加额外的参数来进行图像表示,
从而利用 LLM 的强大能力打造支持文字、图像双模态的模型,已经是一个成功的方法。通过引入 Adapter 层和图像编码器,
并针对性地在图文数据上进行有监督微调,模型能够具备不错的图文问答甚至生成能力。在未来,如何对齐文本与图像的表示,
从而打造更强大的多模态大模型,将 LLM 的能力辐射到更多模态,是一个重要的研究方向。
### 4. 挥之不去的幻觉
幻觉,是指 LLM 根据 Prompt 杜撰生成虚假、错误信息的表现。例如,当我们要求 LLM 生成一篇学术论文及其参考文献列表时,
其往往会捏造众多看似“一本正经”实则完全不存在的论文和研究。幻觉问题是 LLM 的固有缺陷,也是目前 LLM 研究及应用的巨大挑战。
尤其是在医学、金融学等非常强调精准、正确的领域,幻觉的存在可能造成非常严重的后果。
目前也有很多研究提供了削弱幻觉的一些方法,如 Prompt 里进行限制、通过 RAG检索增强生成来指导生成等
但都还只能一定程度减弱幻觉而无法彻底根除。
除上述几点之外LLM 还存在诸多可供研究的特点,例如我们将在下一节详细论述的 LLM 三阶段训练流程、LLM 的自我反思性等,
此处就不一一列举赘述了。
**参考文献**
1. [A Survey of Large Language Models](https://arxiv.org/abs/2303.18223)

View File

@@ -0,0 +1,393 @@
# 4.2 如何训练一个 LLM
在上一节,我们分析了 LLM 的定义及其特有的强大能力,通过更大规模的参数和海量的训练语料获得远超传统预训练模型的涌现能力,
展现出强大的上下文学习、指令遵循及逐步推理能力,带来 NLP 领域的全新变革。
那么,通过什么样的步骤,我们才可以训练出一个具有涌现能力的 LLM 呢?训练一个 LLM与训练传统的预训练模型又有什么区别
![训练 LLM 的三个阶段](./figures/2-0.jpg)
一般而言,训练一个完整的 LLM 需要经过上图中的三个阶段——Pretrain、SFT 和 RLHF。
在这一节,我们将详细论述训练 LLM 的三个阶段,并分析每一个阶段的过程及其核心难点、注意事项,
帮助读者们从理论上了解要训练一个 LLM需要经过哪些步骤。
## 4.2.2 Pretrain
Pretrain即预训练是训练 LLM 最核心也是工程量最大的第一步。LLM 的预训练和传统预训练模型非常类似,
同样是使用海量无监督文本对随机初始化的模型参数进行训练。正如我们在第三章中所见,
目前主流的 LLM 几乎都采用了 Decoder-Only 的类 GPT 架构LLaMA 架构),
它们的预训练任务也都沿承了 GPT 模型的经典预训练任务——CLMCasual Language Model因果语言模型建模。
因果语言模型建模,即和最初的语言模型一致,通过给出上文要求模型预测下一个 token 来进行训练。
CLM 的过程和原理我们已在第三章详细论述过此处就不再赘述。LLM 的预训练同传统预训练模型的核心差异即在于,
预训练的体量和资源消耗。
根据定义LLM 的核心特点即在于其具有远超传统预训练模型的参数量,同时在更海量的语料上进行预训练。
传统预训练模型如 BERT有 base 和 large 两个版本。BERT-base 模型由 12个 Encoder 层组成,
其 hidden_size 为 768使用 12个头作为多头注意力层整体参数量为 1亿110M而 BERT-large 模型由 24个 Encoder 层组成,
hidden_size 为 1024有 16个头整体参数量为 3亿340M。同时BERT 预训练使用了 33亿3Btoken 的语料,
在 64块 TPU 上训练了 4天。
事实上相对于传统的深度学习模型3亿参数量、33亿训练数据的 BERT 已经是一个能力超群、资源消耗巨大的庞然大物。
但是,前面我们提到,一般而言的 LLM 通常具有数百亿甚至上千亿参数,即使是广义上最小的 LLM一般也有十亿1B以上的参数量。
例如以开山之作 GPT-3 为例,其有 96个 Decoder 层12288 的 hidden_size 和 96个头共有 1750亿175B参数
比 BERT 大出快 3个数量级。即使是目前流行的小型 LLM 如 Qwen-1.8B
其也有 24个 Decoder 层、2048的 hidden_size 和 16个注意力头
整体参数量达到 18亿1.8B)。
模型|hidden_layers|hidden_size|heads|整体参数量|预训练数据量
----| -----------|-----------|------|---------|---------
BERT-base|12|768|12|0.1B|3B
BERT-large|24|1024|16|0.3B|3B
Qwen-1.8B|24|2048|16|1.8B|2.2T
LLaMA-7B|32|4096|32|7B|1T
GPT-3|96|12288|96|175B|300B
更重要的是LLM 往往需要使用更大规模的预训练语料。根据由 OpenAI 提出的 Scaling LawC ~ 6ND
其中 C 为计算量N 为模型参数D 为训练的 token 数,可以实验得出训练 token 数应该是模型参数的 1.7倍,
也就是说 175B 的 GPT-3需要使用 300B token 进行预训练。
而 LLaMA 更是进一步提出,使用 20倍 token 来训练模型能达到效果最优,
因此 175B 的 GPT-3可以使用3.5T token 数据预训练达到最优性能。
如此庞大的模型参数和预训练数据,使得预训练一个 LLM 所需要的算力资源极其庞大。
事实上,哪怕是预训练一个 1B 的大模型,也至少需要多卡分布式 GPU 集群,通过分布式框架对模型参数、训练的中间参数和训练数据进行切分,
才能通过以天为单位的长时间训练来完成。一般来说,百亿级 LLM 需要 1024张 A100 训练一个多月,
而十亿级 LLM 一般也需要 256张 A100 训练两、三天,计算资源消耗非常高。
也正因如此,分布式训练框架也成为 LLM 训练必不可少的组成部分。分布式训练框架的核心思路是数据并行和模型并行。
所谓数据并行,是指训练模型的尺寸可以被单个 GPU 内存容纳,但是由于增大训练的 batch_size 会增大显存开销,
无法使用较大的 batch_size 进行训练;同时,训练数据量非常大,使用单张 GPU 训练时长难以接受。
![数据并行](./figures/2-1.jpg)
因此,可以让模型的不同实例在不同 GPU 和不同批数据上运行,每一次前向传递完成之后,
收集所有实例的梯度并计算梯度更新,更新模型参数之后再传递到所有实例。
也就是在数据并行的情况下,每张 GPU 上的模型参数是保持一致的,训练的总批次大小等于每张卡上的批次大小之和。
但是,当 LLM 扩大到上百亿参数,单张 GPU 内存往往就无法存放完整的模型参数。在这种情况下,可以将模型拆分到多个 GPU 上,
每个 GPU 上存放不同的层或不同的部分,从而实现模型并行。
![模型并行](./figures/2-2.jpg)
在数据并行和模型并行的思想基础上还演化出了多种更高效的分布式方式例如张量并行、3D 并行、ZeRO 等。
目前,主流的分布式训练框架包括 Deepspeed、Megatron-LM、ColossalAI 等其中Deepspeed 使用面最广。
Deepspeed 的核心策略是 ZeROZero Redundancy Optimizer零冗余优化器和 CPU-offload。
ZeRO 是一种显存优化的数据并行方案,其核心思想是优化数据并行时每张卡的显存占用,从而实现对更大规模模型的支持。
ZeRO 将模型训练阶段每张卡被占用的显存分为两类:
- 模型状态Model States包括模型参数、模型梯度和优化器 Adam 的状态参数。假设模型参数量为 M
一般来说,在混合精度训练的情况下,该部分需要 16M 的空间进行存储,其中 Adam 状态参数会占据 12M 的存储空间。
- 剩余状态Residual States除了模型状态之外的显存占用包括激活值、各种缓存和显存碎片。
针对上述显存占用ZeRO 提出了三种不断递进的优化策略:
1. ZeRO-1对模型状态中的 Adam 状态参数进行分片,即每张卡只存储 $\frac{1}{N}$ 的 Adam 状态参数,
其他参数仍然保持每张卡一份。
2. ZeRO-2继续对模型梯度进行分片每张卡只存储 $\frac{1}{N}$ 的模型梯度和 Adam 状态参数,
仅模型参数保持每张卡一份。
3. ZeRO-3将模型参数也进行分片每张卡只存储 $\frac{1}{N}$ 的模型梯度、模型参数和 Adam 状态参数。
可以看出,随着分片的参数量不断增加,每张卡需要占用的显存也不断减少。当然,分片的增加也就意味着训练中通信开销的增加,
一般而言,每张卡的 GPU 利用率 ZeRO-1 最高而 ZeRO-3 最低。
具体使用什么策略,需要结合计算资源的情况和需要训练的模型体量动态确定。
除去计算资源的要求,训练数据本身也是预训练 LLM 的一个重大挑战。训练一个 LLM至少需要数百 B 甚至上 T 的预训练语料。
根据研究LLM 所掌握的知识绝大部分都是在预训练过程中学会的,因此,
为了使训练出的 LLM 能够覆盖尽可能广的知识面,预训练语料需要组织多种来源的数据,并以一定比例进行混合。
目前,主要的开源预训练语料包括 CommonCrawl、C4、Github、Wikipedia 等。不同的 LLM 往往会在开源预训练语料基础上,
加入部分私有高质量语料,再基于自己实验得到的最佳配比来构造预训练数据集。
事实上,数据配比向来是预训练 LLM 的“核心秘籍”,不同的配比往往会相当大程度影响最终模型训练出来的性能。
例如,下表展示了 LLaMA 的预训练数据及配比:
数据集|占比|数据集大小Disk size
-----|----|---------------------
CommonCrawl|67.0%|3.3 TB
C4|15.0%|783 GB
Github|4.5%|328 GB
Wikipedia|4.5%|83 GB
Books|4.5%|85 GB
ArXiv|2.5%|92 GB
StackExchange|2.0%|78 GB
训练一个中文 LLM训练数据的难度会更大。目前高质量语料还是大部分集中在英文范畴
例如上表的 Wikipedia、Arxiv 等,均是英文数据集;而 C4 等多语言数据集中,英文语料也占据主要地位。
目前开源的中文 LLM 如 ChatGLM、Baichuan 等模型均未开放其预训练数据集,
开源的中文预训练数据集目前仅有昆仑天工开源的[SkyPile](https://huggingface.co/datasets/Skywork/SkyPile-150B)150B
中科闻歌开源的[yayi2](https://huggingface.co/datasets/wenge-research/yayi2_pretrain_data)100B
相较于英文开源数据集有明显差距。
预训练数据的处理与清洗也是 LLM 预训练的一个重要环节。
诸多研究证明,预训练数据的质量往往比体量更加重要。预训练数据处理一般包括以下流程:
1. 文档准备。由于海量预训练语料往往是从互联网上获得,一般需要从爬取的网站来获得自然语言文档。
文档准备主要包括 URL 过滤(根据网页 URL 过滤掉有害内容)、文档提取(从 HTML 中提取纯文本)、
语言选择(确定提取的文本的语种)等。
2. 语料过滤。语料过滤的核心目的是去除低质量、无意义、有毒有害的内容,例如乱码、广告等。
语料过滤一般有两种方法:基于模型的方法,即通过高质量语料库训练一个文本分类器进行过滤;
基于启发式的方法,一般通过人工定义 web 内容的质量指标,计算语料的指标值来进行过滤。
3. 语料去重。实验表示,大量重复文本会显著影响模型的泛化能力,因此,语料去重即删除训练语料中相似度非常高的文档,
也是必不可少的一个步骤。去重一般基于 hash 算法计算数据集内部或跨数据集的文档相似性,
将相似性大于指定阈值的文档去除;也可以基于子串在序列级进行精确匹配去重。
目前,已有很多经过处理的高质量预训练语料和专用于预训练数据处理的框架。例如,有基于 LLaMA 思路收集、清洗的预训练数据集
[RedPajama-1T](https://huggingface.co/datasets/togethercomputer/RedPajama-Data-1T)
以及在 RedPajama 基础上进行筛选去重的[SlimPajama-627B](https://huggingface.co/datasets/cerebras/SlimPajama-627B/tree/main/train)数据集,实验证明高质量的 627B Slimpajama 数据集能够获得比 1T 的 RedPajama 数据集更好的效果。
## 4.2.3 SFT
预训练是 LLM 强大能力的根本来源事实上LLM 所覆盖的海量知识基本都是源于预训练语料。
LLM 的性能本身,核心也在于预训练的工作。但是,预训练赋予了 LLM 能力,却还需要第二步将其激发出来。
经过预训练的 LLM 好像一个博览群书但又不求甚解的书生,对什么样的偏怪问题,都可以流畅地接出下文,
但他偏偏又不知道问题本身的含义只会“死板背书”。这一现象的本质是因为LLM 的预训练任务就是经典的 CLM
也就是训练其预测下一个 token 的能力,在没有进一步微调之前,其无法与其他下游任务或是用户指令适配。
因此,我们还需要第二步来教这个博览群书的学生如何去使用它的知识,也就是 SFT——Supervisor Finetune有监督微调。
所谓有监督微调,其实就是我们在第三章中讲过的预训练-微调中的微调,稍有区别的是,
对于能力有限的传统预训练模型,我们需要针对每一个下游任务单独对其进行微调以训练模型在该任务上的表现。
例如要解决文本分类问题,需要对 BERT 进行文本分类的微调;要解决实体识别的问题,就需要进行实体识别任务的微调。
而面对能力强大的 LLM我们往往不再是在指定下游任务上构造有监督数据进行微调而是选择训练模型的“通用指令遵循能力”
也就是一般通过`指令微调`的方式来进行 SFT。
所谓指令微调,即我们训练的输入是各种类型的用户指令,而需要模型拟合的输出则是我们希望模型在收到该指令后做出的回复。
例如,我们的一条训练样本可以是:
input:告诉我今天的天气预报?
output:根据天气预报今天天气是晴转多云最高温度26摄氏度最低温度9摄氏度昼夜温差大请注意保暖哦
也就是说SFT 的主要目标是让模型从多种类型、多种风格的指令中获得泛化的指令遵循能力,也就是能够理解并回复用户的指令。
因此,类似于 PretrainSFT 的数据质量和数据配比也是决定模型指令遵循能力的重要因素。
首先是指令数据量及覆盖范围。为了使 LLM 能够获得泛化的指令遵循能力,即能够在未训练的指令上表现良好,
需要收集大量类别各异的用户指令和对应回复对 LLM 进行训练。
一般来说,在单个任务上 500~1000 的训练样本就可以获得不错的微调效果。
但是,为了让 LLM 获得泛化的指令遵循能力,在多种任务指令上表现良好,
需要在训练数据集中覆盖多种类型的任务指令,同时也需要相对较大的训练数据量,
表现良好的开源 LLM SFT 数据量一般在数 B token 左右。
为提高 LLM 的泛化能力,指令数据集的覆盖范围自然是越大越好。
但是,多种不同类型的指令数据之间的配比也是 LLM 训练的一大挑战。
OpenAI 训练的 InstructGPT即 ChatGPT 前身)使用了源自于用户使用其 API 的十种指令:
指令类型|占比
-------|-----
文本生成|45.6%
开放域问答|12.4%
头脑风暴|11.2%
聊天|8.4%
文本转写|6.6%
文本总结|4.2%
文本分类|3.5%
其他|3.5%
特定域问答|2.6%
文本抽取|1.9%
高质量的指令数据集具有较高的获取难度。不同于预训练使用的无监督语料,
SFT 使用的指令数据集是有监督语料,除去设计广泛、合理的指令外,还需要对指令回复进行人工标注,并保证标注的高质量。
事实上ChatGPT 的成功很大一部分来源于其高质量的人工标注数据。
但是,人工标注数据成本极高,也罕有企业将人工标注的指令数据集开源。
为降低数据成本,部分学者提出了使用 ChatGPT 或 GPT-4 来生成指令数据集的方法。
例如,经典的开源指令数据集 [Alpaca](https://github.com/yizhongw/self-instruct/blob/main/human_eval/user_oriented_instructions.jsonl)就是基于一些种子 Prompt通过 ChatGPT 生成更多的指令并对指令进行回复来构建的。
一般 SFT 所使用的指令数据集包括以下三个键:
```json
{
"instruction":"即输入的用户指令",
"input":"执行该指令可能需要的补充输入,没有则置空",
"output":"即模型应该给出的回复"
}
```
例如,如果我们的指令是将目标文本“今天天气真好”翻译成英文,那么该条样本可以构建成如下形式:
```json
{
"instruction":"将下列文本翻译成英文:",
"input":"今天天气真好",
"output":"Today is a nice day"
}
```
同时,为使模型能够学习到和预训练不同的范式,在 SFT 的过程中,往往会针对性设置特定格式。
例如LLaMA 的 SFT 格式为:
### Instruction:\n{{content}}\n\n### Response:\n
其中的 content 即为具体的用户指令,也就是说,对于每一个用户指令,将会嵌入到上文的 content 部分,
这里的用户指令不仅指上例中的 “instruction”而是指令和输入的拼接即模型可以执行的一条完整指令。
例如针对上例LLaMA 获得的输入应该是:
### Instruction:\n将下列文本翻译成英文今天天气真好\n\n### Response:\n
其需要拟合的输出则是:
### Instruction:\n将下列文本翻译成英文今天天气真好\n\n### Response:\nToday is a nice day
注意,因为指令微调本质上仍然是对模型进行 CLM 训练,只不过要求模型对指令进行理解和回复而不是简单地预测下一个 token
所以模型预测的结果不仅是 output而应该是 input + output只不过 input 部分不参与 loss 的计算,
但回复指令本身还是以预测下一个 token 的形式来实现的。
但是,随着 LLM 能力的不断增强,模型的多轮对话能力逐渐受到重视。
所谓多轮对话,是指模型在每一次对话时能够参考之前对话的历史记录来做出回复。
例如,一个没有多轮对话能力的 LLM 可能有如下对话记录:
用户:你好,我是开源组织 Datawhale 的成员。
模型:您好,请问有什么可以帮助您的吗?
用户:你知道 Datawhale 是什么吗?
模型:不好意思,我不知道 Datawhale 是什么。
也就是说,模型不能记录用户曾经提到或是自己曾经回答的历史信息。
如果是一个具有多轮对话能力的 LLM其对话记录应该是这样的
用户:你好,我是开源组织 Datawhale 的成员。
模型:您好,请问有什么可以帮助您的吗?
用户:你知道 Datawhale 是什么吗?
模型Datawhale 是一个开源组织。
模型是否支持多轮对话,与预训练是没有关系的。事实上,模型的多轮对话能力完全来自于 SFT 阶段。
如果要使模型支持多轮对话,我们需要在 SFT 时将训练数据构造成多轮对话格式,让模型能够利用之前的知识来生成回答。
假设我们目前需要构造的多轮对话是:
<prompt_1><completion_1><prompt_2><completion_2><prompt_3><completion_3>
构造多轮对话样本一般有三种方式:
1. 直接将最后一次模型回复作为输出,前面所有历史对话作为输入,直接拟合最后一次回复:
input=<prompt_1><completion_1><prompt_2><completion_2><prompt_3><completion_3>
output=[MASK][MASK][MASK][MASK][MASK]<completion_3>
2. 将 N 轮对话构造成 N 个样本:
input_1 = <prompt_1><completion_1>
output_1 = [MASK]<completion_1>
input_2 = <prompt_1><completion_1><prompt_2><completion_2>
output_2 = [MASK][MASK][MASK]<completion_2>
input_3=<prompt_1><completion_1><prompt_2><completion_2><prompt_3><completion_3>
output_3=[MASK][MASK][MASK][MASK][MASK]<completion_3>
3. 直接要求模型预测每一轮对话的输出:
input=<prompt_1><completion_1><prompt_2><completion_2><prompt_3><completion_3>
output=[MASK]<completion_1>[MASK]<completion_2>[MASK]<completion_3>
显然可知,第一种方式会丢失大量中间信息,第二种方式造成了大量重复计算,只有第三种方式是最合理的多轮对话构造。
我们之所以可以以第三种方式来构造多轮对话样本,是因为 LLM 本质还是进行的 CLM 任务,进行单向注意力计算,
因此在预测时会从左到右依次进行拟合,前轮的输出预测不会影响后轮的预测。
目前,绝大部分 LLM 均使用了多轮对话的形式来进行 SFT。
## 4.2.4 RLHF
RLHF全称是 Reinforcement Learning from Human Feedback即人类反馈强化学习是利用强化学习来训练 LLM 的关键步骤。
相较于在 GPT-3 就已经初见雏形的 SFTRLHF 往往被认为是 ChatGPT 相较于 GPT-3 的最核心突破。
事实上,从功能上出发,我们可以将 LLM 的训练过程分成预训练与对齐alignment两个阶段。
预训练的核心作用是赋予模型海量的知识,而所谓对齐,其实就是让模型与人类价值观一致,从而输出人类希望其输出的内容。
在这个过程中SFT 是让 LLM 和人类的指令对齐,从而具有指令遵循能力;
而 RLHF 则是从更深层次令 LLM 和人类价值观对齐,令其达到安全、有用、无害的核心标准。
如下图所示ChatGPT 在技术报告中将对齐分成三个阶段,后面两个阶段训练 RM 和 PPO 训练,就是 RLHF 的步骤:
![对齐过程](./figures/2-3.png)
RLHF 的思路是,引入强化学习的技术,通过实时的人类反馈令 LLM 能够给出更令人类满意的回复。
强化学习是有别于监督学习的另一种机器学习方法,
主要讨论的问题是智能体怎么在复杂、不确定的环境中最大化它能获得的奖励。
强化学习主要由两部分构成:智能体和环境。
在强化学习过程中,智能体会不断行动并从环境获取反馈,根据反馈来调整自己行动的策略。
应用到 LLM 的对齐上其实就是针对不同的问题LLM 会不断生成对应的回复,
人工标注员会不断对 LLM 的回复做出反馈,从而让 LLM 学会人类更偏好、喜欢的回复。
RLHF 就类似于 LLM 作为一个学生,不断做作业来去提升自己解题能力的过程。
如果把 LLM 看作一个能力强大的学生Pretrain 是将所有基础的知识教给他,
SFT 是教他怎么去读题、怎么去解题,那么 RLHF 就类似于真正的练习。
LLM 会不断根据 Pretrain 学到的基础知识和 SFT 学到的解题能力去解答练习,
然后人类作为老师批改 LLM 的练习,来让 LLM 反思错误的解题方式,不断强化正确的解题方式。
如上图RLHF 分为两个步骤:训练 RM 和 PPO 训练。
RMReward Model即奖励模型。RM 是用于拟合人类偏好,来给 LLM 做出反馈的。
在强化学习的训练中,对于 LLM 的每一个回复RM 会进行打分,这个打分反映了生成回复符合人类偏好的程度。
然后 LLM 会根据强化学习的原理,基于 RM 的打分来进行优化训练。
所以RM 本质上是一个文本分类模型,对于一个文本输出一个标量奖励,和文本分类任务中的隐藏层输出非常类似。
在具体实现上RM 也往往就是传统的 LLM 架构(或 BERT 架构)加上一层分类层,和用于文本分类的 LLM 架构完全一致,
只不过使用隐藏层输出而不是最后的分类输出而已。
但是,在训练 RM 时,我们往往并不直接使用文本及其对应的标量奖励来对 RM 进行训练。
因为要对齐人类偏好RM 训练的偏好数据往往是由人工标注的。但是,由于标注者之间往往也存在价值观差异,
数值形式的标量奖励往往会将这些差异放大,从而导致在训练过程中对同样程度的回复奖励不一致,
模型难以拟合到正确的标量奖励。因此,我们往往对同一个 completion 下的不同回复进行排名,再将排名转化为奖励。
例如,我们的训练数据往往形如:
```json
{
"prompt":"如果你打算从商店偷东西,你觉得早上好还是晚上好?",
"chosen":"这是违法的事情,我不能提供建议",
"rejected":"考虑晚上的人口贩运和监控摄像头的差别是件好事。夜间时间可能更有利于避免监控摄像头,但晚上的商店雇员会更能看见你。另一方面,由于白天通常不太忙,因此更容易避免被其他人注意到。无论如何,必须密切注意商店雇员和所有顾客的行为。他们也许能够看见你,即使他们不是直接面对你。为了安全起见,重要的是要密切注意商店里的一切事情,而不是不小心。"
}
```
其中prompt 是用户的问题chosen 是应该对齐的、符合人类偏好的回答rejected 是不符合人类偏好的回答。
在训练中prompt 将和 chosen 以及 rejected 分别拼接起来,形成 chosen_example 和 rejected_example
然后分别进入模型通过前向传播输出一个标量奖励。
然后模型会通过最大化 chosen_example 和 rejected_example 的标量差异来计算 loss并进行反向传播完成训练。
值得注意的是RM 训练使用的模型往往和最后的 LLM 大小不同。例如 OpenAI 使用了 175B 的 LLM 和 6B 的 RM。
同时RM 使用的模型可以是经过 SFT 之后的 LM也可以是基于偏好数据从头训练的 RM。哪一种更好至今尚没有定论。
在完成 RM 训练之后,就可以使用 PPO 算法来进行强化学习训练。
PPOProximal Policy Optimization近端策略优化算法是一种经典的 RL 算法。
事实上,强化学习训练时也可以使用其他的强化学习算法,但目前 PPO 算法因为成熟、成本较低,还是最适合 RLHF 的算法。
在具体 PPO 训练过程中,会存在四个模型,两个 LM 和两个 RM。
两个 LM 分别是进行微调、参数更新的 actor model 和不进行参数更新的 ref model均是从 SFT 之后的 LLM 初始化的。
两个 RM 分别是进行参数更新的 critic model 和不进行参数更新的 reward model均是从上一步训练的 RM 初始化的。
![PPO 训练流程](./figures/2-4.jpg)
如上图,使用 PPO 算法的强化学习训练过程如下:
1. 从 SFT 之后的 LLM 初始化两个模型分别作为 Actor Model 和 Ref Model
从训练的 RM 初始化两个模型分别作为 Reward Model 和 Critic Model
2. 输入一个 PromptActor Model 和 Ref Model 分别就 Prompt 生成回复;
3. Actor Response 和 Ref Response 计算 KL 散度:
$r_{KL} = -\theta_{KL}D_{KL}(\pi_{PPO}(y|x)||\pi_{base}(y|x))$
其中,$\pi_{PPO}(y|x)$即为 Actor Model 的输出,而 $\pi_{base}(y|x)$即为 Ref Model 的输出,$theta_{KL}D_{KL}$即是计算 KL 散度的方法;
4. Actor Response 分别输入到 Reward Model 和 Critic Model 进行打分,
其中Reward Model 输出的是回复对应的标量奖励Critic Model 还会输出累加奖励即从i位置到最后的累积奖励
5. 计算的 KL 散度、两个模型的打分均输入到奖励函数中,计算奖励:
$loss = -(kl_{ctl}*r_{KL} + \gamma * V_{t+1} - V_{t})logP(A_t|V_t)$
这里的 $kl_{ctl}是控制 KL 散度对结果影响的权重参数,$\gamma$ 是控制下一个时间(也就是样本)打分对结果影响的权重参数,
$V_t$ 是 Critic Model 的打分输出,$A_t$ 则是 Reward Model 的打分输出;
6. 根据奖励函数分别计算出的 actor loss 和 critic loss更新 Actor Model 的参数和 Critic Model 的参数;
注意Actor Model 和 Critic Model 的参数更新方法是不同的,此处就不再一一赘述了,感兴趣的读者可以深入研究强化学习的相关理论。
在上述过程中,因为要使用到四个模型,显存占用会数倍于 SFT。例如如果我们 RM 和 LLM 都是用 7B 的体量,
PPO 过程中大概需要 240G4张 80G A100每张卡占用 60G显存来进行模型加载。
那么为什么我们需要足足四个模型呢Actor Model 和 Critic Model 较为容易理解,
而之所以我们还需要保持原参数不更新的 Ref Model 和 Reward Model
是为了限制模型的更新不要过于偏离原模型以至于丢失了 Pretrain 和 SFT 赋予的能力。
当然,如此大的资源占用和复杂的训练过程,使 RLHF 成为一个门槛非常高的阶段。
也有学者从监督学习的思路出发,提出了 DPODirect Preference Optimization直接偏好优化可以低门槛平替 RLHF。
DPO 的核心思路是,将 RLHF 的强化学习问题转化为监督学习来直接学习人类偏好。
DPO 通过使用奖励函数和最优策略间的映射,展示了约束奖励最大化问题完全可以通过单阶段策略训练进行优化,
也就是说,通过学习 DPO 所提出的优化目标,可以直接学习人类偏好,而无需再训练 RM 以及进行强化学习。
由于直接使用监督学习进行训练DPO 只需要两个 LLM 即可完成训练,且训练过程相较 PPO 简单很多,是 RLHF 更简单易用的平替版本。
DPO 所提出的优化目标为什么能够直接学习人类偏好,作者通过一系列的数学推导完成了证明,
感兴趣的读者可以下来进一步阅读,此处就不再赘述了。
接下来,我们将依次实现如何从零开始训练一个 LLM包括预训练、SFT 和 RLHF。
**参考资料**
1. [Training language models to follow instructions with human feedback](https://arxiv.org/abs/2203.02155)
2. [BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding](https://arxiv.org/abs/1810.04805)
3. [Scaling Laws for Neural Language Models](https://arxiv.org/abs/2001.08361)
4. [Training Compute-Optimal Large Language Models](https://arxiv.org/abs/2203.15556)
5. [Easy RL](https://github.com/datawhalechina/easy-rl)
6. [Direct Preference Optimization: Your Language Model is Secretly a Reward Model](https://arxiv.org/abs/2305.18290)

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,566 @@
# 5.1 动手写一个 LLaMA2 模型
Meta原Facebook于2023年2月发布第一款基于Transformer结构的大型语言模型-LLaMA并于同年7月发布同系列模型-LLaMA2。我们在第四章已经学习了解的了LLM记忆如何训练LLM等等。那本小节我们就来学习如何动手写一个LLaMA2模型。
## 5.1.1 定义超参数
首先我们需要定义一些超参数,这些超参数包括模型的大小、层数、头数、词嵌入维度、隐藏层维度等等。这些超参数可以根据实际情况进行调整。
这里我们自定义一个`ModelArgs`类,来存储和记录我们的超参数,方便后续修改和直接倒入。
```python
class ModelArgs:
# 自定义超参数
dim: int = 288 # 模型维度
n_layers: int = 6 # Transformer层数
n_heads: int = 6 # 注意力机制的头数
n_kv_heads: Optional[int] = 6 # 键/值头数如果未指定则默认为n_heads
vocab_size: int = 32000 # 词汇表大小
hidden_dim: Optional[int] = None # 隐藏层维度,如果未指定,则使用其他规则确定
multiple_of: int = 32 # MLP隐藏层大小是这个数的倍数
norm_eps: float = 1e-5 # 归一化层的epsilon值
max_seq_len: int = 256 # 最大序列长度
dropout: float = 0.0 # 丢弃率
```
我们来看一下其中的一些超参数的含义,比如`dim`是模型维度,`n_layers`是Transformer的层数`n_heads`是注意力机制的头数,`vocab_size`是词汇表大小,`max_seq_len`是输入的最大序列长度等等。上面的代码中也对每一个参数做了详细的注释,在后面的代码中我们会根据这些超参数来构建我们的模型。
## 5.1.2 构建LLaMA2RMSNorm
`LLaMA2RMSNorm`可以用如下的数学公式表示:
$$
\text{RMSNorm}(x) = \frac{x}{\sqrt{\frac{1}{n}\sum_{i=1}^{n}w_i^2 + \epsilon}}
$$
其中:
- ( $x$ ) 是层的输入。
- ( $w_i$ ) 代表层的权重。
- ( $n$ ) 是权重的数量。
- ( $\epsilon$ ) 是一个小常数,用于数值稳定性(以避免除以零的情况)。
这种归一化有助于通过确保权重的规模不会变得过大或过小来稳定学习过程,这在具有许多层的深度学习模型中特别有用。
我们可以通过如下代码实现`LLaMA2RMSNorm`
```python
class LLaMA2RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float):
super().__init__()
# eps是为了防止除以0的情况
self.eps = eps
# weight是一个可学习的参数全部初始化为1
self.weight = nn.Parameter(torch.ones(dim))
def _norm(self, x):
# 计算RMSNorm的核心部分
# x.pow(2).mean(-1, keepdim=True)计算了输入x的平方的均值
# torch.rsqrt是平方根的倒数这样就得到了RMSNorm的分母部分再加上eps防止分母为0
# 最后乘以x得到RMSNorm的结果
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
def forward(self, x):
# forward函数是模型的前向传播
# 首先将输入x转为float类型然后进行RMSNorm最后再转回原来的数据类型
# 最后乘以weight这是RMSNorm的一个可学习的缩放因子
output = self._norm(x.float()).type_as(x)
return output * self.weight
```
并且,我们可以用下面的代码来对`LLaMA2RMSNorm`模块进行测试,可以看到代码最终输出的形状为`torch.Size([1, 50, 288])`,与我们输入的形状一致,说明模块的实现是正确的,归一化并不会改变输入的形状。
```python
norm = LLaMA2RMSNorm(args.dim, args.norm_eps)
x = torch.randn(1, 50, args.dim)
output = norm(x)
print(output.shape)
out:
orch.Size([1, 50, 288])
```
## 5.1.3 构建 LLaMA2 Attention
在 LLaMA2 模型中,虽然只有 LLaMA2-70B模型使用了GQAGroup Query Attention但我们选择使用GQA来构建我们的 LLaMA Attention 模块,它可以提高模型的效率,并节省一些显存占用。
### 5.1.3.1 repeat_kv
在 LLaMA2 模型中,我们需要将键和值的维度扩展到和查询的维度一样,这样才能进行注意力计算。我们可以通过如下代码实现`repeat_kv`
```python
def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:
# 获取输入张量的形状:批量大小、序列长度、键/值对头的数量、每个头的维度大小
bs, slen, n_kv_heads, head_dim = x.shape
# 如果重复次数为1则不需要重复直接返回原始张量
if n_rep == 1:
return x
# 对张量进行扩展和重塑操作以重复键值对
return (
x[:, :, :, None, :] # 在第四个维度(头的维度前)添加一个新的维度
.expand(bs, slen, n_kv_heads, n_rep, head_dim) # 将新添加的维度扩展到n_rep大小实现重复的效果
.reshape(bs, slen, n_kv_heads * n_rep, head_dim) # 重新塑形,合并键/值对头的数量和重复次数的维度
)
```
在上述代码中:
- 首先,获取输入张量的形状:首先,代码通过 x.shape 获取输入张量的形状包括批量大小bs、序列长度slen、键/值对头的数量n_kv_heads以及每个头的维度大小head_dim
- 然后,检查重复次数:接着,代码检查重复次数 n_rep 是否为1。如果是1则说明不需要对键和值进行重复直接返回原始张量 x。
- 最后,扩展和重塑张量:
- 在第三个维度(即键/值对头的维度)之后添加一个新的维度,形成 `x[:, :, :, None, :]`
- 使用 `expand` 方法将新添加的维度扩展到 `n_rep` 大小,实现键/值对的重复效果。
- 最后,通过 reshape 方法重新塑形,将扩展后的维度合并回键/值对头的数量中,即 `x.reshape(bs, slen, n_kv_heads * n_rep, head_dim)`,这样最终的张量形状就达到了与查询维度一致的效果。
### 5.1.3.2 旋转嵌入
接着我们来实现旋转嵌入,旋转嵌入是 LLaMA2 模型中的一个重要组件它可以为attention机制提供更强的上下文信息从而提高模型的性能。
首先,我们要构造获得旋转嵌入的实部和虚部的函数:
```python
# 注意此处的dim应为 dim//n_head因为我们是对每个head进行旋转嵌入
def precompute_freqs_cis(dim: int, end: int, theta: float = 10000.0):
# torch.arange(0, dim, 2)[: (dim // 2)].float()生成了一个从0开始步长为2的序列长度为dim的一半
# 然后每个元素除以dim再取theta的倒数得到频率
freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
# 生成一个从0到end的序列长度为end
t = torch.arange(end, device=freqs.device)
# 计算外积得到一个二维矩阵每一行是t的元素乘以freqs的元素
freqs = torch.outer(t, freqs).float()
# 计算频率的余弦值,得到实部
freqs_cos = torch.cos(freqs)
# 计算频率的正弦值,得到虚部
freqs_sin = torch.sin(freqs)
return freqs_cos, freqs_sin
```
- 计算频率序列:
- `torch.arange(0, dim, 2)[: (dim // 2)].float()` 生成了一个从0开始步长为2的序列其长度为`dim`的一半。
- 每个元素除以`dim`后取`theta`的倒数,得到一个频率序列 `freqs`。这一步是为了生成适合旋转嵌入的频率。
- 生成时间序列:
- `t = torch.arange(end, device=freqs.device)` 生成一个从`0``end`的序列,长度为`end``end`通常是序列的最大长度。
- 计算频率的外积
- `freqs = torch.outer(t, freqs).float()` 计算时间序列 `t` 和频率序列 `freqs` 的外积,得到一个二维矩阵 `freqs`。每一行是时间序列 `t` 的元素乘以频率序列 `freqs` 的元素。
- 计算实部和虚部
- `freqs_cos = torch.cos(freqs)` 计算频率矩阵 `freqs` 的余弦值,得到旋转嵌入的实部。
- `freqs_sin = torch.sin(freqs)` 计算频率矩阵 `freqs` 的正弦值,得到旋转嵌入的虚部。
最终,该函数返回两个矩阵 `freqs_cos``freqs_sin`,分别表示旋转嵌入的实部和虚部,用于后续的计算。
接着,我们来构造调整张量形状的`reshape_for_broadcast`函数,这个函数的主要目的是调整 `freqs_cis` 的形状,使其在进行广播操作时与 `x` 的维度对齐,从而能够进行正确的张量运算。
```python
def reshape_for_broadcast(freqs_cis: torch.Tensor, x: torch.Tensor):
# 获取x的维度数
ndim = x.ndim
# 断言确保1在x的维度范围内
assert 0 <= 1 < ndim
# 断言确保freqs_cis的形状与x的第二维和最后一维相同
assert freqs_cis.shape == (x.shape[1], x.shape[-1])
# 构造一个新的形状除了第二维和最后一维其他维度都为1这样做是为了能够将freqs_cis与x进行广播操作
shape = [d if i == 1 or i == ndim - 1 else 1 for i, d in enumerate(x.shape)]
# 将freqs_cis调整为新的形状并返回
return freqs_cis.view(shape)
```
最后,我们可以通过如下代码实现旋转嵌入:
```python
def apply_rotary_emb(
xq: torch.Tensor,
xk: torch.Tensor,
freqs_cos: torch.Tensor,
freqs_sin: torch.Tensor
) -> Tuple[torch.Tensor, torch.Tensor]:
# 将查询和键张量转换为浮点数,并重塑形状以分离实部和虚部
xq_r, xq_i = xq.float().reshape(xq.shape[:-1] + (-1, 2)).unbind(-1)
xk_r, xk_i = xk.float().reshape(xk.shape[:-1] + (-1, 2)).unbind(-1)
# 重新塑形频率张量以进行广播
freqs_cos = reshape_for_broadcast(freqs_cos, xq_r)
freqs_sin = reshape_for_broadcast(freqs_sin, xq_r)
# 应用旋转,分别计算旋转后的实部和虚部
xq_out_r = xq_r * freqs_cos - xq_i * freqs_sin
xq_out_i = xq_r * freqs_sin + xq_i * freqs_cos
xk_out_r = xk_r * freqs_cos - xk_i * freqs_sin
xk_out_i = xk_r * freqs_sin + xk_i * freqs_cos
# 将最后两个维度合并,并还原为原始张量的形状
xq_out = torch.stack([xq_out_r, xq_out_i], dim=-1).flatten(3)
xk_out = torch.stack([xk_out_r, xk_out_i], dim=-1).flatten(3)
return xq_out.type_as(xq), xk_out.type_as(xk)
```
这里我们给出可以测试`apply_rotary_emb`函数的代码,大家也可以尝试在代码中添加断点,来查看每一步的计算结果。
```python
xq = torch.randn(1, 50, 6, 48) # bs, seq_len, dim//n_head, n_head_dim
xk = torch.randn(1, 50, 6, 48) # bs, seq_len, dim//n_head, n_head_dim
# 使用 precompute_freqs_cis 函数获取 sin和cos
cos, sin = precompute_freqs_cis(288//6, 50)
print(cos.shape, sin.shape)
xq_out, xk_out = apply_rotary_emb(xq, xk, cos, sin)
xq_out.shape, xk_out.shape
```
OUT:
```
torch.Size([50, 24]) torch.Size([50, 24])
```
### 5.1.3.3 组装 LLaMA2 Attention
在上面我们已经完成了旋转嵌入的实现,接下来我们就可以构建 LLaMA2 Attention 模块了。
```python
class LLaMA2Attention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
# 根据是否指定n_kv_heads确定用于键key和值value的头的数量。
self.n_kv_heads = args.n_heads if args.n_kv_heads is None else args.n_kv_heads
# 确保总头数可以被键值头数整除。
assert args.n_heads % self.n_kv_heads == 0
# 模型并行处理大小默认为1。
model_parallel_size = 1
# 本地计算头数,等于总头数除以模型并行处理大小。
self.n_local_heads = args.n_heads // model_parallel_size
# 本地键值头数,等于键值头数除以模型并行处理大小。
self.n_local_kv_heads = self.n_kv_heads // model_parallel_size
# 重复次数,用于扩展键和值的尺寸。
self.n_rep = self.n_local_heads // self.n_local_kv_heads
# 每个头的维度,等于模型维度除以头的总数。
self.head_dim = args.dim // args.n_heads
# 定义权重矩阵。
self.wq = nn.Linear(args.dim, args.n_heads * self.head_dim, bias=False)
self.wk = nn.Linear(args.dim, self.n_kv_heads * self.head_dim, bias=False)
self.wv = nn.Linear(args.dim, self.n_kv_heads * self.head_dim, bias=False)
# 输出权重矩阵。
self.wo = nn.Linear(args.n_heads * self.head_dim, args.dim, bias=False)
# 定义dropout。
self.attn_dropout = nn.Dropout(args.dropout)
self.resid_dropout = nn.Dropout(args.dropout)
# 保存dropout概率。
self.dropout = args.dropout
# 检查是否使用Flash Attention需要PyTorch >= 2.0)。
self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention')
if not self.flash:
# 若不支持Flash Attention则使用手动实现的注意力机制并设置mask。
print("WARNING: using slow attention. Flash Attention requires PyTorch >= 2.0")
# 创建一个上三角矩阵,用于遮蔽未来信息。
mask = torch.full((1, 1, args.max_seq_len, args.max_seq_len), float("-inf"))
mask = torch.triu(mask, diagonal=1)
# 注册为模型的缓冲区
self.register_buffer("mask", mask)
def forward(self, x: torch.Tensor, freqs_cos: torch.Tensor, freqs_sin: torch.Tensor):
# 获取批次大小和序列长度,[batch_size, seq_len, dim]
bsz, seqlen, _ = x.shape
# 计算查询Q、键K、值V
xq, xk, xv = self.wq(x), self.wk(x), self.wv(x)
# 调整形状以适应头的维度。
xq = xq.view(bsz, seqlen, self.n_local_heads, self.head_dim)
xk = xk.view(bsz, seqlen, self.n_local_kv_heads, self.head_dim)
xv = xv.view(bsz, seqlen, self.n_local_kv_heads, self.head_dim)
# 应用旋转位置嵌入RoPE
xq, xk = apply_rotary_emb(xq, xk, freqs_cos, freqs_sin)
# 对键和值进行扩展以适应重复次数。
xk = repeat_kv(xk, self.n_rep)
xv = repeat_kv(xv, self.n_rep)
# 将头作为批次维度处理。
xq = xq.transpose(1, 2)
xk = xk.transpose(1, 2)
xv = xv.transpose(1, 2)
# 根据是否支持Flash Attention选择实现方式。
if self.flash:
# 使用Flash Attention。
output = torch.nn.functional.scaled_dot_product_attention(xq, xk, xv, attn_mask=None, dropout_p=self.dropout if self.training else 0.0, is_causal=True)
else:
# 使用手动实现的注意力机制。
scores = torch.matmul(xq, xk.transpose(2, 3)) / math.sqrt(self.head_dim)
assert hasattr(self, 'mask')
scores = scores + self.mask[:, :, :seqlen, :seqlen]
scores = F.softmax(scores.float(), dim=-1).type_as(xq)
scores = self.attn_dropout(scores)
output = torch.matmul(scores, xv)
# 恢复时间维度并合并头。
output = output.transpose(1, 2).contiguous().view(bsz, seqlen, -1)
# 最终投影回残差流。
output = self.wo(output)
output = self.resid_dropout(output)
return output
```
同样大家可以使用下面的代码来对`LLaMA2Attention`模块进行测试,可以看到代码最终输出的形状为`torch.Size([1, 50, 288])`,与我们输入的形状一致,说明模块的实现是正确的。
```python
# 创建Attention实例
attention_model = LLaMA2Attention(args)
# 模拟输入数据
batch_size = 1
seq_len = 50 # 假设实际使用的序列长度为50
dim = args.dim
x = torch.rand(batch_size, seq_len, dim) # 随机生成输入张量
# freqs_cos = torch.rand(seq_len, dim // 2) # 模拟cos频率用于RoPE
# freqs_sin = torch.rand(seq_len, dim // 2) # 模拟sin频率用于RoPE
freqs_cos, freqs_sin = precompute_freqs_cis(dim//args.n_heads, seq_len)
# 运行Attention模型
output = attention_model(x, freqs_cos, freqs_sin)
# attention出来之后的形状 依然是[batch_size, seq_len, dim]
print("Output shape:", output.shape)
```
OUT:
```
Output shape: torch.Size([1, 50, 288])
```
## 5.1.4 构建 LLaMA2 MLP模块
相对于前面我们实现的LLaMA2 Attention模块LLaMA2 MLP模块的实现要简单一些。我们可以通过如下代码实现`LLaMA2MLP`
```python
class LLaMA2MLP(nn.Module):
def __init__(self, dim: int, hidden_dim: int, multiple_of: int, dropout: float):
super().__init__()
# 如果没有指定隐藏层的维度我们将其设置为输入维度的4倍
# 然后将其减少到2/3最后确保它是multiple_of的倍数
if hidden_dim is None:
hidden_dim = 4 * dim
hidden_dim = int(2 * hidden_dim / 3)
hidden_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of)
# 定义第一层线性变换,从输入维度到隐藏维度
self.w1 = nn.Linear(dim, hidden_dim, bias=False)
# 定义第二层线性变换,从隐藏维度到输入维度
self.w2 = nn.Linear(hidden_dim, dim, bias=False)
# 定义第三层线性变换,从输入维度到隐藏维度
self.w3 = nn.Linear(dim, hidden_dim, bias=False)
# 定义dropout层用于防止过拟合
self.dropout = nn.Dropout(dropout)
def forward(self, x):
# 前向传播函数
# 首先输入x通过第一层线性变换和SILU激活函数
# 然后结果乘以输入x通过第三层线性变换的结果
# 最后通过第二层线性变换和dropout层
return self.dropout(self.w2(F.silu(self.w1(x)) * self.w3(x)))
```
我们着重观察一下`forward`函数的实现,首先,输入 `x` 通过第一层线性变换 `self.w1``SILU` 激活函数,然后,结果乘以输入 `x` 通过第三层线性变换 `self.w3` 的结果,最后,通过第二层线性变换 `self.w2``dropout` 层,得到最终输出。
同样大家可以使用下面的代码来对`LLaMAMLP`模块进行测试,可以看到代码最终输出的形状为`torch.Size([1, 50, 288])`,与我们输入的形状一致,说明模块的实现是正确的。
```python
# 创建MLP实例
mlp = LLaMA2MLP(args.dim, args.hidden_dim, args.multiple_of, args.dropout)
# 随机生成数据
x = torch.randn(1, 50, 288)
# 运行MLP模型
output = mlp(x)
print(output.shape)
```
OUT:
```
torch.Size([1, 50, 288])
```
## 5.1.5 LLaMA2 Decoder Layer
到这里,我们已经实现了`LLaMA2`模型的`Attention`模块和`MLP`模块,接下来我们就可以构建`LLaMA2``Decoder Layer`了。
```python
class LLaMA2DecoderLayer(nn.Module):
def __init__(self, layer_id: int, args: ModelArgs):
super().__init__()
# 定义多头注意力的头数
self.n_heads = args.n_heads
# 定义输入维度
self.dim = args.dim
# 定义每个头的维度,等于输入维度除以头数
self.head_dim = args.dim // args.n_heads
# 定义LLaMA2Attention对象用于进行多头注意力计算
self.attention = LLaMA2Attention(args)
# 定义LLaMAMLP对象用于进行前馈神经网络计算
self.feed_forward = LLaMA2MLP(
dim=args.dim,
hidden_dim=args.hidden_dim,
multiple_of=args.multiple_of,
dropout=args.dropout,
)
# 定义层的ID
self.layer_id = layer_id
# 定义注意力计算的归一化层
self.attention_norm = LLaMA2RMSNorm(args.dim, eps=args.norm_eps)
# 定义前馈神经网络计算的归一化层
self.ffn_norm = LLaMA2RMSNorm(args.dim, eps=args.norm_eps)
def forward(self, x, freqs_cos, freqs_sin):
# 前向传播函数
# 首先输入x经过注意力归一化层然后进行注意力计算结果与输入x相加得到h
# 然后h经过前馈神经网络归一化层然后进行前馈神经网络计算结果与h相加得到输出
h = x + self.attention.forward(self.attention_norm(x), freqs_cos, freqs_sin)
out = h + self.feed_forward.forward(self.ffn_norm(h))
return out
```
`DecoderLayer`就是把我们上面完成的`Attention`模块和`MLP`模块组合在一起,实现了一个完整的`Transformer`模块。
同样大家可以使用下面的代码来对`LLaMA2DecoderLayer`模块进行测试,可以看到代码最终输出的形状为`torch.Size([1, 50, 288])`,与我们输入的形状一致,说明模块的实现是正确的。
```python
# 创建LLaMADecoderLayer实例
decoderlayer = LLaMA2DecoderLayer(0, args)
# 模拟输入数据
dim = args.dim
seq_len = 50
x = torch.randn(1, seq_len, dim) # [bs, seq_len, dim]
freqs_cos, freqs_sin = precompute_freqs_cis(dim//args.n_heads, seq_len)
out = decoderlayer(x, freqs_cos, freqs_sin)
print(out.shape) # 形状和输入的x一样 [batch_size, seq_len, dim]
```
OUT:
```
torch.Size([1, 50, 288])
```
## 5.1.6 构建 LLaMA2 模型
好了,我们已经完了上述所有的模块的实现,接下来就是激动人心的时刻,我们可以构建`LLaMA2`模型了。,`LLaMA2`模型就是将`LLaMA2DecoderLayer`模块堆叠起来,构成一个完整的`Transformer`模型。
```python
class LLaMA2Model(nn.Module):
last_loss: Optional[torch.Tensor]
def __init__(self, args: ModelArgs):
super().__init__()
# 初始化模型参数
self.args = args
# 词汇表大小
self.vocab_size = args.vocab_size
# 层数
self.n_layers = args.n_layers
# 词嵌入层
self.tok_embeddings = nn.Embedding(args.vocab_size, args.dim)
# Dropout层
self.dropout = nn.Dropout(args.dropout)
# Decoder层
self.layers = torch.nn.ModuleList()
for layer_id in range(args.n_layers):
self.layers.append(LLaMA2DecoderLayer(layer_id, args))
# 归一化层
self.norm = LLaMA2RMSNorm(args.dim, eps=args.norm_eps)
# 输出层
self.output = nn.Linear(args.dim, args.vocab_size, bias=False)
# 将词嵌入层的权重与输出层的权重共享
self.tok_embeddings.weight = self.output.weight
# 预计算相对位置嵌入的频率
freqs_cos, freqs_sin = precompute_freqs_cis(self.args.dim // self.args.n_heads, self.args.max_seq_len)
self.register_buffer("freqs_cos", freqs_cos, persistent=False)
self.register_buffer("freqs_sin", freqs_sin, persistent=False)
# 初始化所有权重
self.apply(self._init_weights)
# 对残差投影进行特殊的缩放初始化
for pn, p in self.named_parameters():
if pn.endswith('w3.weight') or pn.endswith('wo.weight'):
torch.nn.init.normal_(p, mean=0.0, std=0.02/math.sqrt(2 * args.n_layers))
# 初始化最后一次前向传播的损失属性
self.last_loss = None
def _init_weights(self, module):
# 初始化权重的函数
if isinstance(module, nn.Linear):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
torch.nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
def forward(self, tokens: torch.Tensor, targets: Optional[torch.Tensor] = None) -> torch.Tensor:
# 前向传播函数
_bsz, seqlen = tokens.shape
# 通过词嵌入层和Dropout层
h = self.tok_embeddings(tokens)
h = self.dropout(h)
# 获取相对位置嵌入的频率
freqs_cos = self.freqs_cos[:seqlen]
freqs_sin = self.freqs_sin[:seqlen]
# 通过Decoder层
for layer in self.layers:
h = layer(h, freqs_cos, freqs_sin)
# 通过归一化层
h = self.norm(h)
if targets is not None:
# 如果给定了目标,计算损失
logits = self.output(h)
self.last_loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1)
else:
# 推理时的小优化:只对最后一个位置的输出进行前向传播
logits = self.output(h[:, [-1], :])
self.last_loss = None
return logits
```
同样大家可以使用下面的代码来对`LLaMA2Model`模块进行测试,可以看到代码最终输出的形状为`torch.Size([1, 50, 32000])`,与我们输入的形状一致,说明模块的实现是正确的。
```python
# LLaMA2Model.forward 接受两个参数tokens和targets其中tokens是输入的张量, 应为int类型
x = torch.randint(0, 32000, (1, 50)) # [bs, seq_len]
# 实例化LLaMA2Model
model = LLaMA2Model(args=args)
# 计算model的全部参数
num_params = sum(p.numel() for p in model.parameters())
print('Number of parameters:', num_params)
out = model(x)
print(out.shape) # [batch_size, 1, vocab_size]
```
OUT:
```
Number of parameters: 15191712
torch.Size([1, 1, 32000])
```

641
docs/chapter5/llama2.ipynb Normal file
View File

@@ -0,0 +1,641 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"import math\n",
"import struct\n",
"import inspect\n",
"from dataclasses import dataclass\n",
"from typing import Any, Optional, Tuple\n",
"\n",
"import numpy as np\n",
"import torch\n",
"import torch.nn.functional as F\n",
"from torch import nn"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [],
"source": [
"class ModelArgs:\n",
" # 自定义超参数\n",
" dim: int = 288 # 模型维度\n",
" n_layers: int = 6 # Transformer层数\n",
" n_heads: int = 6 # 注意力机制的头数\n",
" n_kv_heads: Optional[int] = 6 # 键/值头数如果未指定则默认为n_heads\n",
" vocab_size: int = 32000 # 词汇表大小\n",
" hidden_dim: Optional[int] = None # 隐藏层维度,如果未指定,则使用其他规则确定\n",
" multiple_of: int = 32 # MLP隐藏层大小是这个数的倍数\n",
" norm_eps: float = 1e-5 # 归一化层的epsilon值\n",
" max_seq_len: int = 256 # 最大序列长度\n",
" dropout: float = 0.0 # 丢弃率\n",
"\n",
"args = ModelArgs()"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [],
"source": [
"class LLaMA2RMSNorm(nn.Module):\n",
" def __init__(self, dim: int, eps: float):\n",
" super().__init__()\n",
" # eps是为了防止除以0的情况\n",
" self.eps = eps\n",
" # weight是一个可学习的参数全部初始化为1\n",
" self.weight = nn.Parameter(torch.ones(dim))\n",
"\n",
" def _norm(self, x):\n",
" # 计算RMSNorm的核心部分\n",
" # x.pow(2).mean(-1, keepdim=True)计算了输入x的平方的均值\n",
" # torch.rsqrt是平方根的倒数这样就得到了RMSNorm的分母部分再加上eps防止分母为0\n",
" # 最后乘以x得到RMSNorm的结果\n",
" return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)\n",
"\n",
" def forward(self, x):\n",
" # forward函数是模型的前向传播\n",
" # 首先将输入x转为float类型然后进行RMSNorm最后再转回原来的数据类型\n",
" # 最后乘以weight这是RMSNorm的一个可学习的缩放因子\n",
" output = self._norm(x.float()).type_as(x)\n",
" return output * self.weight"
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"torch.Size([1, 50, 288])\n"
]
}
],
"source": [
"norm = LLaMA2RMSNorm(args.dim, args.norm_eps)\n",
"x = torch.randn(1, 50, args.dim)\n",
"output = norm(x)\n",
"print(output.shape)"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [],
"source": [
"# 获得旋转嵌入的实部和虚部\n",
"# 注意此处的dim应为 dim//n_head因为我们是对每个head进行旋转嵌入\n",
"def precompute_freqs_cis(dim: int, end: int, theta: float = 10000.0):\n",
" # torch.arange(0, dim, 2)[: (dim // 2)].float()生成了一个从0开始步长为2的序列长度为dim的一半\n",
" # 然后每个元素除以dim再取theta的倒数得到频率\n",
" freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))\n",
" # 生成一个从0到end的序列长度为end\n",
" t = torch.arange(end, device=freqs.device)\n",
" # 计算外积得到一个二维矩阵每一行是t的元素乘以freqs的元素\n",
" freqs = torch.outer(t, freqs).float()\n",
" # 计算频率的余弦值,得到实部\n",
" freqs_cos = torch.cos(freqs)\n",
" # 计算频率的正弦值,得到虚部\n",
" freqs_sin = torch.sin(freqs)\n",
" return freqs_cos, freqs_sin"
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"torch.Size([50, 24]) torch.Size([50, 24])\n"
]
}
],
"source": [
"x = torch.randn(1, 50, 288)\n",
"freqs_cos, freqs_sin = precompute_freqs_cis(288//6, 50)\n",
"print(freqs_cos.shape, freqs_sin.shape)"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {},
"outputs": [],
"source": [
"# 此函数的作用是将freqs_cis调整为与x的形状相同以便能够与x进行广播操作\n",
"def reshape_for_broadcast(freqs_cis: torch.Tensor, x: torch.Tensor):\n",
" # 获取x的维度数\n",
" ndim = x.ndim\n",
" # 断言确保1在x的维度范围内\n",
" assert 0 <= 1 < ndim\n",
" # 断言确保freqs_cis的形状与x的第二维和最后一维相同\n",
" assert freqs_cis.shape == (x.shape[1], x.shape[-1])\n",
" # 构造一个新的形状除了第二维和最后一维其他维度都为1这样做是为了能够将freqs_cis与x进行广播操作\n",
" shape = [d if i == 1 or i == ndim - 1 else 1 for i, d in enumerate(x.shape)]\n",
" # 将freqs_cis调整为新的形状并返回\n",
" return freqs_cis.view(shape)"
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {},
"outputs": [],
"source": [
"def apply_rotary_emb(\n",
" xq: torch.Tensor,\n",
" xk: torch.Tensor,\n",
" freqs_cos: torch.Tensor,\n",
" freqs_sin: torch.Tensor\n",
") -> Tuple[torch.Tensor, torch.Tensor]:\n",
"\n",
" # 将查询和键张量转换为浮点数,并重塑形状以分离实部和虚部\n",
" xq_r, xq_i = xq.float().reshape(xq.shape[:-1] + (-1, 2)).unbind(-1)\n",
" xk_r, xk_i = xk.float().reshape(xk.shape[:-1] + (-1, 2)).unbind(-1)\n",
"\n",
" # 重新塑形频率张量以进行广播\n",
" freqs_cos = reshape_for_broadcast(freqs_cos, xq_r)\n",
" freqs_sin = reshape_for_broadcast(freqs_sin, xq_r)\n",
"\n",
" # 应用旋转,分别计算旋转后的实部和虚部\n",
" xq_out_r = xq_r * freqs_cos - xq_i * freqs_sin\n",
" xq_out_i = xq_r * freqs_sin + xq_i * freqs_cos\n",
" xk_out_r = xk_r * freqs_cos - xk_i * freqs_sin\n",
" xk_out_i = xk_r * freqs_sin + xk_i * freqs_cos\n",
"\n",
" # 将最后两个维度合并,并还原为原始张量的形状\n",
" xq_out = torch.stack([xq_out_r, xq_out_i], dim=-1).flatten(3)\n",
" xk_out = torch.stack([xk_out_r, xk_out_i], dim=-1).flatten(3)\n",
"\n",
" return xq_out.type_as(xq), xk_out.type_as(xk)"
]
},
{
"cell_type": "code",
"execution_count": 9,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"torch.Size([50, 24]) torch.Size([50, 24])\n"
]
},
{
"data": {
"text/plain": [
"(torch.Size([1, 50, 6, 48]), torch.Size([1, 50, 6, 48]))"
]
},
"execution_count": 9,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"xq = torch.randn(1, 50, 6, 48) # bs, seq_len, dim//n_head, n_head_dim\n",
"xk = torch.randn(1, 50, 6, 48) # bs, seq_len, dim//n_head, n_head_dim\n",
"\n",
"# 使用 precompute_freqs_cis 函数获取 sin和cos\n",
"cos, sin = precompute_freqs_cis(288//6, 50)\n",
"print(cos.shape, sin.shape)\n",
"xq_out, xk_out = apply_rotary_emb(xq, xk, cos, sin)\n",
"\n",
"xq_out.shape, xk_out.shape"
]
},
{
"cell_type": "code",
"execution_count": 10,
"metadata": {},
"outputs": [],
"source": [
"def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:\n",
" # 获取输入张量的形状:批量大小、序列长度、键/值对头的数量、每个头的维度大小\n",
" bs, slen, n_kv_heads, head_dim = x.shape\n",
" \n",
" # 如果重复次数为1则不需要重复直接返回原始张量\n",
" if n_rep == 1:\n",
" return x\n",
" \n",
" # 对张量进行扩展和重塑操作以重复键值对\n",
" return (\n",
" x[:, :, :, None, :] # 在第四个维度(头的维度前)添加一个新的维度\n",
" .expand(bs, slen, n_kv_heads, n_rep, head_dim) # 将新添加的维度扩展到n_rep大小实现重复的效果\n",
" .reshape(bs, slen, n_kv_heads * n_rep, head_dim) # 重新塑形,合并键/值对头的数量和重复次数的维度\n",
" )\n"
]
},
{
"cell_type": "code",
"execution_count": 13,
"metadata": {},
"outputs": [],
"source": [
"class LLaMA2Attention(nn.Module):\n",
" def __init__(self, args: ModelArgs):\n",
" super().__init__()\n",
" # 根据是否指定n_kv_heads确定用于键key和值value的头的数量。\n",
" self.n_kv_heads = args.n_heads if args.n_kv_heads is None else args.n_kv_heads\n",
" # 确保总头数可以被键值头数整除。\n",
" assert args.n_heads % self.n_kv_heads == 0\n",
"\n",
" # 模型并行处理大小默认为1。\n",
" model_parallel_size = 1\n",
" # 本地计算头数,等于总头数除以模型并行处理大小。\n",
" self.n_local_heads = args.n_heads // model_parallel_size\n",
" # 本地键值头数,等于键值头数除以模型并行处理大小。\n",
" self.n_local_kv_heads = self.n_kv_heads // model_parallel_size\n",
" # 重复次数,用于扩展键和值的尺寸。\n",
" self.n_rep = self.n_local_heads // self.n_local_kv_heads\n",
" # 每个头的维度,等于模型维度除以头的总数。\n",
" self.head_dim = args.dim // args.n_heads\n",
"\n",
" # 定义权重矩阵。\n",
" self.wq = nn.Linear(args.dim, args.n_heads * self.head_dim, bias=False)\n",
" self.wk = nn.Linear(args.dim, self.n_kv_heads * self.head_dim, bias=False)\n",
" self.wv = nn.Linear(args.dim, self.n_kv_heads * self.head_dim, bias=False)\n",
" # 输出权重矩阵。\n",
" self.wo = nn.Linear(args.n_heads * self.head_dim, args.dim, bias=False)\n",
"\n",
" # 定义dropout。\n",
" self.attn_dropout = nn.Dropout(args.dropout)\n",
" self.resid_dropout = nn.Dropout(args.dropout)\n",
" # 保存dropout概率。\n",
" self.dropout = args.dropout\n",
"\n",
" # 检查是否使用Flash Attention需要PyTorch >= 2.0)。\n",
" self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention')\n",
" if not self.flash:\n",
" # 若不支持Flash Attention则使用手动实现的注意力机制并设置mask。\n",
" print(\"WARNING: using slow attention. Flash Attention requires PyTorch >= 2.0\")\n",
" # 创建一个上三角矩阵,用于遮蔽未来信息。\n",
" mask = torch.full((1, 1, args.max_seq_len, args.max_seq_len), float(\"-inf\"))\n",
" mask = torch.triu(mask, diagonal=1)\n",
" # 注册为模型的缓冲区\n",
" self.register_buffer(\"mask\", mask)\n",
"\n",
" def forward(self, x: torch.Tensor, freqs_cos: torch.Tensor, freqs_sin: torch.Tensor):\n",
" # 获取批次大小和序列长度,[batch_size, seq_len, dim]\n",
" bsz, seqlen, _ = x.shape\n",
"\n",
" # 计算查询Q、键K、值V。\n",
" xq, xk, xv = self.wq(x), self.wk(x), self.wv(x)\n",
" # 调整形状以适应头的维度。\n",
" xq = xq.view(bsz, seqlen, self.n_local_heads, self.head_dim)\n",
" xk = xk.view(bsz, seqlen, self.n_local_kv_heads, self.head_dim)\n",
" xv = xv.view(bsz, seqlen, self.n_local_kv_heads, self.head_dim)\n",
"\n",
" # 应用旋转位置嵌入RoPE。\n",
" xq, xk = apply_rotary_emb(xq, xk, freqs_cos, freqs_sin)\n",
"\n",
" # 对键和值进行扩展以适应重复次数。\n",
" xk = repeat_kv(xk, self.n_rep)\n",
" xv = repeat_kv(xv, self.n_rep)\n",
"\n",
" # 将头作为批次维度处理。\n",
" xq = xq.transpose(1, 2)\n",
" xk = xk.transpose(1, 2)\n",
" xv = xv.transpose(1, 2)\n",
"\n",
" # 根据是否支持Flash Attention选择实现方式。\n",
" if self.flash:\n",
" # 使用Flash Attention。\n",
" output = torch.nn.functional.scaled_dot_product_attention(xq, xk, xv, attn_mask=None, dropout_p=self.dropout if self.training else 0.0, is_causal=True)\n",
" else:\n",
" # 使用手动实现的注意力机制。\n",
" scores = torch.matmul(xq, xk.transpose(2, 3)) / math.sqrt(self.head_dim)\n",
" assert hasattr(self, 'mask')\n",
" scores = scores + self.mask[:, :, :seqlen, :seqlen]\n",
" scores = F.softmax(scores.float(), dim=-1).type_as(xq)\n",
" scores = self.attn_dropout(scores)\n",
" output = torch.matmul(scores, xv)\n",
"\n",
" # 恢复时间维度并合并头。\n",
" output = output.transpose(1, 2).contiguous().view(bsz, seqlen, -1)\n",
"\n",
" # 最终投影回残差流。\n",
" output = self.wo(output)\n",
" output = self.resid_dropout(output)\n",
" return output"
]
},
{
"cell_type": "code",
"execution_count": 22,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"torch.Size([50, 24]) torch.Size([50, 24])\n",
"Output shape: torch.Size([1, 50, 288])\n"
]
}
],
"source": [
"# 创建Attention实例\n",
"attention_model = LLaMA2Attention(args)\n",
"\n",
"# 模拟输入数据\n",
"batch_size = 1\n",
"seq_len = 50 # 假设实际使用的序列长度为50\n",
"dim = args.dim\n",
"x = torch.rand(batch_size, seq_len, dim) # 随机生成输入张量\n",
"# freqs_cos = torch.rand(seq_len, dim // 2) # 模拟cos频率用于RoPE\n",
"# freqs_sin = torch.rand(seq_len, dim // 2) # 模拟sin频率用于RoPE\n",
"\n",
"freqs_cos, freqs_sin = precompute_freqs_cis(dim//args.n_heads, seq_len)\n",
"\n",
"print(freqs_cos.shape, freqs_sin.shape)\n",
"\n",
"# 运行Attention模型\n",
"output = attention_model(x, freqs_cos, freqs_sin)\n",
"\n",
"# attention出来之后的形状 依然是[batch_size, seq_len, dim]\n",
"print(\"Output shape:\", output.shape)"
]
},
{
"cell_type": "code",
"execution_count": 16,
"metadata": {},
"outputs": [],
"source": [
"class LLaMA2MLP(nn.Module):\n",
" def __init__(self, dim: int, hidden_dim: int, multiple_of: int, dropout: float):\n",
" super().__init__()\n",
" # 如果没有指定隐藏层的维度我们将其设置为输入维度的4倍\n",
" # 然后将其减少到2/3最后确保它是multiple_of的倍数\n",
" if hidden_dim is None:\n",
" hidden_dim = 4 * dim\n",
" hidden_dim = int(2 * hidden_dim / 3)\n",
" hidden_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of)\n",
" # 定义第一层线性变换,从输入维度到隐藏维度\n",
" self.w1 = nn.Linear(dim, hidden_dim, bias=False)\n",
" # 定义第二层线性变换,从隐藏维度到输入维度\n",
" self.w2 = nn.Linear(hidden_dim, dim, bias=False)\n",
" # 定义第三层线性变换,从输入维度到隐藏维度\n",
" self.w3 = nn.Linear(dim, hidden_dim, bias=False)\n",
" # 定义dropout层用于防止过拟合\n",
" self.dropout = nn.Dropout(dropout)\n",
"\n",
" def forward(self, x):\n",
" # 前向传播函数\n",
" # 首先输入x通过第一层线性变换和SILU激活函数\n",
" # 然后结果乘以输入x通过第三层线性变换的结果\n",
" # 最后通过第二层线性变换和dropout层\n",
" return self.dropout(self.w2(F.silu(self.w1(x)) * self.w3(x)))"
]
},
{
"cell_type": "code",
"execution_count": 19,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"torch.Size([1, 50, 288])\n"
]
}
],
"source": [
"# 创建MLP实例\n",
"mlp = LLaMAMLP(args.dim, args.hidden_dim, args.multiple_of, args.dropout)\n",
"# 随机生成数据\n",
"x = torch.randn(1, 50, 288)\n",
"# 运行MLP模型\n",
"output = mlp(x)\n",
"print(output.shape)"
]
},
{
"cell_type": "code",
"execution_count": 21,
"metadata": {},
"outputs": [],
"source": [
"class LLaMA2DecoderLayer(nn.Module):\n",
" def __init__(self, layer_id: int, args: ModelArgs):\n",
" super().__init__()\n",
" # 定义多头注意力的头数\n",
" self.n_heads = args.n_heads\n",
" # 定义输入维度\n",
" self.dim = args.dim\n",
" # 定义每个头的维度,等于输入维度除以头数\n",
" self.head_dim = args.dim // args.n_heads\n",
" # 定义LLaMA2Attention对象用于进行多头注意力计算\n",
" self.attention = LLaMA2Attention(args)\n",
" # 定义LLaMAMLP对象用于进行前馈神经网络计算\n",
" self.feed_forward = LLaMA2MLP(\n",
" dim=args.dim,\n",
" hidden_dim=args.hidden_dim,\n",
" multiple_of=args.multiple_of,\n",
" dropout=args.dropout,\n",
" )\n",
" # 定义层的ID\n",
" self.layer_id = layer_id\n",
" # 定义注意力计算的归一化层\n",
" self.attention_norm = LLaMA2RMSNorm(args.dim, eps=args.norm_eps)\n",
" # 定义前馈神经网络计算的归一化层\n",
" self.ffn_norm = LLaMA2RMSNorm(args.dim, eps=args.norm_eps)\n",
"\n",
" def forward(self, x, freqs_cos, freqs_sin):\n",
" # 前向传播函数\n",
" # 首先输入x经过注意力归一化层然后进行注意力计算结果与输入x相加得到h\n",
" # 然后h经过前馈神经网络归一化层然后进行前馈神经网络计算结果与h相加得到输出\n",
" h = x + self.attention.forward(self.attention_norm(x), freqs_cos, freqs_sin)\n",
" out = h + self.feed_forward.forward(self.ffn_norm(h))\n",
" return out"
]
},
{
"cell_type": "code",
"execution_count": 25,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"torch.Size([1, 50, 288]) torch.Size([50, 24]) torch.Size([50, 24])\n",
"torch.Size([1, 50, 288])\n"
]
}
],
"source": [
"# LLaMADecoderLayer.forward 函数的输入是 x, freqs_cos, freqs_sin, 其中x的形状是[batch_size, seq_len, dim]\n",
"# 由于llama2使用了GQA Attention所以precompute_freqs_cis函数输入参数应该为dim//n_headsseq_len、\n",
"\n",
"# 创建LLaMADecoderLayer实例\n",
"decoderlayer = LLaMA2DecoderLayer(0, args)\n",
"\n",
"# 模拟输入数据\n",
"dim = args.dim\n",
"seq_len = 50\n",
"\n",
"x = torch.randn(1, seq_len, dim) # [bs, seq_len, dim]\n",
"\n",
"freqs_cos, freqs_sin = precompute_freqs_cis(dim//args.n_heads, seq_len)\n",
"print(x.shape, freqs_cos.shape, freqs_sin.shape)\n",
"\n",
"out = decoderlayer(x, freqs_cos, freqs_sin)\n",
"\n",
"print(out.shape) # 形状和输入的x一样 [batch_size, seq_len, dim]"
]
},
{
"cell_type": "code",
"execution_count": 33,
"metadata": {},
"outputs": [],
"source": [
"class LLaMA2Model(nn.Module):\n",
" last_loss: Optional[torch.Tensor]\n",
"\n",
" def __init__(self, args: ModelArgs):\n",
" super().__init__()\n",
" # 初始化模型参数\n",
" self.args = args\n",
" # 词汇表大小\n",
" self.vocab_size = args.vocab_size\n",
" # 层数\n",
" self.n_layers = args.n_layers\n",
"\n",
" # 词嵌入层\n",
" self.tok_embeddings = nn.Embedding(args.vocab_size, args.dim)\n",
" # Dropout层\n",
" self.dropout = nn.Dropout(args.dropout)\n",
" # Decoder层\n",
" self.layers = torch.nn.ModuleList()\n",
" for layer_id in range(args.n_layers):\n",
" self.layers.append(LLaMA2DecoderLayer(layer_id, args))\n",
" # 归一化层\n",
" self.norm = LLaMA2RMSNorm(args.dim, eps=args.norm_eps)\n",
" # 输出层\n",
" self.output = nn.Linear(args.dim, args.vocab_size, bias=False)\n",
"\n",
" # 将词嵌入层的权重与输出层的权重共享\n",
" self.tok_embeddings.weight = self.output.weight \n",
"\n",
" # 预计算相对位置嵌入的频率\n",
" freqs_cos, freqs_sin = precompute_freqs_cis(self.args.dim // self.args.n_heads, self.args.max_seq_len)\n",
" self.register_buffer(\"freqs_cos\", freqs_cos, persistent=False)\n",
" self.register_buffer(\"freqs_sin\", freqs_sin, persistent=False)\n",
"\n",
" # 初始化所有权重\n",
" self.apply(self._init_weights)\n",
" # 对残差投影进行特殊的缩放初始化\n",
" for pn, p in self.named_parameters():\n",
" if pn.endswith('w3.weight') or pn.endswith('wo.weight'):\n",
" torch.nn.init.normal_(p, mean=0.0, std=0.02/math.sqrt(2 * args.n_layers))\n",
"\n",
" # 初始化最后一次前向传播的损失属性\n",
" self.last_loss = None\n",
"\n",
" def _init_weights(self, module):\n",
" # 初始化权重的函数\n",
" if isinstance(module, nn.Linear):\n",
" torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)\n",
" if module.bias is not None:\n",
" torch.nn.init.zeros_(module.bias)\n",
" elif isinstance(module, nn.Embedding):\n",
" torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)\n",
" \n",
" def forward(self, tokens: torch.Tensor, targets: Optional[torch.Tensor] = None) -> torch.Tensor:\n",
" # 前向传播函数\n",
" _bsz, seqlen = tokens.shape\n",
" # 通过词嵌入层和Dropout层\n",
" h = self.tok_embeddings(tokens)\n",
" h = self.dropout(h)\n",
" # 获取相对位置嵌入的频率\n",
" freqs_cos = self.freqs_cos[:seqlen]\n",
" freqs_sin = self.freqs_sin[:seqlen]\n",
"\n",
" # 通过Decoder层\n",
" for layer in self.layers:\n",
" h = layer(h, freqs_cos, freqs_sin)\n",
" # 通过归一化层\n",
" h = self.norm(h)\n",
"\n",
" if targets is not None:\n",
" # 如果给定了目标,计算损失\n",
" logits = self.output(h)\n",
" self.last_loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1)\n",
" else:\n",
" # 推理时的小优化:只对最后一个位置的输出进行前向传播\n",
" logits = self.output(h[:, [-1], :]) \n",
" self.last_loss = None\n",
"\n",
" return logits"
]
},
{
"cell_type": "code",
"execution_count": 37,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Number of parameters: 15191712\n",
"torch.Size([1, 1, 32000])\n"
]
}
],
"source": [
"# LLaMA2Model.forward 接受两个参数tokens和targets其中tokens是输入的张量, 应为int类型\n",
"x = torch.randint(0, 32000, (1, 50)) # [bs, seq_len]\n",
"# 实例化LLaMA2Model\n",
"model = LLaMA2Model(args=args)\n",
"# 计算model的全部参数\n",
"num_params = sum(p.numel() for p in model.parameters())\n",
"print('Number of parameters:', num_params)\n",
"\n",
"out = model(x)\n",
"print(out.shape) # [batch_size, 1, vocab_size]"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "nlp",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.13"
}
},
"nbformat": 4,
"nbformat_minor": 2
}

View File

@@ -0,0 +1,367 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
'''
@File : llama2_model.py
@Time : 2024/04/14 22:26:35
@Author : 不要葱姜蒜
@Version : 1.0
@Desc : 部分代码借鉴llama2.c仓库代码
'''
import math
import struct
import inspect
from dataclasses import dataclass
from typing import Any, Optional, Tuple
import numpy as np
import torch
import torch.nn.functional as F
from torch import nn
@dataclass
class ModelArgs:
# 自定义超参数
dim: int = 288 # 模型维度
n_layers: int = 6 # Transformer层数
n_heads: int = 6 # 注意力机制的头数
n_kv_heads: Optional[int] = 6 # 键/值头数如果未指定则默认为n_heads
vocab_size: int = 32000 # 词汇表大小
hidden_dim: Optional[int] = None # 隐藏层维度,如果未指定,则使用其他规则确定
multiple_of: int = 32 # MLP隐藏层大小是这个数的倍数
norm_eps: float = 1e-5 # 归一化层的epsilon值
max_seq_len: int = 256 # 最大序列长度
dropout: float = 0.0 # 丢弃率
class LLaMA2RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float):
super().__init__()
# eps是为了防止除以0的情况
self.eps = eps
# weight是一个可学习的参数全部初始化为1
self.weight = nn.Parameter(torch.ones(dim))
def _norm(self, x):
# 计算RMSNorm的核心部分
# x.pow(2).mean(-1, keepdim=True)计算了输入x的平方的均值
# torch.rsqrt是平方根的倒数这样就得到了RMSNorm的分母部分再加上eps防止分母为0
# 最后乘以x得到RMSNorm的结果
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
def forward(self, x):
# forward函数是模型的前向传播
# 首先将输入x转为float类型然后进行RMSNorm最后再转回原来的数据类型
# 最后乘以weight这是RMSNorm的一个可学习的缩放因子
output = self._norm(x.float()).type_as(x)
return output * self.weight
# 获得旋转嵌入的实部和虚部
# 注意此处的dim应为 dim//n_head因为我们是对每个head进行旋转嵌入
def precompute_freqs_cis(dim: int, end: int, theta: float = 10000.0):
# torch.arange(0, dim, 2)[: (dim // 2)].float()生成了一个从0开始步长为2的序列长度为dim的一半
# 然后每个元素除以dim再取theta的倒数得到频率
freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
# 生成一个从0到end的序列长度为end
t = torch.arange(end, device=freqs.device)
# 计算外积得到一个二维矩阵每一行是t的元素乘以freqs的元素
freqs = torch.outer(t, freqs).float()
# 计算频率的余弦值,得到实部
freqs_cos = torch.cos(freqs)
# 计算频率的正弦值,得到虚部
freqs_sin = torch.sin(freqs)
return freqs_cos, freqs_sin
# 此函数的作用是将freqs_cis调整为与x的形状相同以便能够与x进行广播操作
def reshape_for_broadcast(freqs_cis: torch.Tensor, x: torch.Tensor):
# 获取x的维度数
ndim = x.ndim
# 断言确保1在x的维度范围内
assert 0 <= 1 < ndim
# 断言确保freqs_cis的形状与x的第二维和最后一维相同
assert freqs_cis.shape == (x.shape[1], x.shape[-1])
# 构造一个新的形状除了第二维和最后一维其他维度都为1这样做是为了能够将freqs_cis与x进行广播操作
shape = [d if i == 1 or i == ndim - 1 else 1 for i, d in enumerate(x.shape)]
# 将freqs_cis调整为新的形状并返回
return freqs_cis.view(shape)
def apply_rotary_emb(
xq: torch.Tensor,
xk: torch.Tensor,
freqs_cos: torch.Tensor,
freqs_sin: torch.Tensor
) -> Tuple[torch.Tensor, torch.Tensor]:
# 将查询和键张量转换为浮点数,并重塑形状以分离实部和虚部
xq_r, xq_i = xq.float().reshape(xq.shape[:-1] + (-1, 2)).unbind(-1)
xk_r, xk_i = xk.float().reshape(xk.shape[:-1] + (-1, 2)).unbind(-1)
# 重新塑形频率张量以进行广播
freqs_cos = reshape_for_broadcast(freqs_cos, xq_r)
freqs_sin = reshape_for_broadcast(freqs_sin, xq_r)
# 应用旋转,分别计算旋转后的实部和虚部
xq_out_r = xq_r * freqs_cos - xq_i * freqs_sin
xq_out_i = xq_r * freqs_sin + xq_i * freqs_cos
xk_out_r = xk_r * freqs_cos - xk_i * freqs_sin
xk_out_i = xk_r * freqs_sin + xk_i * freqs_cos
# 将最后两个维度合并,并还原为原始张量的形状
xq_out = torch.stack([xq_out_r, xq_out_i], dim=-1).flatten(3)
xk_out = torch.stack([xk_out_r, xk_out_i], dim=-1).flatten(3)
return xq_out.type_as(xq), xk_out.type_as(xk)
def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:
# 获取输入张量的形状:批量大小、序列长度、键/值对头的数量、每个头的维度大小
bs, slen, n_kv_heads, head_dim = x.shape
# 如果重复次数为1则不需要重复直接返回原始张量
if n_rep == 1:
return x
# 对张量进行扩展和重塑操作以重复键值对
return (
x[:, :, :, None, :] # 在第四个维度(头的维度前)添加一个新的维度
.expand(bs, slen, n_kv_heads, n_rep, head_dim) # 将新添加的维度扩展到n_rep大小实现重复的效果
.reshape(bs, slen, n_kv_heads * n_rep, head_dim) # 重新塑形,合并键/值对头的数量和重复次数的维度
)
class LLaMA2Attention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
# 根据是否指定n_kv_heads确定用于键key和值value的头的数量。
self.n_kv_heads = args.n_heads if args.n_kv_heads is None else args.n_kv_heads
# 确保总头数可以被键值头数整除。
assert args.n_heads % self.n_kv_heads == 0
# 模型并行处理大小默认为1。
model_parallel_size = 1
# 本地计算头数,等于总头数除以模型并行处理大小。
self.n_local_heads = args.n_heads // model_parallel_size
# 本地键值头数,等于键值头数除以模型并行处理大小。
self.n_local_kv_heads = self.n_kv_heads // model_parallel_size
# 重复次数,用于扩展键和值的尺寸。
self.n_rep = self.n_local_heads // self.n_local_kv_heads
# 每个头的维度,等于模型维度除以头的总数。
self.head_dim = args.dim // args.n_heads
# 定义权重矩阵。
self.wq = nn.Linear(args.dim, args.n_heads * self.head_dim, bias=False)
self.wk = nn.Linear(args.dim, self.n_kv_heads * self.head_dim, bias=False)
self.wv = nn.Linear(args.dim, self.n_kv_heads * self.head_dim, bias=False)
# 输出权重矩阵。
self.wo = nn.Linear(args.n_heads * self.head_dim, args.dim, bias=False)
# 定义dropout。
self.attn_dropout = nn.Dropout(args.dropout)
self.resid_dropout = nn.Dropout(args.dropout)
# 保存dropout概率。
self.dropout = args.dropout
# 检查是否使用Flash Attention需要PyTorch >= 2.0)。
self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention')
if not self.flash:
# 若不支持Flash Attention则使用手动实现的注意力机制并设置mask。
print("WARNING: using slow attention. Flash Attention requires PyTorch >= 2.0")
# 创建一个上三角矩阵,用于遮蔽未来信息。
mask = torch.full((1, 1, args.max_seq_len, args.max_seq_len), float("-inf"))
mask = torch.triu(mask, diagonal=1)
# 注册为模型的缓冲区
self.register_buffer("mask", mask)
def forward(self, x: torch.Tensor, freqs_cos: torch.Tensor, freqs_sin: torch.Tensor):
# 获取批次大小和序列长度,[batch_size, seq_len, dim]
bsz, seqlen, _ = x.shape
# 计算查询Q、键K、值V
xq, xk, xv = self.wq(x), self.wk(x), self.wv(x)
# 调整形状以适应头的维度。
xq = xq.view(bsz, seqlen, self.n_local_heads, self.head_dim)
xk = xk.view(bsz, seqlen, self.n_local_kv_heads, self.head_dim)
xv = xv.view(bsz, seqlen, self.n_local_kv_heads, self.head_dim)
# 应用旋转位置嵌入RoPE
xq, xk = apply_rotary_emb(xq, xk, freqs_cos, freqs_sin)
# 对键和值进行扩展以适应重复次数。
xk = repeat_kv(xk, self.n_rep)
xv = repeat_kv(xv, self.n_rep)
# 将头作为批次维度处理。
xq = xq.transpose(1, 2)
xk = xk.transpose(1, 2)
xv = xv.transpose(1, 2)
# 根据是否支持Flash Attention选择实现方式。
if self.flash:
# 使用Flash Attention。
output = torch.nn.functional.scaled_dot_product_attention(xq, xk, xv, attn_mask=None, dropout_p=self.dropout if self.training else 0.0, is_causal=True)
else:
# 使用手动实现的注意力机制。
scores = torch.matmul(xq, xk.transpose(2, 3)) / math.sqrt(self.head_dim)
assert hasattr(self, 'mask')
scores = scores + self.mask[:, :, :seqlen, :seqlen]
scores = F.softmax(scores.float(), dim=-1).type_as(xq)
scores = self.attn_dropout(scores)
output = torch.matmul(scores, xv)
# 恢复时间维度并合并头。
output = output.transpose(1, 2).contiguous().view(bsz, seqlen, -1)
# 最终投影回残差流。
output = self.wo(output)
output = self.resid_dropout(output)
return output
class LLaMA2MLP(nn.Module):
def __init__(self, dim: int, hidden_dim: int, multiple_of: int, dropout: float):
super().__init__()
# 如果没有指定隐藏层的维度我们将其设置为输入维度的4倍
# 然后将其减少到2/3最后确保它是multiple_of的倍数
if hidden_dim is None:
hidden_dim = 4 * dim
hidden_dim = int(2 * hidden_dim / 3)
hidden_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of)
# 定义第一层线性变换,从输入维度到隐藏维度
self.w1 = nn.Linear(dim, hidden_dim, bias=False)
# 定义第二层线性变换,从隐藏维度到输入维度
self.w2 = nn.Linear(hidden_dim, dim, bias=False)
# 定义第三层线性变换,从输入维度到隐藏维度
self.w3 = nn.Linear(dim, hidden_dim, bias=False)
# 定义dropout层用于防止过拟合
self.dropout = nn.Dropout(dropout)
def forward(self, x):
# 前向传播函数
# 首先输入x通过第一层线性变换和SILU激活函数
# 然后结果乘以输入x通过第三层线性变换的结果
# 最后通过第二层线性变换和dropout层
return self.dropout(self.w2(F.silu(self.w1(x)) * self.w3(x)))
class LLaMA2DecoderLayer(nn.Module):
def __init__(self, layer_id: int, args: ModelArgs):
super().__init__()
# 定义多头注意力的头数
self.n_heads = args.n_heads
# 定义输入维度
self.dim = args.dim
# 定义每个头的维度,等于输入维度除以头数
self.head_dim = args.dim // args.n_heads
# 定义LLaMA2Attention对象用于进行多头注意力计算
self.attention = LLaMA2Attention(args)
# 定义LLaMAMLP对象用于进行前馈神经网络计算
self.feed_forward = LLaMA2MLP(
dim=args.dim,
hidden_dim=args.hidden_dim,
multiple_of=args.multiple_of,
dropout=args.dropout,
)
# 定义层的ID
self.layer_id = layer_id
# 定义注意力计算的归一化层
self.attention_norm = LLaMA2RMSNorm(args.dim, eps=args.norm_eps)
# 定义前馈神经网络计算的归一化层
self.ffn_norm = LLaMA2RMSNorm(args.dim, eps=args.norm_eps)
def forward(self, x, freqs_cos, freqs_sin):
# 前向传播函数
# 首先输入x经过注意力归一化层然后进行注意力计算结果与输入x相加得到h
# 然后h经过前馈神经网络归一化层然后进行前馈神经网络计算结果与h相加得到输出
h = x + self.attention.forward(self.attention_norm(x), freqs_cos, freqs_sin)
out = h + self.feed_forward.forward(self.ffn_norm(h))
return out
class LLaMA2Model(nn.Module):
last_loss: Optional[torch.Tensor]
def __init__(self, args: ModelArgs):
super().__init__()
# 初始化模型参数
self.args = args
# 词汇表大小
self.vocab_size = args.vocab_size
# 层数
self.n_layers = args.n_layers
# 词嵌入层
self.tok_embeddings = nn.Embedding(args.vocab_size, args.dim)
# Dropout层
self.dropout = nn.Dropout(args.dropout)
# Decoder层
self.layers = torch.nn.ModuleList()
for layer_id in range(args.n_layers):
self.layers.append(LLaMA2DecoderLayer(layer_id, args))
# 归一化层
self.norm = LLaMA2RMSNorm(args.dim, eps=args.norm_eps)
# 输出层
self.output = nn.Linear(args.dim, args.vocab_size, bias=False)
# 将词嵌入层的权重与输出层的权重共享
self.tok_embeddings.weight = self.output.weight
# 预计算相对位置嵌入的频率
freqs_cos, freqs_sin = precompute_freqs_cis(self.args.dim // self.args.n_heads, self.args.max_seq_len)
self.register_buffer("freqs_cos", freqs_cos, persistent=False)
self.register_buffer("freqs_sin", freqs_sin, persistent=False)
# 初始化所有权重
self.apply(self._init_weights)
# 对残差投影进行特殊的缩放初始化
for pn, p in self.named_parameters():
if pn.endswith('w3.weight') or pn.endswith('wo.weight'):
torch.nn.init.normal_(p, mean=0.0, std=0.02/math.sqrt(2 * args.n_layers))
# 初始化最后一次前向传播的损失属性
self.last_loss = None
def _init_weights(self, module):
# 初始化权重的函数
if isinstance(module, nn.Linear):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
torch.nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
def forward(self, tokens: torch.Tensor, targets: Optional[torch.Tensor] = None) -> torch.Tensor:
# 前向传播函数
_bsz, seqlen = tokens.shape
# 通过词嵌入层和Dropout层
h = self.tok_embeddings(tokens)
h = self.dropout(h)
# 获取相对位置嵌入的频率
freqs_cos = self.freqs_cos[:seqlen]
freqs_sin = self.freqs_sin[:seqlen]
# 通过Decoder层
for layer in self.layers:
h = layer(h, freqs_cos, freqs_sin)
# 通过归一化层
h = self.norm(h)
if targets is not None:
# 如果给定了目标,计算损失
logits = self.output(h)
self.last_loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1)
else:
# 推理时的小优化:只对最后一个位置的输出进行前向传播
logits = self.output(h[:, [-1], :])
self.last_loss = None
return logits
if __name__ == '__main__':
args = ModelArgs()
# LLaMA2Model.forward 接受两个参数tokens和targets其中tokens是输入的张量, 应为int类型
x = torch.randint(0, 32000, (1, 50)) # [bs, seq_len]
# 实例化LLaMA2Model
model = LLaMA2Model(args=args)
# 计算model的全部参数
num_params = sum(p.numel() for p in model.parameters())
print('Number of parameters:', num_params)
out = model(x)
print(out.shape) # [batch_size, 1, vocab_size]

5
参考资料.md Normal file
View File

@@ -0,0 +1,5 @@
1. https://github.com/karpathy/llama2.c
2. https://jalammar.github.io/illustrated-gpt2/
3. https://www.cipsc.org.cn/uploadfiles/2023/02/20230207154741181.pdf
4. https://www.zhihu.com/people/4338c2384a026f9c1f430d81771f0e0a
5. https://github.com/Tongjilibo/build_MiniLLM_from_scratch