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)}")
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]']
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],长的被截断了。这就是模型能吃的「数字饲料」。
transformers库,它提供了BERT、GPT等预训练模型配套的分词器。但今天手写一遍,能帮你理解底层原理——以后遇到bug,你知道该往哪个方向排查。
最后说一句:文本预处理看起来简单,但80%的模型训练问题都出在这一步。数据没洗干净,后面全是白费功夫。所以,别急着调模型,先把预处理搞扎实了。
下一章,咱们会把这些ID序列喂进Transformer的Embedding层。到时候你就知道,今天做的这些「苦力活」,到底有多重要了。