qinghuan
← Transformer 与语言建模 / 全部笔记

单元 19 / Transformer 与语言建模

P0 · 必会主线L2 · 进阶

分词、BPE 与 Embedding:文本如何变成向量

从合并相邻符号理解 BPE 分词,再看 token ID 怎样从 Embedding 矩阵取出向量,以及重复使用的向量怎样累加梯度。

本篇目录 · 按需要跳转

这篇重点学什么

本篇重点:用 BPE 观察词表怎样建立,再把 Token ID 转成可训练的 Embedding,弄清训练实际接收的输入。

开始读正文 ↓
学过相近内容?查看哪些可以跳过

两条路线有些基础相同。已经学会的部分可以跳过,另一条路线只用来补你当前需要的内容。

Token 与 Embedding

共同基础Token、上下文窗口、词表与向量表示的术语可复用。

另一条路线补充什么?

记录请求用了多少 Token、输出是否被截断以及为什么结束,检查调用是否超出预算。

读完这篇,你能做什么

实现确定性的一步BPE合并,并验证Embedding形状、重复索引梯度和padding行行为。

学习理由、准备要求与知识点

为什么学它

模型读的是分词后的序列。分词方式会影响序列长度、成本和每个 token 的损失,不同词表的 token 数不能直接当成同一尺度。

先读这些内容

知识点清单

  • 字符与字节和token区别
  • BPE相邻对频率
  • 合并次序与平局规则
  • 词表和特殊token契约
  • Embedding矩阵形状
  • 重复索引梯度累加
  • padding_idx边界
  • 分词器版本与训练数据分布

运行前先确认PyTorch 2.9.1+cpu、Python 3.14、Java 17。BPE为Unicode码点起步的简化教学实现,不冒充现成模型的字节级分词器;不下载词表或权重。

01先弄懂它是什么

文字进入神经网络前,先要变成数字表示。分词器把文本拆成 token 并转换成整数 ID,再由 Embedding 为每个 ID 查出一个向量。若词表有 V 个条目、每个向量有 D 个数,权重表 E 的形状就是 [V,D];输入 ID 的形状为 [B,T],查表后得到 [B,T,D]。ID 只是表里的编号,两个编号靠得近,不代表对应内容的意思也接近。

如何让常见片段不必拆得太碎?BPE 会从较小符号开始,反复数一数哪些相邻组合最常见,再把高频组合合并为可复用的子词。这个过程是在训练语料上学习分词规则。正式处理新文本时,应使用已经定好的词表和合并规则,而不是每来一句话重新学习。相同频次时如何选择、是否先按字节拆分、怎样预切分和规范化 Unicode,都会影响最终得到的 ID。

字符、码点、字节和token不是同一单位。一个表情可能占多个UTF-8字节,一个视觉字符也可能包含多个码点;Java的char还是UTF-16代码单元。教学算法用codePoints正确保留补充平面码点,但不处理字素簇和真实字节级词表。学习重点是合并机制,迁移到模型时必须使用该模型配套分词器。

文中这几个词,先认一认

词表
记录每个 token 对应哪个整数 ID 的固定表。它需要与模型的 Embedding 行数一致。
BPE合并规则
BPE 学到的相邻符号合并顺序。处理文本时要遵守这个顺序,不是随意找最长字符串。
Embedding
一张可以通过训练调整的向量表。相同 ID 总是查到同一行参数,再把这一行作为数字表示。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

统计一个符号序列[a,a,a,a]时,相邻aa出现三次;但一次从左到右非重叠替换只能合并成[aa,aa]两项。这说明训练频率统计和实际替换次数不同。每轮合并后相邻关系发生变化,下一轮必须基于新序列统计。朴素实现反复全量扫描,适合教学;大型语料通常维护局部统计和优先结构降低重复工作。

Embedding 查表在数学上可看成 one-hot 矩阵乘权重表,但程序无需真的建出巨大的 one-hot 矩阵。同一个 ID 在一批里出现两次,若两次输出收到的梯度都是一,对应权重行就累加得到二。padding_idx 会让指定行不接收普通查表的反向梯度,并在默认初始化时置零;如果之后手动改了这行权重,前向查表不会每次再强制把它变成零。

