3279 字
16 分钟
word2vec原理以及C代码详解
2026-02-03

下面是我的word2vec的算法理解到原始代码分析。叠甲:都是我的个人理解,可能有不对的地方。 做这个是因为我的毕设是word2vec的CUDA加速,所以要先读懂原始的C代码。

word2vec是什么?

我的理解就是,不同的单词有语义,含义不同。语义用一个一定维度的向量来表示。

而word2vec算法就是通过海量的语料,算出每个单词的语义。

word2vec原理#

整体思路#

从头开始,我们最初的目标,肯定是预测语义——输入一个单词(单词的ID编号),然后输出这个语义向量。

但是有个问题来了,没有人知道这个语义长什么样。

没有一个数据集能直接指出:苹果的语义向量应该是 [0.3, -0.7, 0.2, ...]。这个标注根本不存在。

那怎么办呢?

语言学有一个经典假说,叫分布式假说(Distributional Hypothesis):

一个词的含义,由它经常出现在什么样的上下文中决定。

比如你不认识”榴莲”这个词,但你反复看到:

"我喜欢吃榴莲"
"榴莲很好吃"
"买了一个榴莲"

自然会推断:榴莲是一种食物。你是通过它周围的词来理解它的意思的。

所以,word2vec的思路是,既然语义是没法直接去拟合的,那就通过训练,去让模型尝试在训练之中学会这个语义。

举个例子,假设学生在学英语的时候,老师要尝试测试学生是否真的理解了单词的意思,可以用两种方式考学生:

方法一:完形填空。老师可以遮住句子中间一个词,给你看周围的词,让你猜中间是什么。

比如,输入今天,天气,___,不错。你就能猜到是

如果猜到了,你可能就已经学会了这个语义。

方法二:联想。给你一个中间的词,让你猜这个词的两边,通常会出现什么词。(我的理解是,类似于给你一个词,让你去解释。)

这里就是word2vec的两种训练方法。方法一就是CBOW,方法二就是skip-gram。

在训练完成后,这个能输入上下文、输出猜测词的模型(我们先不管这个模型怎么样,具体长什么形状),这个模型,显然是有了我们语料库里的所有词的词义的。

然后我们再想办法,从这个模型的参数之中,找到语义向量。也就是说,这个模型本身就是这一套语义本身。

好了,下面怎么来从这个模型的参数中找到语义向量呢?

首先,这个是从模型结构设计开始,就让语义向量直接暴露在外面的,不需要去找。

模型第一步,就是输入一个词ID(或上下文的词ID),然后去一个词表(目前的词义表)里面去查这个向量。然后用这个向量,去跑模型,算最后的预测的词ID,或者预测的上下文。

这就是整个算法的流程。

模型设计#

大致知道整个训练过程后,下面是比较关键的问题——这个模型(输入上下文词ID,输出预测词ID)到底怎么设计,怎么训练的呢?

word2vec的模型结构非常简单,它不是深度神经网络,核心就是一个点积+sigmoid。

首先,我们回到数学上来,两个向量像不像,怎么判断呢?

——点积。

Score=vu=vi×uiScore = v \cdot u = \sum v_i \times u_i

如果两个词是类似的,方向一致,语义相似。点积结果就是一个很大的正数。如果方向相反,就是很大的负数。如果垂直(没啥关系),就是0。

CBOW#

相关性#

前面说过了,CBOW类似完形填空。

假设我们要预测今天 天气 [真] 不错字。

step 1:输入层(input)

CBOW是不在乎语序的,直接把我们要预测的那个词的附近几个词全部找出来,根据ID找到词向量(假设我们的模型目前把这个词预测成 VwordV_{word} 了),然后直接相加(也可以求均值),变成上下文向量

VContext=V今天+V天气+V不错V_{Context} = V_{今天} + V_{天气} + V_{不错}

这个向量就代表上下文。

step 2:预测层。也就是做点积。

