阶段一 · NLP 基础与文本表示

分词与文本预处理

一句话总结

分词(Tokenization)是 NLP 的第一步--把一段连续文本切成机器能处理的最小单元.
中文没有天然空格分隔,因此中文分词本身就是一个需要解决的问题.

为什么需要分词

回忆上一篇的 pipeline 调用流程:

"你脑子有病吧"

▼ Tokenizer(分词 + 编码)
[101, 872, 5765, 2094, 3300, 4567, 1416, 102]

▼ Model(神经网络计算)
{"label": "negative", "score": 0.66}

模型只认数字,不认文字. 所以必须有一个环节把文字变成数字序列. 这个环节就是 Tokenizer 做的事,核心包含两步:

  1. 分词(Tokenize):把连续文本切成 token 列表
  2. 编码(Encode):把每个 token 映射成词表中的数字 ID

分词器 = Go 中 http.Request 的解析器. 原始请求是一长串字节流,解析器把它拆成 Method, URL, Headers, Body 等结构化字段--分词器的角色等同于这个解析器,把原始文本拆成结构化的 token 序列.

英文分词 vs 中文分词

英文天然有空格分隔单词,分词几乎是"免费的":

"I love this group" → ["I", "love", "this", "group"]

但中文没有空格,所有字连在一起:

"你在这里带什么节奏" → ???
可能的切法:
["你", "在", "这里", "带", "什么", "节奏"] ✓ 正确
["你", "在", "这", "里带", "什么", "节奏"] ✗ "里带"不是词
["你", "在", "这里", "带什么", "节奏"] ✗ "带什么"语义碎裂

中文分词的歧义问题是经典 NLP 难题. 好消息是:现代预训练模型(BERT 系列)使用子词切分,基本绕过了传统分词问题--后面会详细解释.

三种分词粒度

根据切分粒度从粗到细,分词方法分三类:

粒度 示例 优点 缺点 代表工具
词级(Word) "机器学习" → ["机器学习"] 语义完整 词表巨大,OOV(未登录词)多 jieba,pkuseg
子词级(Subword) "机器学习" → ["机器", "学习"] 词表可控,能处理新词 需要预训练词表 BPE,WordPiece
字级(Character) "机器学习" → ["机", "器", "学", "习"] 无 OOV,词表最小 序列过长,丢失词义 逐字切分

OOV 是什么

OOV(Out of Vocabulary,未登录词)指模型词表中没有的词. 比如 "yyds""绝绝子" 这类新词,词级分词遇到它们就无法处理. 子词切分通过把未知词拆成已知的子片段来解决这个问题.

词级分词:jieba 实战

jieba 是中文最常用的词级分词工具,基于 HMM + 词典的混合方案. 虽然 BERT 不直接依赖它,但数据预处理和特征工程阶段经常用到.

import jieba

text = "你在群里带什么节奏别在这里阴阳怪气"

# 精确模式:最合理的切分
print(list(jieba.cut(text, cut_all=False)))
# → ['你', '在', '群里', '带', '什么', '节奏', '别', '在', '这里', '阴阳怪气']

# 搜索引擎模式:在精确基础上再切分长词
print(list(jieba.cut_for_search(text)))
# → ['你', '在', '群里', '带', '什么', '节奏', '别', '在', '这里', '阴阳', '怪气', '阴阳怪气']

jieba 的局限在于它是通用词典,群聊中的缩写("yyds""xswl""nmsl")和新词无法识别. 可以通过添加自定义词典缓解:

jieba.add_word("阴阳怪气", freq=1000, tag="v")
jieba.add_word("带节奏", freq=1000, tag="v")

分词器 vs 编码器:jieba 为什么没有 encode

jieba 只输出字符串列表,而 BERT Tokenizer 还能输出数字 ID 序列. 这不是 jieba 少了什么功能--是它们的职责定位不同:

jieba BERT Tokenizer
定位 纯分词工具(只管切) 模型前端(切词 + 编码 + 加特殊标记)
有 ID 词表吗 有词频词典,但不维护 token → ID 映射 有固定词表(21128 个 token),每个 token 对应唯一 ID
输出 ["你", "脑子", "有", "问题"] [101, 872, 5554, 2094, 3300, 7309, 7579, 102]
下游对接 交给 FastText/sklearn 等各自编码 直接喂给 BERT 模型

