大模型应用:大模型的词元化处理详解:BPE、WordPiece、Unigram.11

一. 引言

词元化(Tokenization)是大模型预处理的核心步骤,将连续文本切分为模型可理解的最小语义单元(Token),这些词元可以是单词、子词或字符。中文没有像英文空格这样的天然分词边界,并且存在大量形近、义近字词,因此分词算法的选择直接影响模型效果。在大模型中,常见的子词词元化方法有BPE(Byte-Pair Encoding)、WordPiece和Unigram。下面我们将分别详细介绍这三种方法的基础原理、核心概念,并给出详细示例。最后,我们将提供一个综合的流程图来展示这些分词方法的典型流程。

核心概念释义:

  • 原子单元:初始切分的最小单元(中文通常为单字,如 "我""爱""中""国")。
  • 合并规则:算法迭代合并高频共现单元的规则(核心差异点)。
  • 词汇表(Vocab):最终生成的 Token 集合,包含原子单元 + 合并后的复合单元。
  • 频率/概率:算法决策合并或保留 Token 的核心依据(BPE/WordPiece 侧重频率,Unigram 侧重概率)。

二、BPE 分词

1. 基础原理

BPE最初是一种数据压缩技术,后来被应用于自然语言处理中的分词。其核心思想是从最小的词元(如字符)开始,逐步合并出现频率最高的连续词元对,直到达到预定的词表大小或不再有可以合并的连续对。

2. 核心概念

  • 词表:由基础字符和合并得到的子词组成。
  • 合并规则:每次合并出现频率最高的连续字节对(或词元对)。
  • 停止条件:达到预定的词表大小或没有更多的连续对可以合并。

3. 处理逻辑

  • 初始化:将文本拆分为原子单元(中文单字),统计每个原子单元的频率;
  • 迭代合并:每次找出出现频率最高的相邻字符对,合并为新 Token;
  • 终止条件:达到预设词汇表大小,或无高频对可合并;
  • 分词:用最终词汇表对文本进行最长匹配切分。

4. 详细示例

4.1 语料准备

原始语料:["我爱中国", "中国很强大", "我爱北京", "北京是中国首都"]

4.2 处理过程

4.2.1 步骤 1:预处理与原子单元统计

拆分原子单元(单字 + 结束符</w>,区分词边界):

  • 我</w> 爱</w> 中</w> 国</w> → 我爱中国</w>
  • 中</w> 国</w> 很</w> 强</w> 大</w> → 中国很强大</w>
  • 我</w> 爱</w> 北</w> 京</w> → 我爱北京</w>
  • 北</w> 京</w> 是</w> 中</w> 国</w> 首</w> 都</w> → 北京是中国首都</w>

统计单字频率:

字符 频率 字符 频率

我</w> 2 北</w> 2

爱</w> 2 京</w> 2

中</w> 3 很</w> 1

国</w> 3 强</w> 1

是</w> 1 大</w> 1

首</w> 1 都</w> 1

4.2.2 步骤 2:迭代合并高频对

第 1 次合并:统计所有相邻字符对频率,中</w>国</w> 出现 3 次(最高),合并为中国</w>,更新语料拆分:

  • 我</w> 爱</w> 中国</w>
  • 中国</w> 很</w> 强</w> 大</w>
  • 我</w> 爱</w> 北</w> 京</w>
  • 北</w> 京</w> 是</w> 中国</w> 首</w> 都</w>

第 2 次合并:北</w>京</w> 出现 2 次(最高),合并为北京</w>,更新语料拆分:

  • 我</w> 爱</w> 中国</w>
  • 中国</w> 很</w> 强</w> 大</w>
  • 我</w> 爱</w> 北京</w>
  • 北京</w> 是</w> 中国</w> 首</w> 都</w>

第 3 次合并:我</w>爱</w> 出现 2 次(最高),合并为我爱</w>,更新语料拆分:

  • 我爱</w> 中国</w>
  • 中国</w> 很</w> 强</w> 大</w>
  • 我爱</w> 北京</w>
  • 北京</w> 是</w> 中国</w> 首</w> 都</w>

4.2.3 步骤 3:终止与词汇表

若预设词汇表大小为 10,最终 Vocab 包含:我</w>、爱</w>、中</w>、国</w>、北</w>、京</w>、我爱</w>、中国</w>、北京</w>、很</w>

4.2.4 步骤 4:分词示例

对新文本我爱北京分词:最长匹配→我爱</w> 北京</w>。

5. BPE流程总结