前面的上下文向量 VcontextV_{context} 可以视为这个预测模型的输入,我们现在要做什么呢?既然我们这个模型要预测最相关的词,也就是点积最大的,假设语料库里总共有 NN 个词,每个词目前的预测词义是 ViV_i (是一个向量),那么我们去算出所有的

oi=(Vcontext,Vi)o_i=(V_{context}, V_i)

然后找到最大的 oj=maxi[1,N]oio_j=\max_{i\in [1,N]} o_i,也就是说,所有词中,VjV_j 是目前和这个上下文最相关的词了(因为点积最大)!这就是我们预测的结果!

好,现在来检查答案,如果 VjV_j 就是结果的话,那我们就猜对了。如果 VjV_j 不是结果,我们就猜错了。

那现在问题来了,在训练时更新权重的时候,我们要知道,正确的输出是什么对吧。那正确的输出是什么呢?

因为我们知道,这个点积越大越好,没有上限的,我们总不能把正确的输出设置成无穷吧?所以这个点积要过一个非线性修正器,也就是激活函数,把无穷的上限映射成1,把负无穷这个下限映射成-1或者0。

这样我们就可以给出正确的输出了——在我们那个的 VanswerV_{answer} 下,点积再过一个激活函数得到的正确结果,应该是1;如果不是 VanswerV_{answer},那就是 0。

激活函数#

然后来考虑这个激活函数。

按照我在看这个算法的时候的考虑,我们这里要加激活函数的原因很简单,把无穷和负无穷映射到0和1,所以我们只需要一个Sigmoid激活函数

P=11+eoiP = \frac{1}{1 + e^{-o_i}}

不过,另一种理论上的做法是 softmax激活函数。

P=eoieokP = \frac{e^{o_i}}{\sum e^{o_k}}

这种要把所有的概率和搞成1。

这样的好处是很直观啊,直接反向传播也很自然。

但是从头开始,我的思路是这样的,每个词去算相似度(不是二分类),互相不需要归一化,因为在预测的时候,直接取max就行了。然后如果训练,就针对正确的那个,结果设置成过完激活函数的1。其他的设置成0。

所以我们只需要一个映射到有界的sigmoid函数就行了。

工程优化问题:负采样#

到这里,还有什么问题吗?

然后重新来回顾训练,CBOW中,我们的训练数据,是一堆上下文作为输入,正确的中心词作为输出。

我们上下文向量 VcontextV_{context} 输入进去,先去更新正确的中心词(假设是 ViV_i )这条路径 (也就是路径 ii ),我们毫无疑问,要求 sigmoid(VcontextVi)=1sigmoid(V_{context}\cdot V_i)=1,根据我们的要求与实际的输出,算loss,然后在这条路径进行反向传播。

其他路径呢?其他路径,我们一定是希望 sigmoid(VcontextVi)1sigmoid(V_{context}\cdot V_i)\neq 1,反正不能是最大的,一般是 00 ,因为不是我们期望的最大输出。同理我们这样更新loss,反向传播。

这对吗?这显然不太对,有一个重要问题:

假设 NN 个单词,那我不是每次训练,要更新 11 次正确向量,N1N-1 次不正确向量?(而实际上 NN 是比较大的,无论是从样本均匀性还是计算量来说,都不太合适。)

这肯定不合适,所以在工程优化上,word2vec不是这样做的,而是采用了负采样(Negative Sampling)

  1. 正样本,就是中心词,我们依然要求 sigmoid的结果是 1。
  2. 负样本,我们是不会全部更新的,我们随机抽取 kk 个不相关的词,要求它的结果是 0。

对于剩下的 N1kN-1-k 的样本呢?我们单次迭代,就不管了,因为语料库很大啊,迭代次数足够多,每个词迟早会被抽中的,只要训练够久,都会被推到正确的位置。

两套矩阵#

前面我推导的时候,上下文向量 VcontextV_{context} 是由周围的词的向量加和得到的,然后预测的时候,拿 VcontextV_{context} 和中心词的向量 VcenterV_{center} 做点积。这里我们前面暂时默认了,一个单词,作为上下文的时候,和作为中心词的时候,两个向量都是从同一个词表中取出来的。

