基于GitHub项目:https://github.com/datawhalechina/llms-from-scratch-cn
字节对编码(BPE)
上一篇博文说到
为什么GPT模型不需要[PAD]
和[UNK]
?
GPT使用更先进的字节对编码(BPE),总能将词语拆分成已知子词
为什么需要BPE?
-
简单分词器的问题:遇到新词就卡住(如"Hello")
-
BPE的解决方案:把陌生词拆成已知的小零件
BPE如何工作?
就像拼乐高:
-
基础零件:先准备256个基础字符(a-z, A-Z, 标点等)
-
拼装训练:统计哪些字符组合常出现
-
创建新零件:把高频组合变成新"积木块"
# 使用GPT-2的BPE分词器
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
text = "Akwirw ier" # 模型没见过的词
integers = tokenizer.encode(text) # [33901, 86, 343, 86, 220, 959]
# 查看每个部分的含义
for i in integers:
print(f"{i} -> {tokenizer.decode([i])}")
# 33901 -> Ak
# 86 -> w
# 343 -> ir
# 86 -> w
# 220 -> (空格)
# 959 -> ier
举例:
陌生词 | BPE分解 | 计算机理解 |
---|---|---|
Akwirw | Ak + w + ir + w | "Ak"是已知前缀,"w"是字母,"ir"是常见组合 |
someunknownPlace | some + unknown + Place | 拆成三个已知部分 |
滑动窗口 - 文本的"记忆训练法"
语言模型的核心任务:根据上文预测下一个词。比如:“白日依山_",语言模型会根据上文推测出下文可能是“尽”,“水”……等,最终经过对比,选取最可能的词填上,得到“白日依山尽”。
#用滑动窗口创建训练数据
文本:"I had a cat" → 分词后:[40, 367, 2885, 1464]
# 滑动窗口(窗口大小=4)
输入(x) 目标(y) 训练内容
[40] → [367] 看到"I"预测"had"
[40, 367] → [2885] 看到"I had"预测"a"
[40,367,2885]→[1464] 看到"I had a"预测"cat"
使用举例:
from torch.utils.data import Dataset
from transformers import GPT2Tokenizer
class GPTDatasetV1(Dataset):
def __init__(self, txt, max_length=256, stride=128): #stride控制相邻片段间的重叠长度
self.tokenizer = GPT2Tokenizer.from_pretrained('gpt2') #使用GPT-2原生分词器
self.tokenizer.pad_token = self.tokenizer.eos_token #用结束符代替填充符
# 分词得到ID序列,自动添加特殊标记(默认添加<|endoftext|>),但不会添加BOS(开始符)
token_ids = self.tokenizer.encode(txt)
# 用滑动窗口创建多个训练片段
self.examples = []
for i in range(0, len(token_ids) - max_length, stride):
input_ids = token_ids[i:i + max_length]
target_ids = token_ids[i + 1:i + max_length + 1]
self.examples.append((input_ids, target_ids))
# 假设 token_ids = [1,2,3,4,5], max_length=3, stride=2
# 窗口1:i=0 → input=[1,2,3], target=[2,3,4]
# 窗口2:i=2 → input=[3,4,5], target=[4,5]
def __len__(self):
return len(self.examples)
def __getitem__(self, idx):
input_ids, target_ids = self.examples[idx]
return input_ids, target_ids
当i + max_length +1
超过数组长度时,target_ids
会自动截断(可能产生短序列)
优化方向建议:
- 动态填充:使用
attention_mask
区分真实token与填充 - 缓存机制:对大型文本文件进行分块处理
- 长度统计:添加样本长度分布分析功能
- 批处理优化:结合
collate_fn
处理变长序列
参数选择指南:
- 短文本(<1k tokens):
max_length=64-128
,stride=32-64
- 长文本(>10k tokens):
max_length=512-1024
,stride=256-512
# 示例使用方式
text = "Self-explanatory knowledge, human intelligence, personal knowledge..." # 你的长文本数据
dataset = GPTDatasetV1(text)
# 获取第一个样本
input_seq, target_seq = dataset[0]
词元嵌入
之前的ID只是编号,没有含义。嵌入层给每个词从多个维度作向量表示
# 创建嵌入层(词汇表大小=6,向量维度=3)
embedding = torch.nn.Embedding(6, 3)
# 查看权重矩阵
print(embedding.weight)
"""
tensor([[ 0.3374, -0.1778, -0.1690], # ID=0的向量
[ 0.9178, 1.5810, 1.3010], # ID=1的向量
... # 以此类推
], requires_grad=True) # 可学习!
"""
嵌入层的本质:
相当于高效版的"独热编码+矩阵乘法":
独热编码:[0,0,0,0,1] → 矩阵乘法 → [0.1, -0.5, 0.8]
嵌入层:直接取矩阵的第5行 → [0.1, -0.5, 0.8]
流程总结:原始文本->BPE分词->ID序列->滑动窗口->训练样本->嵌入层->词向量
单词位置编码
为什么需要位置编码?
简单词嵌入的局限:
-
词嵌入只表示词语含义,不包含位置信息
-
模型会把所有词语当作无序集合处理
比如"猫追老鼠"和"老鼠追猫",虽然词语相同但意思完全相反!
位置编码的解决方案
就像给教室座位编号:每个词语有"含义身份证"(词嵌入)->再加个"座位号"(位置编码)
词嵌入层实现
import torch
import torch.nn as nn
# 定义词嵌入层
token_embedding = nn.Embedding(num_embeddings=50257, embedding_dim=256)
#num_embeddings参数表示嵌入字典的大小
#embedding_dim参数控制输出向量的维度
位置嵌入层实现
# 定义位置嵌入层(假设序列最大长度为4)
pos_embedding = nn.Embedding(num_embeddings=4, embedding_dim=256)
生成位置编码向量
# 生成位置编号0-3的序列
position_ids = torch.arange(4) # tensor([0, 1, 2, 3])
# 获取位置向量(形状为[4, 256])
position_vectors = pos_embedding(position_ids)
组合词嵌入与位置嵌入
# 假设输入的token_ids形状为[batch_size, seq_len]
token_vectors = token_embedding(token_ids)
final_embeddings = token_vectors + position_vectors.unsqueeze(0)
#广播机制:
position_vectors
扩展为(batch_size, seq_len, embedding_dim)
,与token_vectors
维度对齐
假设参数:
batch_size = 4 #4个样本
seq_len = 16 #每个样本有16个tokens
embedding_dim = 512 #词嵌入为512维向量
操作流程:
Token IDs形状 : (4, 16)
↓ 经过嵌入层
Token向量形状 : (4, 16, 512)
Position向量原始形状 : (16, 512)
↓ unsqueeze(0)
Position向量调整后 : (1, 16, 512)
↓ 广播相加
Final嵌入形状 : (4, 16, 512)
- 位置嵌入通常需要扩展到与词嵌入相同的维度
- 在Transformer架构中,位置嵌入可以是可学习的(如本例)或使用固定公式计算
为什么用加法而不是拼接?
维度一致:保持向量维度不变(256维)
计算高效:加法比拼接更省资源
信息融合:位置和语义自然融合