流程步骤说明:

    1. 输入中文语料
    1. 预处理:拆分为单字并加上结束符(例如,每个单词后加</w>)
    1. 统计单字频率
    1. 统计所有相邻字符对频率
    1. 判断是否达到预设的词表大小(Vocab大小)
    1. 如果未达到,合并频率最高的字符对,形成新的Token
    1. 更新语料拆分与频率统计(用新Token替换原有字符对)
    1. 回到步骤4(统计相邻字符对频率)直到达到词表大小
    1. 如果达到词表大小,生成最终词表(Vocab)
    1. 使用最终词表对新文本进行最长匹配分词

6. 代码示例

python 复制代码
import re
from collections import defaultdict, Counter

class BPEChineseTokenizer:
    def __init__(self, vocab_size=10):
        self.vocab_size = vocab_size  # 目标词汇表大小
        self.vocab = {}  # 最终词汇表
        self.merge_rules = {}  # 合并规则((a,b) → ab)
    
    def preprocess(self, corpus):
        """预处理:拆分为单字+结束符,统一格式"""
        processed = []
        for sentence in corpus:
            # 中文单字拆分,每个字后加</w>,词之间用空格分隔(这里按句子拆分)
            tokens = [char + '</w>' for char in sentence]
            processed.append(' '.join(tokens))
        return processed
    
    def get_pair_freq(self, corpus):
        """统计相邻字符对的频率"""
        pair_freq = defaultdict(int)
        for sentence in corpus:
            tokens = sentence.split()
            for i in range(len(tokens)-1):
                pair = (tokens[i], tokens[i+1])
                pair_freq[pair] += 1
        return pair_freq
    
    def merge_pair(self, corpus, pair, new_token):
        """合并语料中的指定字符对"""
        merged_corpus = []
        pattern = re.escape(f' {pair[0]} {pair[1]} ')
        replacement = f' {new_token} '
        for sentence in corpus:
            # 替换所有匹配的字符对
            merged_sentence = re.sub(pattern, replacement, f' {sentence} ').strip()
            merged_corpus.append(merged_sentence)
        return merged_corpus
    
    def train(self, corpus):
        """训练BPE分词器"""
        # 预处理语料
        processed_corpus = self.preprocess(corpus)
        # 初始化词汇表:所有单字
        all_tokens = []
        for sentence in processed_corpus:
            all_tokens.extend(sentence.split())
        initial_vocab = list(set(all_tokens))
        self.vocab = {token: idx for idx, token in enumerate(initial_vocab)}
        
        # 迭代合并直到达到词汇表大小
        while len(self.vocab) < self.vocab_size:
            # 统计字符对频率
            pair_freq = self.get_pair_freq(processed_corpus)
            if not pair_freq:
                break  # 无可用合并对
            # 找频率最高的对
            best_pair = max(pair_freq, key=pair_freq.get)
            # 生成新Token
            new_token = ''.join(best_pair).replace('</w>', '') + '</w>'
            # 记录合并规则
            self.merge_rules[best_pair] = new_token
            # 合并语料中的该对
            processed_corpus = self.merge_pair(processed_corpus, best_pair, new_token)
            # 更新词汇表
            if new_token not in self.vocab:
                self.vocab[new_token] = len(self.vocab)
        
        print("BPE训练完成!")
        print("合并规则:", self.merge_rules)
        print("最终词汇表:", self.vocab)
    
    def tokenize(self, text):
        """对新文本分词"""
        # 预处理文本为单字
        tokens = [char + '</w>' for char in text]
        # 应用合并规则(从长到短匹配)
        # 先将合并规则按新Token长度降序排序
        sorted_merges = sorted(self.merge_rules.items(), 
                              key=lambda x: len(x[1]), reverse=True)
        # 迭代合并
        while True:
            merged = False
            for (pair, new_token) in sorted_merges:
                if pair[0] in tokens and pair[1] in tokens:
                    # 找到相邻的pair
                    idx = tokens.index(pair[0])
                    if idx + 1 < len(tokens) and tokens[idx+1] == pair[1]:
                        # 合并
                        tokens = tokens[:idx] + [new_token] + tokens[idx+2:]
                        merged = True
                        break
            if not merged:
                break
        # 转换为词汇表ID
        token_ids = [self.vocab.get(token, -1) for token in tokens]
        return tokens, token_ids

# 测试代码
if __name__ == "__main__":
    # 中文语料
    corpus = ["我爱中国", "中国很强大", "我爱北京", "北京是中国首都"]
    # 初始化并训练BPE分词器
    bpe_tokenizer = BPEChineseTokenizer(vocab_size=10)
    bpe_tokenizer.train(corpus)
    # 分词测试
    test_text = "我爱北京"
    tokens, token_ids = bpe_tokenizer.tokenize(test_text)
    print(f"\n测试文本:{test_text}")
    print(f"分词结果:{tokens}")
    print(f"Token ID:{token_ids}")

