词向量深度笔记:从 OneHot 到 Word2Vec(逻辑链 + 代码)
前言
这是一篇关于 NLP 基石——词向量(Word Embeddings)的系统笔记,内容来源于课程讲义、教材阅读和个人实践整理。
本文的核心目标是讲清楚逻辑链条:为什么需要词向量?OneHot 哪里不够?Word2Vec 如何改进?FastText 又解决了什么新问题?每个技术点都会按照"问题 → 朴素方案 → 局限 → 改进思路 → 公式/定义 → 直觉解释 → 工程实践"的路径展开。
掌握这些,你就理解了现代 NLP 模型是如何"读懂"文字的第一步。
1. 词向量基础:为什么需要向量化?
逻辑链条
- 问题:计算机只认识数字,无法直接处理"苹果""香蕉"这种符号。
- 需求:必须把文本转化为计算机可处理的向量形式。
- 目标:让模型理解文字的含义,而不仅仅是把它们当成编号。
词向量的作用
将离散的单词映射为低维、稠密、可学习的实数向量,用于刻画词的语义与句法特征。
为什么重要
- 让自然语言变成机器可处理的向量形式,是几乎所有 NLP 神经模型的输入基础。
- 通过向量距离与方向表达词间相似度与类比关系(如king−man+woman≈queen \text{king} - \text{man} + \text{woman} \approx \text{queen}king−man+woman≈queen)。
- 显著优于 OneHot 等手工编码,是现代 NLP 性能提升的关键一步。
2. OneHot 编码:最简单的词表示
2.1 什么是 OneHot?
逻辑链条
- 朴素方案:用数字编号(1=红色,2=蓝色,3=绿色)。
- 局限:这会让模型误以为"3 > 2 > 1",即"绿色比红色大",这在分类问题中是错误的。
- 改进方案:OneHot(独热编码)——每个类别占据独立的一维,彼此正交。
定义
将每个词转换为一个只包含0 和 1的二进制向量。向量中只有一个位置为 1(对应该词在词表中的索引),其余位置均为0。
直觉理解
想象一个巨大的开关面板,词表里有多少个词,就有多少个开关。对于"红色"这个词,只有第 1 个开关是开的;对于"蓝色",只有第 2 个开关是开的。它们在空间里是互相垂直的,完全独立。
示例代码
importnumpyasnpfromsklearn.preprocessingimportOneHotEncoder# 示例数据:包含三个类别的列表categories=['红色','蓝色','绿色','蓝色','红色']categories=np.array(categories).reshape(-1,1)# 创建 OneHotEncoder 对象encoder=OneHotEncoder(sparse_output=False)one_hot_encoded=encoder.fit_transform(categories)print("One-Hot 编码结果:")print(one_hot_encoded)# 输出:# [[0. 0. 1.] <-- 红色# [0. 1. 0.] <-- 蓝色# [1. 0. 0.] <-- 绿色# [0. 1. 0.] <-- 蓝色# [0. 0. 1.]] <-- 红色print("类别顺序:",encoder.categories_)### 2.2 为什么需要 OneHot?(三大理由)|理由|解释||:---|:---||不排队|不用1、2、3这种数字,避免让本来没大小的类别"假装有顺序"||好算账|变成0/1向量,就能在"坐标系"里算距离、点积,让模型听得懂这些特征||能一起算|和别的数值特征放在一起做归一化、训练模型都很自然、很方便|### 2.3 OneHot 的优点与缺点#### 优点-解决了分类器不好处理**属性数据**的问题。-在一定程度上起到了**扩充特征**的作用。-值只有0和1,不同类型存储在**垂直的空间**(几何上等距)。#### 缺点-**维度灾难**:词表有10万个词,每个词就是长度10万的向量(99,999个0),极度稀疏。-**无法表达语义关系**:任意两个不同词的余弦相似度都是0。 $$ \text{similarity}(\text{麦当劳},\text{肯德基})=0$$ 模型无法知道"麦当劳"和"肯德基"是相似的。---## 3. Word2Vec:从符号到语义的飞跃### 逻辑链条-**问题**:OneHot 太稀疏,且完全不懂语义。-**核心假设**:**分布假设**——一个词的含义由它周围的词(上下文)决定。-**方案**:通过预测上下文或中心词,训练出稠密的词向量。### Word2Vec 定义一种用于生成**词向量(Embedding)**的模型,通过训练**浅层神经网络**来学习词向量表示。### 两种架构1.**CBOW**(Continuous Bag of Words):用上下文预测中心词。2.**Skip-Gram**:用中心词预测上下文。---### 3.1 CBOW 模型:用周围词猜中间词#### 逻辑链条-**任务**:做"完形填空"——给你前后文,猜中间的空是什么词。-**输入**:上下文词(左右窗口内的词)。-**处理**:把上下文词的向量**平均化**。-**输出**:预测中心词。#### CBOW 的网络结构1.**输入层**:上下文词的 OneHot 向量(或直接用索引查表)。2.**隐藏层**:查询嵌入矩阵 $W_1$,得到每个词的向量,然后**求平均**$h=\frac{1}{k}\sum_{i}W_1[c_i]$。3.**输出层**:用矩阵 $W_2$ 计算分数 $u=W_2^T h$,经过 Softmax 得到概率分布。#### 代码实现(带详细注释)```pythonimportnumpyasnp# ==== 第一步:准备数据 ====corpus=[["I","love","NLP"],["NLP","is","fun"]]# 语料库vocab=list(set(wforsincorpusforwins))# 去重得到词表word_to_idx={w:ifori,winenumerate(vocab)}# 词→索引V=len(vocab)# 词汇表大小(比如 6 个词)D=3# 词向量维度(3 维空间)# ==== 第二步:随机初始化两个矩阵 ====W1=np.random.randn(V,D)*0.1# 输入矩阵(最终的词向量)W2=np.random.randn(D,V)*0.1# 输出矩阵(工具人矩阵)lr=0.01# 学习率window=1# 窗口大小:左右各看 1 个词# ==== 第三步:Softmax 函数 ====defsoftmax(x):e=np.exp(x-np.max(x))# 防溢出:先减最大值returne/e.sum()# 归一化成概率# ==== 第四步:训练循环 ====forepochinrange(50):# 训练 50 轮forsentincorpus:# 遍历每个句子foriinrange(len(sent)):# 遍历句子里每个词target=sent[i]# 中心词(要预测的)t_idx=word_to_idx[target]# 收集上下文词(左右窗口内的词)ctx=[]forjinrange(max(0,i-window),min(len(sent),i+window+1)):ifj!=i:# 排除中心词自己ctx.append(word_to_idx[sent[j]])ifnotctx:continue# 没上下文就跳过# 【核心】前向传播:上下文词向量的平均h=np.mean(W1[ctx],axis=0)# shape: (D,)# 预测中心词:h 乘以 W2 得到每个词的分数u=W2.T @ h# shape: (V,)y_pred=softmax(u)# 变成概率分布# 计算误差:真实标签 - 预测概率y_true=np.zeros(V)y_true[t_idx]=1# one-hot 向量e=y_pred-y_true# 误差向量# 【核心】反向传播:更新权重dW2=np.outer(h,e)# W2 的梯度dh=W2 @ e# 传回 h 的梯度# 更新两个矩阵W2-=lr*dW2forcinctx:W1[c]-=lr*dh/len(ctx)# 平均分配给每个上下文词# ==== 第五步:使用词向量 ====word_vec=W1[word_to_idx["NLP"]]print("NLP 的词向量:",word_vec)口诀式总结
中心词做主角,逐个预测邻居,每个邻居单独更新。
3.3 CBOW vs Skip-Gram:如何选择?
| 维度 | CBOW | Skip-Gram |
|---|---|---|
| 输入 → 输出 | 上下文 → 中心词 | 中心词 → 上下文 |
| 隐藏层处理 | 上下文词向量平均化 | 直接取中心词向量 |
| 训练样本数 | 每个窗口 1 个样本 | 每个窗口2k2k2k个样本(kkk为窗口大小) |
| 训练速度 | 快(一次预测一个词) | 慢(一次预测kkk个词) |
| 对高频词 | 效果好,语义平滑 | 也好 |
| 对低频词 | 较弱(被平均稀释) | 强(单独建模,更细粒度) |
| 适用场景 | 大规模语料快速训练 | 关注语义精度,尤其是罕见词 |
选择建议
- 数据量巨大、追求训练速度 →CBOW
- 关注低频实体名词质量(如专业术语、人名、地名)→Skip-Gram
- 实践中可两者都训练,比较下游任务性能后再做选择
4. 训练优化:解决 Softmax 的瓶颈
逻辑链条
- 问题:Word2Vec 的输出层是 Softmax,分母要对全词表(比如 100 万个词)求和:escore∑w=1Vescore\frac{e^{score}}{\sum_{w=1}^{V} e^{score}}∑w=1Vescoreescore。每算一个样本都要算 100 万次指数,速度太慢!
- 朴素方案:能不能不算所有词?
- 改进方案 1:负采样(Negative Sampling)——把多分类变成二分类。
- 改进方案 2:层次化 Softmax(Hierarchical Softmax)——用二叉树组织词表。
4.1 负采样(Negative Sampling)
核心思想
我们不再做"从 100 万个词里找正确的那 1 个"这种多分类任务,而是改成做"是非题":
- (中心词,正确上下文) → 标签 1(正样本)
- (中心词,随机抽的噪声词) → 标签 0(负样本)
这样,我们只需要算1 个正样本和kkk个负样本(通常k=5k=5k=5),复杂度从O(V)O(V)O(V)降到了O(k)O(k)O(k)。
目标函数
对于中心词wcw_cwc与真实上下文词wow_owo,最大化:
logσ(vwo⊤vwc)+∑i=1kEwi∼Pn(w)[logσ(−vwi⊤vwc)] \log \sigma(v_{w_o}^\top v_{w_c}) + \sum_{i=1}^{k} \mathbb{E}_{w_i \sim P_n(w)}\left[\log \sigma(-v_{w_i}^\top v_{w_c})\right]logσ(vwo⊤vwc)+i=1∑kEwi∼Pn(w)[logσ(−vwi⊤vwc)]
- 第一项:让正样本的 sigmoid 概率接近 1。
- 第二项:让负样本的 sigmoid 概率接近 0(即−v-v−v的 sigmoid 接近 1)。
代码实现
代码实现(核心区别)
importnumpyasnp# ==== 准备数据(和 CBOW 一样)====corpus=[["I","love","NLP"]]vocab=list(set(wforsincorpusforwins))word_to_idx={w:ifori,winenumerate(vocab)}V,D=len(vocab),3# ==== 初始化两个矩阵 ====W1=np.random.randn(V,D)*0.1W2=np.random.randn(D,V)*0.1lr=0.01window=1defsoftmax(x):e=np.exp(x-np.max(x))returne/e.sum()# ==== 训练循环(核心区别在这里!)====forepochinrange(50):forsentincorpus:foriinrange(len(sent)):center_word=sent[i]# 中心词是"主角"center_idx=word_to_idx[center_word]h=W1[center_idx]# 【关键】直接拿出主角的向量,不用平均!# 收集上下文词(邻居们)context_words_indices=[]forjinrange(max(0,i-window),min(len(sent),i+window+1)):ifj!=i:context_words_indices.append(word_to_idx[sent[j]])ifnotcontext_words_indices:continue# 【关键】对每一个邻居进行一次预测和更新fortarget_ctx_idxincontext_words_indices:# 前向传播:用主角 h 去预测邻居u=W2.T @ h y_pred=softmax(u)# 真实答案是邻居的 one-hoty_true=np.zeros(V)y_true[target_ctx_idx]=1# 计算误差e=y_pred-y_true# 反向传播 & 更新权重dW2=np.outer(h,e)dh=W2 @ e W2-=lr*dW2 W1[center_idx]-=lr*dh# 只更新主角的向量!# 最终词向量就是 W1word_vec=W1[word_to_idx["love"]]print("love 的词向量:",word_vec)实战要点
- 负样本数 (k):常用 2–10,语料越大可以越小。
- 噪声分布 (P_n(w)):实践常用词频的 (3/4) 次幂归一化,兼顾高频与长尾。
- 优点:每次更新只动很少一部分向量,非常适合向量化和并行。
4.2 层次化 Softmax(Hierarchical Softmax)
核心结构
- 用一棵二叉树表示整个词表,每个叶子是一个词。
- 预测一个词的概率 = 从根走到该叶子路径上,所有二分类概率的乘积。
- 计算复杂度与树高成正比,一般是 (O(\log V))。
霍夫曼树(Huffman Tree)
- 建树思路:按词频从小到大构建二叉树:
- 高频词路径短(靠近根)。
- 低频词路径长(离根较远)。
- 预测思路:
- 从根到叶子,每一次用 sigmoid 做“向左 / 向右”的二分类决策。
- 走完整条路径,大约需要 (\log V) 次二分类。
- 和普通 Softmax 对比:
- 普通 Softmax:每次要算 (V) 个得分。
- 层次化 Softmax:只算 (\log V) 个节点,速度可快几百倍。
霍夫曼树代码实现(建树)
importheapq# ==== 节点类定义 ====classNode:def__init__(self,word_id=None,freq=0):self.word_id=word_id# 词的编号(叶子节点才有)self.freq=freq# 频率(用来排序)self.left=Noneself.right=Nonedef__lt__(self,other):returnself.freq<other.freq# ==== 构建霍夫曼树 ====defbuild_huffman_tree(word_freq_dict):""" word_freq_dict: {词id: 出现次数} 返回: 霍夫曼树的根节点 """# 1. 为每个词创建叶子节点nodes=[Node(word_id,freq)forword_id,freqinword_freq_dict.items()]# 2. 扔进最小堆(按频率排序)heapq.heapify(nodes)# 3. 循环合并:每次取最小的两个,造一个新 parentwhilelen(nodes)>1:child1=heapq.heappop(nodes)child2=heapq.heappop(nodes)parent=Node(freq=child1.freq+child2.freq)parent.left=child1 parent.right=child2 heapq.heappush(nodes,parent)# 4. 最后剩一个节点,就是树根returnnodes口诀式总结
建树:按频率从小到大建二叉树,高频词路径短(近根),低频词路径长。
预测:从根到叶子,每层用 sigmoid 二分类(去左 or 去右),总共 (\log(V)) 次计算。
对比:Softmax 算 (V) 次,Huffman 只算 (\log(V)) 次,速度快几百倍。
4.3 负采样 vs 层次化 Softmax
| 维度 | 负采样 | 层次化 Softmax |
|---|---|---|
| 复杂度 | (O(k)),(k) 通常为 5 | (O(\log V)) |
| 概率分布 | 近似,不是真正 Softmax | 精确,可恢复真正概率 |
| 实现难度 | 简单易实现,易并行 | 稍复杂,需要建树与路径编码 |
| 工程特点 | 非常适合大规模词向量训练 | 适合类别极多的分类任务 |
| 常见用途 | Word2Vec、推荐、图嵌入等 | 文本分类、大标签空间任务 |
5. FastText:子词信息的引入
逻辑链条
- 问题:Word2Vec 把每个词当作原子单位,不知道 “apple” 和 “apples” 有关系;对未登录词(OOV)也无能为力。
- 思路:词是由字符或子词组成的,子词蕴含形态与语义(词根、前后缀等)。
- 方案:FastText引入 n-gram 子词特征,让词向量 = 词本身向量 + 子词向量之和。
5.1 FastText 的定义与特点
定义
FastText 是 Facebook 提出的高效文本分类与词向量学习工具,核心是利用子词(subword)和 n-gram 特征来表示词。
与 CBOW 的相同点
- 都是三层结构:输入层、隐藏层、输出层。
- 输入都是一堆向量(词或 n-gram 的 embedding)。
- 隐藏层通过对这些向量做求和 / 平均得到句子或文档表示。
与 CBOW 的不同点
| 维度 | CBOW | FastText |
|---|---|---|
| 输出目标 | 中心词(词预测) | 文档类别标签(文本分类) |
| 输入单位 | 词 | 词 + n-gram 子词 |
| 编码方式 | OneHot 或索引查表 | 直接用 embedding 查表 |
| OOV 能力 | 弱(只能用 UNK) | 强(子词组合出未见词的向量) |
5.2 FastText 的技术细节
损失函数
- 文本分类部分使用交叉熵损失:
L=−∑cyclogpc \mathcal{L} = - \sum_{c} y_c \log p_cL=−c∑yclogpc - 等价于最大化正确类别的对数概率。
输出层的层次化 Softmax
- 按类别频率构建霍夫曼树。
- 复杂度从 (N) 降到 (\log N)。
- 每个非叶节点对应一个带参数的 sigmoid 单元。
- 负类沿左子树编码为 0,正类沿右子树编码为 1。
N-gram 特征
- 将文本按字符或词为单位,用长度为 (N) 的窗口滑动,生成一系列 n-gram 片段。
- 例如对单词 “apple”,字符 3-gram 可能包括:
<ap、app、ppl、ple、le>等。 - FastText 为每个 n-gram 分配一个向量,最终词向量 = 词本身向量 + 所有 n-gram 向量的和或平均。
工程优化
- 过滤出现次数过少的词和 n-gram,减少噪声与内存消耗。
- 用哈希技巧存储大量 n-gram 特征,控制参数量。
- 在保持精度的前提下,保证训练和推理速度都非常快。
6. 语言模型基础:从 N-gram 到自回归
6.1 自回归语言模型
定义
自回归语言模型假设:当前 token 的分布只依赖于之前已经生成的历史序列。
整个句子的概率分解为:
P(x1,…,xT)=∏t=1TP(xt∣x1,…,xt−1) P(x_1, \dots, x_T) = \prod_{t=1}^{T} P(x_t \mid x_1, \dots, x_{t-1})P(x1,…,xT)=t=1∏TP(xt∣x1,…,xt−1)
生成时:
- 先根据 (P(x_1)) 采样第一个 token。
- 再根据 (P(x_2 \mid x_1)) 采样第二个 token。
- 如此循环,直到生成终止符。
训练目标
最小化负对数似然:
L=−∑t=1TlogP(xt∣x1,…,xt−1) \mathcal{L} = -\sum_{t=1}^{T} \log P(x_t \mid x_1, \dots, x_{t-1})L=−t=1∑TlogP(xt∣x1,…,xt−1)
等价于最大化训练语料的似然,让真实句子更“有可能”。
采样温度的作用
对 logits (z_i) 进行缩放:
zi′=ziT z_i' = \frac{z_i}{T}zi′=Tzi
再喂入 softmax:
- 当 (T \to 0) 时,分布极尖锐,接近贪心选最大概率词。
- 当 (T) 较大时,分布变平,采样更随机,多样性提高。
6.2 N-gram 语言模型
定义
N-gram 模型是自回归模型的一个有限记忆特例,只看最近的 (n-1) 个词:
P(xt∣x1,…,xt−1)≈P(xt∣xt−n+1,…,xt−1) P(x_t \mid x_1, \dots, x_{t-1}) \approx P(x_t \mid x_{t-n+1}, \dots, x_{t-1})P(xt∣x1,…,xt−1)≈P(xt∣xt−n+1,…,xt−1)
概率估计示例
- 二元模型(Bigram):
P(xt∣xt−1)≈count(xt−1,xt)count(xt−1) P(x_t \mid x_{t-1}) \approx \frac{\text{count}(x_{t-1}, x_t)}{\text{count}(x_{t-1})}P(xt∣xt−1)≈count(xt−1)count(xt−1,xt) - 三元模型(Trigram):
P(xt∣xt−2,xt−1)≈count(xt−2,xt−1,xt)count(xt−2,xt−1) P(x_t \mid x_{t-2}, x_{t-1}) \approx \frac{\text{count}(x_{t-2}, x_{t-1}, x_t)}{\text{count}(x_{t-2}, x_{t-1})}P(xt∣xt−2,xt−1)≈count(xt−2,xt−1)count(xt−2,xt−1,xt)
实际中必须结合平滑(如加一、Kneser-Ney)避免零概率。
工程特点
- 训练阶段主要是大规模 n-gram 计数和归一化。
- 存储代价高,需要裁剪和压缩。
- 推理阶段依赖查表和缓存优化。
6.3 自回归神经 LM vs N-gram
| 维度 | N-gram | 神经 LM(RNN / Transformer) |
|---|---|---|
| 条件依赖 | 只看最近 (n-1) 个词 | 理论上可看全局历史 |
| 表示方式 | 离散计数 | 连续向量表示 |
| 数据稀疏 | 严重,未见组合概率为 0 | 可泛化到未见组合 |
| 长距离依赖 | 几乎无能为力 | 可以建模长依赖甚至整篇文档 |
可以把 N-gram 看作自回归模型的一种有限阶马尔可夫近似。
7. 词向量知识架构总览
基础:词向量(NLP 根基)
├── OneHot 编码
├── Word2Vec(CBOW / Skip-Gram)
└── FastText(子词信息)
演进:RNN(早期序列模型)
核心:Attention 机制和 Transformer
深入:KV Cache、位置编码、归一化
应用:BERT、GPT、LLaMA、DeepSeek
优化:训练、微调、RLHF
实战:推理部署、RAG 技术
总结:逻辑链与记忆口诀
| 技术 | 问题 | 方案 | 口诀 |
|---|---|---|---|
| OneHot | 计算机不认识文字 | 每个词一个独立维度 | 不排队、好算账、能一起算 |
| CBOW | OneHot 无语义 | 用上下文预测中心词 | 上文求平均,预测中间,误差回传 |
| Skip-Gram | CBOW 对低频词弱 | 用中心词预测上下文 | 中心做主角,逐个预测邻居 |
| 负采样 | Softmax 太慢 | 多分类变二分类 | 1 个正样本 + (k) 个负样本 |
| 层次化 Softmax | Softmax 太慢 | 用二叉树组织词表 | 建霍夫曼树,(\log(V)) 次计算 |
| FastText | Word2Vec 不懂子词 | 引入 n-gram 特征 | 词向量 + n-gram 向量平均 |
核心脉络回顾:
- OneHot把离散符号嵌入欧式空间,但维度高且没有语义。
- Word2Vec利用“上下文预测”任务学习稠密词向量,CBOW 偏快,Skip-Gram 偏精细。
- 负采样 / 层次化 Softmax解决大词表下的训练效率问题。
- FastText把子词信息也嵌入进来,显著增强对未登录词和形态变化的处理能力。
- 在更高一层,语言模型(LM)用这些向量建模整句概率,从 N-gram 发展到自回归神经网络,再到 Transformer 大模型。
把这条线记住,再看任何 NLP 模型的 embedding 层,就都会更顺畅。