3、文本预处理基础:分词(Tokenizer)、构建词表(Vocabulary)、将文本转为ID序列

说实话,很多同学一上来就扎进Transformer的注意力机制里,结果跑代码时连数据都喂不进去。为什么?因为模型不认识「你好世界」,它只认数字。文本预处理,就是搭起人类语言和机器数字之间的那座桥。

今天咱们就把这座桥拆开揉碎了讲。我会带你手写一个最基础的分词器、构建词表、再把文本转成ID序列。嗯,这部分虽然基础,但坑不少。我在项目中遇到过因为词表没处理好,模型训了三天结果全是乱码的惨案……

3.1 分词(Tokenizer)——把句子切成小块

分词说白了,就是把一句话拆成模型能理解的最小单元。你想想看,模型又不是人,它没法理解「今天天气真好」这个整体含义。它需要先认识「今天」、「天气」、「真好」这些碎片。

常见的分词粒度有三种:

分词粒度 例子("我爱自然语言处理") 优缺点
字级(Character) 我、爱、自、然、语、言、处、理 词表小,但序列长,语义弱
词级(Word) 我、爱、自然语言处理 语义强,但词表大,OOV问题严重
子词(Subword) 我、爱、自然、语言、处理 平衡方案,BPE/WordPiece为代表

我个人习惯在入门阶段先用字级分词。为什么?简单、无脑、不出错。等你把整个流程跑通了,再换成BPE也不迟。

来,直接看代码:

def tokenize_chinese(text):
    """
    最简单的字级分词器
    把中文句子拆成单个字,英文单词按空格切分
    """
    import re
    
    # 先按空格切分,保留英文单词
    tokens = []
    for token in re.findall(r'[\w]+|[^\w\s]', text):
        # 如果是中文字符,拆成单个字
        if re.match(r'[\u4e00-\u9fff]', token):
            tokens.extend(list(token))
        else:
            tokens.append(token)
    return tokens

# 试试看
text = "我爱Transformer!"
print(tokenize_chinese(text))
# 输出:['我', '爱', 'Transformer', '!']

这里有个小细节——我特意保留了标点符号。为什么?因为在实际任务中,标点往往携带情感或语气信息。比如「好。」和「好!」完全是两个意思。

我的经验:如果你做情感分析,标点符号一定要保留。如果是做机器翻译,可以酌情去掉。没有绝对的对错,看任务需求。

3.2 构建词表(Vocabulary)——给每个词发身份证

分词之后,我们得到了一堆字符串。但模型只认数字,所以得给每个词分配一个唯一的ID。这个过程就是构建词表。

词表里一般会包含几个特殊标记:

  • [PAD]:填充符,ID=0。让不同长度的序列对齐。
  • [UNK]:未知词,ID=1。遇到词表里没有的词就用它代替。
  • [CLS]:分类标记,ID=2。BERT里用的,咱们先留着。
  • [SEP]:分隔符,ID=3。区分两个句子。

我曾经犯过一个低级错误——忘了加[UNK]。结果模型在推理时遇到生僻字直接崩溃。嗯,从那以后我再也不敢省这一步了。

class Vocabulary:
    def __init__(self, max_size=10000):
        self.max_size = max_size
        # 特殊标记
        self.special_tokens = {
            '[PAD]': 0,
            '[UNK]': 1,
            '[CLS]': 2,
            '[SEP]': 3
        }
        self.token_to_id = dict(self.special_tokens)
        self.id_to_token = {v: k for k, v in self.special_tokens.items()}
        
    def build(self, texts):
        """从文本列表构建词表"""
        from collections import Counter
        
        # 统计词频
        counter = Counter()
        for text in texts:
            tokens = tokenize_chinese(text)
            counter.update(tokens)
        
        # 按词频排序,取前max_size个
        most_common = counter.most_common(self.max_size - len(self.special_tokens))
        
        # 分配ID
        for token, _ in most_common:
            idx = len(self.token_to_id)
            self.token_to_id[token] = idx
            self.id_to_token[idx] = token
            
    def __len__(self):
        return len(self.token_to_id)
    
    def encode(self, token):
        """将单个词转为ID,未知词返回[UNK]的ID"""
        return self.token_to_id.get(token, self.token_to_id['[UNK]'])
    
    def decode(self, idx):
        """将ID转回词"""
        return self.id_to_token.get(idx, '[UNK]')

