☰
滑动窗口数字采样:从零手写BPE文本编码
2026/11/3 4:52:39 网站建设 项目流程

回到从零手搓大模型这条主线。S07文本编码做到E03,前面已经把人话、Unicode、UTF-8这几个地基盘得差不多了,这次集中解决一个看起来很小、实际坑非常多的环节:滑动窗口的数字采样。

说直白一点,就是让一个固定大小的窗口在“文本转换出的整数序列”上滑动,把滑过的相邻数字对收集起来,按出现次数统计,用这些数字样本作为后续BPE合并的候选依据。这个环节在主流BPE教程里通常只有一行伪代码,但自己动手实现时,几乎所有tokenizer词表异常、训练变慢、merge结果错乱,根子都藏在这里。

如果你正在跟着系列实现自己的大模型,或者只想搞清楚文本编码里“字节变成token”这中间到底发生了什么,这节都建议仔细看。我们不会直接调transformers库,而是用Python把一整套最小可跑的BPE数字采样器写出来,跑通“原始语料 -> 滑动窗口采样 -> 频次表 -> top1合并 -> 词表扩展”全流程。看完你应该能理解为什么现代大模型普遍采用字节级BPE,也能顺手排查掉一大类tokenizer实现里的潜在问题。

1. 先拆开看:滑动窗口和数字采样在文本编码里分别做了什么

在学习具体代码前,得先把标题里的三个关键词对齐:文本编码、滑动窗口、数字采样。它们不是三个独立概念,而是一条流水线里的三个动作。文本编码是目标,滑动窗口是手段,数字采样是结果。

1.1 文本编码不是“查码表”一步到位

很多初学者的第一反应是:文本编码不就是查码表吗?每个字符对应一个整数,查完就完事。这种思路做一个小玩具没问题,但要支撑大模型训练就完全不够。

原因有三个。第一,真实语言里字符组合接近无限,固定码表必然遇到未登录词OOV。第二,如果按词给ID,词表会膨胀到几十万上百万,嵌入层参数爆炸,模型学不动。第三,如果退回去按字给ID,序列太长,Transformer每个token都要参与注意力计算,成本随序列长度平方上涨。

BPE这类子词算法就是来打破这个死局的。它先让字母和符号变成UTF-8字节,再把字节序列里高频相邻组合逐步合并成子词单位。经过若干轮合并之后,模型看到的token既不是某个汉字,也不是整个词,而是“文本中反复出现的最小可复用单元”。所以文本编码链其实是多步的:原始文本 -> Unicode码点 -> UTF-8字节 -> 子词token。滑动窗口就发生在最后这一步里。

1.2 滑动窗口到底在扫什么

滑动窗口是一个很老的概念,但在这个语境下指的事情非常具体:对当前token ID序列,用一个固定大小为2的窗口,从左往右一步一步滑,每次取出窗口内的两个数字组成一个二元组。

举个例子,假设计算机内部把某个片段表示成数字序列:

ids = [231, 154, 132, 32, 97]

窗口大小取2,步长取1,滑动一次得到一对数字:

  • 第一次窗口落在位置0和1,得到(231, 154)
  • 第二次窗口落在位置1和2,得到(154, 132)
  • 第三次窗口落在位置2和3,得到(132, 32)
  • 第四次窗口落在位置3和4,得到(32, 97)

这一步就像拿一个扫描仪贴着序列移动,每个相邻数字都不会漏掉。窗口重叠的部分恰恰是BPE能发现跨边界组合的关键。如果步长不是1而是2,那会漏掉(154, 132)这类发生在相邻位置之间的组合,统计出来的频次就会失真,某些该被合并的高频组合可能直接消失。

所以“滑动窗口”在这里的核心意义是:用最简洁的方式保证所有相邻数字对都有机会被当作候选样本。

1.3 数字采样:统计的不是词,而是数字样本

“数字采样”这个说法在中文社区里容易产生歧义,因为它听起来像从某个概率分布里随机抽数。但在文本编码这一步,我理解并强调的是它的工程含义:把窗口里出现的离散数字组合当成样本采集下来,喂给计数器做频率统计。

每个token在内部只是一个整数ID。初始阶段,ID就是字节值,范围0到255。合并之后,新生成的token会拿到一个大于255的新ID。整个训练过程不断给这些数字补充新成员,采样空间也跟着变化。我们关注的是数字对的共现频次,不是具体字符对不对得上。

如果你语料特别大,也可以先做随机下采样,只统计一部分文本片段,那属于“语料采样”。本文说的数字采样是在选定语料基础上,用滑动窗口全量收集数字样本。两者可以叠加,但不要混为一谈。

