【BPE实战】从零实现 BPE 分词器:训练、编码与解码

从零实现BPE分词器:原理、Python代码与实战指南 在自然语言处理(NLP)中,分词器(Tokenizer)是将文本转换为模型可处理数字序列的核心组件。其核心原理在于解决词汇表平衡问题:既要控制词汇表大小以降低模型复杂度,又要保证对未知词汇(OOV)的泛化能力。字节对编码(BPE)作为一种数据驱动的子词分词算法,通过迭代合并高频字符对,从语料中自动学习一个大小可控的词汇表,在压缩表示和语义保留之间取得了良好平衡。该技术的工程价值在于能有效处理多语言、社交媒体文本中的缩写和新词,显著提升下游任务性能。本文以Python实现为例,深入解析BPE的贪心合并过程、数 阅读详情

​ 在BPE理论篇中,我们理解了 BPE 的核心思想:从 256 个字节值出发,反复合并最高频的相邻符号对,用更大的词表换取更短的序列。这篇实践篇,我们用 Python 从零把它写出来。

​ 全文代码可以直接复制运行,不依赖任何第三方库。

一、文本到字节:一切的起点

​ BPE 操作的对象不是字符串,而是字节序列。这是很多人的第一个误解:以为分词器直接处理文字。实际上,无论什么语言的文本,第一步都要通过 UTF-8 编码转成字节流。Python 中只需一行:

text = "Hello, 你好! 😄"

# 文本 → UTF-8 字节 → 整数列表
tokens = list(text.encode("utf-8"))
print(f"原始文本: {text}")
print(f"字符数: {len(text)}")
print(f"字节序列: {tokens}")
print(f"字节数: {len(tokens)}")

​ 运行结果:

原始文本: Hello, 你好! 😄
字符数: 12
字节序列: [72, 101, 108, 108, 111, 44, 32, 228, 189, 160, 229, 165, 189, 33, 32, 240, 159, 152, 132]
字节数: 19

​ 12个字符变成了 19 个字节。英文字母各占 1 字节,中文汉字各占 3 字节,emoji 占了 4 字节,另外包含了2个标点符号和2个空格。这也直观地说明了为什么中文在大模型中"更费 token":同样一个字符,中文在起跑线上就比英文多出 2 个字节,需要更多的合并才能压缩成一个 token。

​ list(text.encode("utf-8")) 将字节对象转成整数列表,方便后续用 Python 进行操作。此时每个整数都在 0~255 范围内,这就是 BPE 的初始词表—256 种字节值,每种对应一个 token。

二、基础实现

2.1 统计相邻符号对

​ 有了字节序列,BPE 的每一轮都需要回答同一个问题:哪个相邻符号对出现得最多? 这就是 get_stats 函数的职责。

​ 理论篇中我们讲过,BPE 用大小为 2 的滑动窗口扫描序列,逐位统计所有相邻对。Python 中有一个非常简洁的写法—zip(ids, ids[1:]):

def get_stats(ids):
    """统计序列中所有相邻符号对的出现次数"""
    counts = {}
    for pair in zip(ids, ids[1:]):
        counts[pair] = counts.get(pair, 0) + 1
    return counts

​ 返回的字典中,键是符号对的元组(如 (101, 108)),值是出现次数。

  • 如果你不熟悉这个写法,可以拆开看看它在做什么:

    # 演示 zip 的配对效果
    sample = [1, 2, 3, 4, 5]
    print(list(zip(sample, sample[1:])))
    # [(1, 2), (2, 3), (3, 4), (4, 5)]
    
    • zip 把原列表和偏移一位的列表"拉链式"配对,天然生成了所有相邻对。相比手动用 for i in range(len(ids)-1) 再 (ids[i], ids[i+1]),这种写法更 Pythonic,也更不容易出错
    • counts.get(pair, 0) + 1 是另一个实用技巧:如果 pair 不在字典中,返回默认值 0 再加 1,避免了 KeyError。
  • 用我们的字节序列试一下:

    stats = get_stats(tokens)
    print(f"共 {len(stats)} 种不同的符号对")
    

    输出:

    共 18 种不同的符号对
    
  • 为了方便查看,我们先实现一个辅助函数。把字节值转成可读字符,方便后续查看输出——ASCII 范围内的直接转字符,超出的显示为 ?:

    # 辅助函数:字节值转可读字符(非 ASCII 显示为 ?)
    def show_byte(b):
        return chr(b) if b < 128 else "?"
    

    现在来统计并展示频率最高的 5 个符号对:

    # 按频率降序,展示前 5 个
    top5 = sorted(stats.items(), key=lambda x: x[1], reverse=True)[:5]
    for pair, count in top5:
        left, right = show_byte(pair[0]), show_byte(pair[1])
        print(f"{pair}: {count}次  →  '{left}' + '{right}'")
    
    (72, 101): 1次  →  'H' + 'e'
    (101, 108): 1次  →  'e' + 'l'
    (108, 108): 1次  →  'l' + 'l'
    (108, 111): 1次  →  'l' + 'o'
    (111, 44): 1次  →  'o' + ','
    