jieba = Go 的 json.Decoder, 只负责把 []byte 拆成 token 流,下游爱怎么用怎么用.
BERT Tokenizer = proto.Marshal, 直接输出模型能消费的二进制格式,和模型绑定.

jieba 路线下,"编码为 ID" 这一步由下游各自处理:

  • FastText:内部自带词表,喂字符串进去它自己编码
  • TF-IDF + LogisticRegression:sklearn 的 vectorizer 把词转为稀疏向量,不需要 ID
  • 自训练神经网络:自己建 word2id 字典做映射

关键原则:ID 只是查找键,不同词表产生不同 ID,但这不影响模型效果. 真正重要的是词表覆盖率(能不能编码所有输入词)和粒度匹配(用什么粒度训练的模型,推理时就得用同样的粒度).

子词分词:BERT 的 WordPiece

BERT 是什么的缩写

Bidirectional Encoder Representations from Transformers--来自 Transformer 的双向编码表示. 名字透露了两个核心特征:

  1. 双向:同时看一个词的左边和右边上下文来理解它,而不是像 GPT 那样只看左边
  2. 编码器:只做"理解"(编码输入文本的语义),不做"生成"(写出新文本). 对分类任务来说,只需要理解就够了,所以 BERT 正好对口.

BERT 使用的是 WordPiece 算法--一种子词切分方法. 核心思路:

  1. 从字符粒度开始
  2. 统计语料中相邻字符对的共现频率
  3. 把高频字符对合并为一个子词
  4. 重复直到词表达到预设大小(中文 BERT 通常 21128 个)

WordPiece = HTTP/2 的 HPACK 头部压缩. 高频出现的 header 组合(如 "content-type: application/json")被分配一个短编码. WordPiece 做的事一样--高频字符组合变成一个 token,节省序列长度.

实际效果:

from transformers import BertTokenizer

tokenizer = BertTokenizer.from_pretrained("bert-base-chinese")

text = "你在群里带什么节奏"
tokens = tokenizer.tokenize(text)
print(tokens)
# → ['你', '在', '群', '里', '带', '什', '么', '节', '奏']
# 中文 BERT 的 WordPiece 基本就是逐字切分(因为中文字本身就是有意义的最小单元)

ids = tokenizer.encode(text)
print(ids)
# → [101, 872, 1762, 5765, 7027, 2424, 784, 720, 5765, 1952, 102]
# 101=[CLS], 102=[SEP] 是 BERT 的特殊标记

中文 BERT 的 WordPiece 约等于字级切分

对于中文,BERT 的 WordPiece 结果基本是逐字切分. 这不是缺陷--BERT 通过多层 Transformer 的自注意力机制,在后续计算中自动学习哪些字组成词,哪些字组成短语. 分词的"智能"从 Tokenizer 转移到了 Model 内部.

特殊 Token:CLS 和 SEP

BERT Tokenizer 编码后,序列头尾会自动加上两个特殊标记:

  • [CLS] = Classification,分类标记
  • [SEP] = Separator,分隔标记
原文:  "你脑子有病吧"
编码: [CLS] 你 脑 子 有 病 吧 [SEP]
ID: 101 872 5765 2094 3300 4567 1416 102

CLS 的作用:整句语义的汇聚点

BERT 模型内部有多层 Transformer,每一层中每个 token 都会"看"其他所有 token(这就是 Self-Attention). 经过 12 层计算后,[CLS] 这个位置的输出向量已经融合了整个句子所有字的信息.

[CLS] = Go 里的 context.Context. 它本身不是业务数据,但在整个调用链中被层层传递,最终承载了请求的全部上下文信息(超时,取消信号,trace ID 等). [CLS] 就是 BERT 的 "context 对象"--本身只是一个占位符,经过多层注意力计算后汇聚了整句话的语义.

做文本分类时,工作流是这样的:

输入:  [CLS] 你 脑 子 有 病 吧 [SEP]
│ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────── 12 层 Transformer ───────┐
│ 每个位置都和其他位置交互信息 │
└─────────────────────────────────┘
│ │ │ │ │ │ │ │

