前面学习文本处理时,我们已经知道:自然语言不能直接送进模型,必须先变成数字。

最朴素的方法是 One-hot。比如词表里有 10000 个词,每个词就用一个长度为 10000 的向量表示,属于自己的位置为 1,其他位置为 0。

但 One-hot 有两个明显问题:

  • 向量太稀疏,绝大多数位置都是 0。
  • 词和词之间没有语义关系,“国王”和“王后”在 One-hot 空间里并不比“国王”和“香蕉”更接近。

word2vec 要解决的就是这个问题:

把词表示成低维、稠密、带有语义关系的向量。

例如:

国王 -> [0.32, -0.18, 0.71, ...]
王后 -> [0.29, -0.15, 0.69, ...]
香蕉 -> [-0.44, 0.83, 0.12, ...]

如果训练得好,“国王”和“王后”的向量会更接近,而“国王”和“香蕉”的向量会更远。

这篇文章就从 word2vec 出发,理解词向量是怎么训练出来的、怎么改进和评价词向量,并进一步过渡到神经网络文本分类器。

从 word2vec 到神经网络分类器

1. 词向量到底是什么

词向量,也叫 word embedding,本质上就是给每个词分配一个可学习的向量。

如果词表大小是 $V$,词向量维度是 $d$,那么所有词向量可以组成一个矩阵:

$$
E \in \mathbb{R}^{V \times d}
$$

其中:

  • $V$ 是词表大小。
  • $d$ 是词向量维度,例如 50、100、300。
  • 矩阵第 $i$ 行就是第 $i$ 个词的向量。

如果“喜欢”在词表中的编号是 12,那么:

embedding[12]

就是“喜欢”这个词的向量。

在神经网络里,Embedding 层看起来像一层网络,但它本质上更像查表:

词 id -> 去词向量矩阵中取对应行 -> 得到词向量

例如:

import torch
import torch.nn as nn

embedding = nn.Embedding(num_embeddings=10000, embedding_dim=100)

word_ids = torch.tensor([12, 35, 87])
vectors = embedding(word_ids)

print(vectors.shape)  # torch.Size([3, 100])

这里输入 3 个词 id,输出 3 个 100 维词向量。

2. word2vec 的核心思想

word2vec 的核心假设来自语言学中的分布式假说:

一个词的含义,可以从它经常出现的上下文中推断出来。

比如:

我 喜欢 自然语言 处理
我 喜欢 机器 学习
我 喜欢 深度 学习

“自然语言”“机器”“深度”经常和“学习”“处理”“模型”等词一起出现。
随着语料越来越多,模型会逐渐发现:

  • 经常出现在相似上下文中的词,语义可能相近。
  • 经常互相搭配的词,向量应该具有较高相似度。
  • 很少一起出现、上下文差异很大的词,向量应该相对远一些。

word2vec 并不是靠人工告诉模型“国王”和“王后”相似。
它是通过大量文本中的共现关系,把这种相似性训练出来。

3. 训练样本从哪里来

word2vec 不需要人工标注情感类别、主题类别。
它可以直接从普通文本中构造训练样本。

假设有一句话:

我 喜欢 自然语言 处理

如果窗口大小为 1,那么每个词只看左右各 1 个词。

对于中心词“喜欢”:

上下文:我,自然语言
中心词:喜欢

对于中心词“自然语言”:

上下文:喜欢,处理
中心词:自然语言

也就是说,word2vec 会用滑动窗口从文本中自动构造很多训练样本。

4. CBOW 和 Skip-gram

word2vec 常见有两种训练方式:

  • CBOW:Continuous Bag of Words
  • Skip-gram

它们的区别在于预测方向不同。

CBOW 与 Skip-gram 对比

4.1 CBOW:用上下文预测中心词

CBOW 的训练目标是:

给定上下文词,预测中心词

例如:

我 ___ 自然语言 处理

模型看到“我”“自然语言”“处理”,要预测中间缺失的词可能是“喜欢”。

如果用公式表达,可以写成:

$$
P(w_t \mid w_{t-k}, \cdots, w_{t-1}, w_{t+1}, \cdots, w_{t+k})
$$

其中:

  • $w_t$ 是中心词。
  • $k$ 是窗口大小。
  • 周围的词是上下文。

CBOW 通常训练速度较快,因为它把多个上下文词的信息平均或相加后,用来预测一个中心词。

4.2 Skip-gram:用中心词预测上下文

Skip-gram 的训练目标刚好相反:

给定中心词,预测它周围可能出现哪些上下文词

例如中心词是“喜欢”,模型要预测:

我
自然语言
处理
机器
学习

也就是学习:

$$
P(w_{t+j} \mid w_t),\quad -k \le j \le k,\ j \ne 0
$$