输出结果:

BPE训练完成!

合并规则: {}

最终词汇表: {'是</w>': 0, '北</w>': 1, '中</w>': 2, '我</w>': 3, '爱</w>': 4, '大</w>': 5, '强</w>': 6, '很</w>':
7, '京</w>': 8, '首</w>': 9, '国</w>': 10, '都</w>': 11}

测试文本:我爱北京

分词结果:['我</w>', '爱</w>', '北</w>', '京</w>']

Token ID:[3, 4, 1, 8]

注意:

输出的结果,分词结果出现的还是一个个字,没有组成词,由于我们在代码中设置的词汇表长度为10(vocab_size=10),还没有经过多次合并时,词汇表已经满了

下面我们加大词汇表的容量倒15,再看看输出结果;

BPE训练完成!

合并规则: {('中</w>', '国</w>'): '中国</w>', ('我</w>', '爱</w>'): '我爱</w>', ('北</w>', '京</w>'): '北京</w>'}
最终词汇表: {'北</w>': 0, '是</w>': 1, '京</w>': 2, '很</w>': 3, '都</w>': 4, '强</w>': 5, '中</w>': 6, '大</w>':
7, '首</w>': 8, '国</w>': 9, '爱</w>': 10, '我</w>': 11, '中国</w>': 12, '我爱</w>': 13, '北京</w>': 14}

测试文本:我爱北京
分词结果:['我爱</w>', '北京</w>']

Token ID:[13, 14]

发现此时的分词结果是词组的形式了。

三、WordPiece 分词

1. 基础原理

WordPiece与BPE类似,也是从字符开始,迭代合并子词。但合并的标准不是频率,而是合并后对语言模型似然的提升,即合并后的 Token 能最大程度提升整体语料的概率。具体来说,每次选择合并后能最大程度增加语言模型似然的词元对。

2. 核心概念

  • 合并标准:选择使语言模型似然增加最大的对。
  • 语言模型:通常是一个基于词元的n-gram模型。

3. 处理逻辑

    1. 初始化:和BPE一样,拆分为原子单元,统计 Token 频率;
    1. 迭代合并:对每个候选字符对(a,b),计算合并为ab的对数似然增益:
    • gain = log(P(ab)/(P(a)×P(b))) = logP(ab) − logP(a) − logP(b)
    • 其中P(x)为 Tokenx的频率/总 Token 数,先有印象,后面通过示例强化理解
    1. 终止条件:达到 Vocab 大小,或增益≤0;
    1. 分词:最长匹配,这一步也同BPE一样

4. 详细示例

4.1 语料准备

沿用 BPE 的语料:["我爱中国", "中国很强大", "我爱北京", "北京是中国首都"]

4.2 处理过程

4.2.1 步骤 1:初始化频率

总 Token 数 = 2+2+3+3+2+2+1+1+1+1+1+1= 20

  • P(我</w>) = 2/20 = 0.1,P(爱</w>) = 2/20 = 0.1,P(中</w>) = 3/20 = 0.15,P(国</w>) = 3/20 = 0.15
  • P(北</w>) = 2/20 = 0.1,P(京</w>) = 2/20 = 0.1

4.2.2 步骤 2:计算候选对增益

  • 候选对"中</w>国</w>":
    • 合并前:P(中</w>) = 0.15,P(国</w>) = 0.15,共现频率 = 3 → P(中</w>国</w>) = 3/20 = 0.15
    • 增益 = log(0.15)−log(0.15)−log(0.15) = −log(0.15) ≈ 0.81(最大)
  • 候选对"北</w>京</w>":
    • 增益 = log(2/20)−log(2/20)−log(2/20) = −log(0.1) ≈ 1.0

(注:此处频率为 2,总 Token 数合并后变为 18,实际计算需调整,核心是增益最高优先合并)

4.2.3 步骤 3:迭代合并

  • 优先合并增益最高的北</w>京</w>→北京</w>,再合并中</w>国</w>→中国</w>,最终 Vocab 与 BPE 类似,但合并顺序可能因增益计算不同而调整。

5. WordPiece流程总结