​ 其中,频率最高的那个 pair,就是下一步要合并的目标。

2.2 合并符号对

​ 找到最高频的 pair 后,下一步是遍历整个序列,把所有出现的该 pair 替换为一个新的 token ID。这个操作需要注意一个关键点:替换不能重叠。

​ 举个例子:序列 [1, 1, 1] 中合并 (1, 1),应该得到 [新token, 1](前两个合并,第三个落单),而不是 [新token, 新token](重叠使用了中间的 1)。

​ 实现方式是用一个指针从左到右扫描,匹配到 pair 就合并并跳过两个位置,否则逐个前进:

def merge(ids, pair, idx):
    """将序列中所有出现的 pair 替换为新 token idx"""
    new_ids = []
    i = 0
    while i < len(ids):
        # 如果当前位置匹配 pair 的前半部分,且下一个位置匹配后半部分
        if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
            new_ids.append(idx)
            i += 2  # 跳过两个位置
        else:
            new_ids.append(ids[i])
            i += 1
    return new_ids

​ i += 2 是整个函数的关键——它保证了被合并的两个位置不会再参与后续匹配。这和理论篇中讲的"统计时窗口可以重叠,但替换时不能"是同一件事,只不过这里我们用代码把它表达了出来。

  • 验证一下:

    test = [5, 6, 6, 7, 9, 1]
    print(f"原始: {test}")
    merged_test = merge(test, (6, 7), 99)
    print(f"合并(6,7)→99: {merged_test}")
    

    输出:

    原始: [5, 6, 6, 7, 9, 1]
    合并(6,7)→99: [5, 6, 99, 9, 1]
    

    ​ 注意第一个 6 没有被合并因为它和后面的 6 构成的是 (6, 6) 而不是 (6, 7)。merge 函数只替换精确匹配的 pair。

  • 还有一个容易忽略的细节:合并后,参与合并的 token 并没有从词表中消失。看结果 [5, 6, 99, 9, 1]—第一个 6 因为不匹配 (6, 7) 而被保留了下来,它仍然是一个有效的独立 token。合并只是在匹配到的位置把两个相邻 token 替换成一个新的,不会影响同一 token 在其他位置的存在。

三、训练:迭代合并构建词表

3.1 训练流程

​ 有了 get_stats 和 merge 这两个基础函数,训练过程就是把它们组合成一个循环:
在这里插入图片描述

  1. 统计所有相邻 pair 的频率

  2. 找出频率最高的 pair

  3. 用一个新 token ID 替换所有该 pair

  4. 记录这条合并规则

  5. 回到第 1 步,重复

  • 循环的次数就是 vocab_size - 256

    因为初始词表有 256 个字节值(0~255),每合并一次新增一个 token。为什么是 256?因为一个字节能表示的值恰好是 2 8 2^8 28,也就是256 种。这不仅仅是 ASCII——0~127 对应 ASCII 字符(如 72 → H),而 128~255 是 UTF-8 多字节序列的组成部分(比如汉字"你"编码为 [228, 189, 160],这三个字节各自占据初始词表的一个位置)。vocab_size >= 256 的约束就是要求词表至少覆盖所有字节值,否则有些字节就无法表示。新增的合并 token 从 ID 256 开始编号。

3.2 实现