取 [CLS] 位置的输出(768 维向量)

▼ 接一个线性分类层(768 → 类别数)
[对骂: 0.82, 正常: 0.18]

为什么不取其他位置? 因为 BERT 预训练时就是让 [CLS] 承担句子级任务(Next Sentence Prediction),所以这个位置天然适合做整句分类. 其他位置的输出更适合做 token 级任务(如命名实体识别--判断每个字属于什么实体).

SEP 的作用:句子边界标记

SEP 标记句子的结束. 当需要同时输入两段文本时(比如判断两句话是否构成对骂对话),SEP 用来分隔它们:

单句输入(对骂检测):
[CLS] 你 脑 子 有 病 吧 [SEP]

双句输入(判断两句话是否在对骂):
[CLS] 你 脑 子 有 病 吧 [SEP] 你 才 有 病 [SEP]
─── 句子 A ─── ─── 句子 B ───

如果后续要做对话级对骂检测(把上下文两条消息一起输入判断是否在互骂),就会用到双句输入格式.

简单记忆

CLS = 汇总器,经过模型计算后它"读过"了整句话,输出向量代表全句语义,直接用来做分类.
SEP = 分隔符,告诉模型"这里是一句话的结束/两句话的边界".
这两个 token 不是文本内容,是给模型的结构信号.

做对骂检测(文本分类)时,模型取 [CLS] 位置的输出向量,接一个分类头(线性层)得到预测结果. 这是标准范式,后续阶段三会详细展开.

文本预处理流水线

在真实群聊场景中,原始消息很脏. 分词之前通常需要一系列清洗步骤:

flowchart LR A["原始消息"] --> B["去除特殊字符"] B --> C["处理表情/emoji"] C --> D["统一编码"] D --> E["去除 URL/at"] E --> F["繁简转换"] F --> G["可选:去停用词"] G --> H["分词/Tokenize"]

每个步骤的作用和群聊场景示例:

步骤 做什么 群聊示例
去特殊字符 删除零宽字符,控制符 肉眼看不到但会干扰分词的字符
处理表情 emoji 转文本描述或移除 😡 → [愤怒] 或直接删除
统一编码 全角 → 半角,大写 → 小写 "NMSL nmsl" 统一为 "nmsl"
去 URL/at 移除链接和 @提及 "@张三 你看这个 http://..." → "你看这个"
繁简转换 繁体统一为简体 "這個人真煩" → "这个人真烦"
去停用词 移除 "的""了""啊" 等无意义词 对分类类任务通常不去,因为语气词携带情绪信息

什么是停用词

停用词(Stop Words)是指在文本中高频出现但通常不携带实质语义的词. 类比 Go 代码中的 if, for, return--它们是语法骨架,但不是业务逻辑本身.

中文常见停用词举例:

类别 示例 特点
助词 的,地,得,了,过 几乎每句话都有,不影响核心语义
介词/连词 在,和,与,或,但是 连接作用,本身无意义
代词 这,那,它,其 指代功能,独立时无意义
语气词 啊,呢,吧,嘛,呗 表达语气--在情感分析中有用

去停用词的典型做法是维护一个停用词列表(几百到几千个词),分词后把命中的词删掉:

# 示例:去停用词的流程
stopwords = {"的", "了", "在", "是", "我", "有", "和", "就"}
tokens = ["你", "是", "不是", "脑子", "有", "问题"]
filtered = [t for t in tokens if t not in stopwords]
# → ["不是", "脑子", "问题"] ← "是" 和 "有" 被移除

去停用词在搜索引擎和文本摘要中很有用--减少噪声,让模型聚焦关键内容. 但对情感分析来说是把双刃剑.

对骂检测不要轻易去停用词

"你他妈的滚" 中的 "的" 是停用词,但 "他妈" 是关键特征,去掉 "的" 后 "他妈" 可能也被拆散.

情感分析类任务中语气助词("啊""呢""吧""呗")承载攻击性强度信息--"滚吧" 比 "滚" 多一分轻蔑,"滚啊" 比 "滚" 多一分激烈. 建议保留. 去停用词更适合搜索,摘要等纯语义理解任务.