流程步骤说明:

    1. 输入中文语料。
    1. 预处理:将文本拆分为单字,并在每个词后面加上结束符(或按WordPiece的方式,将文本拆分为单个字符,并在词尾添加特殊符号)。
    1. 统计每个Token(初始为单字)的频率,并计算初始概率(通常为频率除以总Token数)。
    1. 生成所有相邻的字符对(即Token对)。
    1. 计算每个候选字符对的对数似然增益(公式为: log(P(ab)/(P(a)×P(b))) = logP(ab) − logP(a) − logP(b) ),选择最大的)。
    1. 检查是否还有增益大于0的字符对,并且词表大小是否达到预设值。如果都没有达到,则继续合并。
    1. 合并增益最高的字符对,形成新的Token。
    1. 更新Token的频率和概率。
    1. 重复步骤4-8,直到达到词表大小或者没有增益大于0的字符对。
    1. 生成最终的词表。
    1. 对新文本进行分词(使用最长匹配策略)。

注意:在WordPiece中,通常使用一个语言模型来评估合并后的似然变化,但这里我们使用对数似然增益的公式作为合并标准。

6. 代码示例

python 复制代码
import re
import math
from collections import defaultdict, Counter

class WordPieceChineseTokenizer:
    def __init__(self, vocab_size=10):
        self.vocab_size = vocab_size
        self.vocab = {}
        self.merge_rules = {}
        self.token_freq = defaultdict(int)  # Token频率
    
    def preprocess(self, corpus):
        """预处理:拆分为单字+结束符"""
        processed = []
        total_tokens = 0
        for sentence in corpus:
            tokens = [char + '</w>' for char in sentence]
            processed.append(' '.join(tokens))
            # 统计初始Token频率
            for token in tokens:
                self.token_freq[token] += 1
            total_tokens += len(tokens)
        self.total_tokens = total_tokens  # 总Token数
        return processed
    
    def calculate_gain(self, a, b, corpus):
        """计算合并(a,b)的对数似然增益"""
        # 统计a、b、ab的频率
        a_freq = self.token_freq.get(a, 0)
        b_freq = self.token_freq.get(b, 0)
        # 统计ab的共现频率
        ab_freq = 0
        for sentence in corpus:
            tokens = sentence.split()
            for i in range(len(tokens)-1):
                if tokens[i] == a and tokens[i+1] == b:
                    ab_freq += 1
        if a_freq == 0 or b_freq == 0 or ab_freq == 0:
            return -float('inf')  # 无增益
        
        # 计算概率
        p_a = a_freq / self.total_tokens
        p_b = b_freq / self.total_tokens
        p_ab = ab_freq / (self.total_tokens - ab_freq)  # 合并后总Token数减少ab_freq
        
        # 对数似然增益
        gain = math.log(p_ab) - math.log(p_a) - math.log(p_b)
        return gain
    
    def merge_pair(self, corpus, pair, new_token):
        """合并语料中的指定字符对"""
        merged_corpus = []
        pattern = re.escape(f' {pair[0]} {pair[1]} ')
        replacement = f' {new_token} '
        for sentence in corpus:
            merged_sentence = re.sub(pattern, replacement, f' {sentence} ').strip()
            merged_corpus.append(merged_sentence)
        # 更新Token频率
        a, b = pair
        ab_freq = self.token_freq.get(a, 0) + self.token_freq.get(b, 0) - (self.token_freq.get(new_token, 0))
        self.token_freq[new_token] = ab_freq
        del self.token_freq[a]
        del self.token_freq[b]
        self.total_tokens -= ab_freq  # 总Token数减少
        return merged_corpus
    
    def train(self, corpus):
        """训练WordPiece分词器"""
        processed_corpus = self.preprocess(corpus)
        # 初始化词汇表
        initial_vocab = list(self.token_freq.keys())
        self.vocab = {token: idx for idx, token in enumerate(initial_vocab)}
        
        while len(self.vocab) < self.vocab_size:
            # 生成所有候选字符对
            candidate_pairs = set()
            for sentence in processed_corpus:
                tokens = sentence.split()
                for i in range(len(tokens)-1):
                    candidate_pairs.add((tokens[i], tokens[i+1]))
            if not candidate_pairs:
                break
            
            # 计算每个候选对的增益
            gains = {}
            for pair in candidate_pairs:
                gain = self.calculate_gain(pair[0], pair[1], processed_corpus)
                gains[pair] = gain
            
            # 找增益最高的对
            best_pair = max(gains, key=gains.get)
            best_gain = gains[best_pair]
            if best_gain <= 0:
                break  # 增益≤0,停止合并
            
            # 生成新Token
            new_token = ''.join(best_pair).replace('</w>', '') + '</w>'
            self.merge_rules[best_pair] = new_token
            # 合并语料
            processed_corpus = self.merge_pair(processed_corpus, best_pair, new_token)
            # 更新词汇表
            if new_token not in self.vocab:
                self.vocab[new_token] = len(self.vocab)
        
        print("WordPiece训练完成!")
        print("合并规则:", self.merge_rules)
        print("最终词汇表:", self.vocab)
    
    def tokenize(self, text):
        """分词(最长匹配)"""
        tokens = [char + '</w>' for char in text]
        # 按新Token长度降序应用合并规则
        sorted_merges = sorted(self.merge_rules.items(), 
                              key=lambda x: len(x[1]), reverse=True)
        while True:
            merged = False
            for (pair, new_token) in sorted_merges:
                if pair[0] in tokens and pair[1] in tokens:
                    idx = tokens.index(pair[0])
                    if idx + 1 < len(tokens) and tokens[idx+1] == pair[1]:
                        tokens = tokens[:idx] + [new_token] + tokens[idx+2:]
                        merged = True
                        break
            if not merged:
                break
        token_ids = [self.vocab.get(token, -1) for token in tokens]
        return tokens, token_ids