def train(text, vocab_size):
    """
    训练 BPE 分词器

    参数:
        text: 训练文本
        vocab_size: 目标词表大小(必须 >= 256)

    返回:
        merges: 合并规则字典 {(p0, p1): new_idx}
        vocab: 词表字典 {idx: bytes}
    """
    assert vocab_size >= 256, "词表大小不能小于 256(初始字节数)"
    num_merges = vocab_size - 256

    tokens = list(text.encode("utf-8"))

    # 记录合并规则
    merges = {}

    for i in range(num_merges):
        stats = get_stats(tokens)
        if not stats:
            break

        # 找出频率最高的 pair
        pair = max(stats, key=stats.get)

        # 分配新 token ID(从 256 开始)
        idx = 256 + i

        # 执行合并
        tokens = merge(tokens, pair, idx)
        merges[pair] = idx

        print(f"合并 {i+1}/{num_merges}: {pair} → {idx} (出现 {stats[pair]} 次)")

    # 构建词表:idx → bytes
    vocab = {idx: bytes([idx]) for idx in range(256)}
    for (p0, p1), idx in merges.items():
        vocab[idx] = vocab[p0] + vocab[p1]

    print(f"\n训练完成!")
    print(f"原始长度: {len(list(text.encode('utf-8')))} 字节")
    print(f"压缩后: {len(tokens)} token")
    print(f"压缩率: {len(list(text.encode('utf-8'))) / len(tokens):.2f}x")
    print(f"词表大小: {len(vocab)}")

    return merges, vocab
  • 训练函数有两个产出,理解它们的区别很重要:

    • merges 是合并规则字典,记录"哪两个 token 合并成了哪个新 token"。编码时需要它来决定怎么切分新文本。
    • vocab 是词表字典,记录"每个 token ID 对应什么字节"。解码时需要它来把 token 还原成文本。
  • 构建 vocab 的那几行代码值得细看:

    • 先用 {idx: bytes([idx]) for idx in range(256)} 初始化 256 个原始字节。

      也就是把整数 0~255 各自转成对应的单字节 bytes对象,建立初始词表的映射:

      {0: b'\x00', 1: b'\x01', 2: b'\x02', 3: b'\x03', 4: b'\x04', 5: b'\x05', ...}
      
    • 然后按合并顺序遍历 merges,每个新 token 的字节就是它的两个"父 token"字节的拼接。

      其实,就是把合并规则也写进词表。比如训练时 (32, 116) → 256,这行代码就做:vocab[256] = vocab[32] + vocab[116],即 b ' '+ b't' = b' t'。这样每个合并产生的 token ID 都能查到它对应的完整字节序列,解码时直接查表就行。

    • 这里依赖了 Python 3.7+ 字典保持插入顺序的特性:如果顺序乱了,后面的 token 可能引用到还未创建的父 token,导致错误。

3.3 测试

​ 用一段真实文本来训练。为了看到有意义的合并效果,我们需要一段足够长的文本:

training_text = """The quick brown fox jumps over the lazy dog. The dog barked at the fox.
The fox ran quickly through the forest. The forest was dark and quiet.
Natural language processing is a subfield of linguistics and artificial intelligence.
Language models process text by breaking it into tokens. Tokenization is the first step.
The byte pair encoding algorithm iteratively merges the most frequent pairs of bytes.
This process continues until the desired vocabulary size is reached.
Understanding tokenization helps explain why language models behave the way they do.
The relationship between tokens and words is not always straightforward.
Some words are split into multiple tokens while common words remain as single tokens.
This encoding scheme balances vocabulary size with sequence length efficiently."""

merges, vocab = train(training_text, vocab_size=276)

​ 运行输出(部分):

合并 1/20: (101, 32) → 256 (出现 27 次)
合并 2/20: (115, 32) → 257 (出现 25 次)
合并 3/20: (101, 110) → 258 (出现 14 次)
合并 4/20: (104, 256) → 259 (出现 13 次)
...
合并 20/20: (111, 107) → 275 (出现 6 次)

训练完成!
  原始长度: 796 字节
  压缩后: 597 token
  压缩率: 1.33x
  词表大小: 276

​ 这里有一个非常值得注意的现象:看第 4次合并,(104, 256) → 259。token 256 是第 1 次合并刚产生的 "e "(e+空格),现在它和 104(字母 “h”)合并成了 "he "。新 token 立刻参与了下一轮合并——这就是理论篇中说的"层层嵌套的二叉树结构"。token 259 ("he ") 的内部结构是:

259 = (104, 256) = (104, (101, 32)) = "h" + ("e" + " ") = "he "

​ 随着合并次数增加,token 会变得越来越长,从单个字节逐渐积累成常见的词或短语片段。20 次合并的压缩率只有 1.35 x 1.35x 1.35x,但真实的分词器执行 50,000+次合并后,压缩率可以达到 3 ∼ 4 x 3 \sim 4x 3∼4x。

三、编码与解码

​ 编码和解码是分词器对外提供的两个核心接口。

在这里插入图片描述

3.1 解码:从 token 还原文本

​ 我们先讲解码,因为它更简单,也更容易建立直觉。

  • 解码要做的事情很直接:给定一个 token ID 序列,在词表中查找每个 ID 对应的字节,把所有字节拼接起来,最后用 UTF-8 解码回字符串。

    def decode(ids, vocab):
        """将 token 序列解码为文本"""
        tokens = b"".join(vocab[idx] for idx in ids)
        text = tokens.decode("utf-8", errors="replace")
        return text
    
  • 短短两行代码,但 errors="replace" 这个参数值得专门说一说。

    ​ 并非所有字节序列都是合法的 UTF-8 编码。UTF-8 有严格的格式规则,比如字节值 128(二进制 10000000)不能作为一个字符的起始字节。如果 LLM 恰好预测出这样一个 token,decode("utf-8") 默认会抛出 UnicodeDecodeError。加上 errors="replace" 后,无效字节会被替换为 �(U+FFFD 替换字符),程序不会崩溃:

    # 正常解码
    print(decode([72, 101, 108, 108, 111], vocab))  # → "Hello"
    
    # 无效 UTF-8 字节
    print(decode([128], vocab))  # → "�"(替换字符)
    

    ​ 当你在 LLM 的输出中看到 � 时,通常就意味着模型生成了无法解码为有效 UTF-8 的 token 序列。这在实际使用中很少见,但一个健壮的分词器必须处理这种边界情况。OpenAI 的 tiktoken 源码中也用了同样的 errors="replace" 策略。