# 构建词表
vocab = Vocabulary(max_size=5000)
corpus = ["我爱自然语言处理", "深度学习很有趣", "Transformer改变了NLP"]
vocab.build(corpus)

print(f"词表大小:{len(vocab)}")
print(f"'我'的ID:{vocab.encode('我')}")
print(f"ID 5对应的词:{vocab.decode(5)}")
核心要点:词表大小是个trade-off。太小了,[UNK]太多,信息丢失严重;太大了,模型参数爆炸,训练变慢。我一般先设5000-10000,看效果再调。

3.3 文本转ID序列——让模型看懂你的话

有了分词器和词表,最后一步就是把整段文本转成一串数字。这个过程其实很简单:先分词,再查表,把每个词换成ID。

但这里有个实际问题——句子长度不一样。模型训练时要求输入是固定长度的张量,所以我们需要做填充(Padding)或截断(Truncation)。

def text_to_ids(text, vocab, max_len=10):
    """
    将文本转为ID序列
    支持填充和截断
    """
    # 1. 分词
    tokens = tokenize_chinese(text)
    
    # 2. 转ID
    ids = [vocab.encode(token) for token in tokens]
    
    # 3. 截断:超长的部分直接砍掉
    if len(ids) > max_len:
        ids = ids[:max_len]
    
    # 4. 填充:短的在后面补[PAD]
    padding_len = max_len - len(ids)
    ids = ids + [vocab.encode('[PAD]')] * padding_len
    
    return ids

# 测试
text = "我爱深度学习"
ids = text_to_ids(text, vocab, max_len=8)
print(f"原始文本:{text}")
print(f"ID序列:{ids}")
print(f"解码回来:{[vocab.decode(idx) for idx in ids]}")
# 输出:['我', '爱', '深', '度', '学', '习', '[PAD]', '[PAD]']
避坑指南:填充位置有讲究。我见过有人把[PAD]放在序列前面,结果模型学到的全是填充符的模式,真实信息反而被淹没了。记住:填充符放后面,这是行业惯例。

3.4 完整流程串起来

好了,三个步骤都讲完了。咱们把它们串成一个完整的预处理流水线:

def preprocess_pipeline(texts, max_vocab_size=5000, max_len=10):
    """
    完整的文本预处理流水线
    输入:原始文本列表
    输出:ID序列矩阵、词表对象
    """
    # 1. 构建词表
    vocab = Vocabulary(max_size=max_vocab_size)
    vocab.build(texts)
    
    # 2. 所有文本转ID序列
    ids_matrix = []
    for text in texts:
        ids = text_to_ids(text, vocab, max_len=max_len)
        ids_matrix.append(ids)
    
    return ids_matrix, vocab

# 跑一遍完整流程
corpus = [
    "我爱自然语言处理",
    "深度学习改变了AI",
    "Transformer是里程碑"
]

ids_matrix, vocab = preprocess_pipeline(corpus, max_len=6)
print("ID序列矩阵:")
for i, ids in enumerate(ids_matrix):
    decoded = [vocab.decode(idx) for idx in ids]
    print(f"  文本{i+1}: {ids} -> {decoded}")

输出结果应该是这样的:

ID序列矩阵:
  文本1: [4, 5, 6, 7, 8, 9] -> ['我', '爱', '自', '然', '语', '言']
  文本2: [10, 11, 12, 13, 14, 0] -> ['深', '度', '学', '习', '改', '[PAD]']
  文本3: [15, 16, 17, 18, 19, 0] -> ['Transformer', '是', '里', '程', '碑', '[PAD]']

看到没?每个文本都变成了长度一致的ID序列。短的补了[PAD],长的被截断了。这就是模型能吃的「数字饲料」。

我的建议:在实际项目中,别自己手写分词器。直接用HuggingFace的transformers库,它提供了BERT、GPT等预训练模型配套的分词器。但今天手写一遍,能帮你理解底层原理——以后遇到bug,你知道该往哪个方向排查。

最后说一句:文本预处理看起来简单,但80%的模型训练问题都出在这一步。数据没洗干净,后面全是白费功夫。所以,别急着调模型,先把预处理搞扎实了。

下一章,咱们会把这些ID序列喂进Transformer的Embedding层。到时候你就知道,今天做的这些「苦力活」,到底有多重要了。