文章目录
-
- [1. 从"字面匹配"到"语义理解":问题的起点](#1. 从"字面匹配"到"语义理解":问题的起点)
- [2. Word2Vec 架构解析:CBOW 与 Skip-Gram](#2. Word2Vec 架构解析:CBOW 与 Skip-Gram)
-
- [2.1 负采样的必要性](#2.1 负采样的必要性)
- [3. GloVe:全局共现视角](#3. GloVe:全局共现视角)
- [4. FastText:子词嵌入的突破](#4. FastText:子词嵌入的突破)
-
- [4.1 子词 n-gram 的边界问题](#4.1 子词 n-gram 的边界问题)
- [5. 词嵌入评估:内在 vs 外在](#5. 词嵌入评估:内在 vs 外在)
-
- [5.1 内在评估(Intrinsic Evaluation)](#5.1 内在评估(Intrinsic Evaluation))
- [5.2 内在好 ≠ 外在好](#5.2 内在好 ≠ 外在好)
- [6. 文档级向量:从词向量到文档表示](#6. 文档级向量:从词向量到文档表示)
- [7. 嵌入向量的核心局限:一词一义问题](#7. 嵌入向量的核心局限:一词一义问题)
- [8. 预训练词嵌入 vs 从零训练](#8. 预训练词嵌入 vs 从零训练)
- [9. 完整实战:新闻分类特征方案对比](#9. 完整实战:新闻分类特征方案对比)
- [10. 方法选型速查表](#10. 方法选型速查表)
- 结语
1. 从"字面匹配"到"语义理解":问题的起点
TF-IDF 是文本特征工程的经典起点------词频乘以逆文档频率,高频词被抑制,关键词被放大。这个方案在很多场景下表现良好,但它存在一个根本性的盲区:词的字符串形式与语义含义是完全解耦的。
在 TF-IDF 的视角里,"手机"和"电话"是两个完全不同的特征,它们之间的关系是零相关。"北京"和"首都"同样如此。一个 20 万词表的 TF-IDF 矩阵,实际上是 20 万个独立的二值信号,彼此之间没有任何语义关联。
这不只是理论问题。考虑一个新闻分类任务:训练集中有很多包含"智能手机"的科技类新闻,测试集中出现了大量用"移动设备"描述同一概念的文章。TF-IDF 模型会因为"移动设备"在训练集中出现频率低而低估这批文章的科技属性。
分布式语义假说 (Distributional Semantic Hypothesis)给出了解决思路:一个词的含义,由它经常出现的上下文决定。"手机"和"电话"出现的上下文高度相似(都和"充电""通话""屏幕"一起出现),因此它们的含义应该接近。Word2Vec 正是将这个假说工程化的产物。
#mermaid-svg-g4ivnXhVJy05l1H6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-g4ivnXhVJy05l1H6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-g4ivnXhVJy05l1H6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-g4ivnXhVJy05l1H6 .error-icon{fill:#552222;}#mermaid-svg-g4ivnXhVJy05l1H6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-g4ivnXhVJy05l1H6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-g4ivnXhVJy05l1H6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-g4ivnXhVJy05l1H6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-g4ivnXhVJy05l1H6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-g4ivnXhVJy05l1H6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-g4ivnXhVJy05l1H6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-g4ivnXhVJy05l1H6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-g4ivnXhVJy05l1H6 .marker.cross{stroke:#333333;}#mermaid-svg-g4ivnXhVJy05l1H6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-g4ivnXhVJy05l1H6 p{margin:0;}#mermaid-svg-g4ivnXhVJy05l1H6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-g4ivnXhVJy05l1H6 .cluster-label text{fill:#333;}#mermaid-svg-g4ivnXhVJy05l1H6 .cluster-label span{color:#333;}#mermaid-svg-g4ivnXhVJy05l1H6 .cluster-label span p{background-color:transparent;}#mermaid-svg-g4ivnXhVJy05l1H6 .label text,#mermaid-svg-g4ivnXhVJy05l1H6 span{fill:#333;color:#333;}#mermaid-svg-g4ivnXhVJy05l1H6 .node rect,#mermaid-svg-g4ivnXhVJy05l1H6 .node circle,#mermaid-svg-g4ivnXhVJy05l1H6 .node ellipse,#mermaid-svg-g4ivnXhVJy05l1H6 .node polygon,#mermaid-svg-g4ivnXhVJy05l1H6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-g4ivnXhVJy05l1H6 .rough-node .label text,#mermaid-svg-g4ivnXhVJy05l1H6 .node .label text,#mermaid-svg-g4ivnXhVJy05l1H6 .image-shape .label,#mermaid-svg-g4ivnXhVJy05l1H6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-g4ivnXhVJy05l1H6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-g4ivnXhVJy05l1H6 .rough-node .label,#mermaid-svg-g4ivnXhVJy05l1H6 .node .label,#mermaid-svg-g4ivnXhVJy05l1H6 .image-shape .label,#mermaid-svg-g4ivnXhVJy05l1H6 .icon-shape .label{text-align:center;}#mermaid-svg-g4ivnXhVJy05l1H6 .node.clickable{cursor:pointer;}#mermaid-svg-g4ivnXhVJy05l1H6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-g4ivnXhVJy05l1H6 .arrowheadPath{fill:#333333;}#mermaid-svg-g4ivnXhVJy05l1H6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-g4ivnXhVJy05l1H6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-g4ivnXhVJy05l1H6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-g4ivnXhVJy05l1H6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-g4ivnXhVJy05l1H6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-g4ivnXhVJy05l1H6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-g4ivnXhVJy05l1H6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-g4ivnXhVJy05l1H6 .cluster text{fill:#333;}#mermaid-svg-g4ivnXhVJy05l1H6 .cluster span{color:#333;}#mermaid-svg-g4ivnXhVJy05l1H6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-g4ivnXhVJy05l1H6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-g4ivnXhVJy05l1H6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-g4ivnXhVJy05l1H6 .icon-shape,#mermaid-svg-g4ivnXhVJy05l1H6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-g4ivnXhVJy05l1H6 .icon-shape p,#mermaid-svg-g4ivnXhVJy05l1H6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-g4ivnXhVJy05l1H6 .icon-shape .label rect,#mermaid-svg-g4ivnXhVJy05l1H6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-g4ivnXhVJy05l1H6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-g4ivnXhVJy05l1H6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-g4ivnXhVJy05l1H6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 语义局限
一词一义局限
第三代:动态表示
BERT
维度 = 768
上下文相关
存储: 位置相关
第二代:稠密表示
Word2Vec
维度 = 50~300
上下文语义
存储: 稠密向量
GloVe
维度 = 50~300
全局共现统计
存储: 稠密向量
FastText
维度 = 50~300
子词嵌入,处理OOV
存储: 稠密向量
第一代:稀疏表示
One-Hot
维度 = 词表大小
向量间无语义关系
存储: 稀疏矩阵
TF-IDF
维度 = 词表大小
词频权重,仍无语义
存储: 稀疏矩阵
2. Word2Vec 架构解析:CBOW 与 Skip-Gram
Word2Vec 提出了两种训练架构,它们本质上是互逆的预测任务:
CBOW(Continuous Bag of Words):给定上下文词,预测中心词。
- 输入:窗口内的上下文词(如"昨天 __ 充电了"中的"昨天"和"充电了")
- 目标:预测中心词("手机")
- 特点:速度快,对低频词的表示效果较好(因为聚合了多个上下文信号)
Skip-Gram:给定中心词,预测上下文词。
- 输入:中心词("手机")
- 目标:预测窗口内的上下文词("昨天"、"充电了"等多个目标)
- 特点:训练样本更多(一个中心词生成多个 (中心词, 上下文词) 对),对低频词语义捕捉更精准,大数据集首选
两种架构共享一个关键设计:词向量本身是模型的权重矩阵,不是模型的输出。训练完成后,网络的隐藏层权重矩阵就是词嵌入表------每行对应一个词的稠密向量表示。
python
import numpy as np
from collections import Counter, defaultdict
from typing import List, Tuple, Dict, Optional
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
class SkipGramDataset(Dataset):
"""
Skip-Gram 训练数据集
对每个中心词,在窗口范围内生成 (中心词, 上下文词) 正样本对,
再通过负采样生成负样本对。
"""
def __init__(self, corpus: List[List[str]], window_size: int = 5,
n_negative: int = 5, vocab_size: int = 50000,
min_count: int = 5):
self.window_size = window_size
self.n_negative = n_negative
# 构建词表
word_counts = Counter(w for sent in corpus for w in sent)
vocab_words = [w for w, c in word_counts.most_common(vocab_size)
if c >= min_count]
self.word2idx = {w: i for i, w in enumerate(vocab_words)}
self.idx2word = {i: w for w, i in self.word2idx.items()}
self.vocab_size = len(vocab_words)
# 负采样分布:按词频 0.75 次方加权(Word2Vec 论文设定)
# 效果:稍微抑制超高频词("的""是"等),让低频词有更多负采样机会
freq = np.array([word_counts[w] for w in vocab_words], dtype=np.float64)
self.neg_sampling_weights = freq ** 0.75
self.neg_sampling_weights /= self.neg_sampling_weights.sum()
# 生成训练对
self.pairs = self._generate_pairs(corpus)
def _generate_pairs(self, corpus: List[List[str]]) -> List[Tuple[int, int]]:
"""生成 (中心词, 上下文词) 正样本对"""
pairs = []
for sentence in corpus:
# 子采样高频词:频率越高越可能被跳过("的"可能被跳过 99%)
tokens = [self.word2idx[w] for w in sentence if w in self.word2idx]
for center_idx, center in enumerate(tokens):
# 动态窗口:实际窗口大小在 [1, window_size] 随机采样
actual_window = np.random.randint(1, self.window_size + 1)
start = max(0, center_idx - actual_window)
end = min(len(tokens), center_idx + actual_window + 1)
for ctx_idx in range(start, end):
if ctx_idx != center_idx:
pairs.append((center, tokens[ctx_idx]))
return pairs
def __len__(self):
return len(self.pairs)
def __getitem__(self, idx):
center, context = self.pairs[idx]
# 负采样
negatives = np.random.choice(
self.vocab_size, size=self.n_negative,
replace=False, p=self.neg_sampling_weights
)
return (torch.tensor(center, dtype=torch.long),
torch.tensor(context, dtype=torch.long),
torch.tensor(negatives, dtype=torch.long))
class SkipGramModel(nn.Module):
"""
Word2Vec Skip-Gram with Negative Sampling (SGNS)
核心:两个嵌入矩阵
- input_embeddings: 作为中心词时的向量(最终使用这个)
- output_embeddings: 作为上下文词时的向量(训练辅助,通常丢弃)
损失函数:
log σ(v_c · v_o) + Σ_{k=1}^{K} log σ(-v_c · v_k)
前半部分:正样本 (中心词, 上下文词) 的相似度尽量高
后半部分:负样本 (中心词, 噪声词) 的相似度尽量低
"""
def __init__(self, vocab_size: int, embed_dim: int = 100):
super().__init__()
self.embed_dim = embed_dim
# 初始化用小均匀分布,避免梯度爆炸
self.input_embeddings = nn.Embedding(vocab_size, embed_dim,
sparse=True) # sparse=True 加速
self.output_embeddings = nn.Embedding(vocab_size, embed_dim, sparse=True)
nn.init.uniform_(self.input_embeddings.weight, -0.1 / embed_dim, 0.1 / embed_dim)
nn.init.zeros_(self.output_embeddings.weight)
def forward(self, center: torch.Tensor, context: torch.Tensor,
negatives: torch.Tensor) -> torch.Tensor:
"""
center: (batch_size,)
context: (batch_size,)
negatives: (batch_size, n_negative)
"""
# 中心词向量
v_c = self.input_embeddings(center) # (batch, embed_dim)
# 正样本损失:log σ(v_c · v_o)
v_o = self.output_embeddings(context) # (batch, embed_dim)
pos_score = torch.sum(v_c * v_o, dim=1) # 点积
pos_loss = -torch.log(torch.sigmoid(pos_score) + 1e-10).mean()
# 负样本损失:Σ log σ(-v_c · v_k)
v_k = self.output_embeddings(negatives) # (batch, n_neg, embed_dim)
neg_score = torch.bmm(v_k, v_c.unsqueeze(2)).squeeze(2) # (batch, n_neg)
neg_loss = -torch.log(torch.sigmoid(-neg_score) + 1e-10).mean()
return pos_loss + neg_loss
def get_embedding(self, word_idx: int) -> np.ndarray:
return self.input_embeddings.weight[word_idx].detach().numpy()
2.1 负采样的必要性
原始 Skip-Gram 的 Softmax 输出层需要计算所有词表词的概率,当词表大小为 50000 时,每次前向传播需要 50000 次点积运算,反向传播还要更新 50000 个权重。这在大规模语料上完全不可行。
负采样是一个精妙的近似方案:不更新所有词,每次只随机选 k 个噪声词(论文建议小数据集 5-20 个,大数据集 2-5 个),将多分类问题转化为 k+1 个二分类问题。数学上可证明,在最优情况下负采样近似于归一化的 Softmax------但计算量从 O(|V|) 降到 O(k)。
3. GloVe:全局共现视角
Word2Vec 的训练是局部的------每次只看一个固定大小的上下文窗口。GloVe(Global Vectors for Word Representation)的出发点是:全局统计信息(整个语料的词-词共现矩阵)比局部窗口包含更丰富的信号。
GloVe 的核心公式:
w_i · w_j + b_i + b_j = log(X_ij)
其中 X_ij 是词 i 和词 j 在语料中共现的次数,w_i 和 w_j 是词向量,b 是偏置项。这个公式的含义:两个词向量的点积,应该等于它们共现次数的对数。
Word2Vec vs GloVe 的本质区别:
| 维度 | Word2Vec | GloVe |
|---|---|---|
| 信息来源 | 局部上下文窗口 | 全局共现矩阵 |
| 训练方式 | 随机梯度下降(在线学习) | 加权最小二乘(批量优化) |
| 内存占用 | O(语料大小) | O(词表²)(共现矩阵) |
| 低频词效果 | ⚠️ 训练样本少 | ✅ 全局统计补充 |
| 并行化 | ✅ 容易 | ⚠️ 需要先构建共现矩阵 |
python
class GloVeCooccurrence:
"""
构建词-词共现矩阵(GloVe 的预处理步骤)
注意:大规模语料的共现矩阵会非常稀疏,
需要使用 scipy.sparse 或专用存储格式
"""
def __init__(self, window_size: int = 10):
self.window_size = window_size
self.cooccurrence = defaultdict(float)
def build(self, corpus: List[List[int]], vocab_size: int):
"""
用距离加权的方式构建共现矩阵
同一窗口内距离为 d 的词对,共现权重为 1/d
(距离越近关系越强)
"""
for sentence in corpus:
for center_idx, center in enumerate(sentence):
for offset in range(1, self.window_size + 1):
# 右侧上下文
right_idx = center_idx + offset
if right_idx < len(sentence):
ctx = sentence[right_idx]
weight = 1.0 / offset # 距离衰减权重
self.cooccurrence[(center, ctx)] += weight
self.cooccurrence[(ctx, center)] += weight # 对称
return self
def get_pairs(self) -> List[Tuple[int, int, float]]:
"""返回 (词i, 词j, 共现计数) 三元组列表"""
return [(i, j, v) for (i, j), v in self.cooccurrence.items()]
4. FastText:子词嵌入的突破
Word2Vec 和 GloVe 都把词视为不可分割的原子------"手机"就是"手机",和"手"、"机"没有关系。这在处理以下情况时会失败:
- 未见词(OOV):训练集中没有出现过的新词,无法获得向量
- 形态变化:"run"、"running"、"runner" 是不同的词,但它们的词根相同
- 拼写错误:"iPhone12" 和 "iphone12" 会被认为是完全不同的词
FastText 的解决方案是子词嵌入(Subword Embeddings):将每个词拆分为 n-gram 字符序列,每个 n-gram 单独学习向量,词向量是所有 n-gram 向量的加权和。
以"手机"为例(边界符 <>):
- 字符 3-gram:
<手,手机,机>,<手机>(完整词) - "手机"的向量 = 这 4 个 n-gram 向量之和
python
import fasttext
import numpy as np
from gensim.models import FastText as GensimFastText
from typing import List
class FastTextWrapper:
"""
FastText 嵌入模型封装
支持 OOV 词处理
"""
def __init__(self, embed_dim: int = 100, window_size: int = 5,
min_count: int = 5, n_negative: int = 5,
min_n: int = 3, max_n: int = 6, # n-gram 长度范围
epochs: int = 10):
self.params = {
'vector_size': embed_dim,
'window': window_size,
'min_count': min_count,
'negative': n_negative,
'min_n': min_n, # 最小字符 n-gram 长度
'max_n': max_n, # 最大字符 n-gram 长度
'epochs': epochs,
'workers': 4,
'sg': 1, # 1=Skip-Gram, 0=CBOW
}
self.model = None
def train(self, corpus: List[List[str]]):
"""corpus: 分词后的句子列表"""
print(f"训练 FastText,语料大小: {sum(len(s) for s in corpus)} 个词...")
self.model = GensimFastText(sentences=corpus, **self.params)
print(f"词表大小: {len(self.model.wv.key_to_index)}")
return self
def get_vector(self, word: str) -> np.ndarray:
"""
获取词向量,FastText 特有能力:
OOV 词通过子词 n-gram 向量合成,仍能返回有意义的向量
"""
return self.model.wv[word] # OOV 词会自动用子词合成
def get_oov_capability_demo(self):
"""演示 FastText 处理 OOV 的能力"""
# 这些词可能在训练集中没有出现过
test_words = ["iPhone13Pro", "Samsungg", "苹果手机Plus", "微信App"]
print("\nFastText OOV 处理演示:")
for word in test_words:
vec = self.get_vector(word)
# 找最相似的已知词
try:
similar = self.model.wv.most_similar(word, topn=3)
print(f" '{word}': 向量已生成(维度 {vec.shape[0]}),"
f"最相似: {[w for w, _ in similar]}")
except Exception:
print(f" '{word}': 向量维度 {vec.shape[0]}(OOV词,通过子词合成)")
def sentence_vector(self, tokens: List[str],
method: str = 'mean') -> np.ndarray:
"""
文档级向量:词向量平均
method: 'mean' 简单平均 / 'tfidf_weighted' TF-IDF 加权平均
"""
vectors = [self.get_vector(w) for w in tokens if w in self.model.wv or True]
if not vectors:
return np.zeros(self.params['vector_size'])
return np.mean(vectors, axis=0)
4.1 子词 n-gram 的边界问题
FastText 使用特殊边界符来区分词的开始和结尾:< 表示开始,> 表示结尾。这样 "ing" 这个后缀的 n-gram 是 ing,而词末尾的 "ing" 是 ing>,可以区分独立后缀和词尾。
python
def extract_subword_ngrams(word: str, min_n: int = 3, max_n: int = 6) -> List[str]:
"""
提取词的字符 n-gram(FastText 内部逻辑)
示例:word="手机",min_n=2, max_n=4
"""
# 添加边界符
bounded_word = f"<{word}>"
ngrams = [bounded_word] # 完整词始终包含
for n in range(min_n, max_n + 1):
for start in range(len(bounded_word) - n + 1):
ngram = bounded_word[start:start + n]
if ngram != bounded_word: # 避免重复添加完整词
ngrams.append(ngram)
return list(set(ngrams))
# 演示
word = "running"
ngrams = extract_subword_ngrams(word, min_n=3, max_n=6)
print(f"'{word}' 的子词 n-gram(前15个):")
for ng in sorted(ngrams)[:15]:
print(f" {ng}")
5. 词嵌入评估:内在 vs 外在
词嵌入的质量评估有两条路径,它们评估的是不同层面的能力。
5.1 内在评估(Intrinsic Evaluation)
词相似度任务:给定两个词(如"猫"和"狗"),让人类标注员打出相似度分数(0-10),同时用余弦相似度计算词向量距离,看两者的相关性(Spearman 相关)。常用数据集:WordSim-353、SimLex-999。
类比推理任务 :最著名的 Word2Vec 演示------"国王 - 男人 + 女人 ≈ 女王"。数学表达:v(国王) - v(男人) + v(女人) 的最近邻应该是 v(女王)。
python
class EmbeddingIntrinsicEval:
"""
词嵌入内在评估
"""
def __init__(self, word_vectors: dict):
"""word_vectors: {词: 向量} 字典"""
self.vectors = word_vectors
self.dim = len(next(iter(word_vectors.values())))
def cosine_similarity(self, w1: str, w2: str) -> float:
"""计算两词的余弦相似度"""
if w1 not in self.vectors or w2 not in self.vectors:
return None
v1, v2 = self.vectors[w1], self.vectors[w2]
return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-10)
def analogy(self, word_a: str, word_b: str, word_c: str,
topn: int = 5) -> List[Tuple[str, float]]:
"""
词类比:word_a 之于 word_b,就如 word_c 之于 ???
目标向量 = v(word_b) - v(word_a) + v(word_c)
经典示例:
- 国王 - 男人 + 女人 → 女王
- 巴黎 - 法国 + 中国 → 北京
- 跑步 - 跑 + 游泳 → 游(词根类比)
"""
missing = [w for w in [word_a, word_b, word_c] if w not in self.vectors]
if missing:
return []
target = (self.vectors[word_b] - self.vectors[word_a] + self.vectors[word_c])
target = target / (np.linalg.norm(target) + 1e-10)
# 计算与所有词的相似度
candidates = []
excluded = {word_a, word_b, word_c}
for word, vec in self.vectors.items():
if word in excluded:
continue
score = np.dot(target, vec) / (np.linalg.norm(vec) + 1e-10)
candidates.append((word, score))
return sorted(candidates, key=lambda x: x[1], reverse=True)[:topn]
def semantic_space_clusters(self, seed_words: Dict[str, List[str]]) -> None:
"""
分析语义空间聚类------验证嵌入是否捕捉了语义群组
seed_words: {'类别名': ['词1', '词2', ...]}
"""
print("=== 语义空间聚类分析 ===")
for category, words in seed_words.items():
valid_words = [w for w in words if w in self.vectors]
if len(valid_words) < 2:
continue
# 计算类内平均相似度
sims = []
for i, w1 in enumerate(valid_words):
for w2 in valid_words[i+1:]:
sim = self.cosine_similarity(w1, w2)
if sim is not None:
sims.append(sim)
print(f" {category}: {valid_words[:5]} ... "
f"类内平均相似度: {np.mean(sims):.4f}")
5.2 内在好 ≠ 外在好
这是一个经常被忽视的陷阱:词相似度任务得分很高的嵌入,在下游任务(文本分类、NER、情感分析)上不一定表现更好。
原因:词相似度任务评估的是"同义词是否接近"这一语言学属性;但下游分类任务可能需要的是"情感极性词是否分离"(褒义词/贬义词应该在向量空间中处于不同区域,而不是接近)。
实践建议:内在评估用于快速调参(比外在评估快 100 倍),外在评估是最终质量门控。两者都做,以外在为准。
6. 文档级向量:从词向量到文档表示
词向量解决了单个词的表示问题,但分类任务的输入通常是整篇文档。将词向量聚合为文档向量有几种策略:
python
from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np
class DocumentVectorizer:
"""
文档向量化策略对比
策略 1: TF-IDF 向量(稀疏,高维)
策略 2: 词向量简单平均(稠密,低维)
策略 3: TF-IDF 加权词向量平均(稠密,低维,权重合理)
策略 4: SIF 加权(Smooth Inverse Frequency,去除主成分)
"""
def __init__(self, word_vectors: dict, embed_dim: int = 100,
tfidf_corpus: List[str] = None):
self.word_vectors = word_vectors
self.embed_dim = embed_dim
# 训练 TF-IDF 用于加权
self.tfidf = TfidfVectorizer(max_features=50000)
if tfidf_corpus:
self.tfidf.fit(tfidf_corpus)
self.vocab = self.tfidf.vocabulary_
def mean_pooling(self, tokens: List[str]) -> np.ndarray:
"""简单词向量平均"""
vecs = [self.word_vectors[t] for t in tokens if t in self.word_vectors]
return np.mean(vecs, axis=0) if vecs else np.zeros(self.embed_dim)
def tfidf_weighted_mean(self, text: str) -> np.ndarray:
"""
TF-IDF 加权词向量平均
核心思想:停用词("的"、"是")TF-IDF 权重低,
关键词(主题词)权重高,平均时更多关注关键词
"""
tokens = text.lower().split()
tfidf_scores = {}
# 获取每个词在这个文档中的 TF-IDF 权重
if hasattr(self, 'vocab'):
doc_vec = self.tfidf.transform([text])
feature_names = self.tfidf.get_feature_names_out()
for i, score in zip(doc_vec.indices, doc_vec.data):
tfidf_scores[feature_names[i]] = score
weighted_vecs = []
weights = []
for token in tokens:
if token in self.word_vectors:
weight = tfidf_scores.get(token, 0.1) # 未知词给小权重
weighted_vecs.append(self.word_vectors[token] * weight)
weights.append(weight)
if not weighted_vecs:
return np.zeros(self.embed_dim)
total_weight = sum(weights)
return sum(weighted_vecs) / total_weight if total_weight > 0 else np.zeros(self.embed_dim)
def sif_embedding(self, sentences: List[List[str]],
alpha: float = 1e-3) -> np.ndarray:
"""
SIF (Smooth Inverse Frequency) Sentence Embedding
论文:Arora et al. 2017, "A Simple But Tough-to-Beat Baseline for Sentence Embeddings"
步骤:
1. 对每个句子,用 alpha/(alpha + p(w)) 加权词向量(p(w) 是词频概率)
2. 对所有句子向量构成矩阵,减去第一主成分(去除通用语义方向)
效果:在大多数短文本任务上,SIF 优于 TF-IDF 加权平均
"""
# 计算词频
all_words = [w for sent in sentences for w in sent]
total = len(all_words)
word_freq = Counter(all_words)
# Step 1: SIF 加权平均
sentence_vecs = []
for sent in sentences:
vecs = []
for w in sent:
if w in self.word_vectors:
p_w = word_freq[w] / total
weight = alpha / (alpha + p_w)
vecs.append(weight * self.word_vectors[w])
if vecs:
sentence_vecs.append(np.mean(vecs, axis=0))
else:
sentence_vecs.append(np.zeros(self.embed_dim))
sentence_matrix = np.array(sentence_vecs)
# Step 2: 减去第一主成分
from sklearn.decomposition import TruncatedSVD
svd = TruncatedSVD(n_components=1, random_state=42)
svd.fit(sentence_matrix)
first_pc = svd.components_[0]
# 去除每个句子向量在第一主成分方向上的投影
sentence_matrix -= sentence_matrix.dot(first_pc[:, None]) * first_pc[None, :]
return sentence_matrix
7. 嵌入向量的核心局限:一词一义问题
静态词嵌入(Word2Vec/GloVe/FastText)有一个无法绕过的局限:每个词只有一个向量,无论它出现在什么上下文中。
"bank"这个词:
- "He deposited money in the bank"(银行)
- "She sat on the river bank"(河岸)
静态嵌入给"bank"一个固定向量,这个向量是"银行"含义和"河岸"含义的某种混合------在两种用法中都不够精准。
python
class StaticEmbeddingLimitation:
"""演示静态嵌入的一词一义局限"""
def demonstrate(self, word_vectors: dict):
"""
通过词义相似度展示一词一义的问题
"""
print("=== 静态嵌入的一词一义局限 ===\n")
# 问题1:多义词无法区分上下文
polysemous_examples = [
("bank", ["financial institution", "river bank", "data bank"]),
("apple", ["apple fruit", "Apple company", "apple tree"]),
("python", ["python snake", "Python language"]),
]
print("多义词问题(静态嵌入只有一个向量):")
for word, meanings in polysemous_examples:
if word in word_vectors:
print(f" '{word}':静态向量同时代表 {meanings}")
print(f" 无法区分不同上下文中的不同含义")
# 问题2:同义词距离应该近但有时不够近
synonym_pairs = [
("happy", "joyful"),
("car", "automobile"),
("buy", "purchase"),
]
print("\n同义词余弦相似度(理论上应接近 1.0):")
for w1, w2 in synonym_pairs:
if w1 in word_vectors and w2 in word_vectors:
v1 = np.array(word_vectors[w1])
v2 = np.array(word_vectors[w2])
sim = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
print(f" {w1} ↔ {w2}: {sim:.4f}")
print("\n解决方案:")
print(" BERT(Bidirectional Encoder Representations from Transformers)")
print(" → 上下文相关嵌入:'bank' 在银行语境下和河岸语境下有不同的向量")
print(" → 这需要 Transformer 架构,将在深度学习专栏中详细讲解")
8. 预训练词嵌入 vs 从零训练
在拿到文本任务时,面临一个选择:使用通用预训练词嵌入(如 Google News Word2Vec 300 维,3M 词),还是在本领域语料上从零训练?
python
class EmbeddingStrategySelector:
"""
嵌入策略选择的决策框架
"""
@staticmethod
def select_strategy(domain_corpus_size: int,
is_domain_specific: bool,
has_oov_problem: bool,
downstream_task: str) -> str:
"""
决策框架:
1. 领域语料充足(>100M tokens)且高度专业化
→ 从零训练领域嵌入(医疗/法律/金融专有词语义与通用语料差异大)
2. 领域语料中等(10M-100M tokens)
→ 使用通用预训练嵌入初始化,在领域语料上 fine-tune
3. 领域语料少(<10M tokens)
→ 直接使用通用预训练嵌入(数据不够支撑从零训练)
4. 有 OOV 问题(大量新词/拼写变体)
→ 选择 FastText(子词嵌入)而非 Word2Vec/GloVe
"""
strategy = []
if domain_corpus_size > 100_000_000 and is_domain_specific:
strategy.append("从零训练领域特定嵌入(领域语料充足且高度专业化)")
elif domain_corpus_size > 10_000_000:
strategy.append("预训练嵌入 + 领域语料 fine-tune(迁移学习策略)")
else:
strategy.append("直接使用通用预训练嵌入(语料不足,避免欠拟合)")
if has_oov_problem:
strategy.append("选择 FastText 而非 Word2Vec(子词嵌入处理 OOV)")
if downstream_task in ['sentiment', 'intent_classification']:
strategy.append("TF-IDF 加权平均优于简单平均(情感/意图词权重更高)")
elif downstream_task in ['semantic_similarity', 'clustering']:
strategy.append("SIF 嵌入或句子平均(去除通用语义方向)")
return "\n".join(f"• {s}" for s in strategy)
# 几个典型场景的策略
scenarios = [
(50_000_000, True, True, 'intent_classification', "金融客服意图识别"),
(1_000_000, False, False, 'sentiment', "通用产品评论情感分析"),
(200_000_000, True, False, 'clustering', "医疗文献聚类"),
]
selector = EmbeddingStrategySelector()
for corpus_size, domain_specific, oov, task, name in scenarios:
print(f"\n场景:{name}")
print(selector.select_strategy(corpus_size, domain_specific, oov, task))
9. 完整实战:新闻分类特征方案对比
python
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score, classification_report
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np
def run_text_classification_comparison():
"""
对比四种文本特征方案在新闻分类上的效果
数据:AG News 风格的四类新闻(世界/体育/商业/科技)
指标:Macro F1(考虑类别不平衡)
"""
print("=== 新闻分类特征方案对比 ===\n")
# 模拟数据(真实场景中加载 AG News 数据集)
np.random.seed(42)
n_train, n_test = 10000, 2000
n_features_tfidf = 20000
embed_dim = 100
n_classes = 4
# 特征方案及模拟 F1 分数(基于学术论文典型值)
results = {
'特征方案': [
'TF-IDF(20K 词)',
'TF-IDF + 卡方特征选择(5K)',
'Word2Vec 平均(100d)',
'FastText 平均(100d)',
'TF-IDF 加权 Word2Vec',
'TF-IDF + Word2Vec 拼接',
'FastText(fasttext 原生分类器)',
],
'Macro F1': [0.882, 0.875, 0.869, 0.884, 0.893, 0.903, 0.921],
'训练时间(s)': [2.1, 1.8, 5.3, 4.2, 6.1, 3.4, 12.5],
'推理时间(ms/doc)': [0.8, 0.6, 1.2, 1.1, 1.4, 1.0, 0.5],
'OOV 处理': ['❌', '❌', '❌', '✅', '❌', '❌', '✅'],
'向量维度': ['20000', '5000', '100', '100', '100', '20100', '100'],
}
print(f"{'方案':<32} {'F1':>7} {'训练(s)':>9} {'推理(ms)':>9} {'OOV':>5} {'维度':>8}")
print("-" * 75)
for i in range(len(results['特征方案'])):
print(f"{results['特征方案'][i]:<32} "
f"{results['Macro F1'][i]:>7.3f} "
f"{results['训练时间(s)'][i]:>9.1f} "
f"{results['推理时间(ms/doc)'][i]:>9.1f} "
f"{results['OOV 处理'][i]:>5} "
f"{results['向量维度'][i]:>8}")
print("\n关键结论:")
print("1. TF-IDF 在新闻分类这类主题分类任务上竞争力依然强(词袋假设成立)")
print("2. 单纯词向量平均 < TF-IDF,但 TF-IDF 加权词向量 > 两者单独使用")
print("3. FastText 原生分类器最强(子词+层级 softmax 联合优化)")
print("4. TF-IDF + Word2Vec 拼接在内存/推理效率上不划算,通常不推荐")
# OOV 处理能力专项测试
print("\n=== OOV 处理能力测试 ===")
oov_test_texts = [
"iPhone15ProMax 的 Soc 性能超越了竞争对手",
"ChatGPT4o 最新版本已支持实时语音对话",
"Meta 推出的 VisionOS 混合现实系统备受关注",
]
print("包含大量新词/专有名词的测试文本:")
for text in oov_test_texts:
words = text.split()
# 模拟 OOV 识别(假设词表中没有 iPhone15ProMax 等新词)
oov_words = [w for w in words if len(w) > 6 and any(c.isupper() for c in w)]
print(f"\n 文本: {text[:50]}...")
print(f" 疑似 OOV 词: {oov_words}")
print(f" TF-IDF: 对 OOV 词特征为 0,影响分类")
print(f" FastText: 通过子词 n-gram 仍能提取语义信号")
run_text_classification_comparison()
10. 方法选型速查表
#mermaid-svg-dJChYhcP5tTPmqXK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dJChYhcP5tTPmqXK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dJChYhcP5tTPmqXK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dJChYhcP5tTPmqXK .error-icon{fill:#552222;}#mermaid-svg-dJChYhcP5tTPmqXK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dJChYhcP5tTPmqXK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dJChYhcP5tTPmqXK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dJChYhcP5tTPmqXK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dJChYhcP5tTPmqXK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dJChYhcP5tTPmqXK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dJChYhcP5tTPmqXK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dJChYhcP5tTPmqXK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dJChYhcP5tTPmqXK .marker.cross{stroke:#333333;}#mermaid-svg-dJChYhcP5tTPmqXK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dJChYhcP5tTPmqXK p{margin:0;}#mermaid-svg-dJChYhcP5tTPmqXK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-dJChYhcP5tTPmqXK .cluster-label text{fill:#333;}#mermaid-svg-dJChYhcP5tTPmqXK .cluster-label span{color:#333;}#mermaid-svg-dJChYhcP5tTPmqXK .cluster-label span p{background-color:transparent;}#mermaid-svg-dJChYhcP5tTPmqXK .label text,#mermaid-svg-dJChYhcP5tTPmqXK span{fill:#333;color:#333;}#mermaid-svg-dJChYhcP5tTPmqXK .node rect,#mermaid-svg-dJChYhcP5tTPmqXK .node circle,#mermaid-svg-dJChYhcP5tTPmqXK .node ellipse,#mermaid-svg-dJChYhcP5tTPmqXK .node polygon,#mermaid-svg-dJChYhcP5tTPmqXK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dJChYhcP5tTPmqXK .rough-node .label text,#mermaid-svg-dJChYhcP5tTPmqXK .node .label text,#mermaid-svg-dJChYhcP5tTPmqXK .image-shape .label,#mermaid-svg-dJChYhcP5tTPmqXK .icon-shape .label{text-anchor:middle;}#mermaid-svg-dJChYhcP5tTPmqXK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dJChYhcP5tTPmqXK .rough-node .label,#mermaid-svg-dJChYhcP5tTPmqXK .node .label,#mermaid-svg-dJChYhcP5tTPmqXK .image-shape .label,#mermaid-svg-dJChYhcP5tTPmqXK .icon-shape .label{text-align:center;}#mermaid-svg-dJChYhcP5tTPmqXK .node.clickable{cursor:pointer;}#mermaid-svg-dJChYhcP5tTPmqXK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dJChYhcP5tTPmqXK .arrowheadPath{fill:#333333;}#mermaid-svg-dJChYhcP5tTPmqXK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dJChYhcP5tTPmqXK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dJChYhcP5tTPmqXK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dJChYhcP5tTPmqXK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dJChYhcP5tTPmqXK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dJChYhcP5tTPmqXK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dJChYhcP5tTPmqXK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dJChYhcP5tTPmqXK .cluster text{fill:#333;}#mermaid-svg-dJChYhcP5tTPmqXK .cluster span{color:#333;}#mermaid-svg-dJChYhcP5tTPmqXK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dJChYhcP5tTPmqXK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dJChYhcP5tTPmqXK rect.text{fill:none;stroke-width:0;}#mermaid-svg-dJChYhcP5tTPmqXK .icon-shape,#mermaid-svg-dJChYhcP5tTPmqXK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dJChYhcP5tTPmqXK .icon-shape p,#mermaid-svg-dJChYhcP5tTPmqXK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dJChYhcP5tTPmqXK .icon-shape .label rect,#mermaid-svg-dJChYhcP5tTPmqXK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dJChYhcP5tTPmqXK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dJChYhcP5tTPmqXK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dJChYhcP5tTPmqXK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 主题分类
关键词匹配
语义相似度
情感/意图识别
实体识别
序列标注
< 1M 条
1M ~ 100M 条
> 100M 条
是(专业领域)
否(通用文本)
效果不足
文本特征工程
需求分析
任务类型
TF-IDF
简单高效,可解释
语料规模
词向量 + 序列模型
(见DL专栏)
预训练词嵌入
直接使用 Word2Vec/FastText
是否有大量新词/OOV
从零训练领域嵌入
或 BERT 微调
FastText
子词嵌入处理OOV
Word2Vec/GloVe
+TF-IDF加权平均
升级到
TF-IDF加权词向量
| 方法 | 语义捕捉 | OOV 处理 | 计算成本 | 可解释性 | 推荐场景 |
|---|---|---|---|---|---|
| TF-IDF | ❌ 字面 | ❌ | 极低 | ✅ 高 | 主题分类、关键词匹配 |
| Word2Vec CBOW | ✅ 局部语义 | ❌ | 低 | ⚠️ 中 | 语义相似、低频词少 |
| Word2Vec Skip-Gram | ✅✅ 局部语义 | ❌ | 中 | ⚠️ 中 | 大数据集,语义精度要求高 |
| GloVe | ✅✅ 全局+局部 | ❌ | 中(预训练) | ⚠️ 中 | 通用场景,有预训练资源 |
| FastText | ✅ 局部语义 | ✅✅ | 中 | ⚠️ 中 | OOV 多、新词多、多语言 |
| TF-IDF 加权词向量 | ✅✅ | 取决于基底 | 中 | ✅ 中高 | 多数场景的强基线 |
结语
从 One-Hot 到 TF-IDF,再到 Word2Vec/GloVe/FastText------文本表示经历了从"字面统计"到"分布式语义"的演进。每一代方法的出现都是为了解决上一代的具体局限:TF-IDF 解决了 One-Hot 的维度爆炸,Word2Vec 解决了 TF-IDF 的语义盲区,FastText 解决了 Word2Vec 的 OOV 问题。
但这条演进路线有一个未解决的问题:静态嵌入无法区分同一个词在不同上下文中的含义。"苹果"在"苹果公司发布新品"和"苹果树结果了"中代表完全不同的概念,却共享同一个向量。这个局限,正是 Transformer 和 BERT 要解决的------上下文相关的动态嵌入,将在深度学习专栏中系统讲解。
FastText 的子词嵌入是实用性最强的静态嵌入方法------不仅继承了 Word2Vec 的语义表示能力,还解决了专业领域和多语言场景中的 OOV 问题。在 BERT 不可用(推理资源受限、实时性要求高)的场景下,FastText + TF-IDF 加权是兼顾效果与效率的工程之选。
前文相关阅读:
- 概率模型与 NLP 基础:贝叶斯/朴素贝叶斯/文本分类/语言模型入门(TF-IDF + 朴素贝叶斯的文本分类基础,本篇的进阶版)
- 降维与嵌入:PCA/t-SNE/UMAP 的原理与可视化应用(词嵌入空间的可视化方法)
- 不平衡数据处理实战(文本分类中类别不平衡的处理)
- Learning to Rank:搜索排序场景的点式/对式/列表式方法(词嵌入作为 LTR 特征的应用场景)
- 机器学习项目方法论:从业务问题到算法选型(如何根据 NLP 业务需求选择正确的文本表示方法)
如果这篇文章帮助梳理了文本特征工程的演进脉络,欢迎点赞收藏------这是持续深耕技术内容最直接的鼓励。关注不迷路。