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的映射字典

构建词表的步骤其实很简单:

  1. 统计词频:遍历所有训练文本,统计每个词(或子词)出现的次数。
  2. 排序筛选:按词频从高到低排序,取前N个作为词表。
  3. 添加特殊标记:比如[PAD]、[UNK]、[CLS]、[SEP]等。
  4. 分配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序列

下一章,我们会讲数据加载和批处理,到时候这些预处理好的数据就要真正喂给模型了。

到时候见。