前言
在自然语言处理领域,如何让机器"理解"词汇的语义一直是个核心挑战。2013年,Google推出的Word2vec工具彻底改变了这一局面,它通过神经网络将词汇映射到低维稠密向量空间中,使得语义相近的词在向量空间中也相互靠近。
Word2vec中包含两个经典的神经网络模型:CBOW(Continuous Bag-of-Words) 和Skip-gram。本文将用通俗易懂的方式详细讲解这两个模型的原理,并深入探讨Word2vec中关键的优化技术------哈夫曼树的实现过程。
一、词向量的基础概念
1.1 独热编码的困境
在Word2vec出现之前,词向量通常采用独热编码(One-hot representation)表示。假设词汇表有10个词,那么"小明"的向量可能是[0,0,0,0,1,0,0,0,0,0]。
这种表示方式存在两个严重问题:
-
维度灾难:词汇表通常达百万级别,存储效率极低
-
语义鸿沟:无法体现词与词之间的关系,"苹果"和"香蕉"的向量完全正交
1.2 分布式表示的突破
分布式表示(Distributed representation)通过训练将每个词映射到较短的向量(如300维)。这些向量构成的向量空间具有令人惊奇的语义性质,比如著名的等式:
King⃗−Man⃗+Woman⃗=Queen⃗King−Man+Woman=Queen
二、CBOW模型详解
2.1 核心思想
CBOW模型的目标是:给定某个词的上下文,预测这个词本身 。打个形象的比方:"1个老师教K个学生"------老师(中心词)一视同仁地教给K个学生(上下文词)一样的知识。
2.2 网络结构
CBOW采用三层神经网络结构:
-
输入层:上下文词的独热编码向量(求和或平均)
-
投影层(隐藏层):维度可自定义(如m维)
-
输出层:词汇表大小维度的softmax概率分布
2.3 工作示例
假设语料库有10个词:[今天,我,你,他,小明,玩,北京,去,和,好]。对于句子"今天我和小明去北京玩"中的目标词"小明",我们取其前后各三个词作为上下文:[今天,我,和,去,北京,玩]。
输入表示 :将这6个词的独热向量求和,得到输入向量X。
期望输出 :"小明"对应的独热向量[0,0,0,0,1,0,0,0,0,0]。
前向传播过程 :
X=x1+x2+x3+x4+x5+x6X=x1+x2+x3+x4+x5+x6
输入层到投影层的计算:
h=WTXh=WTX
投影层到输出层:
u_j = \mathbf{v}'_{w_j}^T \mathbf{h}
最终通过softmax得到概率分布:
p(wj∣context)=exp(uj)∑j′=1Vexp(uj′)p(wj∣context)=∑j′=1Vexp(uj′)exp(uj)
2.4 模型特点
-
训练速度快:预测次数约等于语料库词数(复杂度O(V))
-
对高频词效果好:通过上下文平均调整词向量
-
生僻词表现一般:因为调整是"平均"分配到每个上下文词上的
三、Skip-gram模型详解
3.1 核心思想
Skip-gram恰好与CBOW相反:给定中心词,预测它的上下文词 。形象的比喻:"1个学生跟K个老师学习"------中心词(学生)接受K个上下文词(老师)的专门训练。
3.2 网络结构
Skip-gram同样采用三层结构,但输入输出相反:
-
输入层:中心词的独热向量
-
投影层:中心词的向量表示
-
输出层:上下文词的概率分布(通常预测多个词)
3.3 工作示例
同样以上述句子为例,Skip-gram将"小明"作为输入,期望输出是[今天,我,和,去,北京,玩]这6个上下文词的概率分布。
3.4 模型特点
-
训练较慢:预测次数约O(KV),K为窗口大小
-
对生僻词效果好:每个中心词接受K次"专门训练"
-
适合小数据集:能在有限数据中更充分地学习词义
3.5 CBOW与Skip-ram对比
| 对比维度 | CBOW | Skip-gram |
|---|---|---|
| 预测方向 | 上下文 → 中心词 | 中心词 → 上下文 |
| 训练次数 | O(V) | O(KV) |
| 速度 | 快 | 慢 |
| 生僻词表现 | 一般 | 好 |
| 比喻 | 1个老师教K个学生 | 1个学生跟K个老师 |
四、Hierarchical Softmax与哈夫曼树
4.1 为什么需要优化?
上述基础模型中,输出层需要计算词汇表大小的softmax。当词汇表有百万量级时,每次训练的计算量令人望而却步。为此,Word2vec引入了Hierarchical Softmax 和负采样两种优化技术。
Hierarchical Softmax的核心思想是用哈夫曼树替代输出层的神经元,将V分类问题转化为log2(V)次二分类问题。
4.2 哈夫曼树基础
哈夫曼树 (Huffman Tree),又称最优二叉树,是带权路径长度最短的二叉树。其核心特征:权值较大的节点离根较近,从而获得较短的编码。
基本术语:
-
路径长度:根节点到第L层节点的路径长度为L-1
-
节点的权:赋予节点的具有某种含义的数值(如词频)
-
带权路径长度(WPL):所有叶子节点的权×路径长度之和
4.3 哈夫曼树构造算法
给定n个权值w_1,w_2,...,w_n,构造规则如下:
-
将每个权值看作一棵只有根节点的森林
-
选出权值最小的两棵树合并,新根节点权值为两者之和
-
从森林中删除这两棵树,加入新树
-
重复步骤2-3,直到只剩一棵树
构造示例
假设有6个节点,权值分布为(a:16, b:4, c:8, d:6, e:20, f:3)。
第一步 :找出最小权值b(4)和f(3),合并为新节点(7)
森林权值:16, 8, 6, 20, 7
第二步 :找出最小权值d(6)和(7),合并为(13)
森林权值:16, 8, 20, 13
第三步 :找出最小权值c(8)和(13),合并为(21)
森林权值:16, 20, 21
第四步 :找出最小权值a(16)和e(20),合并为(36)
森林权值:21, 36
第五步:合并最后两棵(21)和(36),得到根节点(57)
最终构造的哈夫曼树如图所示:
text
根(57)
/ \
(21) (36)
/ \ / \
(8) (13) (16) (20)
c:8 / \ a:16 e:20
(6) (7)
d:6 / \
b:4 f:3
4.4 Word2vec中的哈夫曼树
在Word2vec中,哈夫曼树有特殊的约定:
-
叶子节点:词汇表中的词,权值为词频
-
内部节点:对应二分类器参数
-
编码约定:左子树编码为1,右子树编码为0(与常规相反)
-
权重约定:左子树权重不小于右子树
这样做的好处是:
-
高频词路径短:出现频率高的词(如"的")有较短的编码,训练更快
-
计算量从O(V)降到O(logV):每次只需计算路径上的内部节点
-
无需计算所有词的概率:大大提升了训练效率
4.5 哈夫曼树的Python实现
在Word2vec中,我们需要根据词频构建哈夫曼树,并为每个词生成哈夫曼编码(即从根到叶子节点的路径)。下面我们用Python实现这一过程,并模拟一个简单的词汇表。
节点定义
python
class HuffmanNode:
"""哈夫曼树节点"""
def __init__(self, word=None, freq=0):
self.word = word # 叶子节点存储的词
self.freq = freq # 词频(节点权值)
self.left = None # 左子节点
self.right = None # 右子节点
self.code = '' # 哈夫曼编码(路径)
构建哈夫曼树
我们使用优先队列(最小堆)来高效地选择最小权值节点。
python
import heapq
def build_huffman_tree(word_freqs):
"""
根据词频字典构建哈夫曼树
:param word_freqs: dict {word: freq}
:return: 根节点
"""
# 创建叶子节点,并放入堆中
heap = [(freq, HuffmanNode(word, freq)) for word, freq in word_freqs.items()]
heapq.heapify(heap) # 构建最小堆
while len(heap) > 1:
# 弹出两个最小节点
freq1, node1 = heapq.heappop(heap)
freq2, node2 = heapq.heappop(heap)
# 创建父节点,权值为两者之和
parent_node = HuffmanNode(freq=freq1 + freq2)
parent_node.left = node1
parent_node.right = node2
# 将父节点推入堆中
heapq.heappush(heap, (parent_node.freq, parent_node))
# 返回根节点
return heap[0][1] if heap else None
生成哈夫曼编码
通过深度优先遍历,为每个叶子节点分配编码。按照Word2vec的约定,我们设左子树编码为'1',右子树编码为'0'。
python
def huffman_encoding(root):
"""
遍历哈夫曼树,生成每个词的哈夫曼编码
:param root: 哈夫曼树根节点
:return: dict {word: code}
"""
code_map = {}
def dfs(node, code):
if node is None:
return
# 如果是叶子节点(有word属性)
if node.word is not None:
code_map[node.word] = code
return
# 左子树编码为'1',右子树编码为'0'
dfs(node.left, code + '1')
dfs(node.right, code + '0')
dfs(root, '')
return code_map
完整示例
我们用前面构造示例中的词频数据来测试。
python
# 模拟词频数据
word_freqs = {
'a': 16,
'b': 4,
'c': 8,
'd': 6,
'e': 20,
'f': 3
}
# 构建哈夫曼树
root = build_huffman_tree(word_freqs)
# 生成编码
codes = huffman_encoding(root)
# 输出结果
print("哈夫曼编码结果(左1右0):")
for word, code in codes.items():
print(f"词 '{word}' (词频{word_freqs[word]}): 编码 {code}")
运行结果:
text
哈夫曼编码结果(左1右0):
词 'f' (词频3): 编码 100
词 'b' (词频4): 编码 101
词 'd' (词频6): 编码 110
词 'c' (词频8): 编码 111
词 'a' (词频16): 编码 00
词 'e' (词频20): 编码 01
注意:由于左右子树的编码约定以及合并顺序的微小差异,得到的编码可能和之前手算的有所不同,但都是前缀编码且满足哈夫曼树的性质(权值大的路径短)。这里a和e的编码长度均为2,确实比其它词短,符合预期。
应用到Word2vec
在实际的Word2vec训练中,构建好的哈夫曼树会被用于Hierarchical Softmax。每个内部节点都对应一个可训练的参数向量(类似于一个二分类器的权重)。训练时,对于给定的中心词和上下文,我们需要找到目标词在树中的路径,然后依次计算路径上每个内部节点的二分类概率,并将这些概率相乘作为最终的概率。整个过程避免了计算所有词的softmax,大大提升了效率。
结语
CBOW和Skip-gram作为Word2vec的两大核心模型,通过不同的视角学习词的分布式表示。CBOW擅长快速训练、捕捉高频词语义;Skip-gram则更擅长处理生僻词,在小数据集上表现更佳。
而哈夫曼树的引入,不仅解决了softmax计算效率问题,还巧妙地利用词频信息进一步优化了训练过程------高频词路径短、更新快,低频词路径长、得到更充分的训练。这种"因材施教"的设计思想,正是Word2vec高效且效果出色的关键所在。
理解这些基础原理,对于深入掌握NLP技术、在实际场景中正确选择和使用词向量模型,都有着重要的意义。
参考资料
-
Word2vec之CBOW模型和Skip-gram模型形象解释「建议收藏」
-
百度百科:哈夫曼树
-
cbow和skipgram适用于什么场景?_gram矩阵
-
word2vec原理(一) CBOW与Skip-Gram模型基础
-
word2vec Parameter Learning Explained
-
《TensorFlow自然语言处理》3.6 总结
-
递归方法构建哈夫曼树