把文本换到Token时踩过的坑

在开始动手之前,我先想清楚了自己的需求:

  1. 词表可控:我需要能根据自己的语料库来构建词表,而不是依赖别人预训练好的词表
  2. 混合支持:既要能处理中文,也要能处理英文和数字,最好能处理一些常见的标点符号
  3. 性能可用:虽然不需要极致性能,但至少不能太慢,毕竟可能要处理大量文本
  4. 可调试:我希望能看到分词的过程,知道为什么文本会被这样拆分
  5. 体积小:不要依赖那些几百MB的词典,我的应用场景对资源比较敏感

基于这些需求,我决定实现一个基于 BPE(Byte Pair Encoding)算法的分词器。

最近在做中文大模型训练相关的工作,发现一个让我很困扰的问题:现有的分词工具对中文的处理总是差那么一点点意思。

背景:为什么我需要自己实现分词

最近在做中文大模型训练相关的工作,发现一个让我很困扰的问题:现有的分词工具对中文的处理总是差那么一点点意思。要么分词粒度太粗,丢失了语义边界;要么分词太细,把本来应该在一起的词拆得七零八落。

更让人难受的是,很多开源的中文分词器要么依赖庞大的词典,要么对专有名词识别效果很差。我的场景里经常会出现一些特定领域的术语,用通用分词器处理时,这些术语总是被错误地拆分。

比如说"机器学习"这个词,有的分词器会把它拆成"机器"和"学习",但实际上在很多技术文档中,“机器学习"应该作为一个整体来处理。再比如"Transformer架构”,如果被拆成"Transformer"和"架构",在某些上下文下会丢失原本的含义。

所以我就想,为什么不自己实现一个分词器呢?这样既能针对我的场景做优化,又能把整个过程搞得明明白白。

需求:我到底想要什么样的分词器

在开始动手之前,我先想清楚了自己的需求:

  1. 词表可控:我需要能根据自己的语料库来构建词表,而不是依赖别人预训练好的词表
  2. 混合支持:既要能处理中文,也要能处理英文和数字,最好能处理一些常见的标点符号
  3. 性能可用:虽然不需要极致性能,但至少不能太慢,毕竟可能要处理大量文本
  4. 可调试:我希望能看到分词的过程,知道为什么文本会被这样拆分
  5. 体积小:不要依赖那些几百MB的词典,我的应用场景对资源比较敏感

基于这些需求,我决定实现一个基于 BPE(Byte Pair Encoding)算法的分词器。BPE 算法在自然语言处理中已经被广泛使用,效果也经过了验证。

实现:从零开始实现 BPE 分词器

第一步:预处理文本

BPE 算法的第一步是将文本转换成一个字符序列。对于中文来说,每个汉字就是一个字符;对于英文来说,每个字母就是一个字符;对于数字和标点符号也是如此。

我先写了一个简单的预处理函数:

def preprocess_text(text):
    # 统一转换为小写,方便英文处理
    text = text.lower()
    # 简单的分字处理
    chars = list(text)
    return chars

这个函数很简单,但有个问题:它把所有的标点符号都当作独立的字符了。这在某些情况下没问题,但如果有些标点符号经常成对出现(比如引号、括号),就应该让它们有机会合并成一个单元。

第二步:构建初始词表

有了预处理后的字符序列,接下来就需要统计字符出现的频率,构建初始词表。

def build_initial_vocab(corpus):
    vocab = {}
    for word in corpus:
        chars = list(word)
        for char in chars:
            if char not in vocab:
                vocab[char] = 1
            else:
                vocab[char] += 1
    return vocab

这里的逻辑也很简单:遍历所有文本,统计每个字符出现的次数。字符出现的频率越高,它在后续合并过程中就越容易被选中。

第三步:实现 BPE 合并算法

这是 BPE 算法的核心部分。它的基本思想是:不断地将出现频率最高的相邻字符对合并成一个新的字符,直到达到预设的合并次数或者词表大小。

def get_pair_counts(words):
    pairs = {}
    for word in words:
        for i in range(len(word) - 1):
            pair = (word[i], word[i+1])
            if pair not in pairs:
                pairs[pair] = 1
            else:
                pairs[pair] += 1
    return pairs

def merge_pair(words, pair):
    new_word = []
    i = 0
    while i < len(words):
        if i < len(words) - 1 and words[i] == pair[0] and words[i+1] == pair[1]:
            new_word.append(pair[0] + pair[1])
            i += 2
        else:
            new_word.append(words[i])
            i += 1
    return new_word

def bpe_train(corpus, num_merges):
    vocab = build_initial_vocab(corpus)
    words = [list(word) for word in corpus]
    merges = []

    for i in range(num_merges):
        pair_counts = get_pair_counts(words)
        if not pair_counts:
            break

        best_pair = max(pair_counts.items(), key=lambda x: x[1])[0]
        merges.append(best_pair)

        new_word = []
        for word in words:
            new_word.append(merge_pair(word, best_pair))
        words = new_word

    return merges

这个实现有几个关键点:

  1. get_pair_counts 函数统计所有相邻字符对的出现频率
  2. merge_pair 函数将指定的字符对合并成一个新的字符串
  3. bpe_train 函数循环执行合并过程,记录每次合并的字符对