3.2 编码:从文本到 token

  • 编码是解码的反向操作:给定文本,输出 token 序列。这是实现中最需要小心的部分。

  • 基本流程和解码类似,先把文本转成字节列表。但接下来不是简单地查表,而是要按合并规则的原始顺序,逐条尝试应用。

(1) 实现

​ 整体逻辑分三步:

  1. 初始化:和训练一样,先把文本转成字节列表,作为初始 token 序列
  2. 循环合并:每轮用 get_stats 统计当前所有相邻 pair,从中挑出在 merges 里优先级最高的那个,调用 merge 执行替换
  3. 终止:当序列中没有任何相邻 pair 存在于合并规则中时,停止循环,返回最终的 token 序列

​ 和训练的区别在于:训练时每轮选频率最高的 pair(max),编码时每轮选在合并规则中编号最小的 pair(min)。这是因为编码不是在"发现新规则",而是在"重放已有规则"。

def encode(text, merges):
    """将文本编码为 token 序列"""
    tokens = list(text.encode("utf-8"))

    while len(tokens) >= 2:
        # 统计当前所有相邻 pair
        stats = get_stats(tokens)

        # 找到在 merges 中优先级最高(索引最小)的 pair
        # 不在 merges 中的 pair 赋予无穷大,表示不参与合并
        pair = min(stats, key=lambda p: merges.get(p, float("inf")))

        # 如果没有任何 pair 在合并规则中,停止
        if pair not in merges:
            break

        idx = merges[pair]
        tokens = merge(tokens, pair, idx)

    return tokens
(2) 说明
  • 这段代码中最关键的一行是 min(stats, key=lambda p: merges.get(p, float("inf")))。

    拆开理解这行代码:

    • stats 是当前序列中所有相邻 pair 的字典,比如 {(84, 104): 1, (104, 101): 2, ...}
    • min(stats, key=...) 遍历 stats 中的每个 pair,按 key 函数的返回值取最小的那个
    • merges.get(p, float("inf")) 是 key 函数——如果 pair p 在合并规则中,返回它的新 token ID(如 256、257…),ID 越小说明越早被创建;如果不在合并规则中,返回 float("inf")(正无穷),保证它永远不会被选中

    所以最终效果是:在当前序列存在的所有相邻 pair 中,挑出在合并规则里编号最小(最早创建)的那一个。

  • 为什么必须按原始顺序? 因为后面的合并规则可能依赖前面的结果。回顾训练输出:

    • 规则 1:(101, 32) → 256(e + 空格 → "e ")
    • 规则 4:(104, 256) → 259(h + "e " → "he ")
  • 如果我们跳过规则 1 直接尝试规则 4,序列中根本不存在 token 256,规则 4 永远无法匹配。编码时的合并顺序必须和训练时一致,这是 BPE 正确性的基本保证。

  • 还有一个特殊情况需要处理:当输入只有一个字符(或为空)时,序列长度不足 2,没有任何 pair 可以合并。while len(tokens) >= 2 这个条件自然处理了这种边界情况。

3.3 测试编码-解码的一致性

​ 这是验证实现正确性最可靠的方式。

  • 用训练文本验证

    assert decode(encode(training_text, merges), vocab) == training_text
    print("训练文本: 编码-解码一致 ✓")
    

    输出:

    训练文本: 编码-解码一致 ✓
    
  • 用从未见过的文本验证:

    encoded = encode(new_text, merges)
    decoded = decode(encoded, vocab)
    print(f"\n新文本: '{new_text}'")
    print(f"编码: {encoded}")
    print(f"解码: '{decoded}'")
    assert decoded == new_text
    print("新文本: 编码-解码一致 ✓")
    

    输出:

    新文本: 'The fox and the dog are friends.'
    编码: [270, 271, 120, 32, 263, 100, 261, 259, 100, 111, 103, 32, 267, 256, 102, 114, 105, 258, 100, 115, 46]
    解码: 'The fox and the dog are friends.'
    新文本: 编码-解码一致 ✓
    