特殊token必须有明确用途与ID,包括padding、句尾和可能的未知符号。词表变更会改变训练任务,不能只增大Embedding维度而不更新编码、输出头和断点。不同分词器下的每token损失与困惑度单位不同,比较模型时应固定分词方案或使用可比较的文本级归一指标,而不是直接比较两个漂亮数字。

先跑通一个最小例子

纯本地小词序列加CPU Embedding,不依赖外部分词库。

展开收起代码 · experiment.pypython · 36 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

experiment.py · python
from collections import Counter
import torch
from torch import nn
torch.manual_seed(19)
torch.set_num_threads(1)

def best_pair(words):
    counts = Counter(pair for word in words for pair in zip(word, word[1:]))
    return min(counts, key=lambda p: (-counts[p], p)) if counts else None

def merge(word, pair):
    result, i = [], 0
    while i < len(word):
        if i + 1 < len(word) and (word[i], word[i+1]) == pair:
            result.append(word[i] + word[i+1])
            i += 2  # 一次替换不允许共享原符号
        else:
            result.append(word[i])
            i += 1
    return result

assert best_pair([list("ab"), list("ac")]) == ("a", "b")
assert merge(list("aaaa"), ("a", "a")) == ["aa", "aa"]
assert best_pair([[]]) is None
embedding = nn.Embedding(6, 3, padding_idx=0)
ids = torch.tensor([[0, 2, 2]], dtype=torch.long)
out = embedding(ids)
assert out.shape == (1, 3, 3)
assert torch.equal(out[0, 0], torch.zeros(3))  # 默认padding行初始化为零
out.sum().backward()
assert torch.equal(embedding.weight.grad[2], torch.full((3,), 2.))  # 重复ID梯度求和
assert torch.equal(embedding.weight.grad[0], torch.zeros(3))
with torch.no_grad():
    embedding.weight[0].fill_(7)
assert torch.equal(embedding(torch.tensor([0])), torch.full((1, 3), 7.))
print("8 BPE and embedding checks passed")

核对运行结果打印8项检查通过;展示padding_idx不会覆盖人工修改后的前向权重。

正在加载流程图…

分词决定离散序列,Embedding为每个固定ID提供可学习向量。
查看流程图源码(Mermaid)
flowchart LR
A["文本与规范化约定"] --> B["初始符号"]
B --> C["固定BPE合并规则"]
C --> D["token ID序列"]
D --> E["Embedding查表 V乘D"]
E --> F["B乘T乘D张量"]

按顺序看,每一步做了什么

  1. 规定初始符号、边界和特殊token。
  2. 统计相邻对并以固定规则打破平局。
  3. 按非重叠顺序替换并记录合并次序。
  4. 验证ID范围、输出形状和重复索引梯度。

03对照实现代码

Embedding将ID和权重交给F.embedding (新标签页)↗

PyTorch v2.9.1 · Embedding.reset_parameters;Embedding._fill_padding_idx_with_zero;Embedding.forward

原创查表与稠密梯度累加模型,未复现稀疏梯度、max_norm、频率缩放或后端内核。

核心机制代码 · python
def lookup_and_grad(weight, ids, padding_idx=None):
    output = [list(weight[i]) for i in ids]  # 查表,不按ID大小插值
    grad = [[0.0] * len(weight[0]) for _ in weight]
    for i in ids:
        if i == padding_idx:
            continue  # 只示范输出元素求和时的padding梯度屏蔽
        for d in range(len(weight[i])):
            grad[i][d] += 1.0  # 同一行多次出现,反向贡献需要相加
    return output, grad

真实初始化先随机初始化权重,再将指定padding行置零,手动改写后的值不由forward再次清零。

forward传入padding_idx、max_norm、norm_type、scale_grad_by_freq和sparse等参数,具体设置会影响行为。

Embedding的参数是矩阵,输入是整数索引;输入ID本身没有可按实数求导的普通意义。