Skip-gram 对低频词通常更友好,因为每个中心词会产生多个训练样本。

可以简单记:

方法 输入 输出 特点
CBOW 上下文词 中心词 训练较快,对高频模式稳定
Skip-gram 中心词 上下文词 更适合学习低频词表示

5. 一个小例子:Skip-gram 如何构造样本

句子:

我 喜欢 自然语言 处理

窗口大小设为 1。

Skip-gram 会构造:

中心词 上下文词
喜欢
喜欢
喜欢 自然语言
自然语言 喜欢
自然语言 处理
处理 自然语言

如果词表是:

我:0, 喜欢:1, 自然语言:2, 处理:3

那么训练样本可以写成:

(0, 1)
(1, 0)
(1, 2)
(2, 1)
(2, 3)
(3, 2)

模型要做的事情是:

输入中心词 id
  -> 查中心词向量
  -> 和候选上下文词向量计算相似度
  -> 让真实上下文词得分更高

6. word2vec 的神经网络结构

以 Skip-gram 为例,一个简化结构如下:

中心词 one-hot
  -> 输入词向量矩阵 W_in
  -> 得到中心词向量 v
  -> 输出词向量矩阵 W_out
  -> 预测上下文词概率

如果用 One-hot 输入,假设中心词编号是 1:

[0, 1, 0, 0]

乘上词向量矩阵:

$$
W_{in} \in \mathbb{R}^{V \times d}
$$

得到的其实就是第 1 行词向量。

所以从实现角度看:

Embedding 查表 等价于 One-hot 向量乘词向量矩阵,但查表更高效。

训练过程中,模型会不断调整 $W_{in}$ 和 $W_{out}$。
训练完成后,通常会取 $W_{in}$ 作为最终词向量。

7. 为什么不能直接对整个词表做 softmax

如果词表很小,模型可以直接预测整个词表的概率:

$$
P(w_o \mid w_i)=
\frac{\exp(v_{w_o}^{\top}v_{w_i})}
{\sum_{w=1}^{V}\exp(v_w^{\top}v_{w_i})}
$$

问题是,真实 NLP 任务中的词表可能非常大。
如果词表有 100000 个词,每个训练样本都要对 100000 个词计算 softmax,成本会很高。

因此 word2vec 常用一些近似训练方法来加速,比如:

  • Hierarchical Softmax
  • Negative Sampling

其中负采样最常见,也最容易理解。

8. 负采样:让真实词对更像,随机词对更不像

负采样的思路是:

不再让模型从整个词表中选正确答案,而是让模型判断“中心词和上下文词是否真的一起出现过”。

例如真实语料中出现过:

喜欢 -> 自然语言

这就是正样本,标签为 1。

然后随机采几个词作为负样本:

喜欢 -> 冰箱
喜欢 -> 火车
喜欢 -> 香蕉

这些大概率不是当前中心词的真实上下文,标签为 0。

负采样训练 word2vec

模型计算中心词向量和上下文词向量的点积:

$$
score = v_c^\top v_o
$$

再通过 Sigmoid 得到概率:

$$
\sigma(score)=\frac{1}{1+e^{-score}}
$$

训练目标是:

  • 正样本的 $\sigma(score)$ 越接近 1 越好。
  • 负样本的 $\sigma(score)$ 越接近 0 越好。

这会推动:

  • 经常共现的词向量更接近。
  • 随机无关的词向量更远。

9. 手工理解一次负采样更新

假设中心词是“喜欢”,真实上下文是“学习”。

它们当前向量点积为:

$$
v_{\text{喜欢}}^\top v_{\text{学习}} = 0.2
$$

经过 Sigmoid:

$$
\sigma(0.2) \approx 0.55
$$

对于正样本来说,目标是 1,当前只有 0.55,说明模型还不够确信这两个词应该共现。
训练会把它们的向量往更相似的方向推。

再看一个负样本:

喜欢 -> 冰箱

假设点积也是 0.2,Sigmoid 也是 0.55。
但负样本的目标是 0,模型现在却给了 0.55,说明它错误地认为“喜欢”和“冰箱”比较相关。
训练会把这两个词的向量往更不相似的方向推。

所以负采样不是直接告诉模型“每个词是什么意思”,而是通过大量这样的正负样本,让向量空间慢慢变得有结构。

10. 高频词和低频词的问题

真实语料中有很多高频词,比如:

的、了、是、在、the、of、and

这些词出现次数很多,但语义区分度不一定强。
如果不处理,它们会在训练样本中占据太大比例,影响词向量质量。

word2vec 常见改进包括:

10.1 高频词下采样

对于特别高频的词,可以按一定概率丢弃一部分训练样本。

直觉是:

“的”少看几次,模型仍然知道它很常见;但给低频词更多机会,可能更有价值。

这样可以减少高频虚词对训练的干扰,也能提升训练速度。

10.2 负样本分布调整

负样本不是完全均匀随机采样。
常见做法是按照词频的 $3/4$ 次方构造采样分布:

$$
P(w)=\frac{f(w)^{3/4}}{\sum_i f(w_i)^{3/4}}
$$

这样既会更常抽到高频词,又不会让最高频词压倒一切。

10.3 调整窗口大小

窗口大小会影响词向量捕捉的信息:

窗口大小 更容易学到
小窗口 语法关系、局部搭配
大窗口 主题关系、语义相关

例如小窗口可能让“吃”和“苹果”靠近,大窗口可能让“电影”“导演”“演员”靠近。

10.4 增加语料和清洗质量

词向量非常依赖语料。

如果语料太少,模型学不到稳定的共现关系。
如果语料领域不匹配,词向量也会偏向错误语义。

例如:

  • 医学语料训练出的“阳性”“阴性”和普通语料不同。
  • 编程语料中的“类”“对象”“继承”有特定含义。
  • 电商评论中的“苹果”可能更多指手机品牌,而不是水果。

所以训练词向量时要注意语料领域。

11. 怎么评价词向量

词向量训练好以后,不能只看训练 loss。
更重要的是看它是否真的学到了语义关系,以及能不能帮助下游任务。

词向量评价方法

11.1 最近邻词

最简单的方法是找某个词最相似的词。

常用余弦相似度:

$$
\cos(a,b)=\frac{a^\top b}{|a||b|}
$$

如果输入“国王”,比较好的结果可能是:

王后、王子、皇帝、君主

如果出现大量无关词,说明词向量质量可能不够好。

11.2 词类比

word2vec 最经典的现象之一是词向量可以表达某些方向关系。

例如:

国王 - 男人 + 女人 ≈ 王后

或者:

巴黎 - 法国 + 日本 ≈ 东京

这说明向量空间中不只是“相似词靠近”,还可能存在某些可迁移的语义方向。

但要注意,这类例子不能神化。
它能说明词向量有一定结构,但不代表模型真正理解了人类意义上的知识。

11.3 可视化

词向量通常是 100 维、300 维,无法直接画出来。
可以用 PCA、t-SNE 或 UMAP 降到二维,再观察相近词是否聚在一起。

例如:

猫、狗、兔子
北京、上海、广州
篮球、足球、网球

如果同类词能形成相对清晰的簇,说明向量空间有一定语义结构。

11.4 下游任务评价

最终最可靠的评价还是下游任务。

例如把词向量用于:

  • 文本情感分类。
  • 新闻主题分类。
  • 搜索召回。
  • 推荐系统。
  • 命名实体识别。

如果使用训练好的词向量后,验证集准确率、F1、召回率等指标提升了,说明这些词向量对任务有帮助。

12. word2vec 的局限

word2vec 很重要,但它也有局限。

12.1 一词多义问题

word2vec 通常给每个词一个固定向量。

例如“苹果”:

我吃了一个苹果
我买了一台苹果手机

这两个句子里的“苹果”含义不同,但 word2vec 里通常只有一个“苹果”向量。

这会导致多义词表示不够精确。
后来 BERT、GPT 这类上下文相关模型会根据句子动态生成词表示,从而缓解这个问题。

12.2 无法直接处理未登录词

如果训练词表里没有某个词,word2vec 就很难直接给它一个好向量。

例如训练时没见过:

超导量子芯片

模型就无法直接查到它的词向量。

后来 fastText 通过子词 n-gram 改进了这一点。
它不仅学习整个词的向量,也学习词内部片段的向量,因此对未登录词更友好。

12.3 只利用局部窗口

word2vec 主要基于局部上下文窗口训练。
它能学到很多共现关系,但对长距离依赖、句法结构、篇章关系的建模能力有限。

这也是后来 RNN、CNN、Transformer 在 NLP 中继续发展的原因。

13. 从词向量过渡到神经网络分类器

训练好词向量以后,下一步通常是把它用于下游任务。

以情感分类为例:

输入:这部电影很好看
输出:正面

模型流程可以是:

词向量到神经网络分类器

具体步骤:

  1. 分词。
  2. 把每个词转成词 id。
  3. 用 Embedding 层把词 id 转成词向量。
  4. 把多个词向量合成一个句子向量。
  5. 用全连接层输出类别 logits。
  6. 使用交叉熵损失训练分类器。

一个最简单的句子分类器可以写成:

句子 token ids
  -> Embedding
  -> Mean Pooling
  -> Linear
  -> 分类 logits

14. PyTorch 实现一个简单文本分类器

下面是一个极简版本,重点看结构。