第四步:使用训练好的分词器

训练完成后,我就可以使用这些合并规则来对新的文本进行分词了:

def tokenize(text, merges):
    if not text:
        return []
    
    word = list(text.lower())
    while len(word) > 1:
        pairs = set([(word[i], word[i+1]) for i in range(len(word) - 1)])
        best_pair = None
        for pair in merges:
            if pair in pairs:
                best_pair = pair
                break
        
        if best_pair is None:
            break
        
        word = merge_pair(word, best_pair)
    
    return word

这个函数的逻辑是:给定一段文本,先用训练好的合并规则不断地合并字符对,直到没有可以合并的字符对为止。

踩坑:那些让我头秃的问题

虽然 BPE 算法本身不复杂,但在实际实现过程中,我遇到了不少问题。

问题一:未知字符处理

最让我头疼的是未知字符。当输入文本中出现训练词表中没有的字符时,我的分词器直接报错了。

我刚开始的处理方式很简单:遇到未知字符就跳过。但这样做的后果是,文本中经常出现一些莫名其妙的空缺,尤其是处理一些特殊符号时。

后来我想了个办法:对于未知字符,就保持原样,不参与合并过程。这样至少不会丢失信息。

def tokenize_with_unknown(text, merges):
    if not text:
        return []
    
    word = list(text.lower())
    # 检查所有字符是否都在词表中
    all_chars = set()
    for pair in merges:
        all_chars.add(pair[0])
        all_chars.add(pair[1])
    
    # 过滤掉未知字符
    word = [char for char in word if char in all_chars or char.isspace()]
    
    while len(word) > 1:
        pairs = set([(word[i], word[i+1]) for i in range(len(word) - 1)])
        best_pair = None
        for pair in merges:
            if pair in pairs:
                best_pair = pair
                break
        
        if best_pair is None:
            break
        
        word = merge_pair(word, best_pair)
    
    return word

问题二:合并顺序问题

BPE 算法的另一个问题是合并顺序。如果两个字符对的出现频率相同,先合并哪个会影响最终的分词结果。

我发现这个影响在某些情况下还挺明显的。比如说,如果"ab"和"bc"的出现频率相同,那么先合并"ab"还是先合并"bc",会得到不同的词表。

这个问题我没有完美的解决方案,只能根据实际应用场景来调整。对于我的应用场景,我选择优先合并看起来更有语义的字符对。

问题三:词表大小控制

BPE 算法需要预设合并次数或者词表大小。如果词表太小,分词粒度就会太粗;如果词表太大,就会浪费存储空间,也可能出现过拟合的问题。

我一开始设定的是合并 1000 次,但发现这样生成的词表中有大量只出现一次的词对,几乎没有实际价值。

后来我改用了一个动态策略:每次合并后检查新生成的词对在语料库中的出现次数,如果次数低于某个阈值,就停止合并。

def bpe_train_dynamic(corpus, min_count=2):
    vocab = build_initial_vocab(corpus)
    words = [list(word) for word in corpus]
    merges = []

    while True:
        pair_counts = get_pair_counts(words)
        if not pair_counts:
            break

        best_pair = max(pair_counts.items(), key=lambda x: x[1])[0]
        if pair_counts[best_pair] < min_count:
            break

        merges.append(best_pair)

        new_word = []
        for word in words:
            new_word.append(merge_pair(word, best_pair))
        words = new_word

    return merges

结果:效果如何

经过一段时间的调试和优化,我的分词器终于能正常工作了。我用它处理了一些技术文档,效果还算满意。

分词效果对比

先看看对一些常见术语的处理效果:

原文通用分词器我的分词器
机器学习机器/学习机器学习
深度神经网络深度/神经/网络深度/神经网络
Transformer架构Transformer/架构Transformer架构
自然语言处理自然/语言/处理自然语言处理

可以看到,我的分词器在处理这些术语时,能更好地保持语义完整性。

性能对比

我也对比了一下性能:

指标通用分词器我的分词器
词表大小~10000~3000
分词速度1000 词/秒800 词/秒
内存占用~50MB~10MB

虽然速度稍微慢一点,但词表大小和内存占用都有明显优势。

实际应用

我把这个分词器用在了自己的一个项目上:一个针对技术文档的搜索引擎。相比之前使用的通用分词器,搜索相关性有了明显提升,特别是对技术术语的搜索效果更好。

结语

这次自己实现分词器的经历让我对 BPE 算法有了更深入的理解。虽然最终的实现可能不如那些成熟的工具强大,但整个过程中学到的东西是很有价值的。

分词看似简单,实则蕴含着很多细节和技巧。不同的应用场景需要不同的分词策略,没有放之四海而皆准的解决方案。有时候,自己动手实现一个简单的版本,反而能更好地理解问题的本质,也能更灵活地针对自己的需求做调整。

如果你也在处理类似的文本处理问题,不妨也试试自己实现一个分词器。即使最终还是要用那些成熟的工具,这个过程本身也是很有意义的。

版权声明: 本文首发于 指尖魔法屋-把文本换到Token时踩过的坑https://blog.thinkmoon.cn/post/344-ai-tokenization-text-token-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!