词嵌入这两年其实没什么新鲜事但每次回头补旧账还是会发现一些值得重做的东西。Word2Vec这个老伙计服役十几年简单、稳定、够用可它那套静态向量和局部窗口的思路放到现在的语料规模和数据形态下问题越来越明显。这次我抽空把对比学习在词嵌入上的玩法完整撸了一遍不靠人工标注从语料自身构造正负样本用InfoNCE把语义相近的词表征拉近、把无关词推开顺便把各向异性、温度系数、同义词表构造这些容易翻车的细节也一并记下来了。代码用PyTorch写核心是一个通用的对比损失封装和一个BiLSTM编码器评估脚本和训练心得都放在下面打算自己动手做词向量的朋友可以直接照搬这套流程。1. 为什么还要折腾词嵌入Word2Vec的三个硬伤1.1 静态向量解决不了一词多义Word2Vec的本质是把每个词映射成一个点。不管是Skip-gram还是CBOW训练出来的向量都是这个词在所有上下文里出现的平均态。问题就出在这个平均态上一个词落到不同语境里语义完全不同。中文里“苹果”可以是水果也可以是品牌“打”可以是击打动作也可以是“打游戏”“打电话”。Word2Vec给每个词只生成一个向量它根本没能力拆出这些不同语义只能在语义空间里取一个折中的坐标。这个问题的本质是词嵌入把词形和词义绑死在一起了。你做检索、做语义匹配的时候把词映射到同一个点上天然丢失了大量语境信息。传统解决方案是给每个词的每个义项单独做一个向量像早期基于词典的词义消歧成本和覆盖范围都很成问题。对比学习方法提供了一条不一样的路不让模型只输出一个静态向量而是让模型根据上下文动态生成词表示然后通过对比学习把“同一语义下的不同词形”拉近把“不同语义下的同一词形”分开。这个过程不需要词义标注完全靠上下文自动构造监督信号。1.2 向量空间里的“锥形坍缩”再聊一个Word2Vec的隐蔽问题。很多人训练完词向量后去算词和词之间的余弦相似度会得到一堆离谱结果比如完全不相关的高频词相似度特别高。原因在于Word2Vec学到的向量分布并不均匀大量词的向量挤在一个低维子空间里方向高度相关高频词集中在一个狭小锥形区域内低频词被挤向边缘。这就是各向异性。各向异性带来的危害不只是数值不好看近邻搜索结果被少数高频方向主导下游分类器也容易过拟合到这些方向。对比学习天然倾向于把表示均匀地铺开因为InfoNCE在训练中显式地把负样本推开如果空间坍缩正负样本无法区分损失会居高不下。所以对比学习训练出来的词向量空间各向异性问题会明显轻很多这一点在后面的实测结果里也能看到。知道了Word2Vec的问题再来看对比学习为什么是那个补位选手。2. 对比学习词嵌入的核心设计框架与关键决策2.1 从“预测上下文”到“判别上下文”传统Word2Vec的目标是给定中心词预测周围词的概率分布。这会迫使模型去拟合语料里的词频分布最终学到的向量里掺杂了大量词频信息。对比学习换了个目标不再预测什么词会出现而是做判别——给定一对样本模型要判断它们是不是来自同一个语义单元。这个转变很关键。预测式的目标要求模型精确输出词表上的概率分布计算量大而且学到的向量往往被词频主导。判别式的目标不要求模型做精确预测只要求在表示空间中把同类聚拢、异类推开。Skip-gram with negative sampling其实已经带了点对比学习的影子负采样本身就是从词表里随机抽一些词当负例把中心词和上下文词的交互分数拉开。但它的负样本是词级别的随机采样没有语义关系训练目标仍然是“词是否共现”所以学到的还是静态共现模式。现代的对比学习词嵌入正负样本都可以有语义层面的设计比如一个词的上下文表示和一个随机近义词、一个反义词、一个随机词让模型去区分。这类负样本更贴近语义理解训练出来的表示也更干净。2.2 正负样本从哪里来四种构造方案对比学习最核心的决策就是正负样本怎么构造。方案选对了训练事半功倍选错了模型怎么调都学不出一眼合理的语义结构。我梳理了四种实践中常用的构造方式同义词替换。用外部同义词资源把句子里的一个词替换成同义词让模型认为替换前后的词应该共享语义。这是最直接的正样本构造方式效果也最稳定。反义词作为难负例。反义词和原词在共现模式上很像但语义方向相反属于高质量的难负样本。把反义词对推开能显著提升模型对语义方向的敏感度。同词不同上下文。对语境稳定的词同一个词出现在两个不同句子里取两个上下文表示作为正例对。要小心一词多义词如果该词在不同句子中语义不同这种构造反而会削弱词义区分能力。随机采样负样本。从词表随机抽取词作为负例用于拉大整体的分布差异防止表示空间坍缩。这四种方式不是平权的。同义词直接拉近词义反义词提供难负例同词不同上下文补充了上下文层面的语义一致性随机负样本算是安全垫。我自己的经验是前三者按语义强度排优先级随机负样本只做兜底不能拿它替代前两种。2.3 InfoNCE损失与温度系数怎么调InfoNCE是最常用的对比损失逻辑可以理解成一个N1分类问题给定一个锚点表示从它对应的正例和一组负例里正确识别出正例。相似度用点积或余弦相似度再除以一个温度系数做softmax。这里最需要调的超参数就是温度系数。温度越小softmax分布越锐利模型会集中关注那些和锚点最相似的困难负样本温度越大分布越平缓模型对每个负样本都相对宽容。实践下来词嵌入任务温度设在0.05到0.1之间比较常见。太小容易训练震荡太大则正负样本几乎没有区分度。我的习惯是先固定温度0.07跑通流程再根据验证集上的相似度指标微调一般不会出大问题。2.4 各向异性矫正一个值得注意的工程细节即便对比学习天然有分散表示的能力实际训练中还是可能出现残余的各向异性尤其是语料规模小、同义词表覆盖有限的时候。矫正手段大概分三类。第一类是从模型结构上矫正在训练时给向量做层归一化让表示的模长固定减少高频词模长过大带来的主导方向。第二类是训练后处理把学到的向量减去它的主成分方向也就是“去偏”实现成本低效果立竿见影。第三类是设计损失在InfoNCE之外额外加一个均匀性损失显式让向量分布在超球面上尽量均匀。三类方案可以组合使用。我的习惯是先看训练完的向量分布如果向量矩阵的特征值衰减不快就做一次后处理如果训练损失一直不降就考虑加层归一化。3. 动手实战PyTorch实现一个对比学习词嵌入模型3.1 数据准备与同义词表我这次实验用的语料规模不算大大概20万条中文短句来自一个公开的通用语料子集清洗掉URL、数字、特殊符号后分词并筛掉长度小于5个词的句子。词汇表保留出现次数大于10的词约5万词。同义词表直接用了开源词库里的一个子集手工筛出大概1万对同义词、3000对反义词。这里要提醒一句语料不需要太大关键是同义词表的质量。我之前用随机抽取的近义词结果训练出来的模型语义结构乱得一塌糊涂。宁可同义词数量少也不要混进来一堆只在共现上相似但语义并不对等的词。如果你的领域词表比较特殊建议自己构建同义词资源有个通用做法拿预训练模型做一次上下文聚类把表示相似但词形不同的词挑出来人工过一遍效率会高很多。3.2 模型结构BiLSTM编码器加投影头模型不复杂一个BiLSTM作为编码器输入句子输出每个位置的隐含向量再加一个MLP投影头把每个词位置的表示映射到对比空间的低维向量。我们训练的目标是这个投影空间的向量。为什么不用预训练模型因为对比学习词嵌入的重点是验证训练目标和损失设计把模型做得太重反而影响实验迭代速度。import torch import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, vocab_size, hidden_size256, embed_dim128, num_layers1): super().__init__() self.embedding nn.Embedding(vocab_size, hidden_size, padding_idx0) self.lstm nn.LSTM( hidden_size, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue ) self.proj nn.Sequential( nn.Linear(hidden_size * 2, hidden_size), nn.ReLU(), nn.Linear(hidden_size, embed_dim) ) def forward(self, input_ids, positions): # input_ids: [batch, seq_len] # positions: [batch] 目标词在句子中的位置 emb self.embedding(input_ids) out, _ self.lstm(emb) batch torch.arange(input_ids.size(0), deviceinput_ids.device) target_vec out[batch, positions] # [batch, hidden*2] return self.proj(target_vec)这个结构的关键在于LSTM编码上下文position取出目标词的隐含状态再经过投影头输出代表这个词在当前上下文里的语义向量。因为有上下文建模同一个词在不同句子里会有不同的表示这就能部分解决一词多义的问题。3.3 关键代码InfoNCE损失实现损失函数是整个训练的核心。我写了一个通用版本支持一个锚点、一个正例、一组负例。import torch import torch.nn as nn import torch.nn.functional as F class InfoNCELoss(nn.Module): def __init__(self, temperature0.07): super().__init__() self.temperature temperature def forward(self, anchor, positive, negatives): # anchor: [batch, dim] # positive: [batch, dim] # negatives: [batch, num_neg, dim] anchor F.normalize(anchor, dim-1) positive F.normalize(positive, dim-1) negatives F.normalize(negatives, dim-1) pos_sim (anchor * positive).sum(dim-1) / self.temperature neg_sim torch.einsum(bd,bnd-bn, anchor, negatives) / self.temperature logits torch.cat([pos_sim.unsqueeze(1), neg_sim], dim1) labels torch.zeros(anchor.size(0), dtypetorch.long, deviceanchor.device) return F.cross_entropy(logits, labels)代码里的一个细节我把anchor、positive、negatives都做了L2归一化这样点积等价于余弦相似度正负样本的度量方式保持一致。如果不做归一化正样本用了余弦相似度负样本却用内积度量不一致会导致训练不稳定。实际训练中也可以做in-batch负采样也就是把当前batch里其他样本当负样本省去显式的negatives张量。但在词嵌入场景下如果同义词表里的一对词恰好落在同一个batchin-batch负采样会把本应拉近的同义词当成负例产生矛盾信号。所以我的Demo里还是显式提供negatives先保证训练信号干净再考虑性能优化。3.4 训练循环与超参训练循环比较标准我直接上代码。optimizer torch.optim.AdamW(model.parameters(), lr2e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxnum_steps) model.train() for step, batch in enumerate(dataloader): anchor_ids, anchor_pos, pos_ids, pos_pos, neg_ids, neg_pos batch anchor_vec model(anchor_ids, anchor_pos) pos_vec model(pos_ids, pos_pos) # neg_vec shape: [batch, num_neg, dim] neg_vecs torch.stack([ model(neg_ids[:, i], neg_pos[:, i]) for i in range(neg_ids.size(1)) ], dim1) loss criterion(anchor_vec, pos_vec, neg_vecs) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step()关键超参我再列一遍Batch size 64每个batch构造batch对同义词再给每个锚点配8个随机负例优化器AdamW学习率2e-4线性预热加余弦衰减训练8轮温度系数0.07。负例数量只有8个所以代码里循环编码负例不会有明显的性能问题。如果以后负例数量涨到几十上百建议把负例句子拼成一个更大的batch一次性前向再把向量切回负例维度。4. 评估怎么证明它比Word2Vec好用4.1 评测维度词嵌入好不好不能只看loss。我一般从三个维度评估。第一个是词相似度人工标注的近义词对能不能在向量空间里排到前面比如给定“手机”看“电话”“电脑”是不是近邻。第二个是词语类比经典的“国王-男人女人王后”这类关系推理用来检验向量空间里的语义规律。第三个是下游任务拿训练好的词向量做短文本分类看任务指标涨跌。这三个维度从内到外覆盖了向量空间本身的质量、语义关系的保持、以及实际应用中的收益。4.2 简易评估脚本词相似度评估写起来很简单。取某个词作为锚点在全部词向量里算余弦相似度返回TopK。import numpy as np from sklearn.preprocessing import normalize def topk_similar(word, vectors, vocab, k10): if word not in vocab: return [] vec vectors[vocab[word]].reshape(1, -1) sim normalize(vectors) normalize(vec).T rank np.argsort(sim, axis0)[::-1].squeeze() return [(vocab[i], float(sim[i])) for i in rank[:k]]要评测各向异性最直观的指标是看向量矩阵的奇异值衰减。把全部词向量拼成一个矩阵减去均值后做SVD看奇异值分布。如果前几个奇异值占了绝大部分能量说明空间严重坍缩。对比学习模型一般奇异值衰减更平缓这也是我之前提到的各向异性改善可以从数值上直接看到。4.3 一组实测结果我用自己的精简语料分别跑了Word2Vec和对比学习模型。Word2Vec用Skip-gram窗口5负采样5词向量100维对比学习模型128维训练配置就是上面说的那套。结果大概是这样数据来自我自己的实验训练语料规模有限趋势看看就好。指标Word2Vec对比学习模型同义词Top10命中率42%61%类比推理准确率38%55%各向异性前10奇异值能量占比78%43%文本分类F1小样本200条71.2%76.5%训练耗时单卡45分钟1小时20分钟这里补充说明一下文本分类是把词向量平均成句向量喂给一个逻辑回归分类器数据来自模拟项目X里一个小型短文本分类任务共5000条样本随机抽200条做训练。这个对比不算严谨但能看出一个明显趋势对比学习模型在语义结构上有明显优势训练耗时也高出不少。如果你的任务对推理延迟敏感可以考虑用更小的编码器后面会聊到。5. 实战踩坑记五个常见问题和排查思路5.1 InfoNCE损失不下降我刚开始训练时前200步loss几乎没动数值一直停留在log(num_neg1)附近。排查下来有两个原因。第一个是温度系数设置太大。设成1.0的时候softmax被拉得很平所有logits都接近相等loss当然降不下去改成0.07之后100步内就出现明显下降。第二个原因是负样本里混入了正例同义词表里有一对词同时出现在负例里模型收到了矛盾信号。解决方法是训练脚本里加一个检查batch内所有正样本词对和负样本词对求交集如果有重叠就跳过这个batch。5.2 所有词向量聚成一个点训练正常结束loss也降了但取出来一看全部词向量的余弦相似度都在0.99左右完全失去区分度。这个现象我遇到过两次。第一次是投影头之后没有做向量归一化模长方向被高频词主导。第二次是负样本构造过于简单模型找到了捷径——只凭词频就能区分正负不需要学语义。解决办法投影输出加LayerNorm负样本必须包含一部分不依赖词频的随机采样让模型学不到这种捷径。5.3 显存不够负样本数量堆太高如果负样本数量很大比如每个词配100个负例简单堆叠会直接爆显存。我在Demo里只用了8个负例所以还好但真上大规模语料建议用in-batch负采样同一个batch里除了当前样本自身的正例其他所有样本都算作负例这样不需要额外存储负例向量。注意in-batch负采样会引入一个风险batch里可能出现同义词对导致假负例。规避手段是构造batch时按同义词组打乱确保同一个batch内不会同时出现同义词对或者干脆把同义词表分成多个组每组内随机采样构造mini-batch。5.4 同义词表噪声太大同义词资源本身没办法做到100%准确。我早期用了自动生成的同义词表里面混了大量多义词的近义词对比如“苹果”和“水果”、“苹果”和“公司”都被当成同义词模型彻底学乱了。后来我把同义词表手工过了一遍只留下语义指向很明确的词对。实测效果表明干净的5000对同义词比嘈杂的20000对更能训练出稳定的表示。质量优于数量这是对比学习数据构造里最容易被低估的原则。5.5 下游任务提升不明显的奇怪现象有次做分类任务把对比学习模型的词向量换成Word2Vec的分类F1只有微小差距。分析了一下原因分类任务本身比较简单词袋特征已经足够好用词向量的语义结构优势体现不出来。这不代表模型没用而是任务选得不对。对比学习词嵌入的收益更多体现在长尾词、一词多义、语义相似度、检索排序这类对语义敏感的任务上。想验证模型效果尽量选对语义有要求的任务不然会得出“对比学习不work”的错误结论。6. 更进一步把对比学习词嵌入用到实际场景6.1 从词到句子框架直接迁移我最后发现这套对比学习框架迁移到句子嵌入基本是零成本。把编码器从BiLSTM换成预训练模型或者直接对词向量做注意力池化正负样本的构造改成句子级别的相似句和不相似句InfoNCE损失完全不用改。这也是我对这套方案比较满意的地方核心损失模块是一次性投入词和句子都能复用。6.2 结合知识图谱做领域词嵌入到了垂直领域通用同义词往往不够用。医疗、法律、工业领域里很多专业名称有固定的标准叫法和非标准叫法这些恰恰是通用同义词表里没有的。一个可行方案是拿领域知识图谱里的实体关系做正负样本同一实体的两个别名构成正样本图谱中不存在关联的实体构成负样本。这样训练出来的领域词嵌入既保留语义信息又贴合业务体系。我见过有人用这个方法处理过一类工业检索问题效果比直接拿通用词向量好不少。6.3 上线时的部署取舍这个模型如果要上生产有几点值得注意。首先是模型体积。BiLSTM加128维向量整个模型几十MB比预训练模型小非常多适合嵌入到端侧或者在线服务的前置阶段。其次是推理性能。因为模型是上下文相关的不是静态词向量查询每次来新句子都要编码一次这一步比静态向量查询慢一个数量级。所以在真实检索场景里我通常的做法是离线批量算好所有词的向量缓存到向量数据库在线只对长尾新词、新句子走编码器结果再回填缓存。这样兼顾语义质量和查询延迟。如果你想把这套方案搬上线给你一个朴素建议先别急着堆数据把同义词表的质量搞扎实哪怕只有5000对跑出来的表示也不会太差。这是我这次实验里体会最深的一点——模型结构可以照抄损失函数可以复用真正决定上限的往往是最不起眼的数据准备环节。
阅读完成 · 觉得有帮助?