现在还剩最后一个问题:这样可以吗?

假设只有一套矩阵 WW,一个词会在两种场景下被更新:

  1. 它作为别的词的上下文的时候。
  2. 它作为被预测的目标的时候。

这两个可能是矛盾的,会干扰训练,总之,我们分出两个矩阵,一个syn0只管上下文中的语义,syn1只管目标词的语义。互不干扰。

这个似乎是工程上的优化。理论上一套矩阵应该也是能跑的。但是我们在实现的时候,为了更快,一般不这么做。

到这里为止,可以直接跳转到后面的代码实现。

[]: #CBOW+负采样 “(CBOW+负采样,代码实现)“

skip-gram#

CBOW很不错,但是有什么不足吗?

有。

输入上下文均值向量,预测中心词,有一个问题——如果中心词很罕见,词频很低,它在上下文中,会被周围的其他上下文给取平均,训练是不足的。

怎么解决呢?

我们有一个想法:直接不取平均了。

我们拿上下文的每个词,直接输入给模型,要求输出是中心词。直接这样训练。单次训练可能不精准,假设上下文和中心词是今天 天气 [真] 不错,我们最后训练出来的,天气不会一定对应,也不会一定对应好,但是海量训练后,会趋向周围词的平均(或者是某种意义的平衡点)。

不过,在实际实现上,我们选择的是输入中心词、输出上下文词。(实际上是和前面是一样的,毕竟天气两个词,互相为上下文,也互相为中心词。)

word2vec的代码详解#

CBOW+负采样#

这里先把word2vec的原始的C代码中,用CBOW+负采样的代码提取出来。

但是除了简单的实现,还涉及到一些工程上的优化问题。

随机采样#

前面说过了,我们的负采样,是抽一个正样本的同时,随机抽 kk 个负样本(比如5个)。

我们怎么随机呢?

假设我们的语料长度为 MM,不同的词有 NN 个。简单的方案有两种:

  1. 按照不同的词来随机,每个词抽到的概率是 1N\frac 1 N
  2. 按照语料来随机,或者说,每个词 ww 被抽到的概率就是出现的频率 P(w)P(w)

有什么问题呢?前者可能会导致,高频词和低频词抽到的概率一样,比如 the , aPeriphaneta 抽到的概率一样,那行吗?肯定不行吧,这样高频词可能无法很好地训练。我们希望最后算出来的词义,能区分 applebanana,而不是能区分 applePeriphaneta

后者呢?也不太好,因为语料库中,the, a 这种非常高频的词,出现的概率是很大很大的,他们会被不断训练,没什么必要。

所以 word2vec 折中了一下,用这个公式

P(w)=count(w)0.75count(u)0.75P(w) = \frac{\text{count}(w)^{0.75}}{\sum \text{count}(u)^{0.75}}

一个词抽到的概率,是它的词频的0.75次方,在所有词词频的0.75次方之和中的占比,适度打压极高频词,适度提升低频词的中签率。

多线程#

在原始的代码中,是用CPU来做多线程的。不然训练的也太慢了。

假设 TT 个线程,它把文件切分成 TT 份,每个线程领取自己的一份。

同时,多线程可能会有一个问题:难道所有的线程一起反向传播,不会冲突吗?

会,但是因为词库非常大,线程数又比较少,冲突的概率比较低。

动态窗口#

中心词和上下文的相关性可能随着距离衰减。

有一种方案是加权求和——比起原来的按照窗口直接累加,可以距离近的加权大一点,距离远的加权小一点。

但是word2vec最初的实现用了更巧妙的方式。它用了一个随机窗口缩放。

word2vec原理以及C代码详解
https://fuwari.vercel.app/posts/nlp/word2vec/
作者
wegret
发布于
2026-02-03
许可协议
CC BY-NC-SA 4.0