​ 对新文本的分词也能正确工作,这说明我们的分词器具备泛化能力——训练时学到的合并规则可以应用到任何新文本上。

  • 最后要强调一点:编码-解码的一致性是单向的。decode(encode(text)) == text 始终成立,但反过来 encode(decode(ids)) == ids 不一定。因为并非所有 token 序列都能解码为合法的 UTF-8 字符串,errors="replace" 会引入 � 导致信息丢失。

四、完整代码

​ 将所有部分整合为一个可直接运行的脚本:

"""BPE 分词器完整实现:训练 + 编码 + 解码"""


def get_stats(ids):
    """统计序列中所有相邻符号对的出现次数"""
    counts = {}
    for pair in zip(ids, ids[1:]):
        counts[pair] = counts.get(pair, 0) + 1
    return counts


def merge(ids, pair, idx):
    """将序列中所有出现的 pair 替换为新 token idx"""
    new_ids = []
    i = 0
    while i < len(ids):
        if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
            new_ids.append(idx)
            i += 2
        else:
            new_ids.append(ids[i])
            i += 1
    return new_ids


def train(text, vocab_size):
    """训练 BPE 分词器,返回 (merges, vocab)"""
    assert vocab_size >= 256
    num_merges = vocab_size - 256
    tokens = list(text.encode("utf-8"))

    merges = {}
    for i in range(num_merges):
        stats = get_stats(tokens)
        if not stats:
            break
        pair = max(stats, key=stats.get)
        idx = 256 + i
        tokens = merge(tokens, pair, idx)
        merges[pair] = idx

    vocab = {idx: bytes([idx]) for idx in range(256)}
    for (p0, p1), idx in merges.items():
        vocab[idx] = vocab[p0] + vocab[p1]

    return merges, vocab


def encode(text, merges):
    """文本 → token 序列"""
    tokens = list(text.encode("utf-8"))
    while len(tokens) >= 2:
        stats = get_stats(tokens)
        pair = min(stats, key=lambda p: merges.get(p, float("inf")))
        if pair not in merges:
            break
        tokens = merge(tokens, pair, merges[pair])
    return tokens


def decode(ids, vocab):
    """token 序列 → 文本"""
    tokens = b"".join(vocab[idx] for idx in ids)
    return tokens.decode("utf-8", errors="replace")


# ---- 示例运行 ----
if __name__ == "__main__":
    training_text = """The quick brown fox jumps over the lazy dog. The dog barked at the fox.
The fox ran quickly through the forest. The forest was dark and quiet.
Natural language processing is a subfield of linguistics and artificial intelligence.
Language models process text by breaking it into tokens. Tokenization is the first step.
The byte pair encoding algorithm iteratively merges the most frequent pairs of bytes.
This process continues until the desired vocabulary size is reached.
Understanding tokenization helps explain why language models behave the way they do.
The relationship between tokens and words is not always straightforward.
Some words are split into multiple tokens while common words remain as single tokens.
This encoding scheme balances vocabulary size with sequence length efficiently."""

    # 训练
    print("=== 训练 ===\n")
    merges, vocab = train(training_text, vocab_size=276)

    original_len = len(list(training_text.encode("utf-8")))
    compressed_len = len(encode(training_text, merges))
    print(f"\n原始: {original_len} 字节 → 压缩后: {compressed_len} token")
    print(f"压缩率: {original_len / compressed_len:.2f}x")

    # 编码-解码验证
    print("\n=== 编码-解码验证 ===\n")
    assert decode(encode(training_text, merges), vocab) == training_text
    print("训练文本: ✓")

    test_text = "The fox and the dog are friends."
    assert decode(encode(test_text, merges), vocab) == test_text
    print(f"新文本 '{test_text}': ✓")

    # 展示分词结果
    print("\n=== 分词示例 ===\n")
    for text in ["The fox", "tokenization", "Hello, 你好!"]:
        ids = encode(text, merges)
        parts = [decode([id], vocab) for id in ids]
        print(f"  '{text}' → {ids}")
        print(f"    拆分: {parts}\n")

五、总结

​ 回顾整个实现,核心就是三个函数加一个循环:

函数作用一句话描述
get_stats统计滑动窗口数相邻 pair 频率
merge替换从左到右把 pair 换成新 token
train训练循环调用上面两个函数,构建合并规则
encode编码按合并规则的顺序逐条应用
decode解码查词表拼字节,UTF-8 解码
  • 几个值得记住的实现细节:

    • zip(ids, ids[1:]) 是统计相邻 pair 的惯用写法,比手动索引更简洁不易出错
    • 合并时 i += 2 保证替换不重叠,对应理论篇中"统计可重叠,替换不可重叠"的规则
    • 编码时用 min 而非 max,按合并规则的原始顺序处理,保证前序依赖(后面的规则可能依赖前面创建的 token)
    • 解码时 errors="replace" 优雅处理无效 UTF-8 字节,这也是 OpenAI tiktoken 的做法
    • 编码-解码一致性是单向的:decode(encode(text)) == text 始终成立,反之不然
    • vocab 构建依赖字典的插入顺序,Python 3.7+ 保证有序,更早的版本需要用 OrderedDict
  • 这是最基础的 BPE 实现。真实的分词器(如 GPT 系列使用的 tiktoken)还引入了许多工程优化:

    • 正则预分割(先按空格、标点等切成"词",避免跨词合并)
    • 特殊 token 处理(如 <|im_start|>)
    • 多线程加速等。

    但核心算法和我们这里实现的完全一致,理解了这 50 行代码,你就理解了所有 BPE 分词器的骨架。