# 测试代码
if __name__ == "__main__":
    corpus = ["我爱中国", "中国很强大", "我爱北京", "北京是中国首都"]
    wp_tokenizer = WordPieceChineseTokenizer(vocab_size=10)
    wp_tokenizer.train(corpus)
    # 分词测试
    test_text = "中国很强大"
    tokens, token_ids = wp_tokenizer.tokenize(test_text)
    print(f"\n测试文本:{test_text}")
    print(f"分词结果:{tokens}")
    print(f"Token ID:{token_ids}")

输出结果:

WordPiece训练完成!

合并规则: {}

最终词汇表: {'我</w>': 0, '爱</w>': 1, '中</w>': 2, '国</w>': 3, '很</w>': 4, '强</w>': 5, '大</w>': 6, '北</w>':

7, '京</w>': 8, '是</w>': 9, '首</w>': 10, '都</w>': 11}

测试文本:中国很强大

分词结果:['中</w>', '国</w>', '很</w>', '强</w>', '大</w>']

Token ID:[2, 3, 4, 5, 6]

四、Unigram 分词

1. 基础原理

Unigram分词与BPE和WordPiece相反,它从一个大的种子词表开始,然后逐步删除词元,直到达到目标词表大小。它基于一个假设:所有词元的出现是独立的,并且通过最大化句子的似然来优化词表。

2. 核心概念

  • 初始大词表:通常由频繁出现的子串组成。
  • 似然最大化:通过EM算法优化词元概率。
  • 词表剪枝:删除概率最低的词元。

3. 处理逻辑

    1. 初始化:生成大量候选 Token(单字、双字、三字...),构建初始大 Vocab;
    1. 训练 Unigram LM:计算每个 Token 的概率(频率 / 总次数);
    1. 迭代删除:计算删除每个 Token 后的困惑度,删除困惑度上升最小的 Token;
    1. 终止条件:达到目标 Vocab 大小;
    1. 分词:对文本生成所有可能的 Token 切分方式,选择概率最高的组合。

4. 详细示例

4.1 语料准备

沿用 BPE 的语料:["我爱中国", "中国很强大", "我爱北京", "北京是中国首都"]

4.2 处理过程

4.2.1 步骤 1:初始化候选 Vocab

基于语料["我爱中国", "中国很强大", "我爱北京", "北京是中国首都"],生成候选 Token:

  • 单字:我、爱、中、国、北、京、很、强、大、是、首、都
  • 双字:我爱、中国、北京、国中、京是、是中...(所有连续双字)
  • 三字:我爱中、爱中国、中国很...(所有连续三字)

4.2.2 步骤 2:训练 Unigram LM

统计所有候选 Token 的频率,计算概率:

  • P(中国) = 3/总次数,P(北京) = 2/总次数,P(我爱) = 3/总次数,P(我) = 2/总次数,P(爱) = 2/总次数...

4.2.3 步骤 3:迭代删除低价值 Token

  • 计算删除"国中"后的困惑度:因"国中"未在语料中出现,删除后困惑度无变化,优先删除;
  • 计算删除"京是"后的困惑度:同理删除;
  • 逐步删除低概率 Token,直到 Vocab 大小达标。

4.2.4 步骤 4:分词示例

对"我爱北京",所有可能切分:

  • 切分 1:我 爱 北 京 → P=0.1×0.1×0.1×0.1=0.0001
  • 切分 2:我爱 北 京 → P=0.1×0.1×0.1=0.001
  • 切分 3:我 爱 北京 → P=0.1×0.1×0.1=0.001
  • 切分 4:我爱 北京 → P=0.1×0.1=0.01(概率最高,选为最终分词结果)