教学模型只对应输出求和的上游梯度,真实反向还需乘各位置的上游梯度并累加。

代码先调用谁,再调用谁

  1. 分词器输出固定ID
  2. Embedding.forward
  3. F.embedding查表
  4. 后端返回向量张量
  5. 反向按索引累积参数梯度

04动手写一个例子

确定性BPE一轮统计与非重叠替换

对由符号组成的多个词序列统计相邻对,选择频率最高且字典序最小的对,再逐词非重叠合并。空输入没有候选。字符入口按Unicode码点拆分。

输入 1
aaaa
输出 1
aa频率3,替换后[aa,aa]
输入 2
ab和ac
输出 2
平局选择(a,b)

用结构化Pair作为键,避免拼接字符串导致不同相邻对碰撞。

频率高者优先,平局依次比较左符号和右符号,确保结果不依赖HashMap迭代顺序。

替换指针命中时前进两格,未命中前进一格;每个原符号最多参加一次本轮合并。

为什么这样写是对的替换过程中已扫描前缀被完整覆盖且不重复消费符号;输出连接后的内容与原序列连接内容相同。

时间 单轮平均O(L+P),字符串比较和拼接成本另计额外空间 O(L+P)

L为全部符号数,P为不同相邻对数;多轮朴素扫描上界为O(RL),不能当作只扫描一次。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 42 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
  record Pair(String left,String right) {}
  static List<String> chars(String word){
    return word.codePoints().mapToObj(c->new String(Character.toChars(c))).toList();
  }
  static int compare(Pair a,Pair b){
    int c=a.left().compareTo(b.left());return c!=0?c:a.right().compareTo(b.right());
  }
  static Pair best(List<List<String>> words){
    Map<Pair,Integer> counts=new HashMap<>();
    for(List<String> word:words)for(int i=0;i+1<word.size();i++)
      counts.merge(new Pair(word.get(i),word.get(i+1)),1,Math::addExact); // 统计包含重叠相邻位置
    Pair answer=null;int maximum=0;
    for(var entry:counts.entrySet()){
      if(entry.getValue()>maximum||(entry.getValue()==maximum&&compare(entry.getKey(),answer)<0)){
        answer=entry.getKey();maximum=entry.getValue();
      }
    }
    return answer;
  }
  static List<String> merge(List<String> word,Pair pair){
    List<String> out=new ArrayList<>();
    for(int i=0;i<word.size();){
      if(pair!=null&&i+1<word.size()&&word.get(i).equals(pair.left())&&word.get(i+1).equals(pair.right())){
        out.add(word.get(i)+word.get(i+1));i+=2; // 本轮只做非重叠合并
      }else{out.add(word.get(i));i++;}
    }
    return List.copyOf(out); // 返回独立结果,不在遍历期间修改输入
  }
  static void check(boolean v){if(!v)throw new AssertionError();}
  public static void main(String[] args){
    check(best(List.of(chars("ab"),chars("ac"))).equals(new Pair("a","b")));
    check(merge(chars("aaaa"),new Pair("a","a")).equals(List.of("aa","aa")));
    check(best(List.of(List.of()))==null);
    check(merge(chars("a"),new Pair("a","a")).equals(List.of("a")));
    check(chars("😊😊").size()==2&&merge(chars("😊😊"),new Pair("😊","😊")).size()==1);
    List<String> merged=merge(chars("abab"),new Pair("a","b"));
    check(String.join("",merged).equals("abab"));
    System.out.println("6 BPE checks passed");
  }
}

这些特殊情况也要试一试

  • 相邻对出现次数可能大于该轮实际可合并次数。
  • Unicode码点不是完整视觉字符或真实字节token。
  • 词边界由调用者提供,本例不跨词合并。
进阶选读 · 生产拓展:增量统计与词表版本化

场景语料巨大,反复全量统计相邻对代价很高。

做法维护受某个合并影响的词和局部相邻计数,用带失效检查的优先队列选择候选;同时固定规范化、边界和特殊ID,保存词表哈希。

