在自然语言处理(NLP)的进化史中,我们见证了从基于规则的系统到统计模型,再到如今大规模预训练语言模型(如 BERT, GPT)的飞跃。然而,如何高效地利用这些海量无标注数据,以及如何让模型真正理解语义的相似性,始终是核心挑战。
今天,我们将深入探讨一种强大的学习范式——对比学习。这不仅是计算机视觉领域的明星技术,近年来在 NLP 领域也掀起了一场革命。特别是引入了像 SimCSE 这样的简洁高效的框架后,对比学习已成为获取高质量句子表征的必备工具。
在这篇文章中,我们将从数学原理出发,带你逐步理解对比学习的核心逻辑,剖析文本增强的各种技巧,并重点解析 SimCSE 的实现细节。无论你是想优化现有系统的搜索相关性,还是想构建精准的语义匹配系统,这篇文章都将为你提供实用的指导和代码示例。
对比学习的核心逻辑
首先,让我们通过一个直观的场景来理解对比学习的目标。想象一下,你在训练一个模型来识别句子的含义。你希望:“机器学习很有趣”和“深度学习非常有趣”这两个句子在向量空间中非常靠近,因为它们语义相似;相反,你希望它们与“今天天气不错”保持距离。
对比学习正是为了解决这个“拉近正样本,推远负样本”的问题。它的核心假设是:通过比较样本之间的相似度,模型可以学到鲁棒的表征。
#### InfoNCE 损失函数详解
让我们把这个直觉转化为数学公式。在对比学习中,我们通常处理成对的数据。假设我们有一个输入句子 $xi$,通过某种方式生成的语义相关句子(增强样本)记为 $xi^{+}$。
我们将这两个句子输入到编码器(例如 BERT 或 RoBERTa)中,得到它们的向量表示 $hi$ 和 $hi^{+}$。训练的目标就是让这两个向量尽可能相似,同时与批次中其他句子的向量不相似。
在一个包含 $N$ 对样本的小批次中,损失函数 $l_i$ 定义为如下形式(也就是我们常说的 InfoNCE Loss):
$$ li =-\log \frac{e^{sim(hi, hi^{+})/ \tau} }{\sum{j=1}^{N} e^{sim(hi, hj^{+})/ \tau} } $$
让我们拆解一下这个公式,看看它是如何工作的:
- 分子 ($e^{sim(hi, hi^{+})/ \tau}$):这是正样本对的相似度分数。我们使用指数函数将其放大。$sim$ 通常指余弦相似度,衡量两个向量方向的夹角。$ au$ 是温度超参数,它控制着分布的平滑程度。
- 分母 ($\sum{j=1}^{N} e^{sim(hi, hj^{+})/ \tau}$):这是当前样本 $hi$ 与批次中所有其他样本(包括它自己)的相似度总和。这就构成了“对比”的背景:模型必须从 $N$ 个候选者中找出真正的正样本。
- 对数似然 ($-\log$):我们希望正样本的概率(分子除以分母)最大化,即负对数似然最小化。
#### 温度系数 $ au$ 的作用
你可能会好奇,这个 $ au$ 到底起什么作用?
在实际工程中,$ au$ 通常是一个小于 1 的数(如 0.05 或 0.1)。
- 低温度($ au \to 0$):模型会对差异非常敏感。正负样本之间的得分差距会被拉大,使得模型更加确信哪个是正样本。这有助于模型学得更加精细的判别特征。
- 高温度($ au \to \infty$):分布变得平滑,模型对正负样本的区分度降低,难以收敛。
文本增强:构建正样本的艺术
在计算机视觉中,生成一张图的“正样本”很简单——随机裁剪、旋转或变色即可。但在 NLP 中,这要困难得多。如果我们简单地打乱词语顺序,句子的语义可能就完全变了;如果只是同义词替换,又可能无法捕捉到句法的多样性。
为了构建有效的 $(xi, xi^{+})$ 对,我们有以下几种主流策略。让我们一一拆解。
#### 1. 反向翻译
这是一种基于外部数据的增强方法。逻辑非常简单:
- 将句子 $x$(中文)翻译成另一种语言(如英文)。
- 将翻译后的英文再翻译回中文。
由于翻译模型的随机性,回译回来的句子 $x^{+}$ 通常会保留原意,但用词和句式会有所不同。这就天然构成了一个完美的对比学习正对。
实战见解:这种方法效果很好,但依赖高质量的翻译模型(如 Google Translate API 或大规模的多语言模型),计算成本较高。
#### 2. 词汇编辑
这种方法也就是我们常说的 EDA(Easy Data Augmentation)。它通过对词汇进行简单的扰动来生成新样本。虽然简单,但在数据稀缺时非常有效。主要包括四种操作:
- 同义词替换:随机选取非停用词,用其同义词替换。
- 随机插入:插入随机同义词。
- 随机交换:交换两个词的位置。
- 随机删除:以一定概率随机删除词语。
局限性:这些操作是基于“词”的,缺乏对整体句子的理解。因此,在大规模预训练时代,直接使用 EDA 的效果不如我们接下来要讲的方法。
#### 3. 截断与 Dropout:现代 NLP 的最爱
这是目前最流行的方法,因为它不需要额外的外部数据,完全“自给自足”。
Span 截断:
这种方法类似于 BERT 中的 Masked Token。我们随机选择句子中的一个连续片段并掩盖掉,或者直接将其从输入中移除,强迫模型通过上下文去恢复语义。这在 Shen 等人的研究中被证明非常有效。
DropOut 作为数据增强:
这是 SimCSE (Simple Contrastive Learning of Sentence Embeddings) 的核心贡献。
传统的 Dropout 是为了防止过拟合。但在 SimCSE 中,斯坦福的研究者发现了一个有趣的现象:在标准 Transformer 中,由于全连接层上应用了 Dropout,即使输入完全相同,两次前向传播得到的表示也会不同。
这简直是天然的“数据增强”!
我们可以直接把同一个句子 $xi$ 输入模型两次。由于每次随机丢弃的神经元不同,得到的向量 $hi^z$ 和 $h_i^{z‘}$ 就构成了正样本对。这种方法简单到令人难以置信,却极其强大。
深入 SimCSE:原理与代码实现
让我们通过代码来真正掌握 SimCSE。我们将使用 PyTorch 和 Hugging Face Transformers 库来实现。
首先,确保你安装了必要的库:
pip install transformers torch
#### 1. 模型构建
我们需要加载一个预训练的 BERT 模型。为了得到更好的句子表征,我们通常使用 [CLS] token 的输出作为句子的表示。
import torch
import torch.nn as nn
from transformers import BertModel, BertTokenizer
class SimCSEModel(nn.Module):
def __init__(self, model_name=‘bert-base-chinese‘, temp=0.05):
super(SimCSEModel, self).__init__()
self.bert = BertModel.from_pretrained(model_name)
self.tokenizer = BertTokenizer.from_pretrained(model_name)
self.temp = temp # 温度参数
def forward(self, input_ids, attention_mask):
# BERT 输出
outputs = self.bert(input_ids, attention_mask=attention_mask)
# 取 [CLS] token 的输出作为句子表示 (batch_size, hidden_size)
embedding = outputs.last_hidden_state[:, 0]
return embedding
def encode(self, sentences, batch_size=32):
"""推理模式:将句子列表编码为向量"""
self.eval()
embeddings = []
with torch.no_grad():
for i in range(0, len(sentences), batch_size):
batch_sent = sentences[i:i+batch_size]
# 分词处理
inputs = self.tokenizer(
batch_sent,
padding=True,
truncation=True,
max_length=128,
return_tensors=‘pt‘
)
# 获取 embeddings
emb = self.forward(inputs[‘input_ids‘].to(self.bert.device),
inputs[‘attention_mask‘].to(self.bert.device))
embeddings.append(emb.cpu())
return torch.cat(embeddings, dim=0)
在上面的代码中,我们定义了 forward 函数来获取 [CLS] 向量。注意,在实际训练中,我们需要利用 Dropout 的随机性来生成两个不同的视图。
#### 2. 对比损失函数实现
让我们动手实现那个核心的 InfoNCE 损失函数。理解这段代码对于调试模型至关重要。
def contrastive_loss(z1, z2, temp):
"""
计算 SimCSE 的对比损失。
z1, z2: 两个批次的嵌入向量
注意:在 SimCSE 中,z1 和 z2 是同一批次输入两次的结果(因为有 Dropout)。
它们的形状都是 (batch_size, hidden_size)。
"""
batch_size = z1.shape[0]
# 1. 拼接两个批次,用于计算相似度矩阵
# 形状变为 (2 * batch_size, hidden_size)
z = torch.cat([z1, z2], dim=0)
# 2. 计算相似度矩阵
# sim_matrix 形状: (2*N, 2*N)
sim_matrix = torch.matmul(z, z.T) / temp
# 3. 排除自身的相似度(将对角线设为极小值,防止被选为正样本)
# 虽然 SimCSE 中正样本不在对角线上(因为是不同 dropout),
# 但对于通常的计算,我们需要屏蔽自己。
mask = torch.eye(2 * batch_size, dtype=torch.bool).to(z.device)
sim_matrix = sim_matrix.masked_fill(mask, -1e9)
# 4. 构建标签
# 在 SimCSE 的无监督设置中:
# 第 i 个样本的正样本是第 (i + batch_size) 个样本
# 第 (i + batch_size) 个样本的正样本是第 i 个样本
labels = torch.arange(batch_size).to(z.device)
labels = torch.cat([labels + batch_size, labels], dim=0)
# 5. 计算 Cross Entropy Loss
# 注意:PyTorch 的 CrossEntropyLoss 默认期望目标标签是类别的索引
loss = nn.CrossEntropyLoss()(sim_matrix, labels)
return loss
# 代码逻辑解析:
# 假设 batch_size=2, 输入是 [A, B]
# 经过两次 Dropout 得到 [A1, B1] 和 [A2, B2]
# 合并为 [A1, B1, A2, B2]
# 正样本对关系:A1 A2, B1 B2
# 目标是让相似度矩阵中特定位置的得分最高。
#### 3. 训练循环实战
现在,让我们把所有组件组合起来,写一个简单的训练循环。
import torch.optim as optim
# 初始化模型
model = SimCSEModel()
device = torch.device(‘cuda‘ if torch.cuda.is_available() else ‘cpu‘)
model.to(device)
# 优化器:通常对比学习使用较大的学习率,如 1e-5 或 5e-5
optimizer = optim.Adam(model.parameters(), lr=1e-5)
# 模拟一个简单的数据集
sentences = [
"今天天气真不错",
"这个苹果非常好吃",
"机器学习是未来",
"深度学习很难",
"我喜欢编程"
]
# 简单的训练步骤
model.train()
batch_size = len(sentences) # 实际应用中应使用 DataLoader
inputs = model.tokenizer(
sentences,
padding=True,
truncation=True,
max_length=32,
return_tensors=‘pt‘
)
input_ids = inputs[‘input_ids‘].to(device)
attention_mask = inputs[‘attention_mask‘].to(device)
# 训练一个 Step
optimizer.zero_grad()
# 核心技巧:同一个 Batch 输入两次,利用 Dropout 生成差异
# 一定要开启 model.training 模式,否则 Dropout 不生效
z1 = model(input_ids, attention_mask)
z2 = model(input_ids, attention_mask)
loss = contrastive_loss(z1, z2, temp=0.05)
print(f"Current Loss: {loss.item():.4f}")
loss.backward()
optimizer.step()
print("训练完成!")
有监督对比学习:利用 NLI 数据
除了无监督的自训练,我们还可以利用带有标签的数据来进一步提升效果。
在自然语言推理(NLI)任务中,我们有成对的数据:
- Premise (前提): "一只足球明星在场上奔跑。"
- Hypothesis (假设): "一个人正在运动。" (标签: Entailment/蕴涵)
如果标签是“蕴涵”,我们可以将这一对视为正样本 $(x, x^{+})$。如果标签是“矛盾”或“中立”,我们可以将它们视为负样本 $(x, x^{-})$。
此时,损失函数需要进行调整,以区分正样本和硬负样本。公式如下:
$$ li = -\log \frac{e^{sim(hi, hi^{+})/\tau} }{\sum{j=1}^{N} (e^{sim(hi, hj^{+})/\tau} + e^{sim(hi, hj^{-})/\tau})} $$
这种有监督的对比学习通常能比无监督 SimCSE 取得更好的性能,因为它迫使模型显式地区分语义相近但实际含义不同的句子(例如:“踢进一球” vs “踢飞了一球”)。
最佳实践与性能优化
在实际工程落地中,仅仅跑通代码是不够的。以下是几点实战经验:
- 使用记忆库:
原始论文中提到,随着 Batch Size 的增加,负样本的数量也会增加,这有助于模型收敛。但在显存有限的情况下,我们无法设置巨大的 Batch Size。解决方案是使用“记忆库”,存储之前几个 Batch 的特征,作为当前 Batch 的负样本。
- 困难负样本挖掘:
很多时候,随机的负样本太容易区分了。模型学不到东西。你可以尝试构建“困难负样本”,例如使用同义词替换生成的句子,或者 NLI 数据集中“中性”关系的句子。
- 评估指标:
不要只看 Loss。你需要使用 SentEval 工具包在 STS-B(语义文本相似度基准测试)数据集上评估你的模型。你的目标是 Pearson/Spearman 相关系数。
- 降维与索引:
虽然我们训练出 768 维的向量,但在实际检索(如搜索引擎)中,通常使用 PCA 将其降维到 256 维或 128 维,然后使用 Faiss 等向量检索引擎进行加速。
总结
在这篇文章中,我们解锁了对比学习在 NLP 中的强大潜力。从 InfoNCE 损失的数学推导,到 SimCSE 利用 Dropout 进行高效自监督训练的巧妙设计,我们看到了如何将简单的数学直觉转化为工程实践。
对比学习的魅力在于它的简洁和通用。无论你是在做问答系统、语义检索,还是文本聚类,掌握对比学习都能让你对模型的理解更上一层楼。
下一步建议:
- 下载 STS-B 数据集,尝试用上面提供的 SimCSE 代码训练一个模型,并计算 Spearman 相关系数。
- 如果你有领域特定的数据(比如医疗或金融),尝试使用反向翻译或 SimCSE 微调一个你的专属 Embedding 模型。
希望这篇指南对你有所帮助,祝你在 NLP 的探索之旅中收获满满!