5. Unigram流程总结

流程步骤说明:

    1. 输入语料
    1. 生成候选Token(例如所有单字、双字、多字组合,或者通过其他方式生成一个大词表)
    1. 构建初始大词表
    1. 训练Unigram语言模型(即计算每个词元的概率)
    1. 判断词表大小是否达到目标,如果未达到,则继续删除词元
    1. 计算删除每个词元后的困惑度(或损失函数,通常是似然的变化)
    1. 删除困惑度上升最小的词元(即对模型影响最小的词元)
    1. 更新词表,并重新计算每个词元的概率(重新训练语言模型)
    1. 重复步骤5-8直到词表大小达标
    1. 生成最终词表
    1. 对新文本,生成所有可能的切分(可以使用动态规划,如Viterbi算法)
    1. 选择概率最高的切分方式(即所有词元概率乘积最大的切分)

注意:在每一步删除词元时,我们需要重新计算每个词元的概率,因为总概率分布发生了变化。

6. 代码示例

python 复制代码
import math
from collections import defaultdict, Counter
from itertools import combinations

class UnigramChineseTokenizer:
    def __init__(self, vocab_size=10):
        self.vocab_size = vocab_size
        self.vocab = {}
        self.token_prob = defaultdict(float)  # Token概率
    
    def generate_candidates(self, corpus, max_len=3):
        """生成候选Token(单字、双字、三字)"""
        candidates = set()
        for sentence in corpus:
            # 生成所有长度≤max_len的连续子串
            for i in range(len(sentence)):
                for j in range(1, min(max_len+1, len(sentence)-i+1)):
                    token = sentence[i:i+j]
                    candidates.add(token)
        return list(candidates)
    
    def calculate_token_freq(self, corpus, candidates):
        """统计候选Token的频率"""
        freq = defaultdict(int)
        total = 0
        for sentence in corpus:
            # 统计每个候选Token的出现次数
            for token in candidates:
                token_len = len(token)
                for i in range(len(sentence)-token_len+1):
                    if sentence[i:i+token_len] == token:
                        freq[token] += 1
                        total += 1
        return freq, total
    
    def calculate_perplexity(self, corpus, vocab, token_prob):
        """计算语料的困惑度"""
        total_log_prob = 0
        total_tokens = 0
        for sentence in corpus:
            # 找到最优切分(概率最高)
            best_log_prob = -float('inf')
            # 简单实现:最长匹配找切分
            tokens = self._longest_match(sentence, vocab)
            # 计算该切分的对数概率
            log_prob = sum([math.log(token_prob.get(t, 1e-10)) for t in tokens])
            total_log_prob += log_prob
            total_tokens += len(tokens)
        # 困惑度 = exp(-平均对数概率)
        avg_log_prob = total_log_prob / total_tokens
        perplexity = math.exp(-avg_log_prob)
        return perplexity
    
    def _longest_match(self, text, vocab):
        """最长匹配切分(用于简化困惑度计算)"""
        tokens = []
        i = 0
        vocab_sorted = sorted(vocab, key=len, reverse=True)  # 按长度降序
        while i < len(text):
            matched = False
            for token in vocab_sorted:
                token_len = len(token)
                if i + token_len <= len(text) and text[i:i+token_len] == token:
                    tokens.append(token)
                    i += token_len
                    matched = True
                    break
            if not matched:
                # 匹配失败,取单字
                tokens.append(text[i])
                i += 1
        return tokens
    
    def train(self, corpus):
        """训练Unigram分词器"""
        # 步骤1:生成候选Token
        candidates = self.generate_candidates(corpus, max_len=3)
        # 步骤2:统计频率,初始化概率
        freq, total = self.calculate_token_freq(corpus, candidates)
        # 初始化Vocab(过滤掉频率为0的Token)
        initial_vocab = [t for t in candidates if freq[t] > 0]
        current_vocab = initial_vocab.copy()
        
        # 步骤3:迭代删除Token直到达到目标大小
        while len(current_vocab) > self.vocab_size:
            # 计算当前Token概率
            current_freq, current_total = self.calculate_token_freq(corpus, current_vocab)
            current_prob = {t: current_freq[t]/current_total for t in current_vocab}
            # 计算当前困惑度
            current_pp = self.calculate_perplexity(corpus, current_vocab, current_prob)
            
            # 计算删除每个Token后的困惑度
            pp_dict = {}
            for token in current_vocab:
                # 临时删除该Token
                temp_vocab = [t for t in current_vocab if t != token]
                if not temp_vocab:
                    pp_dict[token] = float('inf')
                    continue
                # 计算临时概率
                temp_freq, temp_total = self.calculate_token_freq(corpus, temp_vocab)
                temp_prob = {t: temp_freq[t]/temp_total for t in temp_vocab}
                # 计算临时困惑度
                temp_pp = self.calculate_perplexity(corpus, temp_vocab, temp_prob)
                pp_dict[token] = temp_pp
            
            # 找到困惑度上升最小的Token(即pp_dict最小的)
            best_token_to_remove = min(pp_dict, key=pp_dict.get)
            current_vocab.remove(best_token_to_remove)
        
        # 最终Vocab和概率
        final_freq, final_total = self.calculate_token_freq(corpus, current_vocab)
        self.token_prob = {t: final_freq[t]/final_total for t in current_vocab}
        self.vocab = {t: idx for idx, t in enumerate(current_vocab)}
        
        print("Unigram训练完成!")
        print("最终词汇表:", self.vocab)
        print("Token概率:", self.token_prob)
    
    def tokenize(self, text):
        """最优概率切分"""
        # 动态规划找最优切分
        n = len(text)
        # dp[i]:前i个字符的最大对数概率
        dp = [-float('inf')] * (n+1)
        dp[0] = 0.0
        # prev[i]:前i个字符的最优切分位置
        prev = [0] * (n+1)
        
        for i in range(1, n+1):
            for j in range(max(0, i-3), i):  # 最多匹配3字
                token = text[j:i]
                if token in self.token_prob:
                    log_prob = math.log(self.token_prob[token])
                    if dp[j] + log_prob > dp[i]:
                        dp[i] = dp[j] + log_prob
                        prev[i] = j
        
        # 回溯找切分结果
        tokens = []
        i = n
        while i > 0:
            j = prev[i]
            tokens.append(text[j:i])
            i = j
        tokens = tokens[::-1]
        token_ids = [self.vocab.get(t, -1) for t in tokens]
        return tokens, token_ids