预处理代码示例

一个适用于群聊对骂检测的精简预处理函数:

import re
import unicodedata


def preprocess(text: str) -> str:
"""群聊文本预处理流水线"""
# 1. Unicode 规范化(处理全角字符等)
text = unicodedata.normalize("NFKC", text)

# 2. 去除 URL
text = re.sub(r"https?://\S+", "", text)

# 3. 去除 @提及(QQ群格式)
text = re.sub(r"@\S+\s?", "", text)

# 4. 去除多余空白
text = re.sub(r"\s+", " ", text).strip()

# 5. 不去停用词,不去 emoji(它们对情感分类有意义)
return text


# 测试
raw_messages = [
"@张三 你是不是脑子有问题 😡😡😡",
"看看这个链接 https://example.com 什么垃圾",
"  你 算 什 么 东 西 ", # 包含全角空格
]

for msg in raw_messages:
print(f"原始: {msg}")
print(f"清洗: {preprocess(msg)}")
print()

实际工作中谁来分词

理解了分词的原理后,实际工作流是这样的:

场景 谁负责分词 需要做什么
使用 BERT/Transformer 模型 模型自带的 Tokenizer(WordPiece) 只做文本清洗,分词交给 Tokenizer
使用 FastText/传统方法 需要自己先分词(jieba 等) 清洗 + jieba 分词 + 拼接为空格分隔文本
特征工程/数据分析 jieba 或自定义分词 分词后统计词频,提取关键词等

简单记忆

用 BERT → 只管清洗,分词它自己做.
用 FastText → 需要先分词再喂进去.
对骂检测最终会用 BERT 微调,所以重心放在数据清洗而非分词算法本身.

词表大小的工程意义

作为后端开发,可能会关心词表大小对系统的影响:

模型 词表大小 Embedding 层参数 含义
bert-base-chinese 21,128 21128 × 768 ≈ 16M 参数 每个 token 对应一个 768 维向量
GPT-2 50,257 50257 × 768 ≈ 38M 参数 子词粒度更细
jieba 词典 ~58 万词 - 词级粒度,词表巨大

词表越大 → Embedding 层越大 → 模型文件越大 → 推理时内存占用越大. 这就是为什么 BERT 选择 2 万级别的子词词表,而不是用 58 万的完整词典--是效果和资源的平衡.

本篇要点串联

把这篇的知识点放回上一篇的 pipeline 调用流程中:

flowchart TD A["原始群聊消息  
'@张三 你脑子有病吧😡'"] --> B["文本预处理
去@,去emoji → '你脑子有病吧'"] B --> C["Tokenizer 分词
WordPiece → ['你','脑','子','有','病','吧']"] C --> D["编码为 ID
[101,872,5765,2094,3300,4567,1416,102]"] D --> E["送入模型
→ negative 0.82"] style B fill:#dbeafe,stroke:#3b82f6 style C fill:#dbeafe,stroke:#3b82f6 style D fill:#dbeafe,stroke:#3b82f6

蓝色部分就是本篇覆盖的内容. 预处理和分词是能直接控制的环节--清洗质量直接影响模型效果.

快速回顾

  • 分词三种粒度:词级(jieba) → 子词级(WordPiece/BPE) → 字级,BERT 中文约等于字级
  • BERT Tokenizer:自动完成分词+编码,只需要做好前置清洗
  • CLS/SEP:BERT 的特殊标记,CLS 位置承载整句语义用于分类
  • 预处理优先级:对骂检测中,去 URL/@提及 > 统一编码 > 不要去停用词/emoji
  • 工作流分工:BERT 路线只需清洗,FastText 路线需要 jieba 分词

动手练习

  1. 安装 jieba 并分词:执行 uv add jieba,对种子数据集的 20 条对骂文本做分词,观察分词结果是否合理
  2. 对比 WordPiece:使用 BertTokenizer.from_pretrained("bert-base-chinese") 对同样的文本做编码,对比 jieba 和 WordPiece 的切分差异
  3. 编写预处理函数:对种子数据集做清洗,保存为 data/seed_dataset_cleaned.csv