复杂度增量更新成本取决于受影响位置,最坏情况仍可能很大,不能普遍承诺线性训练。

好处与代价速度提升需要复杂索引与过期候选处理,正确性应对照朴素小实现。

05项目里怎么用

预训练、微调与推理必须共享同一套分词契约。文本预处理改变空格、大小写或Unicode形式后,token分布也会改变;在代码和多语言任务中,过度规范化可能破坏重要信息。

词表大小与序列长度存在取舍。大词表可能缩短部分文本,但Embedding和输出投影更大;小词表使序列更长,注意力及训练token预算随之变化。应在目标语料上测量,而不是只比较词表数字。

特殊token应从用户文本和模板层清楚区分。新增token后需要明确初始化与训练方式,检查输出头是否绑定权重;padding梯度不变也不表示该行永远不会受优化器其他机制或人工写入影响。

容易踩的坑

  • 每条输入重新学习BPE规则。
  • 用Java char当作任意Unicode字符。
  • 将ID距离当成语义距离。
  • 把padding_idx误读成强制前向零输出。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
较小词表Embedding参数较少可能产生更长序列资源受限或细粒度建模
较大词表部分文本可更紧凑输出头和词表管理更大有足够数据和参数预算
字节级入口可覆盖任意字节编码文本字符与token关系更复杂需要一致开放词汇覆盖

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01Embedding输入[B,T]输出是什么?

展开参考答案与推导

[B,T,D],每个ID查出一个D维向量。

02aa在aaaa出现三次,为何只合并两次?

展开参考答案与推导

频率统计允许重叠位置,替换不能重复使用同一个原符号。

03同一ID出现两次如何影响梯度?

展开参考答案与推导

两次位置的上游梯度累加到同一权重行。

换个情况,还会处理吗

01换分词器后能直接加载原Embedding吗?

展开参考答案与推导

除非ID与词表语义完全保持,否则行含义改变,需要明确映射和训练。

02两个模型困惑度不同,分词器也不同,能直接排名吗?

展开参考答案与推导

不宜,每token的统计单位不同,需统一编码或采用可比较归一化。

03padding行人工改为7,前向会归零吗?

展开参考答案与推导

不会;padding_idx主要影响初始化和相应梯度处理。

写代码试一试

01ab和ac频率相同按题意选哪个?

输入 / 条件
(a,b)与(a,c)
输出 / 预期
(a,b)

提示 比较右符号。

展开参考答案与推导

左符号相同,b字典序在c之前。

02如何避免相邻对拼接碰撞?

输入 / 条件
(ab,c)与(a,bc)
输出 / 预期
两个不同键

提示 结构化Pair。

展开参考答案与推导

不要都编码为abc,应保留左右两个字段。

03重复两次ID且输出求和,梯度是多少?

输入 / 条件
每位置每维上游梯度1
输出 / 预期
该行每维2

提示 同一参数被多次使用。

展开参考答案与推导

对两次使用分别求导再相加。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

实现确定性的一步BPE合并,并验证Embedding形状、重复索引梯度和padding行行为。

本篇重点加练

词表契约、合并优先级与索引梯度是连接文本和可训练参数的关键接口,增加交叉验证。

  • 合并次序与平局规则
  • 词表和特殊token契约
  • 重复索引梯度累加

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    为文本“学习 AI🙂”记录字符、UTF-8 字节与自定义 token 三种表示;列出编码解码、特殊符号和版本兼容的接口要求。

    提交内容
    表示表与契约
    怎样算完成
    • 不同计数单位不混用
    • 特殊 token 身份明确

    练到的知识点字符与字节和token区别 · 词表和特殊token契约 · 分词器版本与训练数据分布

  2. 作业 02代码推演

    词频为 ababa:3、baba:2、abca:1,初始单位为字符。手工执行三轮 BPE,自己声明平局规则并保留每轮相邻对计数。

    提交内容
    计数与合并轨迹
    怎样算完成
    • 频次权重正确使用
    • 规则可确定性重放

    练到的知识点BPE相邻对频率 · 合并次序与平局规则

  3. 作业 03代码推演

    同一字符词表有两套不同 BPE 合并优先级。自行构造存在竞争合并的短词,比较逐步编码路径并声明未知符号处理。

    提交内容
    两套合并表与编码轨迹
    怎样算完成
    • 优先级与频次含义分开
    • 词表及未知符号契约完整

    练到的知识点合并次序与平局规则 · 词表和特殊token契约

  4. 作业 04原理问答

    同一 token 在序列中出现三次,但每个位置有不同上游梯度。自行给出梯度向量,说明该词表行应承担哪些贡献及归约依据。

    提交内容
    位置到参数的贡献账本
    怎样算完成
    • 位置梯度与参数梯度分开
    • 损失归约前提明确

    练到的知识点重复索引梯度累加