import torch
import torch.nn as nn

class TextClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, num_classes):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.classifier = nn.Linear(embed_dim, num_classes)

    def forward(self, input_ids):
        # input_ids: [batch_size, seq_len]
        x = self.embedding(input_ids)
        # x: [batch_size, seq_len, embed_dim]

        sentence_vec = x.mean(dim=1)
        # sentence_vec: [batch_size, embed_dim]

        logits = self.classifier(sentence_vec)
        # logits: [batch_size, num_classes]

        return logits

假设:

model = TextClassifier(
    vocab_size=10000,
    embed_dim=100,
    num_classes=2
)

input_ids = torch.tensor([
    [12, 35, 87, 0, 0],
    [44, 19, 63, 91, 0]
])

logits = model(input_ids)
print(logits.shape)  # torch.Size([2, 2])

这里:

  • batch_size = 2
  • seq_len = 5
  • embed_dim = 100
  • num_classes = 2

输出 [2, 2] 表示每个句子对应两个类别的 logits。

15. 如何使用预训练 word2vec 初始化 Embedding

如果已经有训练好的 word2vec 向量,可以用它初始化 nn.Embedding

假设预训练矩阵为:

pretrained_weight.shape  # [vocab_size, embed_dim]

可以这样写:

embedding = nn.Embedding.from_pretrained(
    embeddings=pretrained_weight,
    freeze=False
)

其中:

  • freeze=True:训练分类器时固定词向量,不更新。
  • freeze=False:训练分类器时允许词向量继续微调。

如果数据量较小,可以先固定词向量,避免过拟合。
如果下游任务数据较多,通常可以允许词向量微调,让它更适合当前任务。

16. 为什么需要 padding 和 mask

真实句子的长度通常不一样:

很好看
这部电影真的非常好看

为了组成 batch,通常会 padding 到相同长度:

[很好看, PAD, PAD, PAD]
[这部, 电影, 真的, 好看]

如果直接对所有位置做平均,PAD 的向量也会被算进去,影响句子表示。

所以更严谨的 mean pooling 应该排除 PAD:

class TextClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, num_classes, pad_id=0):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_id)
        self.classifier = nn.Linear(embed_dim, num_classes)
        self.pad_id = pad_id

    def forward(self, input_ids):
        x = self.embedding(input_ids)
        # x: [batch_size, seq_len, embed_dim]

        mask = (input_ids != self.pad_id).unsqueeze(-1)
        # mask: [batch_size, seq_len, 1]

        x = x * mask
        lengths = mask.sum(dim=1).clamp(min=1)
        sentence_vec = x.sum(dim=1) / lengths

        logits = self.classifier(sentence_vec)
        return logits

这里的 padding_idx=pad_id 会让 PAD 对应的 embedding 不参与更新。
mask 则保证池化时不把 PAD 算进有效词。

17. word2vec 和现代 Embedding 的关系

word2vec 是理解词向量非常好的入口。

它让我们明白:

  • 词向量不是人工规定的,而是训练出来的。
  • 训练目标可以来自文本自身,不一定需要人工标签。
  • 向量空间可以编码相似性、共现关系和某些语义方向。
  • Embedding 层本质上是可学习的查表矩阵。

后来的很多模型仍然保留了这个核心思想:

离散 token -> 连续向量 -> 神经网络处理

区别在于:

  • word2vec 给每个词一个固定向量。
  • RNN/CNN 会在词向量之上继续建模句子结构。
  • Transformer 会根据上下文动态生成 token 表示。
  • BERT/GPT 里的 embedding 不只是词向量,还包含位置、上下文和深层语义信息。

所以学 word2vec 并不是过时,而是在理解现代 NLP 模型的地基。

18. 总结

word2vec 可以用一句话概括:

通过预测上下文或中心词,让经常出现在相似上下文中的词拥有相似的向量表示。

这篇文章的主线是:

One-hot 太稀疏
  -> 需要稠密词向量
  -> word2vec 用上下文预测训练词向量
  -> CBOW 和 Skip-gram 是两种训练方向
  -> 负采样让训练更高效
  -> 词向量可以通过相似词、类比、可视化和下游任务评价
  -> 词向量可以进一步接入神经网络分类器

理解 word2vec 后,再看文本分类、RNN、CNN、Transformer,会更容易抓住核心:

先把离散词变成连续向量,
再让神经网络在这些向量上学习任务需要的表示。

参考资料

  • Tomas Mikolov 等,Efficient Estimation of Word Representations in Vector Space。
  • Tomas Mikolov 等,Distributed Representations of Words and Phrases and their Compositionality。
  • Yoav Goldberg, Omer Levy,word2vec Explained: deriving Mikolov et al.’s negative-sampling word-embedding method。