feat: 完整中文翻译 maths-cs-ai-compendium(数学·计算机科学·AI 知识大全)

翻译自英文原版 maths-cs-ai-compendium,共 20 章全部完成。

第01章 向量 | 第02章 矩阵 | 第03章 微积分
第04章 统计学 | 第05章 概率论 | 第06章 机器学习
第07章 计算语言学 | 第08章 计算机视觉 | 第09章 音频与语音
第10章 多模态学习 | 第11章 自主系统 | 第12章 图神经网络
第13章 计算与操作系统 | 第14章 数据结构与算法
第15章 生产级软件工程 | 第16章 SIMD与GPU编程
第17章 AI推理 | 第18章 ML系统设计
第19章 应用人工智能 | 第20章 前沿人工智能

翻译说明:
- 所有数学公式 $...$ / $$...$$、代码块、图片引用完整保留
- mkdocs.yml 配置中文导航 + language: zh
- README.md 已翻译为中文(兼 docs/index.md)
- docs/ 目录包含指向各章文件的 symlink
- 约 29,000 行中文内容,排除 .cache/ 构建缓存
This commit is contained in:
2026-05-03 10:23:20 +08:00
commit 2536c937e3
400 changed files with 49040 additions and 0 deletions
@@ -0,0 +1,303 @@
# 语言学基础
*语言学为NLP系统提供了它们隐式学习并利用的结构化词汇。本文涵盖形态学、句法学、语义学、语用学、音系学、成分句法和依存句法分析,以及分布假设——这些人类语言科学构成了AI中词元化、语法和意义的基础。*
- 在构建能够理解或生成语言的系统之前,我们需要理解语言本身是如何运作的。
- 语言学是对语言的科学研究,它为NLP提供了不断借用的概念性词汇。
- 即使是现代神经模型——它们从原始数据中学习语言——也会隐式地重新发现语言学家们几十年来已经编目的许多结构。
- 语言在每一层都具有结构:组成单词的声音、组成单词的部件、将单词组合成句子的规则、这些句子所承载的意义,以及语境如何塑造解读。我们将自下而上地逐层探索。
- **形态学**是对单词内部结构的研究。单词并非不可分割的原子;它们由更小的有意义的单元构建而成,这些单元称为**语素**。
- 单词"unhappiness"包含三个语素:"un-"(前缀,意为"不")、"happy"(词根)和"-ness"(后缀,将形容词转化为名词)。每个语素都对意义有所贡献。
- **词根**(或称词干)是承载主要意义的核心语素。"Happy"、"run"、"compute"都是词根。
- **词缀**是附加到词根上以修饰其意义或语法功能的语素。
- 英语中有**前缀**(位于词根之前:un-、re-、pre-)和**后缀**(位于词根之后:-ing、-ed、-tion)。一些语言还包含中缀(插入词根内部)和环缀(包裹在词根周围)。
![语素树:"unhappiness"分解为前缀"un"、词根"happy"、后缀"ness"](../images/morpheme_tree.svg)
- 形态学对NLP很重要,因为它影响词元化。一个基于词级的词元化器会将"run"、"runs"、"running"和"ran"视为四个互不相关的符号。
- 一个具有形态学意识的系统会识别出它们共享同一个词根。子词词元化(BPE、WordPiece)——我们将在文件02中讨论——是形态学分析的统计近似方法。
- **句法学**研究单词如何组合成短语和句子。每种语言都有控制词序和结构的规则;违反这些规则会产生无意义的输出。
- "The cat sat on the mat"是合乎语法的英语;"Mat the on sat cat the"则不是。
- 描述句法结构主要有两种框架。
- **短语结构语法**(也称为成分语法)认为句子是通过将一个短语嵌套在另一个短语内部构建而成的。一个句子(S)由一个名词短语(NP)和一个动词短语(VP)组成。
- 一个名词短语可能由一个限定词(Det)后跟一个名词(N)组成。一个动词短语可能由一个动词(V)后跟一个名词短语组成。这些规则构建出一棵树:
![成分树:"the cat sat on the mat"S分支为NP和VPNP分支为Det"the"和N"cat"VP分支为V"sat"和PPPP分支为P"on"和NP](../images/constituency_tree.svg)
- 这棵树称为**成分树**(或分析树)。每个内部节点是一个短语类型,每个叶子节点是一个单词。这棵树捕捉了层次化分组:"on the mat"是一个单元(介词短语),"sat on the mat"是一个单元(动词短语),而整个结构是一个句子。
- **上下文无关文法(CFG)**将这些规则形式化。它由一组产生式规则组成,每条规则的形式为 $A \to \alpha$,其中 $A$ 是一个非终结符(如NP或VP这样的短语类型),$\alpha$ 是一个由终结符(单词)和非终结符组成的序列。例如:
```
S → NP VP
NP → Det N
NP → Det N PP
VP → V NP
VP → V PP
PP → P NP
Det → "the" | "a"
N → "cat" | "mat" | "dog"
V → "sat" | "chased"
P → "on" | "under"
```
- 从S开始,反复应用规则,你可以生成该文法允许的所有句子。分析则是相反的过程:给定一个句子,找出产生它的树(或所有可能的树)。一个有多个有效分析树的句子称为**句法歧义**。"I saw the man with the telescope"有两种分析:我使用望远镜看到了那个男人,或者我看到了一个拿着望远镜的男人。
- **依存语法**采取了一种不同的视角。它不依赖短语嵌套,而是描述单词之间的直接关系。句子中的每个单词都恰好依赖于另一个单词(它的**核心词**),除了句子的根节点。结果是一个**依存树**,其中边标有语法关系标签(主语、宾语、修饰语等)。
![依存树:"the cat sat on the mat":从"sat"到"cat"的箭头(nsubj)和到"on"的箭头(prep),从"on"到"mat"的箭头(pobj),从"cat"到"the"的箭头(det),从"mat"到"the"的箭头(det](../images/dependency_tree.svg)
- 在依存视角下,"sat"是根节点。"Cat"作为主语(nsubj)依赖于"sat"。"On"作为介词修饰语依赖于"sat"。"Mat"作为介词宾语依赖于"on"。每个单词都挂在恰好一个核心词上,形成一棵树。
- 依存语法已成为现代NLP中的主导框架,因为依存树更容易用统计分析器生成,而且这些关系更直接地映射到语义角色(谁对谁做了什么)。
- **配价**描述一个动词需要多少个论元。"Sleep"是**不及物动词**(一个论元:睡觉者)。"Eat"是**及物动词**(两个:吃者和被吃之物)。"Give"是**双及物动词**(三个:给予者、给予之物和接受者)。了解动词的配价可以约束哪些分析树是有效的。
- **语义学**是对意义的研究。句法学告诉你句子是如何结构的;语义学告诉你句子意味着什么。
- **词汇语义学**关注单个单词的意义。单词之间以系统性的方式相互关联:
- **同义关系**:具有(几乎)相同意义的单词。"Big"和"large"是同义词。真正完美的同义词是罕见的;几乎总是存在含义或用法上的细微差别。
- **反义关系**:具有相反意义的单词。"Hot"和"cold""buy"和"sell"。
- **上位关系/下位关系**"是一种"关系。"Dog"是"animal"的下位词(狗是一种动物)。"Animal"是"dog"的上位词。这些关系形成分类层次结构。
- **部分整体关系**"组成部分"关系。"Wheel"是"car"的部分词。
- **多义关系**:一个单词具有多个相关意义。"Bank"可以指金融机构或河岸。语境可以消除歧义。
- **词义消歧(WSD)**是根据上下文确定多义词的哪个义项被使用的任务。在"I deposited money at the bank"中,金融义项是正确的。在"We sat by the river bank"中,地理义项是正确的。WSD是早期NLP中的一个核心问题;现代的上下文嵌入(ELMo、BERT)通过为同一个单词的不同用法生成不同的向量表示,在很大程度上解决了这个问题。
- **组合语义学**研究单个单词的意义如何组合以形成短语或句子的意义。**组合性原则**(归功于弗雷格)指出,一个复杂表达式的意义由其组成部分的意义以及组合这些部分的规则共同决定。"The cat chased the dog"与"the dog chased the cat"意义不同,因为句法结构(谁是主语、谁是宾语)与单词意义相互作用。
- 并非所有意义都是组合性的。**习语**如"kick the bucket"(意为"去世")具有无法从其组成部分推导出的意义。这对任何组合性方法都是一个挑战。
- **分布语义学**是支撑现代NLP的计算性意义研究方法。**分布假设**(Firth, 1957)指出:"观其伴,知其意。"You shall know a word by the company it keeps.)出现在相似语境中的单词往往具有相似的意义。这是词嵌入(Word2Vec、GloVe)的理论基础,我们将在文件03中深入探讨。
- **语用学**研究语境如何影响意义。同一个句子根据说话者、时间、地点和原因的不同,可能意味着不同的事情。
- "Can you pass the salt?"在句法上是一个关于能力的疑问句。在语用上,它是一个请求。你不会回答"是的,我能"然后坐着不动。理解这一点需要超越字面意义的知识,具体来说,是关于**言语行为**的惯例知识。
- **言语行为理论**Austin, Searle)区分了:
- **言内行为**:字面内容("Can you pass the salt?"
- **言外行为**:意图实现的功能(一个请求)
- **言后行为**:对听者产生的效果(他们递过盐)
- **隐涵**(Grice)是指被暗示但未明确陈述的意义。如果有人问"Is John a good cook?"而你回答"He's British",你并没有从字面上回答问题,但听者可以推断(通过文化刻板印象,无论公平与否)你的意思是"不好"。Grice的**合作原则**指出,说话者通常会努力做到信息充分、真实、相关和清晰,而听者假定这些准则成立来进行解读。
- **共指**是一种语用现象,其中不同的表达指向同一个实体。在"Alice went to the store. She bought milk"中,"she"指代Alice。解决共指问题对于理解多句文本至关重要,是NLP中的一个关键任务。
- **篇章结构**描述句子如何连接以形成连贯的文本。叙事有开头、中间和结尾。论证有主张和证据。**修辞结构理论(RST)**将文本分析为篇章关系(阐述、对比、因果等)的树状结构。
- 语用学是NLP中最困难的领域。现代语言模型通过训练数据隐式地处理了大部分句法和语义,但语用推理——理解讽刺、隐涵和依赖语境的意义——仍然是一个前沿挑战。
- **音系学**研究语言的声音系统。虽然本章主要关注文本,但简要概述可以衔接音频和语音章节(第09章)。
- **音位**是区分意义的最小声音单位。英语约有44个音位。单词"bat"和"pat"相差一个音位(/b/ 与 /p/),而意义的改变是完全性的。这被称为**最小对立体**。
- **音位变体**是同一个音位的不同物理实现,不改变意义。"pin"中的"p"(送气音,带一股气流)和"spin"中的"p"(不送气音)在英语中是音位/p/的音位变体;母语者将它们视为同一个声音。
- **国际音标(IPA)**为所有语言的音位提供了标准化的记法。单词"cat"转录为/kæt/。IPA是书面文本和语音系统之间的桥梁。
- **韵律**涵盖语音的节奏、重音和语调。"I didn't say he stole the money"根据重音落在哪个单词上,可以有七种不同的含义。韵律携带了纯文本所丢失的信息,这就是为什么文本转语音系统必须仔细建模韵律的原因。
- 在NLP中,音系学知识出现在文本转语音(字形到音位的转换)、语音识别(将声学信号映射到音位),甚至拼写纠正和音译中。
## 编程练习(使用CoLab或notebook
1. 构建一个简单的形态分析器,使用常见前缀和后缀列表将英语单词分解为可能的语素。
```python
prefixes = ['un', 're', 'pre', 'dis', 'mis', 'over', 'under', 'out', 'non']
suffixes = ['ing', 'ed', 'ly', 'ness', 'ment', 'tion', 'able', 'ible', 'er', 'est', 'ful', 'less', 'ous']
def analyse_morphemes(word):
"""使用已知词缀进行简单的语素分析。"""
parts = []
remaining = word.lower()
# 检查前缀
for p in sorted(prefixes, key=len, reverse=True):
if remaining.startswith(p) and len(remaining) > len(p) + 2:
parts.append(f"[prefix: {p}]")
remaining = remaining[len(p):]
break
# 检查后缀
for s in sorted(suffixes, key=len, reverse=True):
if remaining.endswith(s) and len(remaining) > len(s) + 2:
root = remaining[:-len(s)]
parts.append(f"[root: {root}]")
parts.append(f"[suffix: {s}]")
remaining = None
break
if remaining is not None:
parts.append(f"[root: {remaining}]")
return parts
for word in ['unhappiness', 'reusable', 'disconnected', 'overreacting', 'kindness']:
print(f"{word:20s}{' + '.join(analyse_morphemes(word))}")
```
2. 实现一个使用递归下降法的简单上下文无关文法分析器。定义一个小型文法,并将句子分析为成分树。
```python
class CFGParser:
"""用于小型英语文法的递归下降分析器。"""
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
def peek(self):
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def consume(self, expected=None):
tok = self.peek()
if expected and tok != expected:
return None
self.pos += 1
return tok
def parse_det(self):
if self.peek() in ('the', 'a'):
return ('Det', self.consume())
return None
def parse_noun(self):
if self.peek() in ('cat', 'dog', 'mat', 'man'):
return ('N', self.consume())
return None
def parse_verb(self):
if self.peek() in ('sat', 'chased', 'saw'):
return ('V', self.consume())
return None
def parse_prep(self):
if self.peek() in ('on', 'under', 'with'):
return ('P', self.consume())
return None
def parse_np(self):
save = self.pos
det = self.parse_det()
noun = self.parse_noun()
if det and noun:
# 检查可选的PP
pp = self.parse_pp()
if pp:
return ('NP', det, noun, pp)
return ('NP', det, noun)
self.pos = save
return None
def parse_pp(self):
save = self.pos
prep = self.parse_prep()
np = self.parse_np()
if prep and np:
return ('PP', prep, np)
self.pos = save
return None
def parse_vp(self):
save = self.pos
verb = self.parse_verb()
if verb:
np = self.parse_np()
if np:
return ('VP', verb, np)
pp = self.parse_pp()
if pp:
return ('VP', verb, pp)
self.pos = save
return None
def parse_sentence(self):
np = self.parse_np()
vp = self.parse_vp()
if np and vp and self.pos == len(self.tokens):
return ('S', np, vp)
return None
def print_tree(tree, indent=0):
if isinstance(tree, str):
print(' ' * indent + tree)
elif isinstance(tree, tuple):
print(' ' * indent + tree[0])
for child in tree[1:]:
print_tree(child, indent + 2)
sentences = [
"the cat sat on the mat",
"a dog chased the cat",
]
for sent in sentences:
tokens = sent.split()
parser = CFGParser(tokens)
tree = parser.parse_sentence()
print(f"\n'{sent}':")
if tree:
print_tree(tree)
else:
print(" (no parse found)")
```
3. 通过构建一个简单的词图来探索词汇关系。给定一个包含同义、反义和上位关系的小型词汇表,查找单词之间的路径。
```python
relations = {
('big', 'large'): 'synonym',
('big', 'small'): 'antonym',
('small', 'tiny'): 'synonym',
('dog', 'animal'): 'hypernym',
('cat', 'animal'): 'hypernym',
('puppy', 'dog'): 'hypernym',
('happy', 'glad'): 'synonym',
('happy', 'sad'): 'antonym',
('hot', 'cold'): 'antonym',
('hot', 'warm'): 'synonym',
}
# 构建邻接列表
from collections import defaultdict, deque
graph = defaultdict(list)
for (w1, w2), rel in relations.items():
graph[w1].append((w2, rel))
graph[w2].append((w1, rel))
def find_path(start, end):
"""使用BFS在关系图中查找两个单词之间的路径。"""
queue = deque([(start, [(start, None)])])
visited = {start}
while queue:
node, path = queue.popleft()
if node == end:
return path
for neighbor, rel in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [(neighbor, rel)]))
return None
pairs = [('big', 'tiny'), ('puppy', 'cat'), ('happy', 'sad')]
for w1, w2 in pairs:
path = find_path(w1, w2)
if path:
steps = "".join(f"{w}({r})" if r else w for w, r in path)
print(f"{w1}{w2}: {steps}")
else:
print(f"{w1}{w2}: no path found")
```
@@ -0,0 +1,340 @@
# 文本处理与经典NLP
*文本处理将原始字符转换为模型可消费的结构化表示。本文涵盖分词(词级、子词、BPE、WordPiece)、文本规范化、编辑距离、TF-IDF、n元组语言模型、词性标注、命名实体识别和情感分析——这些经典NLP流水线至今仍是现代系统的基础。*
- 原始文本是混乱的。在任何NLP模型处理语言之前,文本必须经过清洗、规范化并转换为结构化表示。本文涵盖了从原始字符到模型可消费特征的完整流水线,以及深度学习兴起之前主导领域的经典NLP算法。
- **文本规范化**将原始文本转换为规范形式。其目标是减少不相关的变异,使"Hello"、"hello"、"HELLO"和"héllo"得到恰当的处理。
- **大小写折叠**将文本转换为小写。这将"The"和"the"合并为一个词元。这对大多数任务有帮助,但在某些情况下会破坏有用信息:"US"(国家)vs "us"(代词),或"Apple"(公司)vs "apple"(水果)。
- **Unicode规范化**处理同一字符有多种编码方式的问题。字符"é"可以是单个码点(U+00E9),也可以是基础"e"加上组合变音符号(U+0065 + U+0301)。NFC规范化将它们组合成一个码点;NFD则进行分解。如果没有规范化,两个看起来相同的字符串可能无法匹配。
- **编辑距离**衡量两个字符串之间的差异程度。**莱文斯坦距离**计算将一个字符串转换为另一个所需的最少单字符插入、删除和替换次数。"kitten" → "sitting"的编辑距离为3k→s,e→i,插入g)。
- 编辑距离使用动态规划计算(我们在算法章节中回顾)。定义 $D[i][j]$ 为字符串 $s$ 的前 $i$ 个字符与字符串 $t$ 的前 $j$ 个字符之间的距离:
```math
D[i][j] = \begin{cases} j & \text{if } i = 0 \\ i & \text{if } j = 0 \\ D[i{-}1][j{-}1] & \text{if } s[i] = t[j] \\ 1 + \min(D[i{-}1][j], \; D[i][j{-}1], \; D[i{-}1][j{-}1]) & \text{otherwise} \end{cases}
```
- 编辑距离支撑着拼写纠正、模糊匹配和DNA序列比对。在NLP中,它用于处理拼写错误和查找相似单词。
- **分词**将文本分割成模型可以处理的离散单元(词元)。这是第一个也是最重要的预处理步骤。分词策略的选择深刻影响着模型行为。
- **空白分词**以空格分割。简单但幼稚:"New York"变成两个词元,"don't"是一个词元(或根据分割器不同,拆分为"don"和"'t"),而中文和日文等语言在词之间根本没有空格。
- **基于规则的分词**使用手工设计的模式(正则表达式)来处理缩写、标点符号和特殊情况。"I'm" → "I" + "'m""U.S.A."保持为一个词元。每种语言都需要自己的规则,这非常耗费人力。
- **子词分词**是现代解决方案。它不是在词边界处分割,而是从数据中学习一个高频子词单元的词汇表。这优雅地处理了未知词:如果"unhappiness"不在词汇表中,它可能被拆分为"un" + "happi" + "ness",保留了形态结构。
!["unhappiness"和"transformers"的词级、字符级和子词分词对比](../images/tokenisation_comparison.svg)
- **字节对编码(BPE)**从单个字符作为词汇表开始。它反复查找最频繁的相邻对并将其合并为一个新词元。经过足够次数的合并后,常见词成为单个词元,罕见词则被拆分为高频子词片段。
- BPE算法:
1. 用训练语料中的所有单个字符初始化词汇表
2. 统计每个相邻词元对的频率
3. 将最频繁的对合并为一个新词元
4. 重复步骤2-3,直到达到所需的合并次数(词汇表大小)
- 例如,从"l o w"5次)、"l o w e r"2次)、"n e w e s t"6次)开始:最频繁的对可能是"e s" → 合并为"es"。然后"es t" → "est"。然后"n e w" → "new"。最终的词汇表同时包含完整单词和子词片段。
- **WordPiece**(BERT使用)与BPE类似,但基于似然而非频率来选择合并。它合并能使训练数据的语言模型似然最大化的对。非词首的子词词元以"##"作为前缀(例如,"playing" → "play" + "##ing")。
- **Unigram**SentencePiece使用)采用相反的方法:从一个大型词汇表开始,迭代地移除那些移除后对训练数据似然损失最小的词元。最终的词汇表是最能解释语料库的子词单元集合。
- **SentencePiece**是一个语言无关的分词库,它将输入视为原始字节流(不在空格上进行预分词)。这使得它适用于任何语言,包括没有空格的语言。它同时实现了BPE和Unigram算法。
- 词汇表大小是一个关键超参数。典型的选择范围从30,000到100,000个词元。更大的词汇表意味着每个序列的词元更少(更高效),但需要更大的嵌入表。更小的词汇表意味着更多的子词分割和更长的序列。
- 两种技术都将词汇简化为基本形式,但方法不同。
- **词干提取**使用粗略规则切除后缀。波特词干提取器将"running"简化为"run""happiness"简化为"happi""studies"简化为"studi"。它速度快但不精确:"university"和"universe"都被词干化为"univers",尽管它们毫不相关。
- **词形还原**使用词汇表和形态学分析来找到真正的词典形式(词元)。"Running" → "run""better" → "good""mice" → "mouse"。它需要知道词性:"saw"作为动词时词形还原为"see",但作为名词时保持为"saw"。
- 现代子词分词在很大程度上已取代了神经NLP中的词干提取和词形还原,但它们在信息检索以及处理较小模型或有限数据时仍然有用。
- **词性标注**为每个词分配一个语法类别:名词、动词、形容词、限定词等。这是最古老的NLP任务之一,也是句法分析的基础。
- 宾州树库标签集是英语中最常用的,包含36个标签(NN表示单数名词,NNS表示复数名词,VB表示动词原形,VBD表示过去式,JJ表示形容词等)。
- 词性标注很棘手,因为许多词是有歧义的。"Book"可以是名词("the book")或动词("book a flight")。"Run"在不同词性下有数十种含义。上下文至关重要。
- 早期的标注器使用第05章中的**隐马尔可夫模型(HMM)**。隐藏状态是词性标签,观测值是单词。转移概率捕捉标签序列(限定词后面很可能跟名词或形容词),发射概率捕捉哪些词与哪些标签一起出现。维特比算法找出最可能的标签序列。
- 用于词性标注的HMM模型:
$$\\hat{t}_{1:n} = \\arg\\max_{t_{1:n}} \\prod_{i=1}^{n} P(w_i \\mid t_i) \\cdot P(t_i \\mid t_{i-1})$$
- 现代词性标注器使用神经网络(双向LSTM或Transformer),在英语上达到超过97%的准确率,接近人类水平。
- **命名实体识别(NER)**识别并分类文本中的专有名词和其他特定实体:人物、组织、地点、日期、货币金额等。
- 在"Apple CEO Tim Cook announced the event in Cupertino on Monday"中,NER系统应识别出:AppleORG组织)、Tim CookPER人物)、CupertinoLOC地点)、MondayDATE日期)。
- NER通常被框架化为**序列标注**,使用**BIO标注**(也称为IOB标注)。每个词元获得一个标签:
- **B-TYPE**TYPE类型实体的开始
- **I-TYPE**TYPE类型实体的内部(延续)
- **O**:实体外部
- "Tim Cook visited New York"变为:Tim/B-PER Cook/I-PER visited/O New/B-LOC York/I-LOC。B标签标记新实体的起始位置,这对于两个同类型实体相邻的情况很重要。
![带有BIO标签颜色编码的句子:B-PER(红色)、I-PER(红色)、O(灰色)、B-LOC(蓝色)、I-LOC(蓝色)](../images/bio_tagging.svg)
- 经典NER使用第05章中的**条件随机场(CRF)**,它对给定输入下整个标签序列的条件概率建模。与生成式模型($P(x, y)$)的HMM不同,CRF是判别式模型,直接建模 $P(y \\mid x)$。线性链CRF定义为:
$$P(y_{1:n} \\mid x_{1:n}) = \\frac{1}{Z(x)} \\exp\\!\\left(\\sum_{i=1}^{n} \\left[\\sum_k \\lambda_k f_k(y_i, x, i) + \\sum_j \\mu_j g_j(y_i, y_{i-1}, x, i)\\right]\\right)$$
- 这里 $f_k$ 是**发射特征**(给定位置 $i$ 的输入,标签 $y_i$ 的可能性),$g_j$ 是**转移特征**(给定前一个标签 $y_{i-1}$,当前标签 $y_i$ 的可能性)。
- 配分函数 $Z(x) = \\sum_{y'} \\exp(\\ldots)$ 对所有可能的标签序列求和,以归一化分布。训练最大化条件对数似然,这需要使用前向算法(第05章)高效计算 $Z(x)$。
- 与独立分类每个词元相比的关键优势:CRF的转移特征强制结构约束(例如,I-PER应该只跟在B-PER或I-PER之后,绝不应出现在O之后)。
- 现代NER将CRF堆叠在神经编码器之上(BiLSTM-CRF或BERT-CRF),其中神经网络产生发射分数,CRF层学习转移结构。
- **句法分析**将句子转换为其句法结构,可以是成分树或依存树(两者均见文件01)。
- **CYK算法**Cocke-Younger-Kasami)使用动态规划结合上下文无关文法解析句子。
- 它要求文法为**乔姆斯基范式**(每条规则的右侧要么有两个非终结符,要么有一个终结符)。它自底向上填充一个三角表格:单元格表示句子的跨度,每个单元格存储可以生成该跨度的非终结符。
- CYK的时间复杂度为 $O(n^3 \\cdot |G|)$,其中 $n$ 是句子长度,$|G|$ 是文法规模。这是精确算法,但对于大型文法来说速度较慢。
- **移进-归约解析**从左到右处理句子,维护一个栈。在每一步,它要么**移进**(将下一个词压入栈),要么**归约**(从栈中弹出元素并用短语替换)。一个训练好的分类器在每一步决定操作。时间复杂度为 $O(n)$,比CYK快得多。
- **依存解析**在实践中比成分解析更为常见。基于转换的依存解析器(如移进-归约)和基于图的解析器(对所有可能的边评分并找到最大生成树)是两种主要方法。使用BiLSTM或Transformer的神经依存解析器取得了最先进的成果。
- 在嵌入出现之前,NLP使用简单的计数方法将文档表示为向量。
- **词袋模型(BoW)**将文档表示为词频向量,完全忽略词序。如果词汇表有 $V$ 个词,每个文档就是 $\\mathbb{R}^V$ 空间中的一个向量(与第01章的向量空间相联系)。词 $w$ 对应的条目是 $w$ 在文档中出现的次数。
![词袋模型:文档转换为词频表,再转换为R^V空间中的稀疏向量,词汇表中每个词对应一个条目](../images/bag_of_words.svg)
- BoW简单但出奇有效,适用于文档分类和垃圾邮件过滤等任务。其主要缺点是每个词都被同等对待:"the"和"revolutionary"获得相同的权重。
- **TF-IDF**(词频-逆文档频率)通过根据词的信息量大小来加权,解决了这个问题。在单个文档中频繁出现但在整个语料库中罕见的词,很可能对该文档很重要。
$$\\text{TF-IDF}(t, d) = \\text{TF}(t, d) \\times \\text{IDF}(t)$$
- **词频** $\\text{TF}(t, d)$ 通常是词 $t$ 在文档 $d$ 中的原始计数(或其对数形式:$1 + \\log(\\text{count})$)。
- **逆文档频率** $\\text{IDF}(t) = \\log\\frac{N}{|\\{d : t \\in d\\}|}$,其中 $N$ 是文档总数。出现在每个文档中的词(如"the")的IDF接近0。罕见词获得高IDF。
- TF-IDF向量可以使用余弦相似度(来自第01章)进行比较,以衡量文档相似性。这是经典信息检索和搜索引擎的基础。
- **语言模型**为词序列分配概率。它回答的是:这个句子的可能性有多大?语言模型是机器翻译、语音识别、拼写纠正和文本生成的核心。
- 句子 $w_1, w_2, \\ldots, w_n$ 的概率,根据概率的链式法则(第05章)为:
$$P(w_1, w_2, \\ldots, w_n) = \\prod_{i=1}^{n} P(w_i \\mid w_1, \\ldots, w_{i-1})$$
- 这是精确的但不实用:你需要为每个可能的历史存储概率。**马尔可夫假设**(第05章)将历史截断到最近 $k-1$ 个词,得到 **n元语法模型**(其中 $n = k$)。
- **二元模型**$n = 2$)仅依赖前一个词:
$$P(w_i \\mid w_1, \\ldots, w_{i-1}) \\approx P(w_i \\mid w_{i-1})$$
- **三元模型**($n = 3$)依赖前两个词。n元语法概率通过在语料库中计数来估计:
$$P(w_i \\mid w_{i-1}) = \\frac{\\text{count}(w_{i-1}, w_i)}{\\text{count}(w_{i-1})}$$
- **困惑度**衡量语言模型对测试集的预测能力。它是测试集概率的倒数,按词数归一化:
$$\\text{PPL} = P(w_1, \\ldots, w_N)^{-1/N} = \\exp\\!\\left(-\\frac{1}{N} \\sum_{i=1}^{N} \\log P(w_i \\mid w_{<i})\\right)$$
- 困惑度越低,说明模型对测试数据越"不惊讶",因此性能越好。在10,000词词汇表上分配均匀概率的模型,困惑度为10,000。一个好的二元模型可能达到约200的困惑度。现代神经语言模型的困惑度低于20。
- 注意,困惑度是指数化的交叉熵(来自第05章的信息论部分)。训练期间最小化交叉熵损失直接最小化困惑度。
- **平滑**处理零概率问题:如果某个n元组从未在训练中出现过,模型会赋予它概率0,这会使整个句子的概率为0。**拉普拉斯平滑**(加1)为每个n元组添加一个小计数:
$$P_{\\text{Laplace}}(w_i \\mid w_{i-1}) = \\frac{\\text{count}(w_{i-1}, w_i) + 1}{\\text{count}(w_{i-1}) + V}$$
- 对于大词汇表来说这过于激进(从已观察到的n元组中挪走了太多概率)。**Kneser-Ney平滑**是n元语法模型的金标准。它结合了两个思想:绝对折扣和用于回退的延续概率。
- 首先,**绝对折扣**从每个观察到的计数中减去一个固定折扣 $d$(通常 $d \\approx 0.75$),而不是添加伪计数。释放出的概率质量重新分配给未见过的n元组。插值形式为:
$$P_{\\text{KN}}(w_i \\mid w_{i-1}) = \\frac{\\max(\\text{count}(w_{i-1}, w_i) - d, \\; 0)}{\\text{count}(w_{i-1})} + \\lambda(w_{i-1}) \\cdot P_{\\text{cont}}(w_i)$$
- 其中 $\\lambda(w_{i-1})$ 是一个归一化常数,用于分配折扣后的质量。关键的创新是**延续概率** $P_{\\text{cont}}(w_i)$,它衡量 $w_i$ 出现在多少个不同的上下文中,而不是它总体上出现的频率:
$$P_{\\text{cont}}(w_i) = \\frac{|\\{w' : \\text{count}(w', w_i) > 0\\}|}{|\\{(w', w'') : \\text{count}(w', w'') > 0\\}|}$$
- 分子统计在语料库中出现在 $w_i$ 之前的不同词的数量。像"Francisco"这样的词出现在很少的上下文中(几乎总是在"San"之后),所以即使"San Francisco"非常频繁,"Francisco"的延续概率也很低,不会在其他上下文中被错误预测。
- 相反,像"the"这样的常见词出现在许多不同词之后,获得高延续概率。这体现了这样一种直觉:对于回退估计而言,词的多功能性比其原始频率更重要。
- n元语法模型几十年来一直是主流技术。它们速度快、可解释性强,且无需训练(只需计数)。但它们难以处理长距离依赖("The keys that I left on the table **are** missing"需要知道主语"keys"是复数,而它与动词相距甚远)。神经语言模型——从RNN开始到Transformer达到顶峰——解决了这一局限性。
## 编程练习(使用CoLab或notebook
1. 使用动态规划实现莱文斯坦编辑距离。在词对上测试,并用于简单的拼写纠正。
```python
import jax.numpy as jnp
def edit_distance(s, t):
"""Compute Levenshtein edit distance using DP."""
m, n = len(s), len(t)
D = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
D[i][0] = i
for j in range(n + 1):
D[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if s[i-1] == t[j-1]:
D[i][j] = D[i-1][j-1]
else:
D[i][j] = 1 + min(D[i-1][j], D[i][j-1], D[i-1][j-1])
return D[m][n]
# Test
pairs = [("kitten", "sitting"), ("sunday", "saturday"), ("hello", "hallo")]
for s, t in pairs:
print(f"d('{s}', '{t}') = {edit_distance(s, t)}")
# Simple spelling correction
dictionary = ["the", "their", "there", "then", "than", "this", "that", "these", "those"]
misspelled = "thier"
corrections = sorted(dictionary, key=lambda w: edit_distance(misspelled, w))
print(f"\nClosest to '{misspelled}': {corrections[:3]}")
```
2. 从头实现BPE分词。从字符级词元开始,迭代地合并最频繁的对。
```python
from collections import Counter
def get_pairs(corpus):
"""Count adjacent token pairs across all words."""
pairs = Counter()
for word, freq in corpus.items():
symbols = word.split()
for i in range(len(symbols) - 1):
pairs[(symbols[i], symbols[i+1])] += freq
return pairs
def merge_pair(pair, corpus):
"""Merge all occurrences of a pair in the corpus."""
new_corpus = {}
bigram = ' '.join(pair)
replacement = ''.join(pair)
for word, freq in corpus.items():
new_word = word.replace(bigram, replacement)
new_corpus[new_word] = freq
return new_corpus
# Training corpus with word frequencies
text = "low low low low low lower lower newest newest newest newest newest newest"
word_freqs = Counter(text.split())
# Initialise: split each word into characters with end-of-word marker
corpus = {' '.join(word) + ' _': freq for word, freq in word_freqs.items()}
print("Initial corpus:")
for word, freq in corpus.items():
print(f" {word}: {freq}")
# Run BPE for 10 merges
for i in range(10):
pairs = get_pairs(corpus)
if not pairs:
break
best_pair = max(pairs, key=pairs.get)
corpus = merge_pair(best_pair, corpus)
print(f"\nMerge {i+1}: {best_pair} (freq={pairs[best_pair]})")
for word, freq in corpus.items():
print(f" {word}: {freq}")
```
3. 构建一个二元语言模型,并计算测试句子的困惑度。尝试拉普拉斯平滑。
```python
from collections import Counter, defaultdict
import math
# Training corpus
train = """the cat sat on the mat . the dog chased the cat .
the cat ran from the dog . a dog sat on a mat .""".split()
# Count bigrams and unigrams
bigrams = Counter(zip(train[:-1], train[1:]))
unigrams = Counter(train)
vocab_size = len(set(train))
def bigram_prob(w2, w1, alpha=0):
"""P(w2 | w1) with optional Laplace smoothing."""
return (bigrams[(w1, w2)] + alpha) / (unigrams[w1] + alpha * vocab_size)
# Compute perplexity
test = "the cat sat on a mat .".split()
for alpha in [0, 1, 0.1]:
log_prob = 0
for w1, w2 in zip(test[:-1], test[1:]):
p = bigram_prob(w2, w1, alpha=alpha)
if p > 0:
log_prob += math.log(p)
else:
log_prob += float('-inf')
ppl = math.exp(-log_prob / (len(test) - 1)) if log_prob > float('-inf') else float('inf')
print(f"Smoothing α={alpha}: perplexity = {ppl:.2f}")
```
4. 从头实现TF-IDF,并使用余弦相似度找到与查询最相似的文档。
```python
import jax.numpy as jnp
import math
from collections import Counter
documents = [
"the cat sat on the mat",
"the dog chased the cat around the park",
"a mat was placed on the floor by the door",
"the quick brown fox jumped over the lazy dog",
]
# Build vocabulary
vocab = sorted(set(word for doc in documents for word in doc.split()))
word_to_idx = {w: i for i, w in enumerate(vocab)}
V = len(vocab)
N = len(documents)
# Compute TF-IDF matrix
doc_freq = Counter()
for doc in documents:
for word in set(doc.split()):
doc_freq[word] += 1
tfidf_matrix = jnp.zeros((N, V))
for i, doc in enumerate(documents):
word_counts = Counter(doc.split())
for word, count in word_counts.items():
tf = 1 + math.log(count)
idf = math.log(N / doc_freq[word])
j = word_to_idx[word]
tfidf_matrix = tfidf_matrix.at[i, j].set(tf * idf)
# Query
query = "cat on the mat"
query_vec = jnp.zeros(V)
query_counts = Counter(query.split())
for word, count in query_counts.items():
if word in word_to_idx:
tf = 1 + math.log(count)
idf = math.log(N / doc_freq.get(word, 1))
query_vec = query_vec.at[word_to_idx[word]].set(tf * idf)
# Cosine similarity (from chapter 01)
def cosine_sim(a, b):
return jnp.dot(a, b) / (jnp.linalg.norm(a) * jnp.linalg.norm(b) + 1e-8)
print(f"Query: '{query}'\n")
for i, doc in enumerate(documents):
sim = cosine_sim(query_vec, tfidf_matrix[i])
print(f" Doc {i} (sim={sim:.3f}): '{doc}'")
```
@@ -0,0 +1,389 @@
# 嵌入与序列模型
*词嵌入将稀疏的符号化文本压缩到稠密向量空间中,使得语义相似性转化为几何邻近性。本文涵盖 Word2VecCBOW、Skip-gram)、GloVe、FastText、RNN、LSTM、GRU、带注意力机制的 seq2seq、编码器-解码器范式,以及从词袋模型到上下文表示的发展历程。*
- 在文件 01 中,我们介绍了分布假设:出现在相似语境中的词往往具有相似的含义。在文件 02 中,我们使用稀疏的、手工设计的特征(如 TF-IDF 向量)来表示文本。这些向量位于极高维空间中(每个词汇表词占一维),且大部分为零。**词嵌入**将这些信息压缩到稠密的低维向量中,捕捉语义关系,并且直接从数据中学习。
- **Word2Vec**Mikolov et al., 2013)通过在简单的预测任务上训练一个浅层神经网络来学习词嵌入。共有两种架构。
- **连续词袋模型(CBOW)**根据目标词周围的上下文词来预测该词。给定一个窗口大小的上下文词(例如,"the cat ___ on the"),模型求它们的嵌入向量的平均值,并将结果通过一个线性层来预测缺失的词("sat")。训练目标最大化:
$$P(w_t \mid w_{t-k}, \ldots, w_{t-1}, w_{t+1}, \ldots, w_{t+k})$$
- **Skip-gram 模型**则反过来:给定一个目标词,预测其周围的上下文词。对于目标词 "sat",模型分别尝试预测 "the"、"cat"、"on"、"the"。目标最大化:
$$P(w_{t+j} \mid w_t) \quad \text{对于每个 } j \in [-k, k], \; j \neq 0$$
![Skip-gram 与 CBOW 架构对比:CBOW 对上下文嵌入求平均来预测中心词,skip-gram 使用中心词嵌入来预测每个上下文词](../images/word2vec_architectures.svg)
- Skip-gram 通常对罕见词效果更好,因为每个词会产生多个训练样本(每个上下文位置一个)。CBOW 速度更快,对频繁词略优,因为它对多个上下文信号取平均。
- 在整个词汇表上训练代价很高,因为 softmax 分母需要对所有 $V$ 个词求和。**负采样**通过将问题转化为二分类来近似这一过程:区分真实的上下文词(正样本)与随机采样的噪声词(负样本)。模型无需计算完整的 softmax,只需更新目标词、真实上下文词以及少数负样本的嵌入:
$$\mathcal{L} = \log \sigma(v_{w_O}^T v_{w_I}) + \sum_{i=1}^{k} \mathbb{E}_{w_i \sim P_n} [\log \sigma(-v_{w_i}^T v_{w_I})]$$
- 这里 $v_{w_I}$ 是输入词嵌入,$v_{w_O}$ 是输出(上下文)词嵌入,$P_n$ 是噪声分布,通常采用词频的 3/4 次方(这会降低"the"这类高频词的权重)。
- 为什么这个简单的目标函数能产生有意义的嵌入?Levy 和 Goldberg2014)证明,带负采样的 skip-gram 实际上是在分解一个**移位点互信息(PMI)**矩阵。在收敛时,两个词向量的点积近似于:
$$v_w^T v_c \approx \text{PMI}(w, c) - \log k$$
- 其中 $\text{PMI}(w, c) = \log \frac{P(w, c)}{P(w) P(c)}$ 衡量词 $w$ 和 $c$ 共现的频率比随机期望高出多少(见第 05 章信息论),$k$ 是负样本数量。共现远高于随机期望的词具有高 PMI,从而具有高点积(相似的嵌入)。共现低于预期的词具有负 PMI 和不相似的嵌入。这表明 Word2Vec 实际上与经典的分布语义学方法(如潜在语义分析,即对共现矩阵做 SVD)在做同样的事情,只是采用了更具扩展性的在线方式。
- Word2Vec 嵌入最令人惊讶的特性是它们能通过**向量算术**捕捉**类比关系**。向量 $v_{\text{king}} - v_{\text{man}} + v_{\text{woman}}$ 最接近 $v_{\text{queen}}$。这是因为嵌入空间将语义关系编码为近似线性方向:"王室"方向大致为 $v_{\text{king}} - v_{\text{man}}$,将其加到 $v_{\text{woman}}$ 上就会落在 $v_{\text{queen}}$ 附近。这与第 01 章的线性代数相关联:语义关系就是向量平移。
- **GloVe**Global Vectors for Word RepresentationPennington et al., 2014)采用不同的方法。它不是一次一个地从局部上下文窗口学习,而是构建一个全局的词共现矩阵 $X$,其中 $X_{ij}$ 统计在整个语料库中词 $j$ 出现在词 $i$ 上下文中的次数。然后模型学习嵌入,使其点积近似于对数共现次数:
$$w_i^T \tilde{w}_j + b_i + \tilde{b}_j = \log X_{ij}$$
- 损失函数通过一个截断函数 $f(X_{ij})$ 对每一对加权,防止非常频繁的共现主导训练:
$$\mathcal{L} = \sum_{i,j=1}^{V} f(X_{ij}) \left(w_i^T \tilde{w}_j + b_i + \tilde{b}_j - \log X_{ij}\right)^2$$
- GloVe 结合了全局矩阵分解(如潜在语义分析)和 Word2Vec 的局部上下文学习的优点。在实践中,GloVe 和 Word2Vec 生成的嵌入质量相近。
- **FastText**Bojanowski et al., 2017)扩展了 skip-gram,将每个词表示为一组字符 n-gram 的集合。对于 $n = 3$,词 "where" 变成:"\<wh"、"whe"、"her"、"ere"、"re\>",加上完整词标记 "\<where\>"。该词的嵌入是其所有 n-gram 嵌入之和。
- 这有一个关键优势:FastText 能够为训练中从未见过的词生成嵌入。词 "whereabouts" 与 "where" 共享 n-gram,因此即使 "whereabouts" 从未出现在训练数据中,其嵌入也是合理的。这对于形态丰富的语言(文件 01)尤为有用,因为这些语言中的词有许多屈折形式。
- **嵌入评估**通常使用两类基准测试。**类比任务**测试 $v_a - v_b + v_c \approx v_d$ 是否成立(例如,"Paris" $-$ "France" $+$ "Italy" $\approx$ "Rome")。**相似性基准**将词对之间的余弦相似度(第 01 章)与人工判断进行比较。常见的数据集包括 WordSim-353、SimLex-999 和 Google 类比测试集。一个实用注意事项:在类比任务上表现出色的嵌入不一定最适合下游任务,如情感分类。最好的评估往往是任务本身。
- 在第 06 章中,我们介绍了 RNN、LSTM 和 GRU 作为处理序列数据的架构。这里我们重点讨论它们如何具体应用于语言任务。
- **语言模型 RNN** 每次读取一个词元,并在每一步预测下一个词元。隐藏状态 $h_t$ 将整个历史序列 $w_1, \ldots, w_t$ 压缩为一个固定大小的向量,线性层加 softmax 将 $h_t$ 映射到词汇表上的分布。训练使用与真实下一词元的交叉熵损失,这等价于最小化困惑度(文件 02)。关键局限在于:固定大小的隐藏状态必须编码关于历史的所有信息,早期词元的信息会逐渐被覆盖。
- **双向 RNN** 从两个方向处理序列:一个 RNN 从左到右读取,另一个从右到左读取。在每个位置 $t$,前向隐藏状态 $\overrightarrow{h}_t$ 和后向隐藏状态 $\overleftarrow{h}_t$ 被拼接起来,形成上下文感知的表示 $h_t = [\overrightarrow{h}_t ; \overleftarrow{h}_t]$。这使模型能够同时访问过去和未来的上下文,对于词性标注和命名实体识别(文件 02)等任务非常有效,因为这些任务中一个词的标签依赖于其前后的词。双向 RNN 不能用于语言建模,因为在预测未来词元时不能窥视它们。
![双向 RNN:前向 RNN 从左到右读取产生隐藏状态,后向 RNN 从右到左读取,每个位置的输出拼接在一起](../images/bidirectional_rnn.svg)
- **深层堆叠 RNN** 将多个 RNN 层叠放在一起。第 $l$ 层所有时间步的隐藏状态成为第 $l+1$ 层的输入序列。堆叠 2-4 层通常能通过构建层次化表示来提升性能,类似于深层 CNN 构建特征层次结构(第 06 章)。超过 4 层时,梯度消失和过拟合会成为问题,除非在层之间添加残差连接。
- **序列到序列(seq2seq**架构(Sutskever et al., 2014)将可变长度的输入序列映射到可变长度的输出序列。它由一个**编码器** RNN(读取输入并将其压缩为上下文向量,即最终的隐藏状态)和一个**解码器** RNN(基于该上下文向量逐步生成输出)组成。
![Seq2seq 编码器-解码器:编码器 RNN 从左到右读取输入词元,最终隐藏状态作为解码器 RNN 的初始状态,解码器自回归地生成输出词元](../images/seq2seq_architecture.svg)
- Seq2seq 是机器翻译的突破性架构。编码器读取法语句子,解码器生成英文翻译。解码器从一个特殊的序列起始词元开始,自回归地生成词元,直到产生序列结束词元。一个实用的技巧:反转输入序列(输入 "chat le" 而不是 "le chat")可以改善结果,因为这使得第一个输入词在计算图中更靠近第一个输出词,缩短了梯度路径。
- 瓶颈问题:整个输入必须被压缩到一个固定大小的向量中。对于长句子,这个向量无法捕捉所有信息,性能会下降。这推动了**注意力机制**的发展。
- 第 06 章介绍了现代的点积注意力 Q、K、V 形式。NLP 中最早的注意力机制以不同的方式提出,作为编码器和解码器状态之间的对齐模型。
- **Bahdanau 注意力**(加性注意力,Bahdanau et al., 2015)使用一个可学习的前馈网络计算解码器隐藏状态 $s_t$ 与每个编码器隐藏状态 $h_i$ 之间的对齐分数:
$$e_{ti} = v^T \tanh(W_s s_{t-1} + W_h h_i)$$
- 分数通过 softmax 归一化为注意力权重,上下文向量是编码器状态的加权和:
$$\alpha_{ti} = \frac{\exp(e_{ti})}{\sum_j \exp(e_{tj})}, \quad c_t = \sum_i \alpha_{ti} h_i$$
- 然后解码器同时使用 $s_{t-1}$ 和 $c_t$ 来生成下一个输出。关键洞察:不是为整个句子使用一个固定的上下文向量,每个解码步骤获得编码器状态的不同加权组合,使模型能够"回顾"输入的相关部分。
- **Luong 注意力**(乘性注意力,Luong et al., 2015)简化了分数计算。**点积**变体使用 $e_{ti} = s_t^T h_i$。**通用**变体使用 $e_{ti} = s_t^T W h_i$。这些比 Bahdanau 的加性分数更快,因为它们使用矩阵乘法而非前馈网络。Luong 注意力还从当前解码器状态 $s_t$(而非 $s_{t-1}$)计算上下文向量,这使得它能获取更多信息,但计算方式略有不同。
![源句子与其翻译之间的注意力对齐热力图,显示每个目标词关注哪些源词,较亮的单元格表示更高的注意力权重](../images/attention_alignment.svg)
- 注意力权重通常可视化为热力图,显示解码器在生成每个输出词元时关注哪些输入词元。在翻译中,这些热力图大致勾勒出源语言和目标语言之间的词对齐关系,对角模式会被重排序打破(例如,形容词-名词顺序在法语和英语中有所不同)。
- 推理时,解码器每一步必须选择一个词元。**贪心解码**在每个位置选择概率最高的词元,但这可能导致次优序列:一个局部好的选择可能迫使模型进入全局不佳的句子。**束搜索**在每一步维护分数最高的 $k$ 个(束宽)部分序列,对每个序列扩展所有可能的下一词元,并保留总体最好的 $k$ 个。
- 当束宽 $k = 1$ 时,束搜索退化为贪心解码。典型值为 $k = 4$ 到 $k = 10$。更大的束能找到更好的序列,但速度会成比例降低。束搜索还需要**长度归一化**,以避免偏向较短的序列(因为较短的序列乘法项更少,自然具有更高的总概率)。归一化后的分数为:
$$\text{score}(y) = \frac{1}{|y|^\alpha} \sum_{t=1}^{|y|} \log P(y_t \mid y_{<t})$$
- 其中 $|y|$ 是序列长度,$\alpha$(通常为 0.6-0.7)控制长度惩罚的强度。当 $\alpha = 0$ 时,没有长度归一化。当 $\alpha = 1$ 时,分数是每个词元的对数概率(几何平均)。中间值在倾向于简洁输出和不过早截断之间取得平衡。
- 虽然 RNN 顺序处理文本,但 **1D CNN** 通过在词元序列上滑动滤波器来并行处理文本。每个滤波器检测一个局部模式(n-gram 特征)。
- **TextCNN**(Kim, 2014)对输入的嵌入矩阵应用多个不同宽度(例如 3、4、5 个词元)的一维卷积滤波器。每个滤波器生成一个特征图,**时序最大池化**从每个特征图中取单一最大值,捕获该模式是否在文本中的任何位置被检测到,而不考虑位置。所有滤波器的池化特征被拼接后传递给分类器。
![TextCNN 架构:输入嵌入通过宽度为 3、4、5 的并行卷积滤波器,每个滤波器后接时序最大池化,然后拼接并馈送到全连接分类器](../images/textcnn_architecture.svg)
- TextCNN 速度快,对于情感分析等文本分类任务效果出奇地好。它能捕获局部 n-gram 模式,但无法建模长距离依赖:宽度为 5 的滤波器只能看到 5 个连续的词元。**膨胀因果卷积**通过在滤波器元素之间插入间隙(膨胀)来解决这个问题。堆叠膨胀率呈指数增长(1、2、4、8、...)的层,可以在不增加参数的情况下指数级地扩大感受野,使模型能够捕获跨越数百个词元的依赖关系。
- 到目前为止讨论的所有嵌入(Word2Vec、GloVe、FastText)针对每个词类型生成单一向量,与上下文无关。"Bank"无论是指金融机构还是河岸,都得到相同的嵌入。这是一个根本性的局限,而**上下文嵌入**解决了这一问题。
- **ELMo**Embeddings from Language ModelsPeters et al., 2018)通过在输入文本上运行一个深层双向 LSTM 语言模型来生成上下文相关的词表示。前向 LSTM 在每个位置预测下一个词;一个独立的后向 LSTM 预测前一个词。两者都在大规模语料库上作为语言模型进行训练。
- 在每个位置 $k$,ELMo 使用任务特定的学习权重组合所有 $L$ 层的隐藏状态:
$$\text{ELMo}_k = \gamma \sum_{j=0}^{L} s_j \, h_{k,j}$$
- 这里 $h_{k,j}$ 是位置 $k$ 层 $j$ 的隐藏状态(层 0 是原始词嵌入),$s_j$ 是 softmax 归一化的标量权重,$\gamma$ 是任务特定的缩放因子。不同层捕获不同信息:较低层捕获句法(词性标注、词形态),较高层捕获语义(词义、语义角色)。通过使用学习到的权重混合所有层,ELMo 嵌入能够适应多样化的下游任务。
- ELMo 标志着**预训练然后微调**范式的开始:在海量无标注文本上训练大型语言模型,然后将其表示用于下游任务。ELMo 具体使用预训练的表示作为固定的或轻度微调的特征,与任务特定的输入拼接在一起。BERT 和 GPT(文件 04)通过端到端地微调整个模型进一步推进了这一范式,事实证明这要有效得多。
- 从 Word2Vec 到 ELMo 的发展过程展示了 NLP 中一个反复出现的主题:从静态表示到动态表示,从局部上下文到全局上下文,从浅层模型到深层模型。每一步都以计算成本换取更丰富的表示。Transformer(文件 04)通过用注意力完全取代循环,实现了深层上下文化和并行计算,完成了这一演进。
## 编程任务(使用 CoLab 或 notebook
1. 从头实现带负采样的 Word2Vec skip-gram。在小型语料库上训练,并使用 PCA 可视化学习到的嵌入。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
# 小型语料库
corpus = """the king ruled the kingdom . the queen ruled the kingdom .
the prince is the son of the king . the princess is the daughter of the queen .
a man worked in the castle . a woman worked in the castle .
the king and queen lived in the castle . the prince and princess played outside .""".lower().split()
vocab = sorted(set(corpus))
word2idx = {w: i for i, w in enumerate(vocab)}
idx2word = {i: w for w, i in word2idx.items()}
V = len(vocab)
# 生成 skip-gram 对,窗口大小为 2
window = 2
pairs = []
for i, word in enumerate(corpus):
for j in range(max(0, i - window), min(len(corpus), i + window + 1)):
if i != j:
pairs.append((word2idx[word], word2idx[corpus[j]]))
pairs = jnp.array(pairs)
print(f"词汇表大小: {V} 个词, 训练样本数: {len(pairs)}")
# 模型参数
embed_dim = 16
key = jax.random.PRNGKey(42)
k1, k2 = jax.random.split(key)
W_in = jax.random.normal(k1, (V, embed_dim)) * 0.1 # 输入嵌入
W_out = jax.random.normal(k2, (V, embed_dim)) * 0.1 # 输出嵌入
# 单个样本对的负采样损失
def neg_sampling_loss(W_in, W_out, target, context, neg_ids):
v_in = W_in[target] # (embed_dim,)
v_out = W_out[context] # (embed_dim,)
v_neg = W_out[neg_ids] # (k, embed_dim)
pos_loss = -jax.nn.log_sigmoid(jnp.dot(v_in, v_out))
neg_loss = -jnp.sum(jax.nn.log_sigmoid(-v_neg @ v_in))
return pos_loss + neg_loss
# 训练循环
num_neg = 5
lr = 0.05
@jax.jit
def train_step(W_in, W_out, target, context, neg_ids):
loss, (g_in, g_out) = jax.value_and_grad(neg_sampling_loss, argnums=(0, 1))(
W_in, W_out, target, context, neg_ids)
return loss, W_in - lr * g_in, W_out - lr * g_out
key = jax.random.PRNGKey(0)
for epoch in range(50):
total_loss = 0.0
for i in range(len(pairs)):
key, subkey = jax.random.split(key)
neg_ids = jax.random.randint(subkey, (num_neg,), 0, V)
loss, W_in, W_out = train_step(W_in, W_out, pairs[i, 0], pairs[i, 1], neg_ids)
total_loss += loss
if (epoch + 1) % 10 == 0:
print(f"Epoch {epoch+1}: avg loss = {total_loss / len(pairs):.4f}")
# 使用 PCA 可视化(第 01 章)
embeddings = W_in
mean = embeddings.mean(axis=0)
centered = embeddings - mean
U, S, Vt = jnp.linalg.svd(centered, full_matrices=False)
coords = centered @ Vt[:2].T # 投影到前两个主成分
plt.figure(figsize=(10, 8))
for i, word in idx2word.items():
plt.scatter(coords[i, 0], coords[i, 1], c='#3498db', s=40)
plt.annotate(word, (coords[i, 0] + 0.02, coords[i, 1] + 0.02), fontsize=9)
plt.title("Word2Vec Skip-gram 嵌入(PCA 投影)")
plt.grid(alpha=0.3); plt.show()
```
2. 构建一个字符级 RNN 语言模型,从一小段训练文本中学习生成文本。
```python
import jax
import jax.numpy as jnp
# 小型训练文本
text = "to be or not to be that is the question "
chars = sorted(set(text))
char2idx = {c: i for i, c in enumerate(chars)}
idx2char = {i: c for c, i in char2idx.items()}
V = len(chars)
data = jnp.array([char2idx[c] for c in text])
# RNN 参数
hidden_dim = 64
key = jax.random.PRNGKey(0)
k1, k2, k3, k4, k5 = jax.random.split(key, 5)
params = {
'Wx': jax.random.normal(k1, (V, hidden_dim)) * 0.1,
'Wh': jax.random.normal(k2, (hidden_dim, hidden_dim)) * 0.05,
'bh': jnp.zeros(hidden_dim),
'Wy': jax.random.normal(k3, (hidden_dim, V)) * 0.1,
'by': jnp.zeros(V),
}
def rnn_step(params, h, x_idx):
x = jnp.eye(V)[x_idx] # one-hot 编码
h = jnp.tanh(x @ params['Wx'] + h @ params['Wh'] + params['bh'])
logits = h @ params['Wy'] + params['by']
return h, logits
def loss_fn(params, inputs, targets):
h = jnp.zeros(hidden_dim)
total_loss = 0.0
for t in range(len(inputs)):
h, logits = rnn_step(params, h, inputs[t])
log_probs = jax.nn.log_softmax(logits)
total_loss -= log_probs[targets[t]]
return total_loss / len(inputs)
grad_fn = jax.jit(jax.grad(loss_fn))
# 训练
inputs = data[:-1]
targets = data[1:]
lr = 0.01
for step in range(500):
grads = grad_fn(params, inputs, targets)
params = {k: params[k] - lr * grads[k] for k in params}
if (step + 1) % 100 == 0:
l = loss_fn(params, inputs, targets)
print(f"Step {step+1}: loss = {l:.4f}")
# 生成文本
def generate(params, seed_char, length=60):
h = jnp.zeros(hidden_dim)
idx = char2idx[seed_char]
result = [seed_char]
key = jax.random.PRNGKey(42)
for _ in range(length):
h, logits = rnn_step(params, h, idx)
key, subkey = jax.random.split(key)
idx = jax.random.categorical(subkey, logits)
result.append(idx2char[int(idx)])
return ''.join(result)
print(f"\n生成文本: {generate(params, 't')}")
```
3. 实现一个带 Bahdanau 注意力的简易 seq2seq 模型,用于序列反转。可视化注意力对齐矩阵。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
# 任务:反转数字序列(例如,[3, 1, 4] -> [4, 1, 3]
vocab_size = 10 # 数字 0-9
SOS, EOS = 10, 11 # 特殊词元
total_vocab = 12
embed_dim, hidden_dim = 16, 32
max_len = 5
key = jax.random.PRNGKey(42)
keys = jax.random.split(key, 8)
params = {
'embed': jax.random.normal(keys[0], (total_vocab, embed_dim)) * 0.1,
'enc_Wx': jax.random.normal(keys[1], (embed_dim, hidden_dim)) * 0.1,
'enc_Wh': jax.random.normal(keys[2], (hidden_dim, hidden_dim)) * 0.05,
'dec_Wx': jax.random.normal(keys[3], (embed_dim, hidden_dim)) * 0.1,
'dec_Wh': jax.random.normal(keys[4], (hidden_dim, hidden_dim)) * 0.05,
# Bahdanau 注意力
'Ws': jax.random.normal(keys[5], (hidden_dim, hidden_dim)) * 0.1,
'Wh_att': jax.random.normal(keys[6], (hidden_dim, hidden_dim)) * 0.1,
'v_att': jax.random.normal(keys[7], (hidden_dim,)) * 0.1,
# 输出投影(从隐藏状态+上下文到词汇表)
'Wo': jax.random.normal(keys[0], (hidden_dim * 2, total_vocab)) * 0.1,
}
def encode(params, seq):
"""编码输入序列,返回所有隐藏状态。"""
h = jnp.zeros(hidden_dim)
states = []
for t in range(len(seq)):
x = params['embed'][seq[t]]
h = jnp.tanh(x @ params['enc_Wx'] + h @ params['enc_Wh'])
states.append(h)
return jnp.stack(states), h
def bahdanau_attention(params, dec_state, enc_states):
"""计算 Bahdanau 注意力权重和上下文向量。"""
scores = jnp.tanh(enc_states @ params['Wh_att'] + dec_state @ params['Ws'])
e = scores @ params['v_att'] # (src_len,)
alpha = jax.nn.softmax(e)
context = alpha @ enc_states
return context, alpha
def decode_step(params, dec_h, prev_token, enc_states):
x = params['embed'][prev_token]
dec_h = jnp.tanh(x @ params['dec_Wx'] + dec_h @ params['dec_Wh'])
context, alpha = bahdanau_attention(params, dec_h, enc_states)
combined = jnp.concatenate([dec_h, context])
logits = combined @ params['Wo']
return dec_h, logits, alpha
def seq2seq_loss(params, src, tgt):
enc_states, enc_final = encode(params, src)
dec_h = enc_final
loss = 0.0
prev_token = SOS
for t in range(len(tgt)):
dec_h, logits, _ = decode_step(params, dec_h, prev_token, enc_states)
log_probs = jax.nn.log_softmax(logits)
loss -= log_probs[tgt[t]]
prev_token = tgt[t]
return loss / len(tgt)
# 生成训练数据:反转序列
key = jax.random.PRNGKey(0)
train_srcs, train_tgts = [], []
for _ in range(200):
key, subkey = jax.random.split(key)
length = jax.random.randint(subkey, (), 3, max_len + 1)
key, subkey = jax.random.split(key)
seq = jax.random.randint(subkey, (int(length),), 0, vocab_size)
train_srcs.append(seq)
train_tgts.append(seq[::-1]) # 反转
# 训练
grad_fn = jax.grad(seq2seq_loss)
lr = 0.01
for epoch in range(100):
total_loss = 0.0
for src, tgt in zip(train_srcs, train_tgts):
grads = grad_fn(params, src, tgt)
params = {k: params[k] - lr * grads[k] for k in params}
total_loss += seq2seq_loss(params, src, tgt)
if (epoch + 1) % 20 == 0:
print(f"Epoch {epoch+1}: avg loss = {total_loss / len(train_srcs):.4f}")
# 可视化一个示例的注意力
test_src = jnp.array([3, 1, 4, 1, 5])
test_tgt = test_src[::-1]
enc_states, enc_final = encode(params, test_src)
dec_h = enc_final
attentions = []
prev_token = SOS
for t in range(len(test_tgt)):
dec_h, logits, alpha = decode_step(params, dec_h, prev_token, enc_states)
attentions.append(alpha)
prev_token = test_tgt[t]
att_matrix = jnp.stack(attentions)
fig, ax = plt.subplots(figsize=(6, 5))
im = ax.imshow(att_matrix, cmap='Blues')
ax.set_xlabel("源位置"); ax.set_ylabel("目标位置")
src_labels = [str(int(x)) for x in test_src]
tgt_labels = [str(int(x)) for x in test_tgt]
ax.set_xticks(range(len(src_labels))); ax.set_xticklabels(src_labels)
ax.set_yticks(range(len(tgt_labels))); ax.set_yticklabels(tgt_labels)
for i in range(len(tgt_labels)):
for j in range(len(src_labels)):
ax.text(j, i, f"{att_matrix[i,j]:.2f}", ha='center', va='center', fontsize=9)
ax.set_title("Bahdanau 注意力对齐(序列反转)")
plt.colorbar(im); plt.tight_layout(); plt.show()
```
@@ -0,0 +1,517 @@
# Transformer与语言模型
*Transformer用自注意力取代了循环结构,成为语言理解和生成的主导架构。本文件涵盖BERT、GPT、T5、位置编码(正弦编码、RoPE)、预训练目标(MLM、CLM)、微调、提示工程和缩放定律——这些是现代大语言模型背后的蓝图。*
- 在第06章中,我们介绍了Transformer架构:自注意力、多头注意力、位置编码以及编码器-解码器结构。这里我们聚焦于Transformer如何适配特定的NLP范式、定义现代NLP的模型(BERT、GPT、T5),以及让它们在大规模下切实可行的技术。
- 回顾核心操作:**缩放点积注意力**计算 $\text{softmax}(QK^T / \sqrt{d_k}) V$,其中查询、键和值都是输入的线性投影。**多头注意力**并行运行 $h$ 个注意力头,每个头使用不同的学习投影,然后将结果拼接起来。Transformer块通过残差连接、层归一化和逐位置前馈网络(第06章)将这一切包裹起来。
- 一个微妙但重要的架构选择是**层归一化**的放置位置。原始Transformer使用**后归一化**:残差和归一化在子层之后执行,即 $\text{LayerNorm}(x + \text{Sublayer}(x))$。
- 大多数现代模型使用**前归一化**:在子层之前进行归一化,即 $x + \text{Sublayer}(\text{LayerNorm}(x))$。前归一化在训练过程中更加稳定,因为残差连接直接将梯度通过恒等路径传递,不受归一化的影响。这使得训练非常深的模型变得更容易,无需仔细的学习率预热。
- 每个Transformer块中的**前馈子层**是一个两层MLP,独立应用于每个标记位置:
$$\text{FFN}(x) = W_2 \cdot \text{GELU}(W_1 x + b_1) + b_2$$
- 内部维度通常是模型维度的4倍(例如,$d_{\text{model}} = 768$$d_{\text{ff}} = 3072$)。这个FFN约占每个块中参数的三分之二,被认为起到键-值记忆的作用,存储训练过程中学到的事实知识。
- **位置编码**为模型提供标记顺序的信息,因为注意力本身是置换等变的。原始的**正弦编码**(第06章)使用不同频率的固定正弦和余弦函数。**可学习位置嵌入**则简单地为每个位置添加一个可训练向量(用于BERT和GPT-2)。两者都是绝对编码:无论上下文如何,位置5总是得到相同的向量。
- **旋转位置编码(RoPE)**通过在二维子空间中旋转查询和键向量来编码位置。对于一对维度 $(q_{2i}, q_{2i+1})$,按角度 $m\theta_i$ 的旋转(其中 $m$ 是位置,$\theta_i = 10000^{-2i/d}$)应用如下:
```math
\begin{bmatrix} q'_{2i} \\ q'_{2i+1} \end{bmatrix} = \begin{bmatrix} \cos m\theta_i & -\sin m\theta_i \\ \sin m\theta_i & \cos m\theta_i \end{bmatrix} \begin{bmatrix} q_{2i} \\ q_{2i+1} \end{bmatrix}
```
![RoPE:每个位置在二维子空间中以不同角度旋转查询和键向量,使注意力分数仅依赖于相对位置](../images/rope_rotation.svg)
- RoPE的精妙之处在于,旋转后的查询和键之间的点积 $q'^T k'$ 仅依赖于相对位置 $m - n$,而非绝对位置。
- 为了理解原因,将旋转写为 $q' = R_m q$ 和 $k' = R_n k$,其中 $R_m$ 是一个块对角旋转矩阵。注意力分数变为:
$$q'^T k' = (R_m q)^T (R_n k) = q^T R_m^T R_n \, k = q^T R_{n-m} \, k$$
- 最后一步利用了旋转群性质:$R_m^T R_n = R_{n-m}$(先向后旋转 $m$ 再向前旋转 $n$,等价于旋转 $n - m$)。
- 这意味着注意力分数仅依赖于相对距离 $n - m$,而非绝对位置 $m$ 和 $n$ 本身。
- 模型无需任何学习的位置参数就能获得自然的距离概念,并且可以泛化到训练时未见过的序列长度。
- **ALiBi**(带线性偏置的注意力)采用了一种更简单的方法:它根据距离向注意力分数添加一个固定的线性惩罚,即 $\text{score}_{ij} = q_i^T k_j - m \cdot |i - j|$,其中 $m$ 是每个头特定的斜率。不同的头使用不同的斜率,使一些头可以关注局部信息,另一些头关注全局信息。ALiBi不需要任何可学习的位置参数,并且能够很好地泛化到比训练时更长的序列。
- 基于Transformer的语言模型的三种主导范式是**仅编码器**、**仅解码器**和**编码器-解码器**。它们在模型能看到的范围(注意力掩码)以及训练方式上有所不同。
![三种Transformer范式:仅编码器(BERT)使用双向注意力进行分类,仅解码器(GPT)使用因果注意力进行生成,编码器-解码器(T5)结合两者用于序列到序列任务](../images/transformer_paradigms.svg)
- **BERT**(来自Transformer的双向编码器表示,Devlin等人,2019)是典型的仅编码器模型。它使用完全的双向注意力处理文本:每个标记可以关注所有其他标记,包括左右两侧。这赋予了BERT丰富的上下文表示,但意味着它不能自回归地生成文本。
- BERT通过两个目标进行预训练。**掩码语言建模(MLM)**随机遮蔽15%的输入标记,并训练模型去预测它们。在被选中的标记中,80%被替换为[MASK]标记,10%被替换为随机词,10%保持不变(以防止模型只学会在看到[MASK]时才进行预测)。训练目标如下:
$$\mathcal{L}_{\text{MLM}} = -\sum_{i \in \mathcal{M}} \log P(w_i \mid w_{\backslash \mathcal{M}})$$
- 其中 $\mathcal{M}$ 是被遮蔽的位置集合,$w_{\backslash \mathcal{M}}$ 是这些位置被遮蔽后的句子。这是一个**去噪**目标:模型学习重建被破坏的输入。
![BERT掩码语言建模:15%的输入标记被遮蔽,双向Transformer在遮蔽位置预测原始标记](../images/bert_mlm.svg)
- **下一句预测(NSP)**训练BERT预测两个句子在原始文本中是否连续。输入开头的特殊[CLS]标记用于此二分类。NSP的加入是为了帮助理解句子关系的任务(如问答),不过后来的工作(RoBERTa)表明其贡献很小,可以去掉。
- BERT的预训练表示通过在其顶部添加特定任务的头部(一个简单的线性层)并微调整个模型来适应下游任务。对于分类任务,使用[CLS]标记的表示。对于标记级任务(命名实体识别、词性标注),使用每个标记的表示。这种**微调**方法将预训练期间学到的语言知识迁移到新任务上,只需相对较少的标注数据。
- **GPT**(生成式预训练TransformerRadford等人,2018)是典型的仅解码器模型。它使用**因果(自回归)注意力**:每个标记只能关注更早位置的标记(以及自身)。这是通过在注意力矩阵中遮蔽未来位置(将其分数设置为 $-\infty$,然后再进行softmax)来实现的。训练目标是简单的**因果语言建模**:根据所有之前的标记预测下一个标记。
$$\mathcal{L}_{\text{CLM}} = -\sum_{i=1}^{n} \log P(w_i \mid w_1, \ldots, w_{i-1})$$
- 这与文件02中的n-gram语言模型目标相同,但采用了Transformer参数化方式,可以基于整个前文进行条件建模,而不仅仅是最后 $k-1$ 个标记。
- **GPT-2**将其规模扩大到15亿参数,并展现了强大的零样本能力:无需任何微调,它就能通过自然语言提示("将英语翻译成法语:……")来执行任务。
- **GPT-3**(1750亿参数)表明,仅凭规模就能实现**上下文学习**:通过在提示中提供几个输入-输出示例,模型无需任何梯度更新就能执行新任务。
- **编码器-解码器模型**如**T5**(文本到文本迁移TransformerRaffel等人,2020)将每个NLP任务都视为文本到文本:输入是一个文本字符串(可能带有任务前缀,如"将英语翻译成德语:"),输出也是一个文本字符串。编码器使用双向注意力处理输入,解码器则通过交叉注意力自回归地生成输出。
- T5通过**跨度破坏**进行预训练:随机连续标记跨度被替换为哨兵标记,模型需要生成原始标记。例如,"The cat sat on the mat"可能变成输入"The [X] on [Y]",目标输出是"[X] cat sat [Y] the mat"。这是BERT的MLM从单个标记向跨度的泛化。
- **BART**(Lewis等人,2020)是另一种编码器-解码器模型,通过去噪目标进行预训练,但它应用了更广泛的破坏策略:标记遮蔽、标记删除、跨度遮蔽、句子置换和文档旋转。多样化的破坏方式迫使模型学习更鲁棒的表示。
- 随着语言模型变得越来越大,**全量微调**(更新所有参数)变得不切实际:一个175B参数的模型仅存储优化器状态就需要数百GB。**参数高效微调(PEFT)**方法只调整一小部分参数。
- **适配器**在现有Transformer层之间插入小型瓶颈层(通常是两个线性层加一个非线性激活:下投影到小维度,再上投影回来)。只有适配器的权重被训练;原始模型权重被冻结。这增加了不到5%的新参数,同时在大多数任务上匹配全量微调的性能。
- **LoRA**(低秩适配)直接修改权重矩阵,而不添加新层。LoRA不更新完整的权重矩阵 $W$,而是学习一个低秩分解的更新:$W' = W + BA$,其中 $B$ 是 $d \times r$ 矩阵,$A$ 是 $r \times d$ 矩阵,且 $r \ll d$(通常 $r = 4$ 到 $r = 64$)。原始 $W$ 被冻结;只训练 $A$ 和 $B$。在推理时,更新可以合并到原始权重中,不会增加额外延迟:
$$W' = W + BA$$
![LoRA:冻结的权重矩阵W被一个通过小矩阵A和B的低秩路径旁路,可训练参数减少32倍,同时匹配全量微调的性能](../images/lora_decomposition.svg)
- **前缀微调**在每个注意力层的键和值矩阵前添加一串可学习的"虚拟标记"。模型像对待真实标记一样关注这些前缀向量,并且只训练前缀参数。这与提示微调类似,但在激活空间而非嵌入空间中操作。
- **提示工程**是设计输入文本的艺术,旨在从预训练模型中引出所需行为,而无需任何参数更新。
- **零样本提示**用自然语言描述任务("对以下评论的情感进行分类:")。
- **少样本提示**在实际查询之前提供输入-输出示例。
- **链式思维(CoT)提示**添加"让我们一步一步地思考"或在示例中包含推理过程,这通过引导模型分解问题,显著提高了算术和逻辑推理任务的性能。
- **上下文学习(ICL)**是大语言模型能够从提示中提供的示例学习执行任务的现象,而无需任何梯度更新。模型的权重没有改变;它将示例作为一种隐式规范来使用。
- ICL在机制上是如何工作的仍然是一个活跃的研究问题;一种假说是注意力层在前向传播中实现了一种梯度下降形式,实际上是在上下文示例上进行"训练"。
- **缩放定律**描述了模型大小、数据大小、计算预算与性能(以损失衡量)之间的可预测关系。Kaplan等人(2020)发现损失在每个变量上都遵循幂律:
$$L(N) \propto N^{-\alpha_N}, \quad L(D) \propto D^{-\alpha_D}, \quad L(C) \propto C^{-\alpha_C}$$
- 其中 $N$ 是参数量,$D$ 是数据集大小,$C$ 是计算预算。这些幂律在多个数量级上成立,表明单纯地扩大规模就能带来可预测的改进。
![缩放定律:损失在对数-对数坐标轴上以幂律递减,Kaplan和Chinchilla的研究结果表明随规模扩大有可预期的改进](../images/scaling_laws.svg)
- **Chinchilla缩放定律**Hoffmann等人,2022)修正了这一点,指出大多数大型模型都训练不足。对于固定的计算预算 $C$,最优分配是同等规模地扩大模型大小和训练数据:
$$N_{\text{opt}} \propto C^{0.5}, \quad D_{\text{opt}} \propto C^{0.5}$$
- 这意味着如果计算预算翻倍,应该同时将模型大小和数据集大小增加 $\sqrt{2}$ 倍,而不仅仅是让模型变得更大。
- Kaplan等人曾建议 $N$ 的缩放速度应快于 $D$,这导致了非常大但训练不足的模型。Chinchilla(70B参数,1.4T标记)在相同的计算预算下匹配了Gopher(280B参数,300B标记)的性能,表明早期模型严重缺乏数据。
- 实用的经验法则:大约每个参数训练20个标记。
- **混合专家(MoE)**是一种在不成比例增加计算量的情况下扩大模型容量的架构。MoE不采用单一的大型前馈层,而是使用多个**专家**FFN层和一个**门控网络**(路由网络)来选择每个标记应该激活哪些专家。
- 门控函数计算每个专家的路由分数,并选择前 $k$ 个(通常 $k = 1$ 或 $k = 2$):
$$G(x) = \text{TopK}(\text{softmax}(W_g x))$$
- 只有被选中的专家处理该标记,因此计算成本随 $k$(活跃专家数)而非总专家数 $E$ 增长。一个有8个专家且采用top-2路由的模型,参数量是稠密模型的4倍,但计算量仅为2倍。
![MoE层:输入标记经过路由网络计算每个专家的分数,选择top-2专家,它们的输出按门控分数加权后求和](../images/moe_layer.svg)
- MoE中一个关键的挑战是**负载均衡**:如果路由网络将大多数标记发送给少数热门专家,其他专家就被浪费了。训练时会添加一个辅助的**负载均衡损失**,鼓励均匀的专家利用率:
$$\mathcal{L}_{\text{balance}} = E \cdot \sum_{i=1}^{E} f_i \cdot p_i$$
- 其中 $f_i$ 是分配给专家 $i$ 的标记比例,$p_i$ 是专家 $i$ 的平均路由概率。当标记比例和概率都均匀(各等于 $1/E$)时,该乘积最小。
- **专家并行**将不同的专家分布到不同的加速器上。在前向传播过程中,通过一个全到全的通信步骤将标记路由到其指定专家所在的设备,然后将结果路由回来。这种通信成本是MoE在大规模部署中的主要工程挑战。Switch Transformer、Mixtral和GShard等模型使用MoE来获得强大的性能,同时保持合理的推理成本。
- 构建模型只是工作的一半;衡量它们是否有效是另一半。NLP评估特别困难,因为语言是模糊的、主观的和开放式的。
- 一个翻译可以有多种正确的表达方式。一个摘要即使与参考摘要没有任何完全相同的词汇,也可能是好的。
- 一个聊天机器人的回复可能既有用、又无害、又诚实,但理性的人仍会对此有不同看法。
- **精确匹配(EM)**是最简单的指标:模型的输出是否与标准答案完全一致?它用于答案简短且无歧义的任务,如抽取式问答(SQuAD)或封闭式数学问题。
- EM是严苛的;"New York City"和"new york city"在不做归一化的情况下无法匹配——但它的简单性使其没有歧义。
- **标记级指标**将NLP视为标记级别的分类问题,使用第06章中的精确率、召回率和F1值。
- **精确率(Precision)**衡量模型预测的标记中正确部分的比例:$P = \text{TP} / (\text{TP} + \text{FP})$。一个预测很少但全部正确的模型具有高精确率。
- **召回率(Recall)**衡量模型找到了多少标准标记:$R = \text{TP} / (\text{TP} + \text{FN})$。一个将所有标记都预测为实体的模型具有完美的召回率但精确率极低。
- **F1**是精确率和召回率的调和平均值:
$$F_1 = \frac{2PR}{P + R}$$
- 调和平均值(而非算术平均值)惩罚不均衡:如果 $P$ 或 $R$ 中任何一个较低,F1就会很低。对于命名实体识别(文件02),F1按每个实体类型分别计算,然后跨类型取宏平均。对于词性标注,标记级准确率更常见,因为每个标记都有一个标签。
- **跨度级F1**(用于SQuAD)比较预测跨度中的标记集与标准跨度中的标记集。这比精确匹配更宽容:如果标准答案是"the Eiffel Tower"而模型预测的是"Eiffel Tower",跨度F1很高(5个重叠标记中的4个),即使EM为零。
- **BLEU**(双语评估替补,Papineni等人,2002)是机器翻译的经典指标。它衡量候选翻译与一个或多个参考翻译之间的n-gram重叠。该评分结合了多个n-gram级别(unigram到4-gram)的精确率和一个简短惩罚:
$$\text{BLEU} = \text{BP} \cdot \exp\!\left(\sum_{n=1}^{N} w_n \log p_n\right)$$
- 其中 $p_n$ 是**修正的n-gram精确率**:候选翻译中每个n-gram的计数被裁剪为其在任何参考翻译中的最大计数,防止像"the the the the"这样的退化候选获得高分。权重 $w_n$ 通常是均匀的($w_n = 1/N$,其中 $N = 4$)。
- **简短惩罚** $\text{BP} = \min(1, \exp(1 - r/c))$ 惩罚比参考翻译短的候选($c$ 是候选长度,$r$ 是参考长度)。没有这个惩罚,模型可以通过输出很少但非常安全的词来获得高精确率。
- BLEU在语料级别(对多个句子取平均)与人类判断有合理的相关性,但在句子级别相关性较差。
- 它奖励精确的n-gram匹配,但会遗漏有效的释义:"the cat is on the mat"和"a feline sits atop the rug"尽管意思相同,但二元组重叠为零。
- BLEU也完全忽略了召回率——只输出最常见词汇的候选在精确率上得分很高。
- **ROUGE**(面向召回率的摘要评估替补,Lin,2004)是摘要的标准指标。与强调精确率的BLEU不同,ROUGE强调召回率:参考n-gram中有多少比例出现在候选摘要中?
- **ROUGE-N**计算n-gram的召回率:$\text{ROUGE-N} = \frac{|\text{n-grams}_{\text{ref}} \cap \text{n-grams}_{\text{cand}}|}{|\text{n-grams}_{\text{ref}}|}$。ROUGE-1unigram)和ROUGE-2bigram)最为常用。
- ROUGE-L使用候选和参考之间的**最长公共子序列(LCS)**,这可以捕捉句子级别的词序信息,而不要求连续匹配。
- LCS长度除以参考长度得到召回率,除以候选长度得到精确率,F度量则组合两者。
- LCS通过动态规划在 $O(mn)$ 时间内计算(类似于文件02中的编辑距离):
$$R_{\text{LCS}} = \frac{\text{LCS}(X, Y)}{m}, \quad P_{\text{LCS}} = \frac{\text{LCS}(X, Y)}{n}, \quad F_{\text{LCS}} = \frac{(1 + \beta^2) R_{\text{LCS}} P_{\text{LCS}}}{R_{\text{LCS}} + \beta^2 P_{\text{LCS}}}$$
- 其中 $m$ 和 $n$ 分别是参考和候选的长度,$\beta$ 通常设置为偏向召回率($\beta \to \infty$ 给出纯召回率)。
- **METEOR**(带显式排序的翻译评估度量,Banerjee和Lavie,2005)通过引入同义词、词干提取和词序来解决BLEU的弱点。
- 它首先使用精确匹配、词干匹配(通过文件02中的Porter词干提取算法)和同义词匹配(通过文件01中的WordNet)在候选和参考之间对齐词汇。
- 然后计算unigram精确率和召回率的调和平均值(偏向召回率),并应用一个碎片化惩罚,惩罚那些匹配词顺序与参考不同的候选。
- **ChrF**(字符n-gram F值)计算字符n-gram而非词汇n-gram的F值。这使其对形态变化具有鲁棒性(对文件01中的黏着语至关重要),并部分处理了分词差异。ChrF++在字符n-gram的基础上增加了词汇二元组。
- 它已成为机器翻译中与BLEU一起推荐的度量标准,特别是对于形态丰富的语言。
- **困惑度**(文件02)衡量语言模型在保留测试集上的预测效果。这是语言模型的标准内在指标:$\text{PPL} = \exp(-\frac{1}{N} \sum_{i} \log P(w_i \mid w_{<i}))$。越低越好。
- 困惑度只能在使用了相同分词方法的模型之间进行比较,因为不同的分词器对同一文本会产生不同的序列长度 $N$。
- 词汇量更大的模型每个标记的困惑度往往更低,但每个句子处理的标记数也更少。
- **每字节比特数(BPB)**按照文本中UTF-8字节数而非标记数进行归一化,使其与分词方式无关:
```math
\text{BPB} = \frac{-\sum_{i} \log_2 P(w_i \mid w_{<i})}{\text{UTF-8字节数}}
```
- **BERTScore**(Zhang等人,2020)超越了表面的n-gram匹配,在嵌入空间中计算相似度。候选中的每个标记与其在参考中最相似的标记进行匹配,使用上下文嵌入(通常来自预训练的BERT模型)的余弦相似度。分数汇总为精确率、召回率和F1:
$$R_{\text{BERT}} = \frac{1}{|r|} \sum_{r_i \in r} \max_{c_j \in c} \cos(r_i, c_j), \quad P_{\text{BERT}} = \frac{1}{|c|} \sum_{c_j \in c} \max_{r_i \in r} \cos(c_j, r_i)$$
- 其中 $r_i$ 和 $c_j$ 是参考和候选标记的上下文嵌入。这捕捉了n-gram指标无法捕捉的语义相似性:"automobile"和"car"得分很高,因为它们的BERT嵌入相似,尽管它们没有共享任何字符。
- **BLEURT**(Sellam等人,2020)在此基础上更进一步,直接在人工质量判断上微调BERT模型。给定一个参考和候选对,它输出一个标量质量分数。BLEURT在合成数据(由BLEU和METEOR等指标评分的参考翻译的随机扰动)上训练,然后在人工评分上微调。它与人类判断的相关性优于任何表面级指标。
- **COMET**(翻译评估跨语言优化指标,Rei等人,2020)是一个用于机器翻译的学习度量,它同时以源句、参考和候选作为条件——而不仅仅是参考和候选。它使用多语言编码器(XLM-R)嵌入三者,并预测质量分数。通过看到源句,COMET可以检测仅基于参考的指标所遗漏的意义错误(例如,流畅但事实错误的翻译)。
- **大语言模型作为裁判(LLM-as-judge)**是大规模评估的现代方法。不再计算与参考的指标,而是让一个强大的语言模型(GPT-4、Claude)被提示评估模型输出的质量。裁判接收输入、模型的回复以及可选的参考答案,并给出评分(例如1-5分)或成对偏好(回复A优于回复B)。
- **成对比较**(用于Chatbot Arena)是最可靠的LLM-as-judge格式。裁判看到两个回复并选择更好的那个,而不是给出绝对分数。这避免了校准问题(不同的裁判可能对"3/5"有不同的基准)。结果汇总为**Elo评分**(源自国际象棋),每个模型从一个基准评分开始,根据与其他模型的对战胜负增减分数。模型 $A$ 对模型 $B$ 的预期获胜概率为:
$$P(A \succ B) = \frac{1}{1 + 10^{(R_B - R_A) / 400}}$$
- 其中 $R_A, R_B$ 是Elo评分。每次比较后,评分更新:$R_A' = R_A + K(S - P(A \succ B))$,其中 $S \in \{0, 1\}$ 是实际结果,$K$ 控制更新幅度。持续击败强对手的模型快速上升;输给弱对手的模型下降。
- **位置偏置**是LLM裁判的一个已知问题:它们倾向于偏好先展示的回复(或者在某些模型中,后展示的回复)。**交换**(以两种顺序对每对进行评估)并平均结果可以缓解这一问题。
- **冗长偏置**是另一个问题:裁判倾向于偏好更长、更详细的回复,即使简洁的回答更好。
- **自一致性**检查裁判在多次评估同一输入时是否给出相同的评分。高方差表明评估信号存在噪音。
- **标注者间一致性**Cohen's kappa或Krippendorff's alpha)衡量多个裁判是否一致,为评估可靠性提供了一个上限。
- **数据污染**是一个关键问题:如果评估数据出现在模型的训练集中,基准分数就会被夸大且毫无意义。
- 这对于在网页抓取数据上训练的大语言模型尤其有问题,因为流行的基准很可能出现在其中。缓解策略包括:使用未公开发布的保留测试集、创建定期重新生成问题的动态基准、**金丝雀字符串**(嵌入在基准数据中用于检测泄露的唯一标识符),以及比较在污染与清洁子集上的性能。
- **标准NLU基准**评估跨多种任务的语言理解能力。
- **GLUE**(通用语言理解评估)和**SuperGLUE**是多任务基准,涵盖情感分析(SST-2)、文本相似度(STS-B)、自然语言推理(MNLI、RTE)、共指消解(WSC)和问答(BoolQ)。
- 模型在每个任务上分别评估,并按聚合指标打分。GLUE现在被认为已经饱和(模型在大多数任务上已超过人类表现);SuperGLUE仍然更具挑战性。
- **MMLU**(大规模多任务语言理解)通过多项选择题评估57个学术科目(数学、历史、法律、医学、计算机科学等)中的知识和推理能力。
- 它测试模型在预训练期间是否吸收了广泛的知识。分数按科目报告并作为宏平均给出。
- **MMLU-Pro**增加了更困难的多步推理问题,有10个选项而非4个。
- **HellaSwag**通过要求模型选择一个场景最合理的续写来测试常识推理。错误的答案是通过模型对抗性生成的,表面看似合理但语义错误。
- **WinoGrande**通过仅一词之差的极小对测试常识共指消解。
- **ARC**(AI2推理挑战)使用小学科学问题,分为简单和挑战集,测试事实和推理能力。
- **推理和数学基准**评估区分强大LLM与弱小LLM的问题解决能力。
- **GSM8K**(小学数学8K)包含8,500道小学算术应用题,需要多步算术推理。它是基础数学推理和评估链式思维提示(文件04)的标准基准。
- **MATH**是一个更难的数据集,包含代数、数论、几何、计数和概率方面的竞赛级数学问题。问题需要多步符号推理,MATH-500是常用的500题子集。
- **AIME**(美国数学邀请赛)问题是竞赛级的:正确解答需要跨越多个步骤的深度数学推理。DeepSeek-R1在AIME 2024上得分为79.8%,展示了经过RL训练的推理模型(文件05)可以接近人类高手。
- **HumanEval**和**MBPP**(基础编程问题)通过检查模型生成的代码是否通过单元测试来评估代码生成能力。HumanEval包含164个Python问题,包括函数签名和文档字符串;模型需要生成函数体。
- 指标是**pass@k**:在 $k$ 个生成的解决方案中至少有一个通过所有测试的概率。对于单个样本:
$$\text{pass@}k = 1 - \frac{\binom{n-c}{k}}{\binom{n}{k}}$$
- 其中 $n$ 是生成的样本总数,$c$ 是通过的数量。这个公式修正了简单取 $k$ 个样本中最好结果的偏差。
- **SWE-bench**更进一步,评估模型能否通过修改现有代码库来解决真实的GitHub问题——这是对实际软件工程能力的更困难测试。
- **GPQA**(研究生级Google-proof问答)包含生物学、物理学和化学领域的专家级问题,即使是领域专家也很难解答。它测试模型是否具有真正的理解能力而非模式匹配。"Diamond"子集是最难的部分。
- **安全和对齐基准**评估模型是否有用、无害和诚实。
- **TruthfulQA**测试模型是否复现了常见的误解。问题设计为最常见的互联网答案是错误的(例如,"如果吞下口香糖会怎样?",常见的谣言是它会在胃里停留7年,但事实是它会正常通过)。那些记忆了流行但不正确说法的模型得分很低。
- **BBQ**(问答偏置基准)测试在年龄、性别、种族和宗教等类别上的社会偏置。问题的结构使得有偏置的模型会系统地选择刻板印象的答案。**Toxigen**评估模型针对特定人口群体生成有害内容的倾向。
- **MT-Bench**使用80个精心设计的问题评估多轮对话能力,涵盖写作、角色扮演、推理、数学、编程、信息抽取、STEM和人文学科。LLM裁判(GPT-4)按1-10分对回复评分。多轮格式测试模型是否能进行后续提问、保持上下文和处理澄清请求。
- **Chatbot Arena**(LMSYS)使用真实用户对匿名模型进行盲法成对比较。用户提交提示并对更好的回复投票,而不知道是哪个模型生成的。由此产生的Elo排行榜被认为是对通用LLM质量最生态有效的评估,因为它反映了真实用户在多样化、未经策划的提示上的偏好。
- **AlpacaEval**通过在一组固定的指令上将模型输出与参考模型(GPT-4)进行比较来自动化成对评估。由裁判模型决定胜率。
- **AlpacaEval 2.0**使用长度控制的胜率来纠正冗长偏置。
- **任务特定评估**需要针对专业领域量身定制的指标。
- **词错误率(WER**用于语音识别:$\text{WER} = (S + D + I) / N$,其中 $S$、$D$、$I$ 分别是替换、删除和插入错误,$N$ 是参考词的数量。这是按参考长度归一化的编辑距离(文件02),应用于词汇级别。
- **槽位F1**用于任务导向的对话系统,衡量模型是否正确地从用户话语中提取结构化信息(例如,从"帮我订一张明天去巴黎的机票"中提取"目的地:巴黎"和"日期:明天")。
- **引用准确率**用于RAG系统(文件05),检查模型生成的引用是否确实支持所提出的主张。将主张与检索到的段落进行验证,指标统计完全支持、部分支持和不支持的主张比例。
- **评估陷阱**很常见,可能使整个基准比较无效。
- **对测试投其所好**:优化基准性能而非真正能力。在MMLU风格的多项选择上微调的模型在MMLU上得分很高,但在以开放式形式提出的相同问题上可能失败。
- **指标游戏化**:模型可以被优化以产生在自动指标上得分很高的输出(高BLEU、低困惑度),但并非真正优秀。BLEU最优的翻译往往是安全、通用的释义,而非自然流畅的翻译。
- **基准饱和**:当模型在基准上接近或超过人类表现时,该基准就不再提供信息。GLUE、SQuAD 1.1和其他几个基准现在已经饱和。
- 该领域不断创建更难的新基准,但这种创建、饱和和替换的循环使得纵向比较变得困难。
- **人工评估**仍然是黄金标准,但成本高、速度慢且难以复现。不同的标注者群体(众包工作者与领域专家、不同文化、不同语言)会产生不同的判断。报告标注者间一致性和标注者人口统计信息对可复现性至关重要。
## 编程任务(使用CoLab或笔记本)
1. 从头实现一个完整的Transformer编码器块(多头注意力、前馈网络、残差连接、层归一化)。将其应用于一个简单的序列分类任务。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
def layer_norm(x, gamma, beta, eps=1e-5):
mean = x.mean(axis=-1, keepdims=True)
var = x.var(axis=-1, keepdims=True)
return gamma * (x - mean) / jnp.sqrt(var + eps) + beta
def multi_head_attention(Q, K, V, W_q, W_k, W_v, W_o, n_heads):
B, T, D = Q.shape
head_dim = D // n_heads
q = Q @ W_q # (B, T, D)
k = K @ W_k
v = V @ W_v
# Reshape to (B, n_heads, T, head_dim)
q = q.reshape(B, T, n_heads, head_dim).transpose(0, 2, 1, 3)
k = k.reshape(B, T, n_heads, head_dim).transpose(0, 2, 1, 3)
v = v.reshape(B, T, n_heads, head_dim).transpose(0, 2, 1, 3)
scores = q @ k.transpose(0, 1, 3, 2) / jnp.sqrt(head_dim)
weights = jax.nn.softmax(scores, axis=-1)
out = (weights @ v).transpose(0, 2, 1, 3).reshape(B, T, D)
return out @ W_o, weights
def transformer_block(x, params):
# Pre-norm multi-head self-attention
normed = layer_norm(x, params['ln1_g'], params['ln1_b'])
attn_out, weights = multi_head_attention(
normed, normed, normed,
params['W_q'], params['W_k'], params['W_v'], params['W_o'],
n_heads=4
)
x = x + attn_out
# Pre-norm feed-forward
normed = layer_norm(x, params['ln2_g'], params['ln2_b'])
ff = jax.nn.gelu(normed @ params['W1'] + params['b1'])
ff = ff @ params['W2'] + params['b2']
x = x + ff
return x, weights
# Initialise parameters
d_model, d_ff, n_heads = 32, 128, 4
key = jax.random.PRNGKey(42)
keys = jax.random.split(key, 10)
params = {
'W_q': jax.random.normal(keys[0], (d_model, d_model)) * 0.05,
'W_k': jax.random.normal(keys[1], (d_model, d_model)) * 0.05,
'W_v': jax.random.normal(keys[2], (d_model, d_model)) * 0.05,
'W_o': jax.random.normal(keys[3], (d_model, d_model)) * 0.05,
'ln1_g': jnp.ones(d_model), 'ln1_b': jnp.zeros(d_model),
'ln2_g': jnp.ones(d_model), 'ln2_b': jnp.zeros(d_model),
'W1': jax.random.normal(keys[4], (d_model, d_ff)) * 0.05,
'b1': jnp.zeros(d_ff),
'W2': jax.random.normal(keys[5], (d_ff, d_model)) * 0.05,
'b2': jnp.zeros(d_model),
}
# Test with random input
x = jax.random.normal(keys[6], (2, 8, d_model)) # batch=2, seq_len=8
out, attn_weights = transformer_block(x, params)
print(f"Input shape: {x.shape}")
print(f"Output shape: {out.shape}")
print(f"Attention weights shape: {attn_weights.shape}") # (B, n_heads, T, T)
# Visualise attention patterns for each head
fig, axes = plt.subplots(1, 4, figsize=(16, 3.5))
for h in range(4):
im = axes[h].imshow(attn_weights[0, h], cmap='Blues', vmin=0)
axes[h].set_title(f"Head {h}")
axes[h].set_xlabel("Key pos"); axes[h].set_ylabel("Query pos")
plt.suptitle("Multi-Head Attention Patterns")
plt.tight_layout(); plt.show()
```
2. 实现因果(自回归)注意力掩码,并与双向注意力进行比较。展示掩码如何防止信息从未来流向过去的标记。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
def attention(Q, K, V, mask=None):
d_k = Q.shape[-1]
scores = Q @ K.T / jnp.sqrt(d_k)
if mask is not None:
scores = jnp.where(mask, scores, -1e9)
weights = jax.nn.softmax(scores, axis=-1)
return weights @ V, weights
seq_len, d_model = 6, 8
key = jax.random.PRNGKey(0)
k1, k2, k3 = jax.random.split(key, 3)
Q = jax.random.normal(k1, (seq_len, d_model))
K = jax.random.normal(k2, (seq_len, d_model))
V = jax.random.normal(k3, (seq_len, d_model))
# Bidirectional (encoder-style): all positions visible
bidir_mask = jnp.ones((seq_len, seq_len), dtype=bool)
bidir_out, bidir_weights = attention(Q, K, V, bidir_mask)
# Causal (decoder-style): only past and current positions visible
causal_mask = jnp.tril(jnp.ones((seq_len, seq_len), dtype=bool))
causal_out, causal_weights = attention(Q, K, V, causal_mask)
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
tokens = [f"t{i}" for i in range(seq_len)]
axes[0].imshow(bidir_weights, cmap='Blues', vmin=0, vmax=0.5)
axes[0].set_title("Bidirectional Attention\n(BERT-style)")
axes[0].set_xticks(range(seq_len)); axes[0].set_xticklabels(tokens)
axes[0].set_yticks(range(seq_len)); axes[0].set_yticklabels(tokens)
axes[1].imshow(causal_mask.astype(float), cmap='Greys', vmin=0, vmax=1)
axes[1].set_title("Causal Mask\n(1 = allowed, 0 = blocked)")
axes[1].set_xticks(range(seq_len)); axes[1].set_xticklabels(tokens)
axes[1].set_yticks(range(seq_len)); axes[1].set_yticklabels(tokens)
axes[2].imshow(causal_weights, cmap='Blues', vmin=0, vmax=0.5)
axes[2].set_title("Causal Attention\n(GPT-style)")
axes[2].set_xticks(range(seq_len)); axes[2].set_xticklabels(tokens)
axes[2].set_yticks(range(seq_len)); axes[2].set_yticklabels(tokens)
for ax in axes:
ax.set_xlabel("Key"); ax.set_ylabel("Query")
plt.tight_layout(); plt.show()
# Verify: in causal attention, output at position i depends only on positions <= i
print("Causal attention weight at position 2 (should only attend to 0, 1, 2):")
print(f" Weights: {causal_weights[2]}")
print(f" Sum of future weights (should be ~0): {causal_weights[2, 3:].sum():.6f}")
```
3. 实现LoRA(低秩适配),并展示它如何以远少于全量微调的可训练参数来修改权重矩阵。
```python
import jax
import jax.numpy as jnp
d_model = 256
rank = 4 # LoRA rank (much smaller than d_model)
key = jax.random.PRNGKey(42)
k1, k2, k3 = jax.random.split(key, 3)
# Original frozen weight matrix
W_frozen = jax.random.normal(k1, (d_model, d_model)) * 0.02
# LoRA matrices (only these are trainable)
B = jnp.zeros((d_model, rank)) # initialised to zero
A = jax.random.normal(k2, (rank, d_model)) * 0.01 # random init
# Forward pass: W_effective = W_frozen + B @ A
x = jax.random.normal(k3, (8, d_model))
# Without LoRA
y_original = x @ W_frozen.T
# With LoRA
W_effective = W_frozen + B @ A
y_lora = x @ W_effective.T
# Parameter counts
full_params = d_model * d_model
lora_params = d_model * rank + rank * d_model # B + A
print(f"Model dimension: {d_model}")
print(f"LoRA rank: {rank}")
print(f"Full fine-tuning parameters: {full_params:,}")
print(f"LoRA parameters: {lora_params:,}")
print(f"Parameter reduction: {full_params / lora_params:.1f}x")
print(f"\nSince B is initialised to zeros, initial LoRA output matches original:")
print(f" Max difference: {jnp.abs(y_original - y_lora).max():.2e}")
# Simulate training: only update A and B
def lora_forward(A, B, W_frozen, x):
return x @ (W_frozen + B @ A).T
def dummy_loss(A, B, W_frozen, x, target):
pred = lora_forward(A, B, W_frozen, x)
return jnp.mean((pred - target) ** 2)
# Target: some transformation of x
target = x @ jax.random.normal(jax.random.PRNGKey(99), (d_model, d_model)).T * 0.02
grad_fn = jax.jit(jax.grad(dummy_loss, argnums=(0, 1)))
lr = 0.01
for step in range(200):
gA, gB = grad_fn(A, B, W_frozen, x, target)
A = A - lr * gA
B = B - lr * gB
loss_before = dummy_loss(jnp.zeros_like(A), jnp.zeros_like(B), W_frozen, x, target)
loss_after = dummy_loss(A, B, W_frozen, x, target)
print(f"\nLoss before LoRA: {loss_before:.6f}")
print(f"Loss after LoRA: {loss_after:.6f}")
print(f"Effective weight change rank: {jnp.linalg.matrix_rank(B @ A)}")
```
@@ -0,0 +1,567 @@
# 高级文本生成
*高级文本生成超越了普通的自回归解码,旨在提升质量、可控性和速度。本文涵盖文本扩散模型(D3PM、MDLM)、OCR、用于对齐的RLHF与DPO、长上下文方法(RoPE缩放、环形注意力)、检索增强生成,以及用于加速推理的推测性解码。*
- 标准的自回归生成(文件04)从左到右逐个生成token。这种方式简单有效,但本质上是串行的,无法进行全局规划,对输出的控制也有限。本文涵盖了超越普通自回归解码的方法:文本扩散模型、光学字符识别、通过人类反馈实现的可控生成、长上下文处理、检索增强生成,以及用于加速推理的推测性解码。
- **文本扩散模型**将扩散框架(在第08章中为图像引入)应用于离散文本。核心挑战在于文本是离散的:你不能像对像素添加噪声那样,向token添加连续的高斯噪声。有几种方法可以解决这个问题。
- **D3PM**(离散去噪扩散概率模型,Austin等人,2021)定义了一个直接在离散token上的前向加噪过程,使用转移矩阵。在每个前向步骤中,一个token有一定概率被另一个token替换(均匀噪声)、被掩码(吸收态)或保持不变。逆向过程学习去噪,从加噪的token预测出干净的token。在步骤$t$处的转移矩阵$Q_t$控制加噪过程:
$$q(x_t \mid x_{t-1}) = \text{Cat}(x_t ; \, x_{t-1} Q_t)$$
- 其中$\text{Cat}$表示类别分布,$x$是一个one-hot向量。多步前向过程$q(x_t \mid x_0)$有一个闭式形式:$q(x_t \mid x_0) = \text{Cat}(x_t ; \, x_0 \bar{Q}_t)$,其中$\bar{Q}_t = Q_1 Q_2 \cdots Q_t$是到步骤$t$为止所有转移矩阵的乘积。训练最小化一个变分下界(ELBO),该下界在不同时间步上分解,与连续情况(第08章)类似:
$$\mathcal{L}_{\text{D3PM}} = D_{\text{KL}}(q(x_T \mid x_0) \| p(x_T)) + \sum_{t=2}^{T} D_{\text{KL}}(q(x_{t-1} \mid x_t, x_0) \| p_\theta(x_{t-1} \mid x_t)) - \log p_\theta(x_0 \mid x_1)$$
- 第一项确保完全加噪后的分布与先验分布(均匀分布或全掩码)匹配。KL项的和训练模型去逆转每个加噪步骤:真实的逆向后验$q(x_{t-1} \mid x_t, x_0)$可以使用贝叶斯规则和已知的转移矩阵以闭式形式计算,模型$p_\theta(x_{t-1} \mid x_t)$被训练去匹配它。
- 由于这两个分布都是类别分布,KL散度就是词汇表条目上的简单求和。最后一项衡量来自最干净加噪状态的重建质量。
- **MDLM**(掩码扩散语言模型,Sahoo等人,2024)通过仅使用掩码作为唯一的加噪操作来简化D3PM:前向过程逐步用[MASK]token替换原始token,逆向过程预测原始token。这使文本扩散与掩码语言建模(BERT,文件04)联系起来,扩散时间步控制被掩码的token比例。在$t = 0$时刻文本完全干净;在$t = T$时刻文本被完全掩码。
- **连续文本扩散**通过在连续的嵌入空间中工作来规避离散问题。Token首先被映射到它们的嵌入向量(第06章),噪声在这个连续空间中被添加,一个去噪模型(通常是Transformer)学习逆转这个过程。在生成时,模型产生连续向量,通过找到最近的嵌入将其映射回离散token。挑战在于连续空间中的小误差可能映射到完全错误的token,因此需要仔细的舍入和裁剪。
![文本扩散过程](../images/text_diffusion.svg)
- 文本扩散的吸引力在于它通过迭代精炼同时生成所有token,而不是从左到右。这允许全局连贯性和轻松填充(在段落中间生成缺失的文本),但当前文本扩散模型在长文本的生成质量上仍然落后于自回归模型。
- **文本OCR**(光学字符识别)是从图像中提取文本的任务。虽然传统上不归入语言生成,但现代OCR系统与NLP深度集成,并且越来越多地使用语言模型组件。
- **场景文本检测**在自然图像(路牌、产品标签、车牌)中定位文本区域。这很有挑战性,因为野外的文本出现在任意角度、尺寸、字体和杂乱的背景中。检测方法通常使用CNN或Transformer主干网络,围绕文本区域生成边界框或分割掩码。
- **CRNN**(卷积循环神经网络,Shi等人,2017)是一种经典的文本识别架构。CNN从文本图像中提取视觉特征,特征图被切片成列序列(每个水平位置一列),双向LSTM读取这个序列以建模上下文。输出使用**CTC**(连接时序分类)进行解码,该算法处理输入列和输出字符之间的对齐,无需显式分割。
- CTC解决的根本问题是:模型产生$T$个输出分布(每个输入列一个),但目标文本有$L \leq T$个字符。
- 我们不知道哪些列对应哪些字符。CTC引入了一个**空白token** $\epsilon$,并定义了一个多对一的映射$\mathcal{B}$,该映射折叠重复字符并移除空白:$\mathcal{B}(\text{\"HH-ee-ll-ll-oo\"}) = \text{\"Hello\"}$(其中\"-\"是空白)。
- 目标序列$y$的概率是所有折叠后得到$y$的输入对齐路径的概率之和:
$$P(y \mid x) = \sum_{\pi \in \mathcal{B}^{-1}(y)} \prod_{t=1}^{T} P(\pi_t \mid x)$$
- 其中$\pi$是一条长度为$T$的对齐路径(每列一个标签,包括空白)。朴素地求和所有路径是指数级的,但**前向算法**(第05章HMM)使用动态规划在$O(T \cdot L)$时间内高效地计算这个和。
- 空白token是必不可少的:没有它,像\"Hello\"中重复的\"ll\"就无法与单个\"l\"区分开来。训练最大化$\log P(y \mid x)$,在推理时,通过在CTC输出上进行束搜索或贪心解码来找到最佳路径。
- **文档OCR**处理结构化文档(发票、表格、科学论文),除了识别字符外,还必须理解布局。像LayoutLM这样的现代系统将文本识别与空间位置特征相结合:每个token既有其文本嵌入,也有编码其在页面上$(x, y)$坐标的位置嵌入。这使得模型能够理解出现在\"总计:\"下面的数字就是总金额。
![CRNN OCR 管线](../images/crnn_ocr_pipeline.svg)
- **视觉-语言OCR**模型如TrOCR将文本识别视为图像到文本的生成:视觉Transformer编码器处理图像,语言模型解码器逐字符生成文本。这利用了预训练视觉和语言模型的能力,无需手工特征工程即可处理多种文字、字体和布局。
- **可控生成**是引导语言模型产生具有所需属性(特定的风格、主题、情感、安全级别或事实准确性)的输出的挑战。模型应当遵循指令,同时保持流畅和连贯。
- **针对文本的分类器无关引导(CFG** 改编自图像生成中的技术。在训练期间,条件信号(如提示词)以一定比例被随机丢弃,从而在同一个模型中同时训练条件模型和无条件模型。在推理时,输出logits被插值:
$$\text{logits}_{\text{guided}} = (1 + w) \cdot \text{logits}_{\text{conditional}} - w \cdot \text{logits}_{\text{unconditional}}$$
- 其中$w > 0$放大了条件的影响。越大的$w$使输出更强烈地遵循提示词,但降低了多样性。
- **RLHF**(基于人类反馈的强化学习,Ouyang等人,2022)是对齐语言模型与人类偏好的主流方法。该过程分为三个阶段:
- 首先,**监督微调(SFT)**:在高质量人工编写的提示-回复数据集上对基础语言模型进行微调。
- 其次,**奖励模型训练**:收集人类比较数据(给定提示$x$和两个回复$y_1, y_2$,哪个更好?)并训练一个奖励模型$r_\phi(x, y)$来预测人类偏好。奖励模型使用成对排序损失进行训练:
$$\mathcal{L}_{\text{RM}} = -\log \sigma(r_\phi(x, y_w) - r_\phi(x, y_l))$$
- 其中$y_w$是更受偏好的回复,$y_l$是不受偏好的回复。
- 第三,**RL微调**:优化语言模型以最大化奖励,同时保持接近SFT模型(以防止模式崩塌)。这使用带有KL惩罚的PPO(近端策略优化,来自第06章):
$$\mathcal{L}_{\text{RL}} = -\mathbb{E}\left[r_\phi(x, y) - \beta \, D_{\text{KL}}(\pi_\theta \| \pi_{\text{SFT}})\right]$$
- KL项防止模型偏离基础模型太远,并防止模型利用奖励模型的缺陷(\"奖励破解\")。
![RLHF 管线](../images/rlhf_pipeline.svg)
- **DPO**(直接偏好优化,Rafailov等人,2023)通过完全消除奖励模型来简化RLHF。关键的数学洞见是,上述KL约束的RL目标有一个闭式最优策略:
$$\pi^\ast(y \mid x) = \frac{1}{Z(x)} \pi_{\text{ref}}(y \mid x) \exp\!\left(\frac{r(x, y)}{\beta}\right)$$
- 其中$Z(x)$是一个归一化配分函数。整理上式求解奖励得$r(x, y) = \beta \log \frac{\pi^\ast(y \mid x)}{\pi_{\text{ref}}(y \mid x)} + \beta \log Z(x)$。将这个隐式奖励代入Bradley-Terry偏好模型$P(y_w \succ y_l) = \sigma(r(x, y_w) - r(x, y_l))$会导致难以处理的$Z(x)$项相互抵消,直接得到DPO损失:
$$\mathcal{L}_{\text{DPO}} = -\log \sigma\!\left(\beta \log \frac{\pi_\theta(y_w \mid x)}{\pi_{\text{ref}}(y_w \mid x)} - \beta \log \frac{\pi_\theta(y_l \mid x)}{\pi_{\text{ref}}(y_l \mid x)}\right)$$
- 这在数学上等价于RLHF,但将奖励模型和RL训练合并为一个单一的监督步骤。
- sigmoid内部的表达式可以理解为:"增加偏好回复的相对概率,降低不偏好回复的相对概率,这是相对于参考模型而言的。"
- 参数$\beta$控制策略可以偏离参考模型的程度。在实践中,DPO实现更简单(只需计算当前模型和参考模型对两个完成序列的对数概率),并且避免了PPO训练的不稳定性。
- **Constitutional AI**(Bai等人,2022)自动化了对齐过程的某些部分。它不再收集人类比较数据,而是让语言模型本身根据一组原则("宪法")来批评和修订自己的输出,例如"选择危害较小的回复"。然后,AI生成的比较数据被用于偏好训练(RLAIF:基于AI反馈的强化学习)。
- **长上下文方法**解决了标准自注意力的$O(n^2)$内存和计算成本问题,这限制了序列长度。当$n$增长到数万或数十万个token时,标准注意力变得不可行。
- **稀疏注意力**将稠密的$n \times n$注意力矩阵替换为一种稀疏模式,其中每个token只关注其他token的一个子集。常见的模式包括**局部注意力**(每个token关注一个固定大小的相邻窗口)、**步长注意力**(关注每隔$k$个token)和**随机注意力**(关注一个随机子集)。这些模式的组合(用于BigBird、Longformer)实现了$O(n)$或$O(n \sqrt{n})$的复杂度,同时保持了捕获局部和全局依赖关系的能力。
![稀疏注意力模式](../images/sparse_attention_patterns.svg)
- **滑动窗口注意力**将每个token限制为只关注其之前的$w$个token(其局部窗口)。这是$O(nw)$而不是$O(n^2)$,但长距离信息必须通过跨层的重叠窗口传播。对于$L$层和窗口大小$w$,有效感受野为$L \times w$个token。
- **环形注意力**通过将设备排列成环形拓扑结构,将长序列分布到多个设备上。每个设备持有序列的一个块,并为其块计算注意力,同时将键值块发送给环中的下一个设备。这种方式将计算与通信重叠,允许任意长度的序列,仅受所有设备总内存的限制,而不受任何单个设备内存的限制。
- **记忆增强模型**通过为Transformer配备一个外部记忆库来扩展上下文。在每个层中,模型可以使用注意力从这个记忆库中读取和写入。Memorizing Transformers缓存来自先前块的键值对,并在后续块中关注它们,从而有效地将上下文扩展到训练窗口之外。检索是近似的(使用缓存键的$k$近邻搜索)以保持高效。
- 上述方法是处理长上下文的**架构**解决方案。同样重要的是模型如何被**训练**以有效使用长上下文。
- **渐进式上下文扩展**是标准方法。从一开始就在非常长的序列上训练代价高昂($O(n^2)$的注意力成本),因此模型在较短的上下文长度上预训练(通常为4K-8K token),然后通过**继续预训练**分阶段扩展到目标长度。
- Llama 3.1从8K扩展到128K,使用了800B token,并逐步增加序列长度。DeepSeek-V3在4K处训练,然后扩展到32K,再到128K。
- 每个阶段使用适中的token数量(相对于完整的预训练预算),因为模型只需要学习如何使用更长的位置,而不是重新学习语言本身。
- 在扩展过程中,位置编码必须进行调整。**RoPE插值**缩小位置索引,使得模型看到与训练时相同的旋转角度,只是分布在更长的序列上。如果模型在长度$L$上训练,你想要扩展到$L' = 4L$,你可以将所有位置索引除以4。
- 这意味着模型永远不会遇到未见过的旋转角度,但相邻位置之间的有效分辨率会下降。
- **RoPE外推**保持原始位置索引不变,直接将RoPE应用于超出$L$的位置,依赖模型对未见角度的泛化能力。
- 插值要稳定得多;在不调整基频(ABF)的情况下,外推会迅速退化。
- **YaRN**Yet another RoPE extensioN,又一种RoPE扩展)改进了朴素插值,因为它认识到并非所有RoPE维度都应被同等对待。
- 高频维度(在$\theta_i = \theta_{\text{base}}^{-2i/d}$中较小的$i$)在训练长度内旋转多次,可以很好地外推。
- 低频维度(较大的$i$)旋转缓慢,对长度扩展更敏感。
- YaRN只插值低频维度,外推高频维度,并对注意力logits应用温度缩放$t$以补偿分布偏移:
$$\text{score}'_{ij} = \frac{q_i^T k_j}{t \sqrt{d_k}}$$
- 其中$t > 1$展平了注意力分布,防止模型在位置信号被压缩时过于尖锐地关注附近的token。
- **长上下文数据策展**是一个关键且常被低估的挑战。大多数预训练语料库由短文档组成(新闻文章、网页、社交媒体帖子)。
- 长上下文训练需要实际利用完整上下文窗口的数据组合:书籍、代码仓库、长篇科学文章、多轮对话日志,以及主题相关的拼接文档。
- 如果模型仅在填充或打包以填满上下文窗口的短文档上训练,它会学会忽略远处的token,因为它们从来都不相关。
- **序列打包**是一种训练效率技术:多个文档拼接成一个训练序列以避免填充浪费,使用注意力掩码防止跨文档的注意力。
- 对于长上下文训练,打包策略很重要:打包许多不相关的短文档会教模型将远处的token视为噪声,而打包更少的、真正长的文档则教它使用完整的上下文。
- 一个已知的失败模式是**"中间迷失"**现象(Liu等人,2023):语言模型能够有效利用上下文窗口开头和结尾的信息,但在处理位于中间的信息时表现困难。
- 这类似于人类记忆中的序列位置效应(首因效应和近因效应)。
- 它部分源于训练数据的分布(重要信息通常在文档的开头或结尾),部分源于注意力模式集中于邻近token和初始token。
- 通过在不同位置放置关键信息进行长上下文训练可以缓解但无法完全解决这个问题。
- **大海捞针**评估测试模型是否能够从长长的干扰上下文("大海")中检索出位于不同位置的特定事实("针")。
- 具有真正长上下文能力的模型应该无论针放在哪里都能实现近乎完美的检索。
- 这个测试清晰地揭示了"中间迷失"效应,并被用作上下文扩展方法的基准。
- **预训练后的长上下文微调**使用有针对性的SFT数据:长多轮对话、证据分散在数千个token中的文档问答、长篇摘要,以及仓库级别的代码理解。
- Qwen3在此阶段使用**双块注意力(DCA)**,它将长序列作为成对的块进行处理,其中块内注意力是完整的,块间注意力是高效的,在微调期间实现了4倍的有效序列容量。
- **状态空间模型(SSM)**提供了一种根本不同的长序列建模方法。它们不是修改注意力,而是用受连续时间控制理论启发的线性动力系统完全取代注意力。
- 一个SSM将输入序列$u(t)$通过一个潜在状态$x(t) \in \mathbb{R}^N$映射到输出$y(t)$,其控制方程为:
$$x'(t) = Ax(t) + Bu(t), \quad y(t) = Cx(t) + Du(t)$$
- 其中$A \in \mathbb{R}^{N \times N}$是状态转移矩阵,$B \in \mathbb{R}^{N \times 1}$是输入投影,$C \in \mathbb{R}^{1 \times N}$是输出投影,$D$是一个跳跃连接。
- 为了将其应用于离散序列(token),使用步长$\Delta$对连续系统进行**离散化**。零阶保持离散化给出:
$$\bar{A} = \exp(\Delta A), \quad \bar{B} = (\Delta A)^{-1}(\exp(\Delta A) - I) \cdot \Delta B$$
- 离散递归变为$x_k = \bar{A} x_{k-1} + \bar{B} u_k$$y_k = C x_k + D u_k$,这看起来像一个RNN:每次用一个隐藏状态处理一个token。
- 与RNN不同,这个递归也可以展开为一个**全局卷积**:因为系统是线性的,输出为$y = \bar{K} \ast u$,其中核$\bar{K} = (C\bar{B}, \, C\bar{A}\bar{B}, \, C\bar{A}^2\bar{B}, \ldots)$仅取决于固定参数。
- 这种**双重视角**——用于高效自回归推理的递归(每步$O(1)$)和用于高效并行训练的卷积(通过FFT实现$O(n \log n)$)——是SSM的核心洞见。
![SSM双重视角:推理时的递归、训练时的卷积,以及Mamba的选择性扩展](../images/ssm_dual_view.svg)
- **S4**(序列建模的结构化状态空间,Gu等人,2022)通过解决关键的数值挑战使SSM变得实用:状态矩阵$A$必须捕获长距离依赖关系,但朴素地参数化会导致梯度消失或爆炸(与普通RNN相同的问题)。
- S4使用**HiPPO**(高阶多项式投影算子)矩阵初始化$A$,该矩阵来源于连续信号最优多项式逼近的理论。HiPPO矩阵具有特定的结构,被证明能使状态以优雅衰减的方式维持整个输入历史的压缩表示:
```math
A_{nk} = -\begin{cases} (2n+1)^{1/2}(2k+1)^{1/2} & \text{if } n > k \\ n+1 & \text{if } n = k \\ 0 & \text{if } n < k \end{cases}
```
- 这种下三角结构确保状态使用勒让德多项式作为信号的在线逼近器。计算长核的$\bar{A}^k$代价高昂,因此S4利用HiPPO矩阵可以分解为低秩项和对角项之和的事实,实现了$O(n \log n)$的核计算。
- **Mamba**Gu和Dao,2023)引入了**选择性状态空间**这一关键创新:使SSM参数依赖于输入。在S4中,矩阵$A$、$B$、$C$和步长$\Delta$是固定的——无论内容如何,相同的动力学应用于每个token。Mamba使$B$、$C$和$\Delta$成为输入的函数:
$$B_k = \text{Linear}(u_k), \quad C_k = \text{Linear}(u_k), \quad \Delta_k = \text{softplus}(\text{Linear}(u_k))$$
- 这种选择性允许模型在每个位置决定哪些信息存入状态、哪些信息忽略——类似于注意力如何选择相关token,但没有二次成本。步长$\Delta_k$控制着"门":大的$\Delta$导致状态强烈地整合当前输入(连续动力学前进一大步,有效重置状态),而小的$\Delta$则保留现有状态并忽略当前输入。
- 权衡之处在于,依赖于输入的参数打破了卷积视角(核不再固定),因此Mamba无法使用基于FFT的训练。相反,它使用一种**硬件感知的并行扫描**算法,利用递归的结合律:状态更新$(x_k, u_k) \mapsto x_{k+1}$可以表示为一串结合性操作,并使用前缀和(扫描)进行并行化,类似于硬件设计中的并行前缀加法。这在GPU上以$O(n)$时间和$O(\log n)$深度运行,几乎与卷积的效率相当。
- Mamba实现了真正每token $O(1)$的推理(只需更新固定大小的状态,没有随上下文增长的KV缓存),使其在长序列长度上从根本上比Transformer更节省内存。状态大小$N$(通常为16)远小于Transformer的KV缓存(存储$O(n \cdot d)$个值)。在实践中,在相同的参数量下,Mamba在语言建模基准上的质量达到或超过Transformer,并且在长序列上推理速度显著更快。
- **混合架构**将SSM层与注意力层相结合,使用SSM处理大部分层(高效的长距离传播),并穿插少量注意力层(精确的基于内容的检索)。像Jamba和Zamba这样的模型交错了Mamba和Transformer块,在保持大部分推理效率优势的同时,实现了比纯SSM更好的质量。这表明注意力和SSM捕获了互补的能力:SSM擅长平滑的长距离状态传播,而注意力擅长精确的、依赖于内容的查找。
- **检索增强生成(RAG)**通过在推理时让语言模型访问外部知识库,来解决语言模型的知识局限性。RAG不是仅依赖于训练期间编码在模型参数中的知识,而是检索相关文档并基于它们进行条件生成。
- 经典的**检索器-阅读器架构**有两个组件。**检索器**接收查询并从语料库中获取最相关的top-$k$个段落。**阅读器**(一个语言模型)基于查询和检索到的段落生成答案。检索器可以使用稀疏方法(BM25,它扩展了文件02中的TF-IDF)或稠密方法。
- **稠密段落检索(DPR)**使用双编码器架构:一个编码器将问题映射为向量,另一个将段落映射为向量。两者通常都是基于BERT的。在索引时,所有段落被编码并存储。在查询时,问题被编码,使用近似最近邻搜索(如FAISS)找到最近的段落。相似度度量是问题向量和段落向量之间的点积。
- **分块策略**显著影响检索质量。文档必须被分割成足够小以使检索器能够处理的段落,但又要足够大以包含完整的思想。固定大小的分块(例如,256个token,50个token重叠)很简单,但可能笨拙地分割句子。语义分块在段落或章节边界处分割。层次化分块在不同粒度上创建一个摘要树。
![RAG 架构](../images/rag_architecture.svg)
- RAG有几个优势:知识库可以更新而无需重新训练模型,模型可以引用来源,并且因为模型可以基于检索到的文本进行回答,幻觉减少了。主要挑战是检索质量(如果检索到错误的段落,模型可能会自信地给出错误答案)和延迟(检索为推理增加了一个步骤)。
- **推测性解码**通过使用一个小的、快速的**草稿模型**并行提出多个token,然后由大的**目标模型**在单个前向传播中进行验证,从而加速自回归生成。
- 该算法的工作方式如下:草稿模型自回归地生成$k$个候选token(因为草稿模型很小,所以这很快)。
- 然后,目标模型在单个前向传播中同时对全部$k$个token进行评分(因为工作被批处理,所以这很高效)。
- 对于从草稿分布$p_d(t)$中采样的每个候选token $t$,它以概率$\min(1, \, p_{\text{target}}(t) / p_d(t))$被接受。如果被拒绝,则从**调整后分布**$p_{\text{adj}}(t) = \max(0, \, p_{\text{target}}(t) - p_d(t))$(经归一化)中重新采样一个修正后的token。
- 这种接受-拒绝方案保证了输出分布与单独使用目标模型完全相同。
- 为了理解原因,考虑生成token $t$的有效概率。它可以直接被接受(概率$p_d(t) \cdot \min(1, p_{\text{target}}(t)/p_d(t))$),或者通过重新采样产生。
- 对于$p_{\text{target}}(t) \leq p_d(t)$的token,直接接受贡献$p_{\text{target}}(t)$。对于$p_{\text{target}}(t) > p_d(t)$的token,直接接受贡献$p_d(t)$,重新采样贡献剩余部分$p_{\text{target}}(t) - p_d(t)$(在考虑拒绝概率之后)。
- 在这两种情况下,生成$t$的总概率等于$p_{\text{target}}(t)$。草稿模型只影响速度,不影响质量。
![推测性解码](../images/speculative_decoding.svg)
- 加速取决于接受率:如果草稿模型与目标模型对齐良好,大多数token被接受,墙上时钟时间大致等于草稿模型的时间。典型加速为2-3倍,且质量无下降。
- **Medusa**(Cai等人,2024)采用不同的方法:不是使用单独的草稿模型,而是在目标模型本身中添加多个轻量级的预测头。每个头同时预测不同的未来token位置(提前$k = 1, 2, 3, \ldots$步)。在每一步,Medusa使用树状结构提出若干候选延续,通过目标模型注意力层的单个前向传播验证哪些候选是一致的。这完全避免了对单独草稿模型的需求。
- **并行生成**方法更广泛地旨在打破自回归解码的串行瓶颈。雅可比解码使用猜测初始化所有位置,并并行地迭代精炼直到收敛,将生成视为一个不动点迭代。非自回归模型(NAT)在单个前向传播中同时生成所有token,但通常遭受质量下降的问题,需要像迭代精炼、CTC损失或来自自回归教师的知识蒸馏这样的技术来缩小差距。
- 上述技术——对齐、长上下文、检索、高效解码、状态空间模型——在现代生产级LLM中结合在一起。
- 本文的其余部分审视了前沿模型的架构创新,展示了文件01-04中的理论思想以及上述方法是如何在实践中结合起来的。
- **分组查询注意力(GQA** 是采用最广泛的注意力效率技术。标准多头注意力(MHA)为每个头维护独立的键和值投影,每个token需要缓存$n_{\text{heads}} \times d_{\text{head}}$个值。GQA将多个查询头分组以共享一个键-值头。
- 使用64个查询头和8个KV头(Llama 3、Qwen、Gemma中的常见配置),每个KV头被8个查询头共享,与MHA相比KV缓存减少了8倍。
- 输出质量几乎与MHA相同,因为查询仍然可以关注不同的模式,它们只是共享相同的键-值子空间。多查询注意力(MQA)是所有查询使用单个KV头的极端情况,但GQA提供了更好的质量-效率权衡。
- **多头潜在注意力(MLA**,由DeepSeek-V2引入,实现了更激进的KV缓存压缩。MLA不是缓存完整的键-值投影(即使使用GQA),而是将隐藏状态下投影为一个低秩的**潜在向量**$c_t \in \mathbb{R}^{d_c}$,其中$d_c \ll n_{\text{heads}} \times d_{\text{head}}$
$$c_t = W_{\text{down}} \, h_t$$
- 仅缓存这个压缩向量。在注意力计算时,通过上投影重建完整的键和值表示:$k_t = W_{\text{up}}^K c_t$$v_t = W_{\text{up}}^V c_t$。在DeepSeek-V3中(671B总参数,37B激活参数),压缩维度为$d_c = 512$,而完整MHA需要$128 \times 128 = 16{,}384$KV缓存减少了93%。
- 一个微妙的点:标准RoPE依赖于位置,与共享压缩不兼容,因此MLA使用**解耦的RoPE**:查询和键的一个小的独立流(每头64维)通过RoPE携带位置信息,而表示的主要部分通过压缩的潜在路径流动。
![注意力KV缓存策略:MHA、GQA和MLA比较](../images/mla_vs_gqa.svg)
- **大规模位置编码**已经从原始的正弦方案显著分化。所有前沿模型都使用**RoPE**(文件04),但针对长上下文有关键修改。原始RoPE公式$\theta_i = \theta_{\text{base}}^{-2i/d}$中的基频$\theta_{\text{base}}$通常为10,000,这限制了超出训练长度的外推能力。
- **调整基频(ABF**只是将$\theta_{\text{base}}$增加到500,000Llama 3)或1,000,000Qwen3、Gemma 3),拉伸旋转周期,使得模型在训练期间遇到更少的完整旋转,从而能够外推得更远。
- **YaRN**Yet another RoPE extensioN,又一种RoPE扩展)应用依赖于频率的插值:低频维度被插值(缩小比例),高频维度被外推,同时温度因子调整注意力分布。DeepSeek-V3、Qwen和Kimi K2都使用基于YaRN的扩展,从预训练时的4K-8K上下文达到128K上下文。
- **iRoPE**(交错RoPE),由Llama 4引入,采取了更激进的方法:每4个注意力层中有一个**完全不使用位置编码**(NoPE),而其他层使用标准RoPE配合分块注意力。
- NoPE层可以在没有任何位置偏差的情况下关注所有位置,而RoPE层提供局部排序。结合推理时的温度缩放,这使得Llama 4 Scout的1000万token上下文窗口成为可能——比任何纯RoPE方法都高出几个数量级。
- **大规模混合专家**已成为前沿模型的主导架构(文件04介绍了MoE基础)。关键的设计选择是专家数量、路由稀疏性和负载均衡。
- **路由稀疏性**差异显著:DeepSeek-V3使用256个专家,top-8路由(32倍稀疏);Qwen3使用128个专家,top-8路由(16倍稀疏);Mixtral使用8个专家,top-2路由(4倍稀疏);Llama 4 Maverick使用128个专家,top-1加一个共享专家(128倍稀疏)。
- 更高的稀疏性意味着在相同激活计算量下拥有更多总参数,但需要更仔细的负载均衡和通信基础设施。
- **无辅助损失的负载均衡**DeepSeek-V3)取代了传统的负载均衡损失(文件04),后者被发现会降低模型质量。每个专家维护一个动态偏置项,在每个训练步骤进行调整:过载的专家其偏置降低(接收更少的token),欠载的专家其偏置增加。这实现了均衡的路由,没有任何辅助损失污染主要训练信号。
- **共享专家**出现在大多数MoE设计中:一个或多个专家FFN处理每个token,无论路由结果如何。这些处理所有token都需要的常见模式(基本语法、功能词),使得路由专家可以专注于 specialization。Llama 4使用1个共享专家加每个token 1个路由专家(非常稀疏);DeepSeek-V3使用1个共享加8个路由。
- **交替稠密层和MoE层**提供了另一个设计维度。Gemma 2和3交替使用局部/全局注意力层(Gemma 3中比例为5:1,其中局部层使用1024 token的滑动窗口,只有全局层缓存完整的128K上下文)。
- Llama 4 Maverick交错使用稠密FFN层和MoE层。Kimi K2使用混合稀疏层(一个稠密层穿插在专家层之间)。这种异构设计允许不同层服务于不同的功能。
- **多token预测(MTP**,用于DeepSeek-V3,训练模型不仅预测下一个token,还预测后面的token。在每个位置,一个次级预测模块(共享主模型的嵌入)预测一个额外的未来token。MTP损失的权重是主下一个token损失的0.1-0.3倍。除了在训练期间改善表示质量外,MTP头还可以在推理时作为推测性解码的草稿头,提供免费的加速。
- **知识蒸馏**是一种训练策略,其中大型"教师"模型的输出指导较小"学生"模型的训练。Gemma 2和3广泛使用蒸馏:较小的模型(2B、4B)在计算最优数据量的50倍上训练,使用教师的概率分布作为软目标。这就是为什么Gemma 3-4B在质量上匹配Gemma 2-27B。
- 蒸馏损失替代或补充了标准交叉熵:学生最小化其输出分布与教师分布之间的KL散度:
$$\mathcal{L}_{\text{distill}} = D_{\text{KL}}(p_{\text{teacher}}(\cdot \mid x) \| p_{\text{student}}(\cdot \mid x))$$
- DeepSeek-R1将其671B推理模型蒸馏到小至1.5B的稠密模型中,使用了80万条精选的思维链样本,产生了推理能力异常强的小模型。
- **基于强化学习的推理**代表了LLM能力中最显著的最新进展。DeepSeek-R1证明,在基础模型上进行纯强化学习(无需监督微调)可以引出思维链推理、自我验证和纠错行为——当模型因给出正确的最终答案而获得奖励时,这些行为会自发涌现。
- DeepSeek-R1使用**GRPO**(组相对策略优化),它消除了PPO所需的价值网络。对于每个提示,GRPO采样一组$G$个输出,计算它们的奖励,并在组内归一化优势值:
$$A_i = \frac{r_i - \text{mean}(r_1, \ldots, r_G)}{\text{std}(r_1, \ldots, r_G)}$$
- 然后策略梯度使用这些组相对优势值,配合一个裁剪目标(类似于PPO的裁剪)。
- 消除评论家网络将RL训练的内存和计算需求减半,使得在671B参数模型上进行RL训练变得可行。
- 一个关键的设计选择:DeepSeek-R1使用**基于规则的奖励**(对照标准答案检查数学答案、运行代码测试用例)而不是神经奖励模型,因为神经奖励模型在此规模下被发现容易受到奖励破解的影响。
- **Qwen3的混合思考模式**将推理(使用`<think>`标签进行逐步思维链)和快速直接回复整合到一个模型中,允许用户控制一个"思考预算",在延迟和推理深度之间进行权衡。
- 这是通过在思考和思考数据上训练实现的,而不是通过单独的模型检查点。
- **大规模训练稳定化**需要超越标准实践的新技术。**Logits软裁剪**(Gemma 2)将注意力分数通过$s \cdot \tanh(\text{logits} / s)$处理,软裁剪值$s$(通常为30-50),以防止无界增长。
- **QK归一化**(Qwen3)在计算注意力分数之前对查询和键向量应用RMSNorm,取代了对QKV偏置的需求。**QK裁剪**Kimi K2的MuonClip优化器)在训练期间监控最大注意力logits,当查询-键权重矩阵超过阈值时对其进行重新缩放,使得1T参数模型的预训练能够稳定进行,且没有不稳定事件。
- **FP8混合精度训练**DeepSeek-V3)在前向和反向传播中使用8位浮点数进行计算密集的矩阵乘法,同时将主权重保持在更高精度。
- 与BF16/FP16训练相比,这大致将吞吐量提升了一倍,且质量损失可忽略不计。DeepSeek-V3使用仅280万H800 GPU小时训练了其671B参数模型——只是同类模型的一小部分——这主要归功于这一优化和其他工程优化。
- **FP8混合精度训练**DeepSeek-V3)在前向和反向传播中使用8位浮点数进行计算密集的矩阵乘法,同时将主权重保持在更高精度。
- 与BF16/FP16训练相比,这大致将吞吐量提升了一倍,且质量损失可忽略不计。DeepSeek-V3使用仅280万H800 GPU小时训练了其671B参数模型——只是同类模型的一小部分——这主要归功于这一优化和其他工程优化。
## 编程练习(使用 CoLab 或 notebook
1. 从头实现一个简单的检索增强生成管线。使用TF-IDF(文件02)索引一组文档,为查询检索最相关的段落,并将其前置到提示中。
```python
import jax.numpy as jnp
import math
from collections import Counter
# 知识库:一组简短段落
knowledge_base = [
"The Eiffel Tower is a wrought-iron lattice tower in Paris, France. It was constructed from 1887 to 1889 as the centerpiece of the 1889 World's Fair.",
"The Great Wall of China is a series of fortifications built along the northern borders of China. Construction began in the 7th century BC.",
"Photosynthesis is the process by which plants convert sunlight, water, and carbon dioxide into glucose and oxygen using chlorophyll.",
"The theory of general relativity, published by Albert Einstein in 1915, describes gravity as the curvature of spacetime caused by mass and energy.",
"Python is a high-level programming language known for its simple syntax and readability. It was created by Guido van Rossum and released in 1991.",
"The mitochondria are organelles found in eukaryotic cells. They generate most of the cell's supply of ATP, used as a source of chemical energy.",
]
# 构建 TF-IDF 索引(重用了文件02中的概念)
def tokenise(text):
return text.lower().split()
vocab = sorted(set(w for doc in knowledge_base for w in tokenise(doc)))
word2idx = {w: i for i, w in enumerate(vocab)}
V = len(vocab)
N = len(knowledge_base)
# 文档频率
doc_freq = Counter()
for doc in knowledge_base:
for w in set(tokenise(doc)):
doc_freq[w] += 1
def tfidf_vector(text):
words = tokenise(text)
counts = Counter(words)
vec = jnp.zeros(V)
for w, c in counts.items():
if w in word2idx:
tf = 1 + math.log(c)
idf = math.log(N / (doc_freq.get(w, 0) + 1))
vec = vec.at[word2idx[w]].set(tf * idf)
return vec
# 索引所有文档
doc_vectors = jnp.stack([tfidf_vector(doc) for doc in knowledge_base])
def cosine_sim(a, b):
return jnp.dot(a, b) / (jnp.linalg.norm(a) * jnp.linalg.norm(b) + 1e-8)
def retrieve(query, top_k=2):
"""为查询检索top-k个最相关的段落。"""
q_vec = tfidf_vector(query)
sims = jnp.array([cosine_sim(q_vec, doc_vectors[i]) for i in range(N)])
top_indices = jnp.argsort(-sims)[:top_k]
return [(int(i), float(sims[i]), knowledge_base[int(i)]) for i in top_indices]
# 测试检索
queries = [
"Who built the Eiffel Tower?",
"How do plants make food?",
"What did Einstein discover?",
]
for query in queries:
results = retrieve(query, top_k=1)
print(f"\nQuery: '{query}'")
for idx, sim, passage in results:
print(f" Retrieved (sim={sim:.3f}): '{passage[:80]}...'")
# RAG风格的提示构建
context = results[0][2]
rag_prompt = f"Context: {context}\n\nQuestion: {query}\nAnswer:"
print(f" RAG prompt:\n {rag_prompt[:120]}...")
```
2. 使用玩具草稿模型和目标模型实现推测性解码。展示接受的输出与目标模型的分布一致。
```python
import jax
import jax.numpy as jnp
# 模拟草稿模型(快速,不太准确)和目标模型(慢速,准确)
vocab_size = 8
seq_len = 5
key = jax.random.PRNGKey(42)
# 目标模型:给定序列返回logits
def target_model(seq, key):
"""模拟的目标模型:产生token logits(昂贵的)。"""
# 实践中这将是一个大型Transformer前向传播
k1, k2 = jax.random.split(key)
logits = jax.random.normal(k1, (len(seq), vocab_size)) * 2
# 使其有些可预测性:偏向于 token (seq[-1] + 1) % vocab_size
for i in range(len(seq)):
logits = logits.at[i, (seq[i] + 1) % vocab_size].add(3.0)
return logits
def draft_model(seq, key):
"""模拟的草稿模型:类似但噪声更大(便宜的)。"""
k1, k2 = jax.random.split(key)
logits = jax.random.normal(k1, (len(seq), vocab_size))
for i in range(len(seq)):
logits = logits.at[i, (seq[i] + 1) % vocab_size].add(2.0)
return logits
def sample_token(logits, key):
return jax.random.categorical(key, logits)
def speculative_decode(prefix, draft_steps=3, key=jax.random.PRNGKey(0)):
"""推测性解码:草稿提出,目标验证。"""
seq = list(prefix)
total_accepted = 0
total_proposed = 0
for _ in range(4): # 生成4轮
key, *subkeys = jax.random.split(key, draft_steps + 3)
# 草稿模型提出draft_steps个token
draft_tokens = []
draft_probs = []
draft_seq = list(seq)
for i in range(draft_steps):
d_logits = draft_model(jnp.array(draft_seq), subkeys[i])
d_probs = jax.nn.softmax(d_logits[-1])
tok = sample_token(d_logits[-1], subkeys[i])
draft_tokens.append(int(tok))
draft_probs.append(d_probs)
draft_seq.append(int(tok))
# 目标模型在一次前向中评估所有草稿token
target_logits = target_model(jnp.array(draft_seq), subkeys[draft_steps])
target_start = len(seq) - 1 # 最后一个前缀token的位置
# 接受/拒绝每个草稿token
accepted = 0
for i in range(draft_steps):
t_probs = jax.nn.softmax(target_logits[target_start + i])
d_prob = draft_probs[i][draft_tokens[i]]
t_prob = t_probs[draft_tokens[i]]
# 以概率 min(1, target_prob / draft_prob) 接受
accept_prob = jnp.minimum(1.0, t_prob / (d_prob + 1e-10))
key, accept_key = jax.random.split(key)
if jax.random.uniform(accept_key) < accept_prob:
seq.append(draft_tokens[i])
accepted += 1
else:
# 拒绝:从调整后的分布中采样
key, resample_key = jax.random.split(key)
adjusted = jnp.maximum(0, t_probs - draft_probs[i])
adjusted = adjusted / (adjusted.sum() + 1e-10)
new_tok = jax.random.categorical(resample_key, jnp.log(adjusted + 1e-10))
seq.append(int(new_tok))
break
total_accepted += accepted
total_proposed += draft_steps
return seq, total_accepted, total_proposed
# 运行推测性解码
prefix = [0, 1]
result_seq, accepted, proposed = speculative_decode(prefix)
acceptance_rate = accepted / proposed if proposed > 0 else 0
print(f"Prefix: {prefix}")
print(f"Generated sequence: {result_seq}")
print(f"Draft proposals: {proposed}")
print(f"Accepted: {accepted}")
print(f"Acceptance rate: {acceptance_rate:.1%}")
print(f"Speedup potential: {(accepted + proposed) / proposed:.2f}x")
```
3. 构建一个简单的DPO训练循环。给定偏好和不偏好的完成序列对,使用DPO损失更新一个小模型。
```python
import jax
import jax.numpy as jnp
# 微型语言模型:从one-hot到logits的线性投影
vocab_size = 10
seq_len = 4
key = jax.random.PRNGKey(42)
k1, k2 = jax.random.split(key)
# 当前策略参数(可训练的)
theta = jax.random.normal(k1, (vocab_size, vocab_size)) * 0.1
# 参考策略参数(theta的冻结副本)
theta_ref = theta.copy()
def log_prob_sequence(params, sequence):
"""计算简单自回归模型下的 log P(sequence)。"""
total = 0.0
for t in range(1, len(sequence)):
# 简单:位置t处的logits取决于位置t-1处的token
logits = params[sequence[t-1]]
log_probs = jax.nn.log_softmax(logits)
total += log_probs[sequence[t]]
return total
def dpo_loss(theta, theta_ref, preferred, dispreferred, beta=0.1):
"""一对数据的直接偏好优化损失。"""
log_pi_w = log_prob_sequence(theta, preferred)
log_pi_l = log_prob_sequence(theta, dispreferred)
log_ref_w = log_prob_sequence(theta_ref, preferred)
log_ref_l = log_prob_sequence(theta_ref, dispreferred)
# DPO目标
return -jax.nn.log_sigmoid(
beta * ((log_pi_w - log_ref_w) - (log_pi_l - log_ref_l))
)
# 偏好数据集:(提示前缀, 偏好完成序列, 不偏好完成序列)
preferences = [
(jnp.array([1, 3, 5, 7]), jnp.array([1, 3, 5, 2])), # 结尾偏好7而不是2
(jnp.array([0, 2, 4, 6]), jnp.array([0, 2, 4, 9])), # 偏好6而不是9
(jnp.array([3, 3, 3, 3]), jnp.array([3, 3, 3, 0])), # 偏好重复而不是0
(jnp.array([5, 6, 7, 8]), jnp.array([5, 6, 7, 1])), # 偏好8而不是1
]
grad_fn = jax.jit(jax.grad(dpo_loss))
lr = 0.05
print("训练 DPO...")
for epoch in range(100):
total_loss = 0.0
for preferred, dispreferred in preferences:
loss = dpo_loss(theta, theta_ref, preferred, dispreferred)
grads = grad_fn(theta, theta_ref, preferred, dispreferred)
theta = theta - lr * grads
total_loss += loss
if (epoch + 1) % 20 == 0:
avg_loss = total_loss / len(preferences)
print(f" Epoch {epoch+1}: avg DPO loss = {avg_loss:.4f}")
# 检查:模型现在应该偏好偏好的完成序列
print("\nDPO训练后的偏好检查:")
for preferred, dispreferred in preferences:
lp_w = log_prob_sequence(theta, preferred)
lp_l = log_prob_sequence(theta, dispreferred)
print(f" Preferred {list(preferred.astype(int))}: logP={lp_w:.3f} "
f"Dispreferred {list(dispreferred.astype(int))}: logP={lp_l:.3f} "
f"{'correct' if lp_w > lp_l else 'WRONG'}")
```