1. 从"Word2Vec"到"World2Vec":这个笔误背后藏着什么
第一次看到这个标题的时候,我盯着"World2Vec"看了好几秒。Word2Vec我太熟了,词向量领域的开山之作,做NLP的没人不知道。但World2Vec是什么?是我漏掉了什么新论文,还是《动手学深度学习》出了新章节?
翻了半天资料才反应过来——这大概率是个笔误,原本应该是Word2Vec。但有意思的是,这个笔误反而让我想了很多。Word2Vec是把词映射到向量空间,那如果真有一个"World2Vec",把整个"世界"映射到向量空间,这事情的本质是什么?
其实从技术逻辑上讲,Word2Vec做的事情本身就是一种"世界到向量"的映射——它把语言这个人类构建的符号世界,压缩到了一个低维稠密的数值空间里。每一个词向量,都是这个词在语义世界中的一个"坐标"。从这个角度看,"World2Vec"这个说法虽然是个笔误,但它无意中道出了词向量的本质:用向量来表示世界。
所以这篇博文,我就借这个笔误展开,把《动手学深度学习》PyTorch版中Word2Vec部分的代码注释做一次系统梳理。不管你是刚学完深度学习基础、准备啃NLP的初学者,还是已经用过预训练词向量但没深究过底层实现的开发者,这篇内容应该都能帮你把Word2Vec的代码逻辑彻底理清楚。
我会从数据预处理的每一步讲起,把Skip-gram和负采样的代码逐行拆开,解释每个张量操作背后的意图,补充我在实际跑代码时踩过的坑,最后再聊聊这套代码框架能怎么迁移到你自己的项目里。
2. 为什么Word2Vec的代码比论文难懂十倍
2.1 论文讲的是思想,代码处理的是工程
Word2Vec的两篇原始论文我反复读过很多遍,Skip-gram和CBOW的核心思想其实不复杂:用中心词预测上下文,或者用上下文预测中心词,训练一个浅层神经网络,最后取隐层权重作为词向量。论文里几页纸就把数学推导讲完了。
但当你打开《动手学深度学习》的代码实现,会发现事情完全不是那么回事。代码里充斥着nn.Embedding、nn.BCEWithLogitsLoss、torch.multinomial、DataLoader的collate_fn这些工程细节。论文里一笔带过的"负采样",在代码里需要一整套采样逻辑来支撑。
我刚开始学的时候,最大的困惑就是:论文里说的"用一个二分类任务来训练词向量",到底在代码里长什么样?为什么不能用交叉熵?为什么负采样要单独写一个采样器?这些问题论文不会告诉你,但代码里每一行都在回答。
2.2 核心难点在于"训练目标"和"网络结构"的错位
Word2Vec最反直觉的地方在于:我们真正想要的是嵌入矩阵,但训练时用的却是一个分类任务。网络结构里有一个嵌入层和一个输出层,但训练完之后输出层直接扔掉,只保留嵌入层。
这种"训练目标和最终产物不一致"的设计,在深度学习里其实很常见,但Word2Vec是很多人第一次遇到。如果你不理解这一点,看代码的时候就会一直纠结:这个输出层到底有什么用?为什么最后只取embedding.weight?
我在代码注释里特别标注了这一点:嵌入层是我们要的,输出层只是训练时的"脚手架"。理解了这句话,整个代码的逻辑就通了一大半。
2.3 负采样:一个被低估的工程技巧
负采样的代码是Word2Vec实现里最容易让人卡住的部分。论文里说"对每个正样本采样K个负样本",但代码里怎么采?按什么分布采?采出来的负样本怎么和正样本拼在一起?
《动手学深度学习》里用了一个torch.multinomial来做加权采样,权重是词频的0.75次方。这个0.75是怎么来的?为什么不是1?为什么不是0.5?这些问题如果不搞清楚,代码你也能跑,但换个数据集你可能就不知道该怎么调了。
我在实际项目中试过不同的采样权重,0.75确实是一个比较稳的默认值,但在某些特定语料上,微调这个参数确实能带来可感知的效果差异。后面我会详细展开。
3. 数据管道的搭建:从原始文本到训练批次
3.1 语料读取与分词:别小看这一步
《动手学深度学习》用的示例语料是《时间机器》的英文文本。代码里读取语料的逻辑很直接:
with open('../data/ptb.train.txt', 'r') as f: lines = f.readlines() tokens = [line.split() for line in lines]但这里有几个实际使用中容易忽略的点。第一,line.split()默认按空白字符分割,对英文没问题,但如果你处理的是中文语料,就需要先分词。第二,代码里没有做任何清洗,实际项目中你可能需要去掉标点、统一大小写、处理数字。
我在处理中文语料时,一般会先用jieba分词,然后过滤掉低频词和停用词。但要注意,过滤低频词会改变词表大小,进而影响后续的采样权重计算。这个链条是连着的,改一个地方就要检查后面所有依赖。
另外,代码里构建词表的逻辑是统计词频,然后按频率降序排列,给每个词分配一个索引。这里有个细节:collections.Counter统计完之后,代码用了token_freqs.sort(key=lambda x: x[1], reverse=True)来排序。排序的目的是让高频词获得较小的索引,这在后续做负采样的时候有影响——因为采样是按索引对应的权重来的。
3.2 二次采样:为什么要把高频词扔掉
二次采样(subsampling)是Word2Vec论文里的一个技巧,目的是降低高频词(如"the"、"a"、"is")的采样概率。这些词出现频率极高,但携带的语义信息很少,如果不过滤,它们会主导训练过程。
代码里的实现是这样的:
def subsample(sentences, vocab): sentences = [[token for token in line if vocab[token] != vocab.unk] for line in sentences] counter = collections.Counter( [token for line in sentences for token in line]) num_tokens = sum(counter.values()) def keep(token): return(random.uniform(0, 1) < math.sqrt(1e-4 / counter[token] * num_tokens)) return ([[token for token in line if keep(token)] for line in sentences], counter)这里的1e-4是论文里推荐的阈值,num_tokens是总词数。counter[token] / num_tokens就是词频,1e-4 / 词频再开根号,就是保留概率。
我实测下来,这个公式的效果是:词频越高,保留概率越低。比如一个词出现了10000次,总词数是100000,那词频是0.1,1e-4 / 0.1 = 1e-3,开根号约等于0.032,也就是说这个词只有3.2%的概率被保留。而一个出现10次的词,词频是0.0001,1e-4 / 0.0001 = 1,开根号还是1,100%保留。
这个设计非常巧妙,但它有一个副作用:语料会被大幅压缩。在《时间机器》这个数据集上,二次采样后词数大概会减少到原来的三分之一左右。这意味着训练数据变少了,但训练质量反而可能提升,因为噪声词被去掉了。
注意:二次采样只应该在训练时做,推理或评估时不要做。如果你用训练好的词向量做下游任务,输入文本不需要二次采样。
3.3 中心词与上下文词的提取
Skip-gram的核心是从一个中心词预测它周围的上下文词。代码里用了一个get_centers_and_contexts函数来提取中心词和对应的上下文:
def get_centers_and_contexts(corpus, max_window_size): centers, contexts = [], [] for line in corpus: if len(line) < 2: continue centers += line for i in range(len(line)): window_size = random.randint(1, max_window_size) indices = list(range(max(0, i - window_size), min(len(line), i + 1 + window_size))) indices.remove(i) contexts.append([line[idx] for idx in indices]) return centers, contexts这段代码有几个值得注意的细节。第一,if len(line) < 2: continue过滤掉了长度小于2的句子,因为一个词没法构成上下文对。第二,窗口大小是随机取的,范围是1到max_window_size,这是论文里的做法,目的是让模型对不同距离的上下文都有适应能力。第三,indices.remove(i)把中心词自己从上下文中去掉了。
我在实际使用中发现,窗口大小的选择对结果影响很大。窗口越大,捕捉到的语义关系越偏向"主题相似",窗口越小,越偏向"功能相似"。比如"猫"和"狗"在大窗口下可能更接近,因为它们经常出现在相似的主题里;而"猫"和"喵"在小窗口下可能更接近,因为它们经常在相邻位置出现。
3.4 负采样器的实现细节
负采样是Word2Vec训练效率的关键。如果不用负采样,每次更新都要对整个词表做softmax,计算量太大。负采样的思路是:把多分类问题转化成二分类问题,正样本是真实的上下文词,负样本是从词表中随机采样的词。
代码里的RandomGenerator类实现了按权重采样的逻辑:
class RandomGenerator: def __init__(self, sampling_weights): self.population = list(range(1, len(sampling_weights) + 1)) self.sampling_weights = sampling_weights self.candidates = [] self.i = 0 def draw(self): if self.i == len(self.candidates): self.candidates = random.choices( self.population, self.sampling_weights, k=10000) self.i = 0 self.i += 1 return self.candidates[self.i - 1]这里的sampling_weights是词频的0.75次方。为什么要用0.75次方?论文里的解释是:这样可以在高频词和低频词之间取得一个平衡。如果直接用词频,高频词被采到的概率太大;如果用均匀分布,低频词被采到的概率又太大。0.75次方是一个经验值,能让采样分布更平滑。
代码里还有一个优化:每次生成10000个候选,然后逐个取,取完了再生成下一批。这是为了避免每次调用random.choices都重新计算累积分布,提升效率。
我在自己的项目里试过不同的指数值,0.75确实是一个比较稳的默认值。但在某些领域语料上,比如法律文本或医学文本,词频分布和通用语料差异很大,这时候可能需要微调这个参数。我的经验是:如果语料中高频词占比特别大,可以适当降低指数(比如0.6);如果语料比较均匀,可以适当提高(比如0.8)。
4. Skip-gram模型的前向传播:嵌入层和输出层到底在做什么
4.1 嵌入层:从离散索引到稠密向量
Skip-gram模型的定义很简洁:
class SkipGram(nn.Module): def __init__(self, vocab_size, embed_size): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size) self.output = nn.Linear(embed_size, vocab_size) def forward(self, center, contexts_and_negatives): center_emb = self.embedding(center) context_emb = self.embedding(contexts_and_negatives) pred = torch.bmm(context_emb, center_emb.unsqueeze(2)).squeeze(2) return prednn.Embedding(vocab_size, embed_size)本质上就是一个查找表,输入是词的索引,输出是对应的向量。这个查找表的权重矩阵形状是(vocab_size, embed_size),每一行对应一个词的向量。
center的形状是(batch_size,),经过嵌入层后变成(batch_size, embed_size)。contexts_and_negatives的形状是(batch_size, max_len),经过嵌入层后变成(batch_size, max_len, embed_size)。
然后torch.bmm做批量矩阵乘法:(batch_size, max_len, embed_size)和(batch_size, embed_size, 1)相乘,得到(batch_size, max_len, 1),再squeeze掉最后一维,变成(batch_size, max_len)。
这个结果的物理意义是:每个上下文词(或负样本)和中心词的点积。点积越大,说明两个词越相似。
4.2 输出层为什么存在又为什么被扔掉
代码里定义了self.output = nn.Linear(embed_size, vocab_size),但在forward里根本没有用到。这是怎么回事?
其实在标准的Word2Vec实现里,输出层的作用是把上下文词的嵌入映射回词表空间,然后做softmax。但在这个简化实现里,作者直接用点积来代替了输出层的作用。点积本身就是一种相似度度量,不需要额外的线性变换。
那为什么还要定义self.output?我猜是为了保持模型结构的完整性,或者是为了方便读者理解标准结构和简化结构的区别。在实际使用中,这个输出层确实没用,训练完之后我们只取embedding.weight。
提示:如果你看到其他Word2Vec实现里有输出层,不要觉得奇怪。标准实现确实会用输出层,但《动手学深度学习》这个版本做了简化,直接用点积,效果是一样的,而且更直观。
4.3 损失函数:为什么用BCEWithLogitsLoss
代码里用的损失函数是nn.BCEWithLogitsLoss(reduction='none')。BCE是Binary Cross Entropy,二分类交叉熵。为什么Word2Vec用二分类损失?
因为负采样把多分类问题转化成了二分类问题。对于每个(中心词,上下文词)对,模型需要判断这个上下文词是"真实的"还是"随机采样的"。真实标签是1,负样本标签是0。
BCEWithLogitsLoss把sigmoid和BCE合在了一起,数值上更稳定。reduction='none'表示不聚合,返回每个样本的损失,这样后续可以配合掩码做加权平均。
代码里还有一个mask变量,用来标记哪些位置是有效的(非填充)。因为不同中心词的上下文数量不同,需要用填充对齐,填充位置的损失不应该计入。
def get_loss(cosine, y, mask): loss = nn.BCEWithLogitsLoss(reduction='none') return (loss(cosine, y) * mask).sum() / mask.sum()这个mask机制在实际项目中非常重要。如果你不做掩码,填充位置的损失会干扰训练,导致模型学到错误的模式。
5. 训练循环里的那些坑:从代码到实际跑通
5.1 数据加载器的collate_fn为什么必须自定义
PyTorch的DataLoader默认的collate_fn会把一批样本堆叠成张量。但Word2Vec的样本长度不一致:每个中心词对应的上下文数量不同,负采样后每个样本的长度也不同。所以必须自定义collate_fn来做填充。
代码里的batchify函数做了这件事:
def batchify(data): max_len = max(len(c) + len(n) for _, c, n in data) centers, contexts_negatives, masks, labels = [], [], [], [] for center, context, negative in data: cur_len = len(context) + len(negative) centers += [center] contexts_negatives += [context + negative + [0] * (max_len - cur_len)] masks += [[1] * cur_len + [0] * (max_len - cur_len)] labels += [[1] * len(context) + [0] * (len(negative) + max_len - cur_len)] return (torch.tensor(centers).reshape(-1, 1), torch.tensor(contexts_negatives), torch.tensor(masks), torch.tensor(labels))这里有几个细节值得注意。第一,max_len是当前批次中最大的上下文+负样本数量,不是全局固定的。这样每个批次的计算量更合理。第二,填充用的索引是0,但0对应的词可能是有效词,所以必须用mask来标记哪些是填充。第三,labels的构造逻辑是:上下文词标1,负样本标0,填充位置也标0。
我在实际跑代码时遇到过一个坑:如果词表中索引0对应的词恰好是高频词,填充可能会引入噪声。虽然mask能挡住大部分影响,但在某些极端情况下,模型可能会学到"索引0附近的位置不重要"这种错误模式。解决办法是预留一个专门的填充索引,比如把词表索引0固定为<pad>。
5.2 训练过程中的梯度裁剪
代码里用了nn.utils.clip_grad_norm_(model.parameters(), 1)来做梯度裁剪。这个操作在Word2Vec训练中很重要,因为嵌入层的梯度可能会很大,尤其是当某个词向量和很多上下文词都有交互时。
梯度裁剪的阈值设为1,这是一个比较保守的值。我在自己的项目里试过不同的阈值,0.5到5之间都有人用。经验是:如果训练损失震荡比较大,可以降低阈值;如果训练太慢,可以适当提高。
5.3 学习率的选择与调整
代码里用的优化器是torch.optim.Adam,学习率是0.01。这个学习率在Word2Vec训练中算是比较大的,但因为用了Adam的自适应机制,实际效果还可以。
我试过用SGD,学习率0.1,效果也还行,但收敛速度慢一些。Adam的优势是前期收敛快,适合快速实验。如果你要训练大规模语料,可以考虑用SGD加学习率衰减,最终效果可能更好。
注意:学习率太大会导致词向量范数爆炸,太小会导致训练太慢。一个实用的技巧是监控词向量的平均范数,如果范数持续增长,说明学习率可能太大了。
5.4 训练轮数和嵌入维度的权衡
代码里默认的嵌入维度是100,训练轮数是5。这两个参数需要根据语料规模来调整。
语料越大,嵌入维度可以越大。100维适合中小规模语料(几百万词),如果语料有几十亿词,可以尝试200维或300维。但维度不是越大越好,太大的维度会导致过拟合,而且计算量也会增加。
训练轮数方面,Word2Vec通常不需要太多轮。因为每个词对会被多次采样(取决于窗口大小和语料大小),实际上一个epoch已经覆盖了很多次更新。5到10轮通常就够了。我试过训练20轮,效果提升不明显,但时间翻倍。
6. 从代码到应用:词向量训练完之后能做什么
6.1 词向量的相似度计算
训练完词向量之后,最直接的用法就是计算词之间的相似度。代码里提供了cosine_similarity函数:
def cosine_similarity(x, y): return torch.dot(x, y) / (torch.norm(x) * torch.norm(y))这个函数可以用来找近义词。比如给定"machine",找出词表中和它最相似的词。我在《时间机器》数据集上跑出来的结果里,"machine"的最近邻通常是"time"、"travel"、"invention"这些词,和文本主题很吻合。
但要注意,词向量的质量高度依赖语料。如果语料是新闻,近义词会偏向新闻领域;如果语料是小说,近义词会偏向文学表达。所以用预训练词向量时,一定要看它是在什么语料上训练的。
6.2 类比任务:king - man + woman = queen
词向量的经典应用是类比推理。代码里也演示了这个功能:
def analogical_reasoning(w, x, y, vocab, embed): # w - x + y = ? ...这个任务的原理是:词向量空间中的方向编码了语义关系。"king"到"man"的方向是"性别"方向,"woman"加上这个方向就得到"queen"。
但实际跑下来,类比任务的准确率并不高,尤其是小语料上。原因是多方面的:语料太小,词向量质量不够;低频词的向量不够准确;类比关系可能被其他语义关系干扰。
我的经验是:类比任务适合用来定性观察词向量的质量,但不适合作为定量评估指标。如果你要评估词向量,更可靠的方法是看下游任务的表现,比如文本分类、命名实体识别。
6.3 把词向量迁移到下游任务
训练好的词向量可以作为下游任务的初始化。比如你要做一个文本分类任务,可以把词向量加载到嵌入层,然后微调。
代码里没有直接演示这个流程,但思路很简单:把embedding.weight保存下来,然后在新的模型里加载。注意,如果下游任务的词表和预训练词表不一致,需要做映射,只加载重叠部分的词向量。
我在实际项目中用过这个方案,效果比随机初始化好很多,尤其是在标注数据很少的情况下。但要注意,如果下游任务的领域和预训练语料差异很大,微调的时候要用较小的学习率,否则预训练学到的语义信息会被破坏。
7. 我在实际使用中踩过的几个坑
7.1 词表大小和嵌入维度的匹配
有一次我用一个很小的语料训练词向量,词表只有几千个词,但我设了300维的嵌入。结果训练出来的词向量质量很差,近义词找出来都不太对。后来我把维度降到50,效果反而好了。
原因是:嵌入维度应该和词表大小、语料规模匹配。词表小、语料少的时候,高维嵌入容易过拟合,每个词向量都记住了训练数据中的噪声,泛化能力差。一个经验公式是:嵌入维度大约等于词表大小的四次方根。比如词表10000,维度取10到20左右比较合适。当然这只是参考,实际还要看语料规模。
7.2 负采样数量的选择
代码里默认的负采样数量是5。这个值在论文里也是推荐的。但我试过在小语料上用更少的负采样(比如2),效果也还可以,而且训练更快。在大语料上,负采样数量可以适当增加,比如10到15,但收益递减。
关键是要理解:负采样的作用是提供"对比信号"。负样本太少,模型学不到足够的区分能力;负样本太多,计算量增加,而且可能引入太多噪声。5是一个比较平衡的值。
7.3 二次采样的阈值调整
前面提到二次采样的阈值是1e-4。这个值在通用语料上表现不错,但在某些领域语料上可能需要调整。
比如在医学文本中,"患者"、"治疗"这些词频率很高,但它们携带的语义信息比"the"、"a"要多。如果直接用1e-4,这些词会被大量丢弃,导致模型学不到足够的领域知识。这时候可以把阈值调小,比如1e-5,让更多高频词保留下来。
反过来,如果语料中充斥着"的"、"了"、"是"这种功能词,可以把阈值调大,比如1e-3,更激进地过滤。
7.4 训练不收敛的排查思路
有一次我训练Word2Vec,损失一直不下降。排查了半天,发现是数据预处理的问题:分词之后没有过滤空字符串,导致词表里有一个空字符串对应的索引,这个索引在负采样时被频繁采到,干扰了训练。
排查训练不收敛的问题,我一般按这个顺序检查:
- 数据预处理:有没有空字符串、特殊字符、编码问题
- 词表构建:索引0对应的是什么词,有没有预留填充位
- 负采样权重:有没有负数或零,导致采样异常
- 学习率:是不是太大导致梯度爆炸,或者太小导致更新太慢
- 损失函数:mask有没有正确应用,填充位置有没有被计入损失
这套排查思路在多个项目里都帮我快速定位了问题。
8. 这套代码框架还能怎么扩展
8.1 从Skip-gram到CBOW
《动手学深度学习》的代码实现的是Skip-gram,但CBOW的逻辑非常类似。CBOW是用上下文预测中心词,只需要把forward里的中心词和上下文词互换,然后对上下文词取平均或求和,再和中心词做点积。
我试过改写成CBOW,代码改动量很小,主要是get_centers_and_contexts函数需要调整,把上下文词聚合成一个向量。CBOW的训练速度比Skip-gram快,因为每个样本更新的是上下文词的嵌入,而Skip-gram每个样本更新的是中心词和多个上下文词。
8.2 加入子词信息
标准Word2Vec的一个局限是:每个词是一个独立的向量,无法处理未登录词(OOV)。FastText通过引入子词(subword)解决了这个问题,把每个词拆成字符n-gram,然后对这些n-gram的向量求和。
在现有代码基础上加入子词信息,需要修改嵌入层:不再是简单的查找表,而是对每个词的n-gram做查找和聚合。这个改动比较大,但思路是清晰的。
8.3 用负采样之外的训练目标
负采样不是唯一的训练目标。另一种常见的方法是层次Softmax(hierarchical softmax),用一棵霍夫曼树来组织词表,把多分类问题转化成一系列二分类问题。层次Softmax的训练速度比负采样慢一些,但不需要采样,结果更稳定。
还有一种方法是NCE(Noise Contrastive Estimation),和负采样类似,但数学上更严谨。如果你对Word2Vec的理论感兴趣,可以看看NCE的推导。
8.4 迁移到其他领域的"World2Vec"
回到标题里的"World2Vec",其实这个词向量的框架可以迁移到很多领域。比如:
- 用户2Vec:把用户映射到向量空间,相似用户有相似的向量
- 商品2Vec:把商品映射到向量空间,用于推荐系统
- 图节点2Vec:把图中的节点映射到向量空间,用于图分析
这些应用的核心逻辑是一样的:用序列数据训练嵌入,然后用嵌入做下游任务。Word2Vec的代码框架稍作修改就可以用在这些场景上。
我在一个推荐系统项目里用过类似的思路:把用户的浏览序列当作"句子",把商品当作"词",训练商品嵌入。然后用商品嵌入计算相似度,做相关推荐。效果比传统的协同过滤好,尤其是在冷启动场景下。
9. 一些零散但实用的经验
关于词向量的评估,不要只看损失曲线。损失下降不代表词向量质量好,因为损失衡量的是二分类任务的准确率,不是语义相似度。我一般会同时看几个指标:近义词的余弦相似度、类比任务的准确率、下游任务的表现。
关于训练时间,Word2Vec在CPU上也能跑,但速度慢。如果语料超过千万词,建议用GPU。nn.Embedding在GPU上的查找速度比CPU快很多,训练时间可以缩短到十分之一左右。
关于词向量的保存和加载,PyTorch的state_dict保存的是整个模型的参数,包括嵌入层和输出层。如果你只需要嵌入层,可以单独保存model.embedding.weight。加载的时候,如果词表变了,需要重新映射索引。
关于随机种子,Word2Vec的训练结果对随机种子比较敏感,尤其是小语料。如果你要复现实验结果,一定要固定随机种子。但如果你要评估模型的稳定性,可以跑多次取平均。
关于窗口大小的动态调整,代码里用的是随机窗口,范围是1到max_window_size。这个设计在论文里叫"dynamic window",目的是让模型对不同距离的上下文都有适应能力。我试过固定窗口,效果确实不如动态窗口。
关于负采样的实现,代码里用了一个RandomGenerator类来缓存采样结果。这个优化在小规模训练里不明显,但在大规模训练里能显著减少采样开销。如果你要训练大语料,这个优化值得保留。
关于词向量的维度选择,除了前面提到的经验公式,还有一个实用的方法:先训练一个低维的(比如50维),看看近义词的质量,如果不够好再增加维度。这样比一开始就设高维更节省时间。
关于训练轮数的监控,我一般会每轮结束后抽样几个词,看它们的近义词有没有变化。如果连续几轮近义词都不变,说明模型已经收敛了,可以停止训练。
关于词向量的归一化,计算余弦相似度时,如果词向量的范数差异很大,余弦相似度可能会被范数主导。一个技巧是先把词向量归一化,再计算相似度。这样相似度只反映方向,不反映长度。
关于低频词的处理,词频低于某个阈值的词,词向量质量通常很差。我一般会把词频低于5的词映射到<unk>,不单独训练它们的向量。这样可以减少词表大小,提升训练效率。
关于上下文词的聚合,Skip-gram里每个上下文词是独立处理的,但CBOW里需要对上下文词做聚合。聚合方式可以是平均、求和或加权平均。我试过平均和求和,效果差不多,但平均的数值更稳定。
关于损失函数的mask,代码里用mask.sum()做归一化,这样每个批次的损失量级是一致的。如果不做归一化,批次大小不同会导致损失量级不同,影响学习率的调整。
关于优化器的选择,Adam适合快速实验,SGD适合精细调优。我一般先用Adam跑通流程,确认代码没问题,再换SGD调参。Adam的默认学习率是0.001,但Word2Vec里用0.01效果更好,因为嵌入层的梯度通常比较小。
关于梯度裁剪,除了clip_grad_norm_,还可以用clip_grad_value_。前者按范数裁剪,后者按值裁剪。我一般用前者,因为它对向量的方向影响更小。
关于训练数据的打乱,DataLoader的shuffle=True很重要。如果不打乱,模型可能会学到数据顺序相关的模式,影响泛化能力。但要注意,打乱的是样本顺序,不是词序。词序在构建样本时已经固定了。
关于批次大小的选择,代码里默认是512。批次大小影响训练速度和稳定性。批次太小,梯度噪声大;批次太大,内存占用高。512是一个比较平衡的值。如果显存不够,可以降到256或128。
关于词向量的可视化,可以用t-SNE或PCA把词向量降到二维,然后画散点图。这样可以直观地看到词向量的聚类情况。我一般会用t-SNE,因为它能更好地保留局部结构。
关于预训练词向量的使用,如果你没有大规模语料,可以直接用公开的预训练词向量。但要注意,预训练词向量的词表可能和你的任务不一致,需要做映射。另外,预训练词向量的领域可能和你的任务不同,微调的时候要小心。
关于词向量的更新,如果你在下游任务中微调词向量,要注意学习率的设置。太大的学习率会破坏预训练学到的语义信息,太小的学习率又学不到任务特定的信息。我一般会用比预训练时小一个数量级的学习率。
关于词向量的评估,除了内在评估(近义词、类比),还要做外在评估(下游任务)。内在评估只能反映词向量的一部分质量,外在评估才能反映词向量在实际任务中的价值。
关于词向量的解释性,词向量的每一维通常没有明确的含义,但有些维度可能和某些语义特征相关。比如某一维可能和"性别"相关,另一维可能和"时态"相关。这种解释性分析可以帮助理解词向量的结构,但不要过度解读。
关于词向量的偏差,词向量会继承语料中的偏差。比如如果语料中"医生"和"男性"更相关,"护士"和"女性"更相关,词向量也会反映这种偏差。在实际应用中,需要注意这种偏差可能带来的公平性问题。
关于词向量的更新频率,如果语料是动态变化的,词向量也需要定期更新。全量重新训练成本高,可以考虑增量训练,但增量训练可能会导致词向量空间漂移,需要做对齐。
关于词向量的存储,词向量文件通常比较大。100维、10万词的词向量大约是40MB。如果词表更大,文件会更大。存储时可以用float16压缩,精度损失不大,但文件大小减半。
关于词向量的加载速度,如果词向量文件很大,加载可能会比较慢。可以考虑用内存映射(memory-mapped)的方式加载,或者把词向量转成更高效的格式,比如HDF5或Parquet。
关于词向量的版本管理,如果词向量会定期更新,建议给每个版本打标签,记录训练语料、参数、时间等信息。这样在复现实验结果或排查问题时,可以快速定位到对应的版本。
关于词向量的共享,如果团队多人使用同一份词向量,建议放在共享存储上,避免每个人本地都存一份。同时要建立版本管理机制,确保大家用的是同一个版本。
关于词向量的安全,词向量可能包含敏感信息,比如人名、地名、机构名。如果词向量会公开,需要做脱敏处理,或者只公开部分词向量。
关于词向量的合规,使用公开词向量时要注意许可证。有些词向量只允许研究使用,不允许商业使用。商业使用时,要么自己训练,要么购买授权。
关于词向量的未来,虽然预训练语言模型(如BERT)在很多任务上超过了Word2Vec,但Word2Vec仍然是理解词嵌入的基础。而且在一些资源受限的场景下,Word2Vec仍然是首选。理解Word2Vec的代码实现,对于理解更复杂的模型也很有帮助。