源仓库链接:https://github.com/stanford-cs336/assignment1-basics
介绍

Byte-Pair Encoding (BPE) Tokenizer
The Unicode Standard

(a)
1 | chr(0) |
(b)
_repr_ (字符串表示):目标是明确和无歧义。它的主要受众是开发者,用于调试和日志记录。理想情况下,它应该返回一个字符串,让你能通过这个字符串重新创建出这个对象。
_str_ (打印表示):目标是可读性好。它的主要受众是最终用户。当你使用 print(obj) 或 str(obj) 时,调用的是这个方法,它应该返回一个对用户友好、易于理解的字符串。
(c)
1 | chr(0) |

(a)
UTF-8 是可变长度编码,对于 ASCII 字符(如英文字母、数字、常见符号)只使用 1 个字节,这些字符在大多数文本中占比较高。这使得 UTF-8 编码的文本通常更小,减少了存储和传输开销,从而提高了 tokenizer 训练的效率。
UTF-16 对于基本多文种平面(BMP)中的字符使用 2 字节,对于辅助平面中的字符使用 4 字节(通过代理对)。对于英文文本,UTF-16 通常比 UTF-8 大,因为 ASCII 字符在 UTF-16 中也需要 2 字节。
UTF-32 始终使用 4 字节 per character,这非常浪费空间。例如,ASCII 文本在 UTF-32 中会变大 4 倍,导致处理速度慢和存储成本高。
(b)
函数是错的,因为它试图逐个字节解码UTF-8字节串。但UTF-8是一种可变长度编码,一个字符可能由多个字节组成。如果逐个字节解码,那些多字节字符会被错误地解码成多个单独的字符,而不是一个正确的Unicode字符。
输入“中”字,其UTF-8编码是b'\xe4\xb8\xad'
函数会将其解析为0xe4, 0xb8, 0xad
报错输出:
1 | UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe4 in position 0: unexpected end of data |
(c)
一个两字节序列的例子是 0xD8 0x00(UTF-16 大端序),它解码为高代理码点 U+D800,该码点在没有对应低代理码位的情况下是无效的,不代表任何 Unicode 字符。
BPE算法示例说明
以下内容基于Sennrich等人[2016]的论文中的一个简化示例,说明Byte Pair Encoding(BPE)算法的工作过程。
语料库文本
假设语料库由以下文本组成:
1 | low low low low low |
此外,词汇表包含一个特殊标记<|endoftext|>。
词汇表初始化
词汇表初始时包含特殊标记<|endoftext|>和256个字节值(即所有可能的字节字符)。
预分词
为简化起见,预分词过程仅基于空格进行分割。分割后,我们得到以下单词频率表:
low:出现5次lower:出现2次widest:出现3次newest:出现6次
在Python中,这些单词被表示为字节元组(每个字节是一个bytes对象)。例如:
low表示为(l, o, w)lower表示为(l, o, w, e, r)widest表示为(w, i, d, e, s, t)newest表示为(n, e, w, e, s, t)
合并过程
BPE算法通过迭代合并最常见字节对来构建词汇表。
第一轮合并
首先,统计所有相邻字节对的频率(基于单词频率):
lo:7(出现在low和lower中)ow:7(出现在low和lower中)we:8(出现在widest和newest中)er:2(出现在lower中)wi:3(出现在widest中)id:3(出现在widest中)de:3(出现在widest中)es:9(出现在widest和newest中)st:9(出现在widest和newest中)ne:6(出现在newest中)ew:6(出现在newest中)
频率最高的对是es和st,频率均为9。由于平局,选择字典序较大的对,即st(因为s在字母表中位于e之后)。因此,合并st为单个单元。
合并后,单词表示为:
(l, o, w):5次(l, o, w, e, r):2次(w, i, d, e, st):3次(st被合并)(n, e, w, e, st):6次(st被合并)
第二轮合并
现在,统计新的字节对频率。注意,e和st现在相邻,对e st的频率为9(出现在widest和newest中),是最常见的对。因此,合并e st为est。
合并后,单词表示为:
(l, o, w):5次(l, o, w, e, r):2次(w, i, d, est):3次(est被合并)(n, e, w, est):6次(est被合并)
后续合并
继续此过程,最终的合并序列为:
- 合并
s t为st - 合并
e st为est - 合并
o w为ow - 合并
l ow为low - 合并
w est为west - 合并
n e为ne - 合并
ne west为newest(但注意,在合并过程中,步骤可能不同) - 合并
w i为wi - 合并
wi d为wid - 合并
wid est为widest - 合并
low e为lowe - 合并
lowe r为lower
合并6次后的词汇表
如果只进行6次合并,合并序列为:['s t', 'e st', 'o w', 'l ow', 'w est', 'n e']。此时,词汇表包含:
- 特殊标记
<|endoftext|> - 256个字节字符
- 合并后的单元:
st,est,ow,low,west,ne
使用此词汇表和合并规则,单词newest会被分词为[ne, west]。
此示例展示了BPE如何通过迭代合并常见字节对来构建子词词汇表。

