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

文本表示:从词袋到词向量

一句话总结

分词解决了"怎么切",文本表示解决"怎么算"--把词语变成带语义信息的数字向量,让模型能做加减乘除.
从词袋到词向量的演进,本质是从"有没有这个词"到"这个词是什么意思"的跃迁.

为什么需要文本表示

上一篇我们知道了分词和编码会把文本变成 ID 序列:

"你脑子有病吧" → [872, 5554, 2094, 3300, 4567, 1416]

但这些 ID 只是查找键--数字 872 比 5554 小,不代表"你"比"脑"更消极. ID 之间没有数学关系,不能直接做加减比较.

我们需要一种表示方式,让:

  • "骂"和"骂人"在数学上靠近(语义相似)
  • "骂"和"夸"在数学上远离(语义相反)
  • 模型能通过向量运算推导出语义关系

ID = 数据库主键,只用于定位行,不能比较大小关系. 但如果给每行加一个"语义向量"字段,就能做相似度搜索了--文本表示做的就是给每个词/句子加上这样一个"语义坐标".

文本表示的输入是什么

上一篇讲了"分词 → 编码为 ID",那文本表示的输入是 ID 序列还是原文字符串? 答案是:取决于用哪种方法,二者是并行的两条路径,不是先后两步.

flowchart TD A["原始文本"] --> B["分词"] B --> C{"两条路径"} C -->|"传统路径"| D["字符串列表  
['你','脑子','有','问题']"] C -->|"BERT 路径"| E["ID 序列
[101,872,5554,2094,...]"] D --> F["TF-IDF / FastText / Word2Vec
(内部各自完成编码)"] E --> G["Embedding 查表
(用 ID 查向量)"] F --> H["得到文本向量"] G --> H style D fill:#d1fae5,stroke:#10b981 style E fill:#dbeafe,stroke:#3b82f6
方法 输入 为什么
TF-IDF 分词后的字符串列表 内部自己统计词频,计算权重
FastText 分词后的字符串文本 内部自己做 word → vector 映射
Word2Vec 分词后的字符串列表 用字符串查预训练向量表
BERT Tokenizer 输出的 ID 序列 用 ID 查 Embedding 矩阵

上一篇讲的"编码为 ID"专指 BERT 路径. 传统方法(TF-IDF/FastText)不需要手动编码--把分词后的字符串喂进去,它们内部处理剩下的事.

词袋模型:最朴素的起点

词袋模型(Bag of Words,BoW)是最简单的文本表示方式--只看文本中出现了哪些词,以及出现了几次,完全忽略词序.

假设词表只有 6 个词:

词表: [你, 脑子, 有, 问题, 滚, 远点]

"你脑子有问题" → [1, 1, 1, 1, 0, 0] ← 前4个词各出现1次
"滚远点" → [0, 0, 0, 0, 1, 1] ← 后2个词各出现1次
"你有问题你有问题" → [2, 0, 2, 2, 0, 0] ← "你""有""问题"各出现2次

每个文本被表示为一个长度等于词表大小的向量,每个位置记录对应词的出现次数.

词袋的致命缺陷

"你有问题"和"问题有你"的词袋向量完全相同--因为词序被丢弃了. "这个东西不好"和"这个东西好"只差一个"不"字,但语义完全相反.

对于对骂检测,"我服了你"和"你服了我"意思截然不同,词袋分不出来.

尽管如此,词袋 + 简单分类器在很多场景下依然有效. 如果对骂文本有明显的标志词("滚""傻""废物"),词袋模型看到这些词出现就能做出正确判断. 它是 baseline 中的 baseline.

TF-IDF:给词袋加权重

词袋模型的另一个问题:所有词同等重要. 但显然"的""了""是"这种高频词不如"废物""垃圾"这种低频词有区分度.

TF-IDF(Term Frequency - Inverse Document Frequency)通过两个因子给每个词打分:

因子 含义 直觉
TF(词频) 这个词在当前文本中出现的频率 出现越多,对这篇文本越重要
IDF(逆文档频率) log(总文档数 / 包含该词的文档数) 越少文档包含这个词,这个词区分度越高

TF-IDF = TF × IDF. 效果是:

  • "废物":TF 高(在对骂文本中频繁出现) × IDF 高(正常文本很少用) → 高权重
  • "的":TF 高 × IDF 极低(几乎所有文本都有) → 低权重

TF-IDF = Go 服务中的限流权重. 不是所有请求同等重要,来自核心接口的请求(低频但重要)权重比心跳探测(高频但无意义)高得多. TF-IDF 自动把"信息量大的词"权重调高.

from sklearn.feature_extraction.text import TfidfVectorizer

# 假设已经用 jieba 分好词,用空格拼接
corpus = [
"你 脑子 有 问题",
"滚 远 点 别 来 烦 我",
"今天 天气 真 不错",
"大家 觉得 方案 怎么样",
]

vectorizer = TfidfVectorizer()
tfidf_matrix = vectorizer.fit_transform(corpus)

# 查看词表和权重
print(vectorizer.get_feature_names_out())
# → ['不错', '别', '天气', '大家', '头', '奏', ...]
print(tfidf_matrix.shape)
# → (4, N) - 4篇文档,N个特征维度

TF-IDF 在项目中的位置

阶段二会用 TF-IDF + LogisticRegression 做第一个 baseline 分类器. 它训练只需几秒钟,效果往往就能达到 80%+,用来快速验证"数据标注质量是否够用"非常合适.

词袋系方法的共同局限

不管是 BoW 还是 TF-IDF,它们有三个本质限制:

  1. 高维稀疏:向量维度 = 词表大小(几万到几十万),但每个文本只命中极少数词,绝大部分位置是 0
  2. 无语义关系:"开心"和"高兴"在向量空间中没有任何关系--它们只是词表中不同位置的 1
  3. 忽略词序:"我打你"和"你打我"表示完全相同

这就引出了下一代方法--词向量:用一个固定长度的稠密向量来表示每个词,让语义相近的词在向量空间中距离更近.

Word2Vec:让词有了"坐标"

Word2Vec(2013,Google)是词向量的开山之作. 核心思想用一句话概括:

一个词的含义,由它周围的词决定.(You shall know a word by the company it keeps.)

直觉解释:如果"废物"和"垃圾"总是出现在相似的上下文中(比如"你这个___"),那么它们的含义应该相近--它们的向量也应该相近.

训练过程(直觉版)

Word2Vec 有两种训练方式,这里讲最好理解的 CBOW(Continuous Bag of Words):

训练样本构造(窗口大小=2):

原文: "你 这个 人 真 有意思"

上下文 [你, 人] → 预测中心词 "这个"
上下文 [这个, 真] → 预测中心词 "人"
上下文 [人, 有意思] → 预测中心词 "真"

模型被迫学会:哪些词经常出现在相似的上下文中
→ 这些词的向量就会靠近

训练完成后,每个词得到一个固定维度的向量(通常 100-300 维):

"废物" → [0.23, -0.45, 0.12, ..., 0.67]  (300维)
"垃圾" → [0.21, -0.42, 0.15, ..., 0.63] (300维) ← 很接近!
"天气" → [-0.56, 0.31, -0.78, ..., 0.02] (300维) ← 很远

词向量的神奇性质

训练出的词向量能做语义算术:

king - man + woman ≈ queen
北京 - 中国 + 日本 ≈ 东京

这意味着向量空间中编码了语义关系. 对对骂检测来说,重要的不是这些花哨的例子,而是一个实用推论:

对骂检测的意义

即使训练数据中只标注了"废物"是骂人的词,模型也能通过词向量的相似性推断出"垃圾""蠢货""白痴"也是类似含义--因为它们的向量坐标靠近. 这就是词向量带来的泛化能力.

FastText:Word2Vec 的实用升级

FastText(2016,Facebook)是 Word2Vec 的改进版,对中文和网络用语场景特别有价值. 核心改进:

基于子词(subword)构建词向量--把每个词拆成字符 n-gram,用子片段的向量相加得到词向量.

Word2Vec:  "nmsl" 不在词表中 → 无法表示(OOV)
FastText: "nmsl" → n-gram: ["nm", "ms", "sl", "nms", "msl"] → 各子片段向量相加

FastText 的优势:

特性 Word2Vec FastText
未登录词(OOV) 无法处理 用子词拼合,总能得到向量
训练速度 同样快(几分钟训完)
拼写错误容忍 有("废物"和"废务"共享子词)
内置分类器 有(直接做文本分类)

FastText 在项目中的位置

阶段二会用 FastText 的文本分类功能(不是词向量功能)做对骂检测的第一个 baseline. 它训练极快,部署极简(一个二进制文件),是验证数据质量的最佳工具.

静态词向量 vs 上下文词向量

Word2Vec 和 FastText 有一个根本限制:每个词只有一个固定向量,不管它出现在什么上下文中.

"你这个人真有意思"  →  "有意思" = [0.3, 0.5, ...]  (正面:有趣)
"你少跟我来这套有意思的" → "有意思" = [0.3, 0.5, ...] (负面:讽刺)
↑ 同一个向量!无法区分

这就是静态词向量(Static Embedding)的局限--一词一义.

BERT 解决了这个问题:它为每个词生成的向量取决于上下文--同一个"有意思",在不同句子中会得到不同的向量表示. 这叫上下文词向量(Contextual Embedding).

静态词向量(Word2Vec/FastText):
"有意思" → 永远是同一个向量,不管上下文

上下文词向量(BERT):
"你真有意思哈哈" → "有意思" = [0.3, 0.8, ...] (正面语义方向)
"你少来这套有意思的" → "有意思" = [-0.4, 0.2, ...] (讽刺语义方向)

静态词向量 = Go 的常量(const),编译时确定,运行时不变.
上下文词向量 = 接口值(interface{}),同一个变量名,绑定的具体实现取决于运行时的上下文.

Embedding:把 ID 变成向量的那一步

前面讲了 TF-IDF, Word2Vec, FastText, BERT 四种文本表示方法,它们看起来很不同,但其实都要解决同一个问题:怎么把一个"词"变成一个"向量".

这个"词 → 向量"的转换动作,在神经网络中有个统一的名字叫 Embedding(嵌入). 理解了它,前面所有方法就能串起来.

Embedding 到底是什么

Embedding 的本质极其简单--一张二维表格,行数 = 词表大小,列数 = 向量维度. 查表就是 Embedding 的全部操作.

Embedding Table(词表大小 21128 × 向量维度 768):

行号(ID) | 向量
──────────┼──────────────────────────────
0 | [0.01, -0.03, 0.12, ..., 0.05] ← [PAD]
1 | [0.04, 0.02, -0.08, ..., 0.11] ← [UNK]
... |
101 | [-0.12, 0.45, 0.03, ..., -0.21] ← [CLS]
... |
872 | [0.34, -0.21, 0.56, ..., 0.09] ← "你"
5554 | [0.18, -0.67, 0.43, ..., 0.22] ← "脑"
... |

查表: token_id=872 → 取第 872 行 → 得到 768 个数字组成的向量
// 用 Go 类比 Embedding 的本质:
// 它就是一个 map[int][]float64,输入 ID,输出向量

type EmbeddingTable struct {
weights [][]float64 // [vocabSize][embedDim]
}

func (e *EmbeddingTable) Lookup(tokenID int) []float64 {
return e.weights[tokenID]
}

Embedding 表 = Redis 的 HGET 操作. 给一个 key(token ID),返回一个 value(向量). 区别在于这张表的 value 不是人写的,而是通过训练"学"出来的--让语义相近的词对应的向量在数学上靠近.

输入方式:整个 ID 序列一起进去

这里的 token ID 就是上一篇 Tokenizer 输出的那个 ID 序列. 输入方式是整个句子的 ID 序列一起输入,同时查表得到一批向量:

输入 ID 序列: [101,   872,   5554,  2094,  3300,  4567,  1416,  102]
CLS 你 脑 子 有 病 吧 SEP
│ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌──────────── Embedding Table 批量查表 ────────────────────┐
│ 每个 ID 各自取对应行,互不干扰 │
└─────────────────────────────────────────────────────────┘
│ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
输出向量: [768维] [768维] [768维] [768维] [768维] [768维] [768维] [768维]

整体形状: 8 个 token × 768 维 = 一个 8×768 的矩阵
func (e *EmbeddingTable) LookupBatch(ids []int) [][]float64 {
result := make([][]float64, len(ids))
for i, id := range ids {
result[i] = e.weights[id]
}
return result // 返回 len(ids) × 768 的矩阵
}

为什么必须一起输入而不是一个个来? 因为 BERT 的下一步(Transformer 层)需要让每个位置的向量"看到"序列中所有其他位置. 如果一个个输入,就没有"其他位置"可以参照了.

这也是 Word2Vec 和 BERT 的本质区别:

  • Word2Vec:一个个查或一起查都无所谓,因为查完就是最终结果,各词向量互不影响
  • BERT:必须整个序列一起输入,因为查表只是第一步,后面 Transformer 要让所有向量互相交互--"病"的最终向量会受到"你""脑子"等其他位置的影响
Word2Vec: 各查各的,互不相干
"你脑子有病吧" 中的 "有" → [0.3, -0.1, ...]
"有什么好推荐的" 中的 "有" → [0.3, -0.1, ...] ← 完全相同

BERT: 一起输入,互相影响
"你脑子有病吧" 中的 "有" → 受 "脑子""病" 影响 → 偏向负面
"有什么好推荐的" 中的 "有" → 受 "推荐" 影响 → 偏向中性

为什么要引入 Embedding 这个概念

因为前面讲的所有方法,本质上都是在回答**"这张表怎么来"**这个问题:

方法 表怎么来 表的特点
词袋 / TF-IDF 统计计算(不是真正的 Embedding) 稀疏,高维,无语义
Word2Vec 用大量文本无监督训练出来的 稠密,低维,有语义,但固定不变
FastText 同上 + 子词增强 同上 + 能处理新词
BERT 大规模预训练出来的 稠密,低维,有语义,且会被后续 Transformer 层动态修改

所以 Embedding 不是一个"新方法",而是对前面所有方法的统一抽象--它们都在做"ID → 向量"这件事,只是表的生成方式和使用方式不同.

静态查表 vs 动态计算

这是最关键的区分:

Word2Vec / FastText(静态 Embedding):
token_id → 查表 → 得到向量 → 结束!向量就是最终结果

BERT(动态 Embedding):
token_id → 查表 → 得到初始向量 → 送入 12 层 Transformer
→ 每一层都根据上下文调整
→ 最终向量 ≠ 初始查表结果

用 Go 类比:

// Word2Vec:查完缓存就返回,不再计算
func (w2v *Word2Vec) GetVector(id int) []float64 {
return w2v.table[id]
}

// BERT:查完缓存只是起点,还要经过多轮处理
func (bert *BERT) GetVector(ids []int) [][]float64 {
vectors := make([][]float64, len(ids))
for i, id := range ids {
vectors[i] = bert.embeddingTable[id]
}
for layer := 0; layer < 12; layer++ {
vectors = bert.transformerLayer[layer].Forward(vectors)
}
return vectors
}

这就是为什么 BERT 能区分"你真有意思"(夸)和"你少来这套有意思的"(讽)--同一个"有意思"的初始 Embedding 相同,但经过 Transformer 层后,受到不同上下文的影响,最终向量截然不同.

对任务怎么选

flowchart TD A["需要快速验证数据质量?"] -->|"是"| B["TF-IDF + LR 或 FastText  
训练几秒到几分钟"] A -->|"已验证,要提升效果"| C["需要理解上下文/反讽?"] C -->|"不需要,明确辱骂即可"| D["FastText 就够
推理 <1ms"] C -->|"需要,有阴阳怪气场景"| E["BERT 微调
推理 5-10ms"] style B fill:#d1fae5,stroke:#10b981 style D fill:#d1fae5,stroke:#10b981 style E fill:#dbeafe,stroke:#3b82f6

实际项目建议两者都做:FastText 作为快速 baseline 和兜底方案(极低延迟),BERT 作为精度方案(处理复杂语义). 线上可以用 FastText 先过滤掉明显的对骂(置信度 > 0.95),剩下不确定的再走 BERT.

快速回顾

  • 词袋/TF-IDF:统计"有没有/多不多",高维稀疏,无语义关系,但够快够简单做 baseline
  • Word2Vec:通过上下文共现学习词向量,语义相近的词向量靠近,但一词一义
  • FastText:Word2Vec 加子词增强,能处理 OOV 和拼写变体,自带文本分类器
  • BERT Embedding:上下文感知的动态词向量,同一个词在不同语境中表示不同--解决反讽/阴阳怪气问题的关键
  • 演进主线:稀疏统计 → 稠密静态 → 稠密动态(上下文感知)

动手练习

  1. 生成 TF-IDF 向量:用 sklearn 的 TfidfVectorizer 对种子数据集的 40 条文本生成 TF-IDF 向量,观察维度和稀疏度
  2. 验证语义空间:用 gensim 加载预训练中文 Word2Vec,查询"废物""垃圾""天气"的相似词,验证语义空间直觉
  3. 计算类间距离:计算种子数据集中对骂样本和正常样本的 TF-IDF 向量的平均余弦相似度--看两类文本在向量空间中是否天然分开