# 测试代码
if __name__ == "__main__":
    corpus = ["我爱中国", "中国很强大", "我爱北京", "北京是中国首都"]
    unigram_tokenizer = UnigramChineseTokenizer(vocab_size=10)
    unigram_tokenizer.train(corpus)
    # 分词测试
    test_text = "北京是中国首都"
    tokens, token_ids = unigram_tokenizer.tokenize(test_text)
    print(f"\n测试文本:{test_text}")
    print(f"分词结果:{tokens}")
    print(f"Token ID:{token_ids}")

输出结果:

Unigram训练完成!

最终词汇表: {'北京是': 0, '首都': 1, '京': 2, '我爱': 3, '北': 4, '很强大': 5, '爱北': 6, '国首': 7, '中国': 8, '

很': 9}

Token概率: {'北京是': 0.06666666666666667, '首都': 0.06666666666666667, '京': 0.13333333333333333, '我爱': 0.13333333333333333, '北': 0.13333333333333333, '很强大': 0.06666666666666667, '爱北': 0.06666666666666667, '国首': 0.06666666666666667, '中国': 0.2, '很': 0.06666666666666667}

测试文本:北京是中国首都

分词结果:['北京是', '中国', '首都']

Token ID:[0, 8, 1]

五、结果分析

针对分词结果出现 "北京是" 这类非语义化复合 Token、且未合理切分为 "北京 / 是 / 中国 / 首都" 的问题,结合 Unigram/BPE/WordPiece 三类分词算法的特性,从算法逻辑、训练数据、参数配置、中文适配四个维度拆解原因,并给出可落地的解决办法。

1. 异常原因

1.1 算法层面

1.1.1 Unigram 算法:候选 Token 生成与概率计算问题

  • 候选 Token 生成阶段:若训练时设置的max_len=3(生成最多 3 字候选),语料中 "北京是" 仅出现 1 次却被纳入候选,且因其他低概率 Token 被优先删除,"北京是" 未被过滤;
  • 概率计算偏差:语料规模过小(仅 4 句),"北京是" 的频率被高估,导致动态规划切分时选择 "北京是" 而非 "北京 + 是";
  • 困惑度计算简化:代码中用 "最长匹配" 替代 "全概率切分",无法精准评估 "北京 + 是" 的组合概率高于 "北京是"。

1.1.2 BPE/WordPiece 算法:合并规则优先级错误

  • 若改用 BPE/WordPiece,出现 "北京是" 的原因是:
  • 相邻字符对统计时,"北 / 京""京 / 是" 的共现频率被错误累加,导致 "北京是" 被优先合并;
  • WordPiece 的对数似然增益计算时,因总 Token 数过少,"北京是" 的增益被误算为正数,触发不必要的合并。

1.2 训练数据层面

  • 语料量极小:仅 4 句训练语料,无法反映中文真实的词频分布(如 "北京" 作为独立地名的高频性、"是" 作为单独虚词的高频性);
  • 语料无标注:未区分 "语义词边界"(如 "北京" 是专有名词,"是" 是谓语动词),算法无法学习到自然的分词逻辑;
  • 无噪声数据:缺乏多样化语料(如 "北京是古都""北京是一线城市"),无法稀释 "北京是" 的偶然共现频率。

1.3 参数配置层面

  • Vocab_size 设置过小:目标词汇表仅 10 个 Token,算法为凑够数量,被迫合并 "北京是" 这类低价值 Token;
  • 候选 Token 长度设置不当:Unigram 的max_len=3过宽,BPE/WordPiece 未限制最大合并长度,导致跨语义单元合并;
  • 终止条件宽松:WordPiece 未严格校验 "增益> 0",BPE 未过滤 "低频单次合并对"。

1.4 中文适配层面

  • 未区分 "语义单元边界":中文专有名词(北京、中国)、虚词(是)、普通名词(首都)需独立成 Token,但算法仅按字符共现合并,忽略语义;
  • 无中文停用词 / 功能词处理:"是" 作为高频功能词,应优先保留为独立 Token,而非与前后字合并;
  • 未引入中文词表辅助:未结合《现代汉语常用词表》过滤不合理的合并结果(如 "北京是" 不在常用词表中)。

2. 优化方案

  • 扩充语料:新增至少 100 + 句包含 "北京""是""中国""首都" 的多样化语料,例如:
  • 引入中文词表:结合《现代汉语常用词表》(如包含 "北京、中国、首都、是" 等基础词),强制将这些词加入初始 Vocab,禁止合并;
  • 人工标注词边界:对训练语料标注词边界(如 "北京 / 是 / 中国 / 首都"),让算法学习正确的切分逻辑。

六、总结

词元化是大模型理解文本的基础预处理步骤,核心是将中文文本切分为有语义的最小单元(Token)。我们需重点掌握三大核心算法:BPE、WordPiece、Unigram,其核心逻辑可概括为 "合并" 与 "筛选" 两类思路。

BPE 和 WordPiece 是 "自底向上合并":从单字开始,BPE 合并高频字符对,WordPiece 则优先合并能提升文本似然性的组合,二者适合处理中文常用词,实现简单且效果稳定。Unigram 是 "自顶向下筛选",先生成大量候选 Token,再逐步删除低价值 Token,切分时选择概率最高的组合,灵活性更强。

中文分词需注意:以单字为初始单元,优先保留 "北京""中国" 等核心词,限制合并长度,建议双字为主,避免出现"北京是" 这类无效组合。

相关推荐
minhuan1 天前
大模型应用:大模型性能评估指标:CLUE任务与数据集详解.10
大模型应用·大模型性能评估·clue基准
网安-搬运工2 天前
万字长文!AI智能体全面爆发前夜:一文讲透技术架构与行业机会_智能体技术架构
人工智能·自然语言处理·llm·agent·ai大模型·智能体·大模型应用
AI-智能4 天前
RAG 系统架构设计模式介绍
人工智能·langchain·llm·agent·知识库·rag·大模型应用
龙亘川8 天前
AI 重构智慧旅游:技术落地、场景实践与行业新机遇
大模型应用·ai 智慧旅游·旅游数字化转型·文旅 ai 技术
minhuan9 天前
大模型应用:大模型本地部署实战:从零构建可视化智能学习助手.2
学习·生成式ai·大模型应用·大模型本地部署·学习助手
minhuan10 天前
大模型应用:基于本地大模型的中文命名实体识别技术实践与应用
命名实体识别·大模型应用·大模型本地部署·医学命名实体
蜂蜜黄油呀土豆10 天前
深入理解 Agent 相关协议:从单体 Agent 到 Multi-Agent、MCP、A2A 与 Agentic AI 的系统化实践
人工智能·ai agent·大模型应用·agentic ai
minhuan14 天前
构建AI智能体:九十六、基于YOLO的智能生活助手:食材识别、植物健康与宠物行为分析
yolo·计算机视觉·视觉大模型·大模型应用
Geo_V16 天前
LangChain Memory 使用示例
人工智能·python·chatgpt·langchain·openai·大模型应用·llm 开发