词嵌入这两年其实没什么新鲜事,但每次回头补旧账,还是会发现一些值得重做的东西。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是最常用的对比损失,逻辑可以理解成一个N+1分类问题:给定一个锚点表示,从它对应的正例和一组负例里,正确识别出正例。相似度用点积或余弦相似度,再除以一个温度系数做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_size=256, embed_dim=128, num_layers=1): super().__init__() self.embedding = nn.Embedding(vocab_size, hidden_size, padding_idx=0) self.lstm = nn.LSTM( hidden_size, hidden_size, num_layers, batch_first=True, bidirectional=True ) 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), device=input_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, temperature=0.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], dim=1) labels = torch.zeros(anchor.size(0), dtype=torch.long, device=anchor.device) return F.cross_entropy(logits, labels)代码里的一个细节:我把anchor、positive、negatives都做了L2归一化,这样点积等价于余弦相似度,正负样本的度量方式保持一致。如果不做归一化,正样本用了余弦相似度,负样本却用内积,度量不一致会导致训练不稳定。
实际训练中也可以做in-batch负采样,也就是把当前batch里其他样本当负样本,省去显式的negatives张量。但在词嵌入场景下,如果同义词表里的一对词恰好落在同一个batch,in-batch负采样会把本应拉近的同义词当成负例,产生矛盾信号。所以我的Demo里还是显式提供negatives,先保证训练信号干净,再考虑性能优化。
3.4 训练循环与超参
训练循环比较标准,我直接上代码。
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_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)) ], dim=1) 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, k=10): if word not in vocab: return [] vec = vectors[vocab[word]].reshape(1, -1) sim = normalize(vectors) @ normalize(vec).T rank = np.argsort(sim, axis=0)[::-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_neg+1)附近。排查下来有两个原因。
第一个是温度系数设置太大。设成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对,跑出来的表示也不会太差。这是我这次实验里体会最深的一点——模型结构可以照抄,损失函数可以复用,真正决定上限的,往往是最不起眼的数据准备环节。