综合应用

  1. 作业 05编程实践

    实现小语料 BPE 编码器和词表保存/加载,测试空文本、未见字符及保留符号;验证同一词表重载后的编码一致性。

    提交内容
    CPU 程序与契约测试
    怎样算完成
    • 训练和编码过程区分
    • 边界策略可复核

    练到的知识点BPE相邻对频率 · 合并次序与平局规则 · 词表和特殊token契约

  2. 作业 06排错实验

    Embedding 权重为 [7,3],输入 ID 为 [[2,2,0],[1,0,2]],指定 padding_idx=0。记录前向、反向及一次优化后的各行状态。

    提交内容
    CPU 实验及逐行状态表
    怎样算完成
    • 重复索引累计可追踪
    • 前向值与梯度行为分开

    练到的知识点Embedding矩阵形状 · 重复索引梯度累加 · padding_idx边界

  3. 作业 07面试场景

    新词表大小不变但 ID 顺序被重排。设计能发现模型与分词器错配的启动检查与小型回归集。

    提交内容
    检查规范与错配复现
    怎样算完成
    • 语义身份不只靠形状
    • 版本证据可核验

    练到的知识点词表和特殊token契约 · Embedding矩阵形状 · 分词器版本与训练数据分布

  4. 作业 08编程实践

    在 CPU 上给词表做一个可逆 ID 置换,同时处理 Embedding 行和输入 ID,比较前向与反向结果;故意漏置换一处作为故障对照。

    提交内容
    置换程序及梯度对应表
    怎样算完成
    • 文本身份保持一致
    • 遗漏位置能够被定位

    练到的知识点词表和特殊token契约 · 重复索引梯度累加

  5. 作业 09排错实验

    构造特殊标记文字也能出现在普通用户输入中的数据,测试编码器是否区分普通文本和受信特殊 token;记录不同编码入口的契约。

    提交内容
    编码入口故障集
    怎样算完成
    • 特殊符号来源明确
    • 测试不是只检查字节相同

    练到的知识点合并次序与平局规则 · 词表和特殊token契约

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    在同一份含两种语言的小语料上训练两种词表规模,比较长度分布、未覆盖符号和可逆性;说明能否据此推出模型质量提升。

    提交内容
    语料、脚本与边界报告
    怎样算完成
    • 语料采样一致
    • 分词统计不替代任务指标

    练到的知识点字符与字节和token区别 · BPE相邻对频率 · 分词器版本与训练数据分布

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    “词表越大,每段文本的 token 数必然不增加”是否适用于任意重新训练的 BPE 词表?自行限定比较条件,并构造反例或给出支持命题的条件。

    提交内容
    词表对照与条件论证
    怎样算完成
    • 新增词表与重训词表区分
    • 编码规则完整可复现

    练到的知识点合并次序与平局规则 · 词表和特殊token契约

  2. 作业 12思维拓展 · 跨域迁移

    把 Embedding 行视为按 ID 共享的参数记录:若两个语义不同的符号错误共享 ID,会如何影响训练可辨识性?用小分类任务设计检验。

    提交内容
    CPU 小任务与共享参数分析
    怎样算完成
    • 冲突符号身份明确
    • 标签分布与表达限制分开

    练到的知识点词表和特殊token契约 · 重复索引梯度累加

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