BPE(Byte-Pair Encoding)简介 BPE简介 阅读详情

相关推荐

BPE训练

这段代码的核心目标是基于自定义 txt 数据训练一个 BPE 分词器导入依赖 → 2. 初始化 BPE 分词器(绑定预分词规则) → 3. 配置训练器(指定词表大小、特殊符号) → 4. 遍历文件夹收集 txt 训练数据 → 5. 训练分词器 → 6. 保存分词器和词表。训练后的分词器可用于 NLP 任务(如文本编码、模型预训练),词表文件则方便人工检查分词效果。

mayaohao的博客 831

每天5分钟搞懂大模型的分词器tokenizer(二):BPE (Byte-Pair Encoding)

字节对编码 (BPE) 最初是作为一种压缩文本的算法开发的,最早是由Philip Gage于1994年在《A New Algorithm for Data Compression》一文中提出,后来被 OpenAI 在预训练 GPT 模型时用于分词器(Tokenizer)。

深数研究院 1649

从零实现BPE分词器:CS336 Assignment 1 实战与调试全记录

分词器是自然语言处理中连接原始文本与语言模型的核心组件,其质量直接影响模型训练效果。字节对编码(BPE)作为一种数据驱动的子词切分算法,通过反复合并高频字节对构建词表,在序列长度与词表规模之间取得平衡。理解BPE的原理与实现,有助于深入掌握大模型预处理流程,并解决多语言、特殊字符等实际工程问题。在训练Transformer等模型前,自实现tokenizer能帮助开发者真正理解数据流的第一环。本文基于CS336课程Assignment 1,详细记录从零实现BPE分词器的完整过程,包括算法拆解、代码实现、调试踩

weixin_29057695的博客 256

分词(tokenization)算法之Byte Pair Encoding (BPE) 算法详解(代码实现)

BPE 的基本思想可以用一句话概括:从字符级别开始,通过统计频率最高的字符对或子词对,逐步构建一个词汇表,用于表示文本中的单词或子词单元。 这种方法既能保留词的语义信息,又能灵活处理未见过的新词(out-of-vocabulary, OOV),在深度学习模型中表现出色。

阿正的梦工坊 4556

BPE 算法原理及使用指南【深入浅出】

退役ACMer NLP方向硕士在读193 人赞同了该文章本文力争通俗易懂,但由于牵扯的知识较多,我也是参考了很多文章才弄清楚BPE、Subword(子词)、WordPiece、Tokenize、Vocabulary(词表)

具身机器人和通用AI 4573

GPT从零构建:训练自己的Tokenizer

这一点非常重要,因为每个utf-8字符都是由一到多个字节组成的,将这个长度为256的编码表中的字节进行组合,理论上就能对世界上所有语言中的字符进行编码,并且还不会出现。

golfxiao的专栏 1986

语言模型解构——从零复现BPE算法

BPE 的全称是 Byte Pair Encoding,以语料库作为输入,通过学习训练得到一个词库;基于训练出来的词库,对文本进行tokenize;让数据告诉我们如何去tokenize,而不是人工标注;subword tokenizer不是以单词为单位,而是以子词为单位,英语上可以理解成词根词缀;

golfxiao的专栏 2758

SQL 通过sum() over(order by ,rownum)实现财务现金日记账实例