我们需要实现的是一个这样的函数train_bpe
1 | vocab, merges = train_bpe(input_path, vocab_size, special_tokens) |
然后给他写进adpaters.py去做测试
参考了网上的几个大神的作业
tokenizer.py
1 | from typing import Dict, Tuple, List, Iterable, Iterator |
初始化与辅助函数:
initialize_vocab: 初始化基础词汇表(256个ASCII字符 + 特殊令牌)。
word_to_bytes: 将字符串转换为UTF-8字节序列。
split_by_special_tokens: 用正则表达式按特殊令牌分割文本。
pre_tokenize_string: 将文本按规则预分词(使用正则模式PAT,用于把输入的内容和标点符号分割成有意义的部分)并统计词频。
多进程处理:
pre_tokenize_string_worker: 多进程 worker 函数,处理文件块并统计词频。
使用multiprocessing.Queue收集结果,tqdm显示进度条。
BPE的核心函数:
pair_counts: 统计相邻字节对的频率。
get_most_frequent_pair: 找到最高频的字节对。
merge_pair: 合并字节对并更新词频统计。
add_pair_to_vocab: 将新合并的令牌加入词汇表。
训练入口:train_bpe:
- 使用多进程预分词文本文件。
- 迭代合并最高频字节对,直到词汇表达到指定大小(vocab_size)。
- 返回词汇表(vocab)和合并词(merges)。
示例:
1 | string = """ |
1 | vocab, merge = train_bpe(vocab_size=269) |
merge
1 | [(b's', b't'), |
BPE 编码示例
输入与参数
- 输入字符串:
'the cat ate' - 词汇表:
1
2
3
4
5
6
7
8
9
10
11
12
13{
0: b' ', # 空格
1: b'a', # 字母 a
2: b'c', # 字母 c
3: b'e', # 字母 e
4: b'h', # 字母 h
5: b't', # 字母 t
6: b'th', # 序列 th
7: b' c', # 空格+c
8: b' a', # 空格+a
9: b'the', # 单词 the
10: b'at' # 序列 at
} - 合并规则:
1
2
3
4
5
6
7[
(b't', b'h'), # t 与 h 合并为 th
(b' ', b'c'), # 空格与 c 合并为 c
(b' ', b'a'), # 空格与 a 合并为 a
(b'th', b'e'), # th 与 e 合并为 the
(b' a', b't') # a 与 t 合并为 at
]
编码过程
1. 预分词
输入字符串通过预分词器分割为:[‘the’, ‘ cat’, ‘ ate’]
2. 处理第一个预分词单元 ‘the’
- 初始字节表示:
[b't', b'h', b'e'] - 应用合并规则:
- 匹配
(b't', b'h')→ 合并为[b'th', b'e'] - 匹配
(b'th', b'e')→ 合并为[b'the']
- 匹配
- 无更多可应用规则,最终对应整数:
[9]
3. 处理第二个预分词单元 ‘ cat’
- 初始字节表示:
[b' ', b'c', b'a', b't'](注意空格保留) - 应用合并规则:
- 匹配
(b' ', b'c')→ 合并为[b' c', b'a', b't'] - 无其他可应用规则(需严格匹配连续字节对)
- 匹配
- 最终对应整数序列:
[7, 1, 5](即b' c'、b'a'、b't')
4. 处理第三个预分词单元 ‘ ate’
- 初始字节表示:
[b' ', b'a', b't', b'e'] - 应用合并规则:
- 匹配
(b' ', b'a')→ 合并为[b' a', b't', b'e'] - 匹配
(b' a', b't')→ 合并为[b' at', b'e'](注意:此处规则中的b' a'是空格+a的合并结果)
- 匹配
- 无更多可应用规则,最终对应整数序列:
[10, 3](即b' at'和b'e')
最终编码结果
整数序列为:[9, 7, 1, 5, 10, 3]
实现Tokenizer类


1 | class Tokenizer: |

线性模型
要自己设计一个线性模型
1 | class Linear(nn.Module): |
设计一个Embedding层


Transformer的嵌入层 Embedding
Transformer 的第一层是嵌入层,它将整数标记 ID 映射到维度为 d_model 的向量空间。文本序列被映射成词汇表的单词ID的数字序列。嵌入层再将每个数字序列射成一个嵌入向量,这是该词含义的一个更丰富的表示。
在这里我们将实现一个自定义的 Embedding 类
1 | import torch |
每个 Transformer 块都包含两个子层:多头自注意力机制和位置感知前馈网络。
在最初的 Transformer 论文中,模型在每个子层周围使用了残差连接,然后进行层归一化。这种架构通常被称为“Post Norm” Transformer,因为层归一化是应用于子层输出的。
前归一化
然而,大量研究发现,==将层归一化从每个子层的输出移到每个子层的输入可以提高 Transformer 的训练稳定性==,下图展示了这种“前归一化”Transformer 块的视觉表示。然后,每个Transformer 块子层的输出通过残差连接加到子层输入上。前归一化的一个直观理解是,从输入嵌入到 Transformer 的最终输出之间存在一条干净的“残差流”,没有任何归一化,据称这有助于改善梯度流动。
这种预归一化 Transformer 现已成为当今语言模型(例如 GPT-3、LLaMA、PaLM 等)的标准,因此我们将实现这一变体。我们将依次介绍预归一化 Transformer 块的每个组件,并按顺序实现它们。
RMS Layer Normalization

1 | class RMSNorm(nn.Module): |
SwiGLU
把SiLU和GLU组合在一起就成为了SwiGLU
先构建SiLu,再构建SwiGLU也就是FFN
1 | def SiLU(x: torch.Tensor) -> torch.Tensor: |
RoPE旋转编码

其作用就是为 Transformer 模型中的 token 嵌入注入位置信息,使得模型能够感知 token 在序列中的顺序(即“谁在前、谁在后”),从而正确理解语言的结构和语义。
1 | import torch |
Softmax

Softmax 将这注意力得分转换为 非负、和为1 的权重:
1 | def softmax(x: torch.Tensor, dim: int = -1) -> torch.Tensor: |
SDPA
SDPA实现标准的缩放点积注意力机制,根据查询(q)与键(k)的相似度,动态地从值(v)中提取加权信息,并支持通过 mask 控制注意力范围,重点读value,忽略不重要的部分。
SDPA就是这个计算:

1 | def scaled_dot_product_attention(q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor: |
输出的结果是每个查询位置得到一个融合了上下文信息的表示
多头自注意力

这里的qkv虽然看起来一样,但是在nn.Linear中其Wq,Wk,Wv权重矩阵是完全不同的。
1.多头(Multi-Head)
把注意力拆成多个“小组”,每个小组独立关注不同的信息模式(比如一个头关注语法,一个头关注语义),最后汇总,效果比单头强得多。
2.位置感知(RoPE)
如果启用了use_rope=True,模型就能知道“第一个词”、“第二个词”……的位置关系,而且这种位置编码对长序列更友好(比传统的加性位置编码更强)。
3.防止作弊(因果掩码)
在生成文本时(比如写句子),模型只能看到当前词及之前的词,不能偷看未来的词。_causal_mask 就是干这个的——像考试时遮住后面的题目。
4.端到端学习
所有投影层(q_proj, k_proj 等)都是可训练的,模型会自动学会如何提取最有用的查询、键、值。
1 | class MultiHeadAttention(nn.Module): |
output是每个词(或 token)在“看过整个句子(或允许看的部分)之后”,重新生成的、带有上下文信息的新表示。