2. 设计一个最小可跑的BPE数字采样器

要把上面的思路落地,得先确定整个训练管线、窗口参数和数据结构。这个阶段多花点时间想清楚,后面写代码就是顺手的事。

2.1 从原始语料到频次统计的完整管线

一个最小可用的BPE训练器通常长这样:

  1. 把训练语料用UTF-8编码成字节数组,得到0到255之间的整数序列。
  2. 初始化基础词表,包含256个字节对应的token。
  3. 在当前整数序列上做滑动窗口数字采样,得到所有相邻数字对的出现次数。
  4. 找出出现次数最高的数字对。
  5. 用一个新的token ID替换序列中所有该数字对出现的位置。
  6. 把新token加入词表,记录本次合并规则。
  7. 重复步骤3到6,直到词表达到目标大小。

这里有一个容易踩坑的隐含逻辑:新token的ID不能复用旧的ID,而是从256开始递增分配。因为旧ID已经被语料序列中的数字占用了,如果改动ID含义,解码和编码都会乱。每个合并产生一个新ID,词表大小就等于256 + 成功合并的次数。这也是后来保存词表时的重要依据。

2.2 为什么窗口大小取2,步长取1

BPE的全称是Byte Pair Encoding,核心单位就是一对相邻元素。所以采样窗口取2是最自然的选择。窗口取3得到的虽然是三元组,但BPE的合并永远发生在两个相邻token之间。哪怕一个完整token最终由三个字节拼成,也必须先合并前两个字节,再合并新token和第三个字节,等于经过两轮pair merge。直接统计三元组既不符合BPE的合并逻辑,也没法指导合并顺序。

步长取1是为了保证相邻pair的覆盖完整性。如果把步长改成2,窗口会跳着走,边界处的重叠pair就丢了。可以这样理解:BPE的候选集合就是“序列上所有连续的二元组”,而只有步长1、窗口大小2的滑动窗口才能完整枚举出这个集合。

2.3 计数结构:用字典还是二维数组

实现数字采样代码之前,先决定用什么数据结构存频次。

理论上,初始阶段数字范围只有0到255,两两组合最多65536种,用二维数组完全可以。但合并一旦开始,新token ID持续加入,二维数组的维度就得跟着扩大,维护成本直线上升。Python里直接用defaultdict(int)最省事:键是数字对(id_a, id_b),值是该数字对出现的次数。

如果数据量大到对性能敏感,可以把数字对编码成一个整数来提升哈希效率,比如用id_a * 当前词表大小 + id_b作为字典键。但教学阶段先不要过度优化,数据结构清晰比跑得快重要。我自己写生产级实现时会单独做一个计数类,对外只暴露add(pair, count)和top_pair()方法,方便以后换成堆或优先队列。

2.4 目标词表大小怎么定

目标词表大小决定采样器跑多少轮合并。这个词表大小并不是越大越好。词表大,嵌入表的参数就多,显存占用直线上升;词表小,合并轮次少,token序列长,注意力计算成本高。两者本质上是在“词表参数”和“序列长度”之间做权衡。

对一个中文语料的小型测试,先把目标词表定在512或1024就够了。要跑一个能发论文级别的模型,通常几万到十几万。GPT-2当年的字节级BPE词表大约5万,这个量级到今天仍是很多模型的起点。

3. 核心代码实现:从零写滑动窗口采样、计数、合并

下面直接进入正题。我的习惯是先实现一个教学版,保证逻辑能跑通,再谈优化。

3.1 把文本变成数字序列

第一步,把原始文本转成数字列表。这一步没有魔法,就是用UTF-8编码之后逐个取字节值。

def text_to_ids(text): return list(text.encode("utf-8"))

比如在Python里测试:

print(text_to_ids("低"))

输出会是[230, 137, 142],因为“低”这个字在UTF-8里占3个字节。这一步把所有字符都统一成了数字,为滑动窗口做好准备。

为什么要用UTF-8而不是直接对Unicode码点做数字采样?因为有两点好处:UTF-8可以编码任意文本,不会遇到未知字符;同时字节层的组合空间固定是256个基本单位,模型能从底层重新学习出合适的中文token、英文子词甚至标点组合。

3.2 滑动窗口采集相邻数字对

核心采样函数只需要一个for循环:

from collections import defaultdict def count_pairs(ids): counts = defaultdict(int) for i in range(len(ids) - 1): counts[(ids[i], ids[i + 1])] += 1 return counts

这段代码就是“滑动窗口的数字采样”最直接的实现。窗口在整数序列上的位置由索引i和i+1决定,每轮循环向右滑动一步。range(len(ids) - 1)保证最后一个元素右侧没有邻居时不会越界。

