Lecture 01
slide
Lecture 02
slide
Lecture 03
slide
Lecture 04
slide
BPE: Byte-Pair Encoding
Motivation
语言模型不能直接处理字符串, 需要先把 text 无损地转换成一串 token id, 再通过 embedding matrix 变成向量. 核心问题是: 如何把文本稳定地映射到数字序列?
一个朴素想法是按词表编码: 给每个词、空格、标点和符号分配一个编号, 再把文本映射成数字序列. 但它有明显问题:
- 词表非常大. 一种语言里可能有几百万个词, 对存储和计算都不友好.
- 切分不唯一. 例如
United Kingdoms可以看作一个词, 也可以看作两个词; 中文还会出现学习/大语言模型,学/习/大/语/言/模/型等多种切法. - 泛化能力差.
Dog和Dogs在纯词级映射下是两个独立 id, 模型很难自然利用它们的形态关系.
另一个朴素想法是直接使用 UTF-8 byte 编码. 它的优点是规则简单、覆盖所有字符、映射一一对应, 初始词表只需要 256 个 byte. 但问题是序列会显著变长. 对 Transformer 来说, 注意力复杂度约为 , 序列越长, 时间和显存开销越大. 同时 byte token 的信息密度太低: 一个词的语义要拆成多个 token 表示, 学习难度增加; embedding matrix 也只能用 256 个向量承载大量语义组合.
因此需要一种折中方案:
- token 尽量能表示接近完整词或常见词根的语义.
- 词表大小可控, 通常是几万规模.
- 编码过程仍然有确定规则, 能从文本无损映射到词表中的 token.
BPE 就是通过统计语料中的高频相邻 byte pair, 逐步把常见 byte 序列合并成更大的 token.
完整算法流程
真实训练时不是每一轮都重新扫描整个 corpus, 而是先把语料切成 pre-token, 再用双向链表、pair 位置表、pair 计数表和优先队列做局部更新.
1. 准备输入和超参数
训练 BPE tokenizer 需要:
corpus: 原始训练文本.special_tokens: 特殊 token, 如<|endoftext|>.vocab_size: 目标词表大小.split_pattern: pre-tokenization 正则.
GPT-2 风格的 pre-tokenization 正则是:
GPT2_SPLIT_PATTERN = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""这个正则会粗略切出英文缩写、带前导空格的单词、数字、连续符号和空白. 它的作用是限制 BPE 只在 pre-token 内部合并, 避免把跨词、跨文档的片段合成一个巨大 token.
2. 初始化 vocabulary
初始 vocabulary 先放入所有 byte token:
0: b'\x00'
1: b'\x01'
...
255: b'\xff'然后加入特殊 token:
256: b'<|endoftext|>'此时 vocab 至少有 256 + len(special_tokens) 个 token. 后续每完成一次 merge, 就向 vocab 追加一个新 token.
3. Pre-tokenization
先按特殊 token 把 corpus 切成多个 chunk, 保证 <|endoftext|> 这类符号作为整体保留下来. 对普通文本 chunk, 再用 split_pattern 切成 pre-token.
例如:
abcd abcd tech tech<|endoftext|> are ü?会变成类似:
b'abcd'
b' abcd'
b' tech'
b' tech'
b'<|endoftext|>'
b' are'
b' \xc3\xbc'
b'?'注意: 每个 pre-token 先编码成 UTF-8 bytes, 后续 merge 只能发生在同一个 pre-token 内部.
4. 构建可局部更新的数据结构
把所有 pre-token 展开成 token 节点, 初始每个节点是一个 byte 或一个 special token. 对这些节点建立:
token_dict:token_idx -> token_value, 记录每个位置当前是什么 token.prev/next: 双向链表, 记录同一个 pre-token 内每个 token 的前驱和后继. pre-token 边界处不连接.pair_positions:pair -> set[token_idx], 记录每个 pair 的左 token 位置.pair_counter:pair -> count, 记录每个 pair 当前出现次数.heap: 优先队列, 用(-count, tie_breaker, pair)找到当前最应该合并的 pair.
这里的 pair 只统计同一个 pre-token 内真正相邻的 token, 不统计跨 pre-token 的相邻 byte.
5. 初始化 pair 统计和优先队列
遍历每个 pre-token 的链表:
- 对每个 token
x, 找到它的next[x]. - 如果
next[x]存在, 得到 pair(token_dict[x], token_dict[next[x]]). - 把
x加入pair_positions[pair]. - 更新
pair_counter[pair]. - 把所有 pair 的计数放入
heap.
这一步只需要完整扫描一次 corpus. 后续每轮 merge 都只更新局部.
6. Merge loop
当 len(vocab) < vocab_size 时重复:
- 从
heap中取出当前 count 最大的 pair. - 做 lazy check: 如果堆顶记录的 count 已经过期, 或这个 pair 在
pair_counter中已经不是当前值, 就丢弃并继续弹出. - 设要合并的 pair 为
(a, b), 新 token 为new = a + b. - 把
new加入vocab, 记录新的 token id. - 把
(a, b) -> new追加到merges, 并记录它的 merge rank. - 取出
pair_positions[(a, b)]中的所有候选位置, 逐个检查和合并.
对每个候选左位置 i:
- 令
j = next[i]. 如果j不存在, 跳过. - 检查
token_dict[i] == a且token_dict[j] == b. 如果不成立, 说明这是 lazy update 留下的旧位置, 跳过. - 取
p = prev[i],n = next[j]. - 合并前, 从统计中删除受影响的旧 pair:
- 如果
p存在, 删除(token_dict[p], a). - 删除
(a, b). - 如果
n存在, 删除(b, token_dict[n]).
- 如果
- 执行合并:
token_dict[i] = new.- 删除或标记失效
j. next[i] = n.- 如果
n存在,prev[n] = i.
- 合并后, 加入新的相邻 pair:
- 如果
p存在, 加入(token_dict[p], new), 位置为p. - 如果
n存在, 加入(new, token_dict[n]), 位置为i.
- 如果
- 对所有被修改过计数的 pair, 把新的 count 重新 push 到
heap. 旧堆项不立即删除, 之后靠 lazy check 跳过.
局部更新的关键是: 合并 (a, b) 时, 真正会变的只有合并点附近的 pair:
(prev_token, a) -> (prev_token, new)
(a, b) -> new
(b, next_token) -> (new, next_token)因此训练阶段不需要每轮重扫语料, 只需要根据 pair_positions 精确访问被合并 pair 的出现位置.
7. 训练产物
训练结束后得到:
vocab:token_id -> token_bytes, 包含 256 个 byte token、special tokens 和所有 merge 出来的 token.merges: 按训练顺序排列的 pair 合并列表.merge_ranks:pair -> rank, 用于之后快速 encode.special_tokens: 编码时需要优先识别并保持整体的特殊 token.
8. 使用训练好的 BPE encode
编码新文本时使用同样的 pre-tokenization, 然后逐个 pre-token 独立合并:
- 把 pre-token 编码成 UTF-8 byte token list.
- 找出当前 token list 中所有相邻 pair.
- 查询这些 pair 在
merge_ranks中的 rank. - 每次选择 rank 最小的 pair 合并, 因为它最早在训练中被 merge.
- 只更新合并位置附近的新 pair.
- 重复直到没有 pair 存在于
merge_ranks. - 用
vocab把最终 token bytes 映射成 token id.
这样 encode 不需要按完整 merges 列表扫描文本, 而是对每个 pre-token 做少量局部合并. 同时因为所有合并都来自训练阶段的 merges, 最终 token 一定能在 vocab 中找到.
Lecture 05
slide
Softmax Activation
将score转换成一个分布:
但是会有问题. 当score为1000左右时, 会导致数值溢出:
为了避免这个问题, 需要把score tensor减去最大值以避免溢出:
RoPE
相对位置编码. 计算方法:
那么, 事实上一个应该长成这样:
但是, 如果直接计算有问题: 缓存. 因为这是一个稀疏矩阵, 因此效率很低. 于是希望使用一维的矩阵进行计算, 使用batch进行加速: 只需要计算一个cos序列和一个sin序列即可. 其他的可以通过batch并行化计算快速得出: 1,3,5直接取cos chunk, 2,4,6取sin chunk并取负号
class RoPE(nn.Module):
def __init__(self,theta,d_k,max_seq_len,device):
super(RoPE,self).__init__()
self.theta-theta
self.d_k=d_k
self.max_seq_len=max_seq_len
self.device=device
d_half=d_k//2
positions=torch.arange(max_seq_len, device=device).unsqueeze(1) # (max_seq_len, 1)
dims=torch.arange(d_half,device=device).unsqueeze(0) # (1,d_half)
angles=positions/(theta**(2*dims/d_k))
cos_values=torch.cos(angles)
self.register_buffer("cos_values", cos_values) # (1, max_seq_len, d_half)
sin_values=torch.sin(angles)
self.register_buffer("sin_values", sin_values)
def forward(self, x, token_positions):
x_splited=x.reshape(*x.shape[:-1], self.d_k//2,2)
cos_chunk=self.cos_values[:,token_positions,:]
sin_chunk=self.sin_values[:,token_positions,:]
even_transform=torch.stack([cos_chunk, -sin_chunk], dim=-1)
odd_transform=torch.stack([sin_chunk, cos_chunk], dim=-1)
x_ratated_odd=torch.sum(odd_transform*x_splited)
x_rotated_even=torch.sum(even_transform*x_splited)
stacked_x=torch.stack([x_rotated_even,x_rotated_odd],dim=-1)
# TODO
return ...FFN
def forward(self,x):
enhanced=self.linear_w1(x)
# TODOFeed Forward Network
Multihead Attention
graph LR a[x] b(Q) c(K) d(V) e(score) f(weight) g(output) a-->|W_Q|b a-->|W_K|c a-->|W_V|d b-->e c-->e e-->f f-->g d-->g
多头注意力: 将sequence切分成num_head个切片, 每一个切片维度为d_k=d_model // num_head的长度. 得到了(bs, seq_len, d_model)->(bs, seq_len, num_heads, d_k). 实际上, 需要参与计算的维度只有seq_len, d_k这两个维度. 因此需要进行transpose把seq_len放后面: (bs, seq_len, num_heads, d_k)->(bs, num_heads, seq_len, d_k)
首先得到score, 然后根据attention mask给每一个head进行mask, mask==0的位置填充float(-inf).
然后, 使用softmax对mask的score计算weight, 把weight和V矩阵相乘, 得到一个attention head的输出.
最终, 把每一个attention head的输出concat到一起
Lecture 06
slide
模型训练和自回归
如何训练一个模型:
- 取数据. 标准的输入输出
- 利用当前模型参数进行forward, 做一次预测
- 将预测的结果和标准输出使用loss function做比较
- 根据loss得到gradients. 如果梯度过大, 需要使用clipping(梯度裁剪)
- 根据训练step更新学习率
- 根据learning rate和gradient更新参数
整个训练过程需要上述过程寻来呢多次
Data Sampling Module
语料库太大, 因此不能一次性将全部的数据都加载进入内存(占据内存过大, 使用时间过长). 因此需要设计一个方法, 只取其中的一部分(取较为精准的一个区间, 把数据取出来):
把数据切成多个chunk. 如果数据在一个chunk中, 那么直接读取一个chunk然后取出数据即可; 如果一个数据在两个chunk中间, 那么只需要读两个chunk然后取出数据即可.
使用np.memmap进行磁盘读取
AdamW Optimizer
梯度下降: SGD:
问题: 会有随机的震荡
解决: AdamW:
其中:
- 是first moment, 称作动量(momentum). 目的: 让更新变得平滑. 在更新的时候同时要考虑之前的梯度信息
- 是second moment, 记录历史梯度波动信息. 目的: 使用对于高频词的方差变大、低频词方差变小. 我们希望, 高频词在更新的梯度不要过大(尽可能不要有震荡), 对于低频词要尽快更新达到最优.
- 表示当前step的梯度
但是上述优化有个问题: 当t非常小时, 有:
假设梯度每次大致相同, 有:
为了要有一个稳定的学习率, 定义:
如此, 有:
Cross-Entropy Loss
原理:
Mathematical Formulation
在多分类 (Multi-Class) 场景下, 假设共有 个类别. 对于某一个样本, 它的真实标签通常用 One-hot 编码表示. 设 为真实标签向量, 其中只有对应的正确类别索引处的元素为 1, 其余为 0. 设 为模型输出的预测概率分布 (通常是经过 Softmax 函数处理后的结果).
单个样本的交叉熵损失公式如下:
由于 是 One-hot 向量, 只有在真实类别 (target class) 处 , 其他位置均为 0. 因此, 上述公式可以简化为仅关注正确类别的预测概率:
这里 是模型预测该样本属于真实类别 的概率. 从这个公式可以看出, 如果模型对正确类别的预测概率 越接近 1, 就越接近 0, 损失也就越小. 反之, 如果 趋向于 0, 损失将趋向于无穷大.
在实际应用中 (如 PyTorch), 为了数值稳定性, 通常直接将模型的原始输出 (Logits) 作为输入, 将 LogSoftmax 和 NLL Loss (负对数似然损失) 合并计算.
Link to original
在训练初期, 可能会有问题: 初始时可能预测的结果非常差, 导致预测到的probability非常接近0, 导致出现Nan.
因此这样处理:
这样能够确保, 不会有问题因为已经在log外面来, 后面的也不会有问题因为一定会大于等于1(因为的时候)
Gradient Clipper
当梯度非常大的时候, 使用AdamW可能会导致, 导致, 导致优化器反常停止
因此当梯度太大的时候需要将其映射到一个足够小的等级. 通过scale, 乘一个系数缩放所有的梯度:
- 是全局的梯度范数
- max norm是一个预定义的参数, 表示可接受的最大的梯度的值
通过缩放梯度让最终AdamW的不要太大
Learning Rate Scheduler
在前左右设置学习率逐渐上升; 后续让学习率逐渐降低(Cosine Annealing, 线性衰减等)
Checkpoint
保存:
- 模型参数
- 优化器状态(Optimizer, Scheduler)