SELECT 类型,to_char(发生日期,'yyyy-MM-dd') 发生日期,期初余额,借方金额,贷方金额, (CASE WHEN 借方金额>0 THEN '借' WHEN 贷方金额>0 THEN '贷' ELSE '平' END) 方向, sum(收支金额) over(order by 发生日期, rownum) 余额 FROM( SELECT LX 类型,账户,账号,摘要,发生日期,单据ID, ...

qq_41451794的博客 1023

基于五笔编码的BPE分词器设计与实现

五笔输入法作为高效的中文编码方案,天然具备。在中文自然语言处理中,传统分词方法面临。

东方佑 909

【C语言基础】野指针与空指针

全网最接地气的C语言野指针介绍,此处对于野指针与空指针知识点做一些简要的介绍,作者实属初学,写博客也是作者学习的一个过程,难免文章中有内容理解不到位或者有不当之处,还请朋友们不吝指正,希望大家多多给予支持,赠人玫瑰,手有余香! 文章目录一:野指针1.1 :野指针的成因2.1 :规避野指针二:空指针 一:野指针 概念:野指针就是指向的内存地址是未知的(随机的,不正确的,没有明确限制的)。 说明:指针变量也是变量,是变量就可以任意赋值。但是,任意数值赋值给指针变量没有意义,因为这样的指针就成了野指针,此指针指向

qq_43727529的博客 2万+

华为防火墙NAT避坑指南:当双ISP出口遇到服务器发布,这些细节不注意会翻车

本文深入解析华为防火墙在双ISP出口环境下服务器发布的实战配置,重点探讨no-reverse参数对Server-Map表的影响、源进源出功能的实现及线路切换时的会话保持问题。通过策略路由和智能选路技术,确保HTTP/FTP服务在多运营商链路中的高可用性,避免路径不对称和NAT映射冲突等常见陷阱。

weixin_30897233的博客 549

LLMs-from-scratch :从零实现字节对编码(BPE)分词器 -- 简化版

1. 识别频繁对在每次迭代中,扫描文本以找到最常出现的字节(或字符)对2. 替换和记录用一个新的占位符ID替换该对(一个尚未使用的ID,例如,如果我们从0…255开始,第一个占位符将是256)在查找表中记录此映射查找表的大小是一个超参数,也称为"词汇表大小"(对于GPT-2,是50,257)3. 重复直到无收益继续重复步骤1和2,不断合并最频繁的对当无法进一步压缩时停止(例如,没有对出现超过一次)解压缩(解码)要恢复原始文本,使用查找表将每个ID替换为其对应的对,逆转过程。

The cure for boredom is curiosity. This is the log of its adventures. 865

【CTFhub】——hate_php2021

【CTFhub】——hate_php2021

manerzi的博客 1605

从零训练LLM-1.训练BPE

分词器将单词从自然语言通过“词典”映射到0, 1, 36这样的数字,可以理解为数字就代表了单词在“词典”中的页码。可以选择自己构造词表训练一个“词典”或者选择比较出名的开源大模型分词器(直接将 tokenizer 的模型文件复制过来,然后 tokenizer.from_pretrained), 正如同直接用新华/牛津词典的优点是token编码压缩率很好,缺点是页数太多,动辄数十万个词汇短语;

厚积而薄发 986

零基础玩转NILM:用nilmtk提取UKDALE电器数据的保姆级教程

本文为零基础用户提供了一份详细的NILM入门指南,重点讲解如何使用nilmtk工具包从UKDALE数据集中提取电器级用电数据。教程涵盖环境搭建、核心概念解析、数据提取实战及常见问题排查,帮助读者快速掌握非侵入式负荷监测的数据预处理关键步骤。

rgv234567的博客 499

影像技术实战02:FFmpeg 镜头切分误检、漏检问题排查与基于场景变化的稳定分镜方案

本文介绍了基于FFmpeg的镜头切分技术在实际应用中的常见问题及解决方案。文章首先指出单纯使用select='gt(scene,0.4)'命令进行镜头检测存在误检、漏检等问题,包括黑场、字幕变化被误判为镜头切换,以及相似场景切换被漏检等情况。随后提出了一套四层架构的稳定解决方案:1)用FFmpeg提取候选镜头变化点;2)解析变化点时间和分数;3)通过后处理过滤异常点;4)按时间点切割视频片段并生成报告。文中详细展示了Python实现代码,包括场景点检测、时间间隔过滤等功能模块,为视频剪辑、影视解说等场景提供

ai技术增长的博客 400

Transformers 自定义分词器实战:从零训练 BPE、构建自定义词表与实现新的 Tokenizer 后端