返回值是字典,键是相邻数字对,值是该数字对在整个序列中出现的次数。比如counts[(231, 154)] = 12表示数字对(231, 154)在序列里出现了12次。这个字典就是下一轮BPE选择合并对象的投票箱。

3.3 合并函数:把选中数字对合成一个新ID

采样结束后,我们会选出频次最高的数字对,接下来要做的是把序列中所有出现该数字对的位置替换成新token ID。

def merge_ids(ids, pair, new_id): result = [] i = 0 n = len(ids) while i < n: if ( i + 1 < n and ids[i] == pair[0] and ids[i + 1] == pair[1] ): result.append(new_id) i += 2 else: result.append(ids[i]) i += 1 return result

这个函数从序列左边开始扫描,一旦发现当前位置和下一个位置组成的数字对就是目标pair,就把它合并成new_id,然后跳过两个位置继续扫描。跳过两个位置很重要,否则同一个旧数字可能被重复利用,导致一轮合并里产生错误结果。

如果你熟悉字符串替换,可能会想问:为什么不用replace把某一个token替换掉?因为现在操作的对象是数字序列,数字对可能出现各种跨token组合,而且新ID会继续参与下一轮合并,用数组替换更可控。每次调用这个函数,序列长度都会减少,减少的量恰好等于该数字对被合并掉的次数。

3.4 完整BPE训练循环

把采样和合并串起来,就是一个最简BPE训练器:

def train_bpe(texts, target_vocab_size): ids = list(" ".join(texts).encode("utf-8")) vocab = {i: bytes([i]) for i in range(256)} merges = [] next_id = 256 while next_id < target_vocab_size: counts = count_pairs(ids) if not counts: break best_pair = max(counts, key=lambda p: counts[p]) ids = merge_ids(ids, best_pair, next_id) vocab[next_id] = vocab[best_pair[0]] + vocab[best_pair[1]] merges.append((best_pair, next_id)) next_id += 1 return ids, vocab, merges

训练循环里做的事情和第一节设计的流程完全一致:采样、找最高频pair、合并、记录词表映射、继续下一轮。

这里我故意省略了并列最高频时的排序规则,真实复现时不能省。同一批语料,如果两个pair计数相同,选谁先合并会直接影响最终词表。后面第5节我会专门讲怎么处理。

训练结束后,vocab里保存的是每个token ID对应的字节片段,merges按顺序记录了每一步合并规则。保存和加载tokenizer时,这两样东西缺一不可。

4. 手跑一遍:重复字场景下的字节对合并观察

只看代码还是太抽象。我建议你找一个非常小的语料实际跑一遍,能看到字节级BPE怎么把一个中文单字“拼”出来。

4.1 准备一个最小语料

先构造一个看上去刻意但非常有效的语料:

text = "低" * 30

这个字符串在内存里就是60个字节,因为“低”占用3个字节。送到text_to_ids之后,得到的是重复出现的三个数字:[230, 137, 142, 230, 137, 142, ...]。

第一轮数字采样后,候选pair中比较显眼的是:

  • (230, 137)出现约30次
  • (137, 142)出现约30次
  • (142, 230)出现在相邻字的边界上,出现约29次

如果你跑出来发现前两个计数一样,不要慌,这说明并列最高频的情况真的会发生。无论tie-break策略选哪一个,经过两轮合并,字的首字节中间字节和末字节都会被拼到一起。

4.2 观察数字样本如何拼出常用字

第一轮合并后,序列里出现一个新ID,假设是256,它表示字节对(230, 137)。此时每个“低”字在序列里变成两个数字:[256, 142]重复出现。继续做第二轮滑动窗口采样,(256, 142)会变成最高频pair,合并成ID 257。

这时候ID 257对应的字节片段就是完整的b'\xE6\xB4\x8E',也就是“低”这个字的UTF-8编码。你看,一个三字节字符变成单个token,只需要两次合并。

这个现象放到更大的中文语料里非常有意思。常见汉字在UTF-8中大多占3个字节,它们的高频字节组合会先被BPE合并,所以最终词表里会自然出现大量“单字token”。如果把“低”和另一个高频字反复共同出现,BPE还会继续把它们合并成双字词。字节级BPE不需要专门为中文设计,却能自动适应中文形态。

4.3 合并后的序列长度变化与词表扩展

运行过程中,建议你打印每一轮的len(ids)和next_id。观察几次后,你会看到一条明确规律:每成功合并一次,序列长度就减少该pair的出现次数,ID池则增加一个成员。

