3、数据组件(一):文本预处理、分词器原理、构建词表
说实话,做Transformer项目,很多人一上来就搭模型、调参数。
但我个人的经验是——数据组件才是真正的基石。
模型再牛,喂进去的是垃圾,吐出来的只能是更精致的垃圾。
这一章,咱们就把数据组件的第一个环节讲透:文本预处理、分词器原理、词表构建。
3.1 文本预处理:别小看这一步
原始文本长什么样?
可能是网页爬下来的,带一堆HTML标签。
可能是用户评论,夹杂着表情符号和错别字。
也可能是学术论文,有各种特殊符号。
你想想看,如果不处理,模型会学到什么?
它可能会把"<br>"当成一个词,把"😂"当成一个有效字符。
嗯,这显然不是我们想要的。
我一般会做这几步清理:
- 统一小写:英文文本全部转小写,减少词表冗余。"Hello"和"hello"不该是两个词。
- 去除HTML标签:用正则或者BeautifulSoup把标签剥离。
- 处理特殊符号:保留句号、逗号、问号等基本标点,其余看情况过滤。
- 去除多余空白:多个空格合并成一个,首尾空格去掉。
- 编码统一:全部转成UTF-8,避免乱码问题。
核心原则:预处理要"够用就行",别过度清洗。
我在项目中遇到过,有人把所有的数字都删掉了,结果模型在日期识别任务上直接崩了。
来看一段我常用的预处理代码:
import re
def clean_text(text):
# 统一小写
text = text.lower()
# 去除HTML标签
text = re.sub(r'<[^>]+>', '', text)
# 保留字母、数字、基本标点
text = re.sub(r'[^a-zA-Z0-9\s\.\,\!\?\']', '', text)
# 合并多余空格
text = re.sub(r'\s+', ' ', text).strip()
return text
sample = "Hello!!! This is a <b>test</b> with extra spaces."
print(clean_text(sample))
# 输出: "hello!!! this is a test with extra spaces."
3.2 分词器原理:把文本切成"积木块"
文本预处理完了,接下来要做什么?
把文本变成模型能理解的数字。
但你不能直接把"hello"扔给模型,模型只认整数ID。
所以我们需要一个中间步骤——分词。
分词器的作用,说白了就是:把文本切成一个个小单元,然后给每个单元分配一个ID。
常见的分词方式有三种:
| 分词方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Word-based | 按空格和标点切词 | 简单直观 | 词表巨大,OOV问题严重 |
| Character-based | 按字符切分 | 词表小,无OOV | 序列太长,语义信息丢失 |
| Subword-based | 按子词单元切分 | 平衡词表和序列长度 | 实现稍复杂 |
我个人最推荐的是Subword-based分词。
为什么?
因为它能处理未登录词(OOV)。
比如"transformer"这个词,如果词表里没有,Word-based就崩了。
但Subword可以把它切成"transform"和"er",这两个子词大概率都在词表里。
小技巧:如果你在做中文NLP,记得先做分词(比如用jieba),再做Subword切分。
中文没有天然的空格分隔,直接做Subword效果往往不好。
3.3 构建词表:给每个词一个"身份证号"
分词器切好了,接下来就是构建词表。
词表,本质上就是一个词到ID的映射字典。
构建词表的步骤其实很简单:
- 统计词频:遍历所有训练文本,统计每个词(或子词)出现的次数。
- 排序筛选:按词频从高到低排序,取前N个作为词表。
- 添加特殊标记:比如[PAD]、[UNK]、[CLS]、[SEP]等。
- 分配ID:从0开始,给每个词分配一个唯一的整数ID。
这里有个坑,我曾经踩过——特殊标记的顺序。
我习惯把[PAD]放在ID=0的位置。
为什么?
因为很多框架默认padding的ID是0,省得你额外配置。
from collections import Counter
def build_vocab(texts, vocab_size=10000):
# 统计词频
counter = Counter()
for text in texts:
tokens = text.split() # 假设已经分词
counter.update(tokens)
# 取前vocab_size-4个词(留4个位置给特殊标记)
most_common = counter.most_common(vocab_size - 4)
# 构建词表
vocab = {
'[PAD]': 0,
'[UNK]': 1,
'[CLS]': 2,
'[SEP]': 3
}
for i, (word, _) in enumerate(most_common):
vocab[word] = i + 4
# 构建反向映射
idx2word = {idx: word for word, idx in vocab.items()}
return vocab, idx2word
# 示例
texts = ["hello world", "hello transformer", "world of nlp"]
vocab, idx2word = build_vocab(texts, vocab_size=10)
print(vocab)
# 输出: {'[PAD]': 0, '[UNK]': 1, '[CLS]': 2, '[SEP]': 3,
# 'hello': 4, 'world': 5, 'transformer': 6, 'of': 7, 'nlp': 8}
注意:词表大小不是越大越好。
词表太大,模型参数会暴增,训练速度变慢。
词表太小,[UNK]标记出现太频繁,信息丢失严重。
我一般建议:英文模型3万-5万,中文模型1万-2万(如果用了分词)。
3.4 实战中的几个建议
讲到这里,我觉得有必要分享几个实战经验:
- 预处理和分词要一起调试:有时候预处理过了头,分词效果反而变差。我习惯先跑一个小样本,肉眼检查分词结果。
- 词表要保存下来:训练完别忘了保存vocab.json或者pickle文件。推理的时候要用同一个词表,否则ID对不上。
- 考虑用现成的分词器:比如HuggingFace的tokenizers库,性能好,功能全。除非你在做研究,否则没必要自己造轮子。
- 处理中文时注意:中文分词本身就是一个研究方向。简单的任务可以用jieba,复杂的建议用BERT的WordPiece分词器。
嗯,这一章的内容就到这里。
数据组件的第一步,说白了就是把原始文本变成干净的、可索引的ID序列。
下一章,我们会讲数据加载和批处理,到时候这些预处理好的数据就要真正喂给模型了。
到时候见。