BPE 词表构建与编解码说明一、BPE 背景BPEByte Pair Encoding字节对编码是一种数据压缩与分词算法后被广泛用于 NLP 的词表构建。其核心思想是从字符或字节级别出发反复将出现频率最高的相邻二元组合并成一个新符号直到词表大小达到设定值。起源早期用于文本压缩在 NLP 中由 Sennrich 等人引入用于机器翻译等任务的子词分词。特点词表在 256单字节基础上扩展能平衡字符级与词级表示对未登录词、多语言更友好。与 LLM 的关系GPT、LLaMA 等大模型都使用 BPE 或类似子词算法如 SentencePiece将文本切分为 token 序列再输入模型。二、任务意义本任务实现一个简化版 BPE完成三件事训练/构建词表在语料上统计相邻字节对频次按频次从高到低依次合并得到合并规则表merges与 id→字节 映射vocab。编码给定字符串与merges将字符串转为 UTF-8 字节后按训练时的合并顺序不断合并得到 token id 列表。解码给定 token id 列表与vocab将每个 id 还原为字节并拼接再按 UTF-8 解码为字符串。意义在于理解子词词表如何从语料中学习得到以及编码/解码如何与合并顺序一致为后续学习 Transformer、Embedding 等打基础。三、如何解决整体思路与代码作用步骤做什么代码/数据结构语料准备读入多文件拼成一个大字符串corpusbuild_vocab(corpus)的输入统计对当前 id 序列统计所有相邻二元组出现次数get_stats(ids)→{ (id_i, id_{i1}): count }选 pair训练时选出现最多的 pair 合并编码时选在 merges 里编号最小的 pair 合并训练max(stats, keystats.get)编码min(stats, keymerges.get(..., inf))合并把序列里所有该 pair 替换成一个新 idmerge(ids, pair, idx)记录规则训练时记 (p0,p1)→新 id并维护 id→字节merges[pair]idxvocab[idx]vocab[p0]vocab[p1]编码字符串→字节 list再按 merges 顺序反复合并encode(text, merges)→list[int]解码id 列表→按 vocab 取字节→拼接→UTF-8 解码decode(ids, vocab)→str核心约束编码时合并顺序必须与训练时一致因此用「在 merges 中的编号」表示顺序编码时每次选编号最小的 pair 合并即最先被学到的规则。四、代码思路与模块作用get_stats(ids)统计ids中所有相邻二元组出现次数返回dict[(int,int), int]。训练时用来找「当前频次最高的 pair」编码时用来找「当前序列里存在、且出现在 merges 里的 pair」。merge(ids, pair, idx)在ids中把所有连续的(pair[0], pair[1])替换成一个idx返回新列表。训练和编码都会反复调用。build_vocab(text)把 text 转为 UTF-8 字节再转为 0–255 的 id 列表。循环若干轮由vocab_size - 256决定每轮get_stats→ 选频次最高的 pair →merge→ 把该 pair→新 id 记入merges新 id 从 256 递增。用 merges 构建vocab0–255 为单字节256 及以上为对应两个子 token 的字节拼接。返回merges, vocab供编码和解码使用。encode(text, merges)把 text 转为字节 id 列表后只要长度≥2 就get_stats→ 在 stats 的键中选「merges 中编号最小」的 pairmin(..., keymerges.get(..., inf))→ 若在 merges 中则merge否则退出。保证合并顺序与训练一致。decode(ids, vocab)按 ids 顺序用vocab[id]取字节并拼接成一条 bytes再decode(utf-8, errorsreplace)得到字符串。五、代码讲解按执行顺序主流程读目录下所有文件 → 拼成corpus→build_vocab(corpus)得到merges, vocabs→ 对示例字符串encode再decode验证无损。get_statszip(ids, ids[1:])得到所有相邻对对每个 pair 计数返回的 key 是 tuple (int,int)value 是出现次数。merge顺序扫描 ids若当前与下一项等于 pair 则压入 idx 并跳过两项否则压入当前项并跳过一项。build_vocab 中的关键pair max(stats, keystats.get)训练时选频次最高的 pair。merges[pair] idx记录 (p0,p1)→新 id新 id 从 256 起递增即「合并顺序」。vocab[idx] vocab[p0] vocab[p1]新 token 的字节 两子 token 字节拼接用于解码。encode 中的关键pair min(stats, keylambda p: merges.get(p, float(inf)))在当前出现的 pair 里选在 merges 中编号最小的即最先被学到的保证与训练顺序一致不在 merges 的 pair 用 inf 避免被选到。若pair not in merges则 break否则按该规则做一次 merge循环直到无法再合并。decodeb.join(vocab[idx] for idx in ids)拼接字节再 UTF-8 解码。五、各代码块输出样式与数据示例以下用具体输入/输出说明每个步骤的数据形式示例中数字与中文仅为说明实际以运行结果为准。1. get_stats(ids)输入id 列表整数序列。输出字典键为相邻二元组(int, int)值为出现次数。# 输入 ids [97, 98, 98, 97, 98] # 输出样式 get_stats(ids) # {(97, 98): 2, (98, 98): 1, (98, 97): 1}2. merge(ids, pair, idx)输入ids列表、要合并的pair元组、新 token 的idx。输出新 id 列表所有该 pair 被替换为 idx。# 输入 ids [97, 98, 98, 97, 98] pair (97, 98) idx 256 # 输出样式 merge(ids, pair, idx) # [256, 98, 97, 98]3. build_vocab(text) 的 merges / vocab输入语料字符串text。输出merges与vocab。merges 输出样式键为(p0, p1)值为新 id从 256 递增。# merges 示例前几条 merges { (228, 184): 256, (184, 187): 257, (230, 136): 258, ... }vocab 输出样式键为 id0~255 为单字节256 起为合并得到的 id值为对应字节串bytes。# vocab 示例片段 vocab { 0: b\x00, 1: b\x01, ... 97: ba, 98: bb, ... 256: b\xe4\xb8\xad, # 例如「中」的 UTF-8 两字节合并后 257: b..., ... }4. encode(text, merges)输入字符串text合并表merges。输出token id 列表list[int]。# 输入 text 亚索托儿索 merges { ... } # 由 build_vocab 得到 # 输出样式 encode(text, merges) # [256, 258, 260, 261, 259, 262, 263] # 实际长度与数值依词表而定此处仅为示例5. decode(ids, vocab)输入token id 列表ids词表vocab。输出解码后的字符串。# 输入 ids [256, 258, 260, 261, 259, 262, 263] vocab { ... } # 由 build_vocab 得到 # 输出样式 decode(ids, vocab) # 亚索托儿索6. 主流程语料 → 编解码corpus 片段样式# 读入目录下所有文件拼接后corpus 为一大段字符串例如 corpus 英雄名亚索\n背景故事亚索是一名来自艾欧尼亚的剑客...\n技能1斩钢闪...构建词表后merges, vocabs build_vocab(corpus) # merges: 244 条 (pair - idx)idx 从 256 到 499 # vocabs: 500 个 id - bytes编码结果样式string 亚索托儿索 encode_ids encode(string, merges) # [256, 258, 260, 261, 259, 262, 263] # 示例实际由词表决定解码结果样式decode_string decode(encode_ids, vocabs) # 亚索托儿索六、总结与思路总结BPE 做了什么从字节序列出发按「频次最高的相邻对优先合并」的规则得到合并表与扩展词表从而在固定词表大小下得到有意义的子词单元。本实现的思路1训练阶段语料→字节 id→多轮「统计→选最高频 pair→合并→记录 (pair→新 id)、构建 id→字节」→得到 merges 与 vocab。2编码阶段字符串→字节 id→按 merges 中编号从小到大的顺序反复合并→得到 token id 列表。3解码阶段id 列表→按 vocab 还原字节→拼接→UTF-8 解码。关键点编码时必须按「训练时的合并顺序」进行因此用min(stats, keymerges.get(..., inf))每次只做「最先被学到」的合并。七、语料英雄名亚索托儿索 背景故事亚索是一名来自艾欧尼亚的剑客也是同门中唯一能掌握传奇风之剑术的弟子。当他被指控谋杀长老时他被迫挥剑自保杀死自己的兄长以证清白。长老之死真相大白后亚索踏上了赎罪之路在故乡的土地上流浪只有疾风指引着他的剑刃。 brbr亚索的剑术迅捷如风他能够斩钢闪突刺敌人第三次施放时更会释放一道击飞敌人的旋风。风之障壁能格挡一切飞行道具踏前斩则让他穿梭于敌阵之中。当敌人被击飞至空中亚索可施放狂风绝息斩瞬移至目标身旁给予致命一击。 技能1斩钢闪, 技能描述向前出剑对直线上的敌人造成物理伤害。若在突进过程中施放斩钢闪会呈环形出剑。在短时间内连续命中两次后第三次斩钢闪会吹出一道击飞敌人的旋风。 技能2风之障壁, 技能描述形成一堵风墙持续数秒。风墙会阻挡敌方的所有飞行道具包括普攻弹道、技能弹道等。 技能3踏前斩, 技能描述向目标敌人突进造成魔法伤害。每次施放都会在短时间内提升下次突进的基础伤害。同一目标在短时间内无法被重复突进。 技能4狂风绝息斩, 技能描述闪烁至一名被击飞的敌方英雄身旁造成物理伤害并使范围内所有被击飞的敌人在空中多停留一段时间。获得满额穿甲加成持续数秒。八、完整代码importosdefget_stats(ids):counts{}forpairinzip(ids,ids[1:]):counts[pair]counts.get(pair,0)1returncountsdefmerge(ids,pair,idx):newids[]i0whileilen(ids):ifilen(ids)-1andids[i]pair[0]andids[i1]pair[1]:newids.append(idx)i2else:newids.append(ids[i])i1returnnewidsdefbuild_vocab(text):vocab_size500num_mergesvocab_size-256tokenstext.encode(utf-8)tokenslist(map(int,tokens))idslist(tokens)merges{}foriinrange(num_merges):statsget_stats(ids)pairmax(stats,keystats.get)idx256i idsmerge(ids,pair,idx)merges[pair]idx vocab{idx:bytes([idx])foridxinrange(256)}for(p0,p1),idxinmerges.items():vocab[idx]vocab[p0]vocab[p1]returnmerges,vocabdefencode(text,merges):tokenslist(text.encode(utf-8))whilelen(tokens)2:statsget_stats(tokens)pairmin(stats,keylambdap:merges.get(p,float(inf)))ifpairnotinmerges:breakidxmerges[pair]tokensmerge(tokens,pair,idx)returntokensdefdecode(ids,vocab):tokensb.join(vocab[idx]foridxinids)texttokens.decode(utf-8,errorsreplace)returntextif__name____main__:dir_pathr/Users/tripleh/Heroescorpusforpathinos.listdir(dir_path):pathos.path.join(dir_path,path)withopen(path,encodingutf-8,errorsreplace)asf:textf.read()corpustext\nmerges,vocabsbuild_vocab(corpus)string亚索托儿索encode_idsencode(string,merges)decode_stringdecode(encode_ids,vocabs)