拿上面“低”字重复30次的实验来说:

  • 原始bytes长度60
  • 第一轮合并后长度变成30
  • 第二轮合并后长度变成30,错了,应该是30个字符每个变成1个token,所以也是30?等等,需要重新算。

让我把数字说准确。30个“低”字,原始bytes长度是90。第一轮把每个字的前两个字节(230, 137)合并成256,序列从[byte, byte, byte]变成了[256, byte],长度从90变成60。第二轮把(256, 142)合并成257,序列长度从60变成30。这才是对的。

所以说,每个三字节中文字变成一个token,总共会把序列长度压缩到原来的三分之一。换到真实大语料里,原始语料经过BPE之后token数会显著减少。模型处理长文本的能力,某种程度上就是被这个压缩过程放大的。

5. 常见问题与性能排查实录

这个环节是我最想分享的,因为我在自己手搓tokenizer时,几乎所有bug都出在看似简单的采样和合并上。

5.1 统计结果和预想不一致

有段时间我统计出来的pair频次总和总是比len(ids) - 1少,说明滑动窗口没有覆盖全部相邻位置。最常见原因是range(len(ids))写错,导致最后一对没统计进去;其次是步长填了2,窗口跳着走,漏掉了重叠边界。

排查方法很简单:写一个校验函数,把counts里所有value加起来,断言它等于len(ids) - 1。如果对不上,采样逻辑一定有问题。这个小断言值得长期保留,后续做增量计数优化时也能帮你验证正确性。

5.2 合并后序列里出现解释不了的数字

如果你发现合并后的序列里冒出了某个不该出现的新ID,或者旧pair的相邻关系变得很怪,多半是合并函数没有正确跳过已经合并的位置。

我建议用最小的例子单测merge_ids:

assert merge_ids([1, 2, 3, 1, 2], (1, 2), 99) == [99, 3, 99]

如果输出不是这个,就检查索引是否跳了两格。这种基础函数越简单越好,不要在里面做额外业务逻辑。

5.3 语料大时越训越慢

教学版在每一轮合并时都重新扫一遍完整序列,这个复杂度是O(合并轮数 * 序列长度)。语料一旦变大,后面的合并轮会非常慢。

应对思路有三个:

  • 设置最低频次阈值,采样后直接丢弃低频pair,减少候选数量。
  • 对超大语料做随机下采样,让滑动窗口只跑在有代表性的子集上,而不是全量数据。
  • 使用优先队列维护高概率候选,只在合并发生时更新受影响的相邻pair计数,而不是每轮全量重算。

前面两点改动不大就能提升速度。第三点是生产级BPE的常见优化方向,但我建议先把教学版跑通再去碰。

5.4 同频pair导致词表不一致

BPE训练里的一个隐藏坑是并列最高频。两个pair出现次数相同,选哪个先合并会影响后续所有合并路径。不同机器、不同Python版本下,字典遍历顺序都有差异,如果不固定选择规则,同一个语料可能训练出两份不同词表。

解决办法是给排序规则加一个确定性tie-break。比如:

best_pair = min(counts, key=lambda p: (-counts[p], p))

这样会先按出现次数从大到小选,出现次数相同时按pair本身从小到大选。规则一旦固定,实验就能复现。词表保存时最好连合并顺序一起存,因为新文本编码时必须按完全相同顺序执行合并规则。

5.5 句子边界处理不到位

最容易忽略的是语料拼接方式。如果你把所有训练文本直接拼成一整段再编码,滑动窗口就会把上一句末尾和下一句开头的字节组合到一起,产生跨句子的垃圾token。

我自己的做法是在数字采样前把长语料按句切好,句与句之间放一个自定义分隔token。采样时,窗口不跨句移动;合并时,只合并句内出现的数字对。这样BPE学到的词表会更贴近真实语言结构,不会学到“句号+下一个字”这种伪组合。

6. 我的实操体会与一个小建议

自己动手写过一遍滑动窗口数字采样之后,我最大的体会是:BPE的难点从来不在“合并”这个动作本身,而在于每一步训练都依赖一份干净的计数统计。计数来自滑动窗口,窗口参数错一点,后面整个词表全偏。

建议你拿到代码后,先用一个100字以内的语料把流程跑通,重点看三件事:counts加总是否等于序列长度减1、merge_ids是否通过最小用例、vocab里前几个新token是不是你能理解的字节组合。这三关过了,再放心去扩语料也不迟。

后面如果要继续做BPE的端到端编码和解码,这节做好的采样器和merge规则可以直接复用。只要你把单词表和merge顺序保存下来,新文本就能走“转字节 -> 按merge顺序反复合并”这条路,得到同一套数字序列。从这里往下,就是文本编码真正进入模型输入前的最后一步了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询