在 Transformers 中,分词器(tokenizer)与其词表是解耦的:你可以初始化一个“空”的分词器用于训练,也可以直接用自己的词表构建一个可用的分词器,而底层的分词流水线(normalizer、pre-tokenizer、分词算法)保持不变,无需从头重建。本文基于 Transformers 官方指南 [custom_tokenizers.md](https://link.gitcode

gitblog_00295的博客 690

一种抑制undershoot/overshoot锐化算法介绍

一、USM 当增强图像的边缘、细节信息等高频信息时,图像的锐化视觉效果会得到较大提升。提高图像锐化的一种经典算法为非锐化掩码(UNsharp Masking)技术。该技术首先通过高通滤波并与缩放系数相乘,其结果再与原始图像相加,即可对高频信息进行增强。其主要的原理如下图所示: 其中,对图像进行高通滤波的另一种等效方法为:原始图像减去原始图像的低通滤波图像,其结果被称之为“掩码”。因此,上述的USM方法可以表示为: 原始图像减去原始图像低通滤波之后的图像得到掩码M。 2.掩...

大熊 4242

2025终极指南:用C语言从零实现Llama2 BPE分词器的完整教程

Llama2.c是一个强大的开源项目,它允许开发者在单个C文件中实现Llama 2模型的推理功能。本文将为您提供一个简单快速的教程,帮助您从零开始用C语言实现Llama2 BPE分词器,即使您是编程新手也能轻松掌握。 ## 什么是BPE分词器? BPE(Byte-Pair Encoding)分词器是一种广泛应用于自然语言处理领域的文本处理技术。它通过将频繁出现的字符对合并为新的字符,从而有效地

gitblog_00084的博客 525

费雪的未来增长评估:预测颠覆性技术的影响

本文旨在提供一个系统性的框架,用于评估和预测颠覆性技术的增长轨迹。我们将重点关注费雪增长模型(Fisher-Pry Model)这一经典技术扩散理论,探讨其在现代技术预测中的应用价值。文章首先介绍费雪增长模型的基本概念,然后深入其数学原理和算法实现。随后展示实际应用案例,讨论工具资源,最后探讨未来发展趋势。每个部分都包含详细的技术实现和理论分析。费雪增长模型(Fisher-Pry Model):一种描述技术替代过程的S型增长模型,用于预测新技术取代旧技术的速率。

欢迎来到我的优快云空间!这里聚焦AI大模型应用实战,分享前沿技术、实战案例与开发经验。 928

android 蓝牙通讯demo

android 下蓝牙通讯demo,实现了发现蓝牙,使能蓝牙,通过蓝牙发送消息。

最完整GPT-4分词器实现:Andrej Karpathy未公开的BPE核心细节全解析

点赞+收藏+关注,不错过更多LLM核心技术解析!下一期我们将深入探讨分词器对模型性能的影响及优化策略。

gitblog_00596的博客 1081

老版QQ原生表情gif动态

这是老版的QQ表情,不过全是动态的的,Windows照片查看器是不会动的,可以右击用浏览器打开查看,再说一遍是老版的,只是分享给宁愿用老版也要动态的人。

大模型分词器如何工作?从零实现BPE完整指南

自然语言处理与大模型训练中,Tokenization是最基础却常被忽视的环节。文本如何被切分为token,直接决定了模型的训练效率、推理成本甚至API账单。字节对编码(BPE)作为GPT、LLaMA等主流模型采用的分词算法,通过统计数据频率动态合并子词,在词表大小与序列长度之间取得平衡。本文从Token基本概念出发,剖析直接按单词切分的缺陷,逐步拆解BPE的训练与编码原理,并用Python手写完整实现,最后通过OpenAI的tiktoken库对照真实大模型的编码效果。无论你是准备训练大模型、理解token计

weixin_33755649的博客 380

SAP B1中文培訓教材_SDK

公司内部培训资料,对开发工具 ,DIAPI, UIAPI,Add-ON有详细解说

TMS_WebGMaps_1.9.8.0_Full_Source

TMS_WebGMaps_1.9.8.0_Full_Source 全源码 delphi cbuilder

BPE分词器的训练

斯坦福CS336《从0开始构建语言模型》作业一的BPE分词器原理部分,内容包括用于GPT2的BPE分词器的训练、编解码操作。部分代码由AI辅助完善。

qq_20199577的博客 219

从零训练大模型之BPE词表构建及数据集加载管道搭建

本文介绍了从OpenWebText数据集构建BPE分词器到数据预处理的全流程。主要内容包括:1) 基于Arrow格式数据集训练专用BPE分词器,详述参数设置和实现方法;2) 构建数据管道,将文本转换为模型可处理的格式;3) 处理长文本的技术方案;4) 提供完整可运行的Python代码。重点强调了定制分词器的必要性,并推荐使用Hugging Face的ByteLevelBPETokenizer进行实现,同时探讨了词表大小、特殊词元等关键参数的设置考量。

2401_89079944的博客 1056

文本编码--BPE

将输入文本拆分为单个字节重复查找并合并、替换字节对,继续合并关系重复步骤2,直到无法构建更多的合并最终构建有效词表,基于其可进行编解码self.bpe_merges = {} # 字典,key是元组,即合并的两个tokend_id,value是合并后的一个token_id"""Args:"""for i, char in enumerate(text): # 将文本中的空格替换为Ġ= 0:if char!

zzfive的博客 956
上一篇: 【理论篇】深入理解 BPE 分词:大模型时代的文本“拆字“艺术
下一篇: 【LLM Tokenizer】WordPiece 分词:BERT 背后的子词算法
未收敛
博客等级 码龄11年 3310粉丝 163原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

未收敛

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值