Files
wehub-resource-sync a203934033
Lint test / lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:34:58 +08:00

322 lines
15 KiB
Python

# Copyright (c) ModelScope Contributors. All rights reserved.
import numpy as np
import os
import torch
import torch.distributed as dist
import torch.nn.functional as F
from accelerate.utils import gather_object
from enum import Enum
from torch import nn
from torch.nn import MSELoss
from transformers.utils import strtobool
from swift.sequence_parallel import sequence_parallel
from swift.utils import get_dist_setting
from .base import BaseLoss
# Code borrowed from sentence_transformers
class SiameseDistanceMetric(Enum):
"""The metric for the contrastive loss"""
EUCLIDEAN = lambda x, y: F.pairwise_distance(x, y, p=2) # noqa
MANHATTAN = lambda x, y: F.pairwise_distance(x, y, p=1) # noqa
COSINE_DISTANCE = lambda x, y: 1 - F.cosine_similarity(x, y) # noqa
def _parse_pair_sentence(outputs):
if isinstance(outputs, dict):
last_hidden_state = outputs['last_hidden_state']
else:
last_hidden_state = outputs
batch_size = last_hidden_state.shape[0]
shape_len = len(last_hidden_state.shape)
first_sentence = list(range(0, batch_size, 2))
second_sentence = list(range(1, batch_size, 2))
if shape_len == 3:
sentence1 = last_hidden_state[first_sentence][:, 0].squeeze(dim=1)
sentence2 = last_hidden_state[second_sentence][:, 0].squeeze(dim=1)
else:
sentence1 = last_hidden_state[first_sentence]
sentence2 = last_hidden_state[second_sentence]
return sentence1, sentence2
class CosineSimilarityLoss(BaseLoss):
def __call__(self, outputs, labels, **kwargs) -> torch.Tensor:
# You need to return a scalar representing the loss.
cos_score_transformation = nn.Identity()
loss_fct = MSELoss()
sentence1, sentence2 = _parse_pair_sentence(outputs)
output = cos_score_transformation(torch.cosine_similarity(sentence1, sentence2))
return loss_fct(output, labels.to(output.dtype).view(-1))
class ContrastiveLoss(BaseLoss):
def __call__(self, outputs, labels, **kwargs) -> torch.Tensor:
sentence1, sentence2 = _parse_pair_sentence(outputs)
distance_metric = SiameseDistanceMetric.COSINE_DISTANCE
distances = distance_metric(sentence1, sentence2)
margin = 0.5
labels = labels.to(sentence1.dtype)
losses = 0.5 * (labels * distances.pow(2) + (1 - labels) * F.relu(margin - distances).pow(2))
return losses.mean()
class OnlineContrastiveLoss(BaseLoss):
def __call__(self, outputs, labels, **kwargs) -> torch.Tensor:
sentence1, sentence2 = _parse_pair_sentence(outputs)
distance_metric = SiameseDistanceMetric.COSINE_DISTANCE
distance_matrix = distance_metric(sentence1, sentence2)
negs = distance_matrix[labels == 0]
poss = distance_matrix[labels == 1]
# select hard positive and hard negative pairs
negative_pairs = negs[negs < (poss.max() if len(poss) > 1 else negs.mean())]
positive_pairs = poss[poss > (negs.min() if len(negs) > 1 else poss.mean())]
positive_loss = positive_pairs.pow(2).sum()
margin = 0.5
negative_loss = F.relu(margin - negative_pairs).pow(2).sum()
loss = positive_loss + negative_loss
return loss
def _parse_multi_negative_sentences(sentences, labels, hard_negatives=None):
split_indices = torch.nonzero(labels, as_tuple=False).squeeze().tolist()
if isinstance(split_indices, int):
split_indices = [split_indices]
split_indices.append(len(labels))
split_indices = np.array(split_indices) + np.array(list(range(len(split_indices))))
split_tensors = []
for i in range(len(split_indices) - 1):
start = split_indices[i]
end = split_indices[i + 1]
split_part = sentences[start:end]
if hard_negatives is not None:
negatives = len(split_part) - 2
assert negatives > 0
if negatives > hard_negatives:
split_part = split_part[:hard_negatives + 2]
elif negatives < hard_negatives:
selected = np.random.choice(list(range(negatives)), size=hard_negatives - negatives, replace=True)
selected += 1 # skip positive
split_part = torch.cat((split_part, split_part[selected]), dim=0)
split_tensors.append(split_part)
return split_tensors
class InfonceLoss(BaseLoss):
def __call__(self, outputs, labels, **kwargs) -> torch.Tensor:
temperature = float(os.environ.get('INFONCE_TEMPERATURE', '0.1')) # temperature
# calculate CE across the batch, meaning all samples will be negative except the matching positive
use_batch = strtobool(os.environ.get('INFONCE_USE_BATCH', 'True'))
hard_negatives = os.environ.get('INFONCE_HARD_NEGATIVES', None) # how many negative prompts kept in one sample
# mask out fake negatives
infonce_mask_fake_negative = strtobool(os.environ.get('INFONCE_MASK_FAKE_NEGATIVE', 'False'))
fake_neg_margin = float(os.environ.get('INFONCE_FAKE_NEG_MARGIN', '0.1'))
# enhanced components to align with Qwen3-Embedding denominator; controlled individually
# defaults set to False for backward compatibility
infonce_include_qq = strtobool(os.environ.get('INFONCE_INCLUDE_QQ', 'False'))
infonce_include_dd = strtobool(os.environ.get('INFONCE_INCLUDE_DD', 'False'))
if hard_negatives is not None:
hard_negatives = int(hard_negatives)
if self.is_megatron:
from megatron.core import mpu
rank, world_size = mpu.get_data_parallel_rank(), mpu.get_data_parallel_world_size()
else:
rank, _, world_size, _ = get_dist_setting()
# repeat of anchor(1)+positive(1)+negatives(n)
sentences = outputs['last_hidden_state']
if world_size > 1 and use_batch:
if getattr(sequence_parallel, 'dp_group', None) is not None:
all_sentences = sequence_parallel._gather_object_dp(sentences.unsqueeze(0))
labels = sequence_parallel._gather_object_dp(labels)
rank = sequence_parallel.dp_rank
elif self.is_megatron:
from megatron.core import mpu
dp_group = mpu.get_data_parallel_group()
shapes = [sentences.new_empty((2, ), dtype=torch.long) for _ in range(world_size)]
dist.all_gather(
shapes,
sentences.new_tensor(sentences.shape, dtype=torch.long),
group=dp_group,
)
all_sentences = [sentences.new_empty(shape.tolist()) for shape in shapes]
dist.all_gather(
all_sentences,
sentences,
group=dp_group,
)
else:
# gather all the sentences and labels across the gpus when calculate loss across all batches of all gpus
all_sentences = gather_object(sentences.unsqueeze(0))
labels = gather_object(labels)
# override the gathered one
all_sentences[rank] = sentences
for idx in range(len(all_sentences)):
if idx == rank:
continue
# we don't calculate grad from other gpus
all_sentences[idx] = all_sentences[idx].detach().to(sentences.device)
sentences = torch.cat(all_sentences, dim=0)
labels = [tensor.to(sentences.device) for tensor in labels]
labels = torch.stack(labels, dim=0)
# split tensors into single sample
# for example: batch_size=2 with tensor anchor(1)+positive(1)+negatives(3) + anchor(1)+positive(1)+negatives(2)
# labels will be [1,0,0,0,1,0,0], meaning 1 positive, 3 negatives, 1 positive, 2 negatives
split_tensors = _parse_multi_negative_sentences(sentences, labels, hard_negatives)
loss = 0
can_batched = hard_negatives is not None
if hard_negatives is None and len(set([s.shape[0] for s in split_tensors])) == 1:
# all tensors have the same batch size
can_batched = True
if not use_batch:
# only calculate loss inside one sample
if can_batched:
# negative numbers are equal
# [B, neg+2, D]
sentences = torch.stack(split_tensors, dim=0)
# [B, 1, D] * [B, neg+1, D]
similarity_matrix = torch.matmul(sentences[:, 0:1], sentences[:, 1:].transpose(1, 2)) / temperature
# The positive one is the first element
labels = torch.zeros(len(split_tensors), dtype=torch.int64).to(sentences.device)
loss = nn.CrossEntropyLoss()(similarity_matrix.squeeze(1), labels)
else:
# the negative numbers may be different, use for loop
for tensor in split_tensors:
# [D] * [neg+1, D]
similarity_matrix = torch.matmul(tensor[0], tensor[1:].T) / temperature
# The positive one is the first element
labels = torch.tensor(0).to(tensor.device)
loss += nn.CrossEntropyLoss()(similarity_matrix, labels)
# avg between all batches in one gpu
loss /= len(split_tensors)
else:
if can_batched:
# [B, neg+2, D]
sentences = torch.stack(split_tensors, dim=0)
# base q->d similarities (includes own positive and all in-batch documents)
queries = sentences[:, 0].squeeze(1) # [B, D]
docs_all = sentences[:, 1:].reshape(-1, sentences.size(2)) # [B*(neg+1), D]
qd_matrix = torch.matmul(queries, docs_all.T) # [B, B*(neg+1)]
# target indices: start of each group's document block (its positive)
labels = torch.tensor(range(0,
sentences.size(0) * (sentences.size(1) - 1),
sentences.size(1) - 1)).view(-1).to(sentences.device)
logits_list = [qd_matrix]
if infonce_include_qq:
# q->q similarities; exclude self via -inf on diagonal to avoid accidental positives
qq_matrix = torch.matmul(queries, queries.T) # [B, B]
qq_matrix = qq_matrix.clone()
qq_matrix.fill_diagonal_(float('-inf'))
logits_list.append(qq_matrix)
if infonce_include_dd:
# d+ -> d (doc-doc) similarities; exclude self-positive column per row
pos_docs = sentences[:, 1].squeeze(1) # [B, D]
dd_matrix = torch.matmul(pos_docs, docs_all.T) # [B, B*(neg+1)]
# mask self positive per row: column index = row_idx * (neg+1)
block = sentences.size(1) - 1 # (neg+1)
if block > 0:
row_idx = torch.arange(dd_matrix.size(0), device=dd_matrix.device)
col_idx = row_idx * block
dd_matrix[row_idx, col_idx] = float('-inf')
logits_list.append(dd_matrix)
if infonce_mask_fake_negative:
# thresholds derived from positive q->d scores per row
row_idx = torch.arange(qd_matrix.size(0), device=qd_matrix.device)
pos_scores = qd_matrix[row_idx, labels]
thresholds = pos_scores.view(-1, 1).detach() + fake_neg_margin
# qd block mask
qd_block = qd_matrix.clone()
qd_mask = qd_block > thresholds
qd_block[qd_mask] = float('-inf')
components = [qd_block]
# qq block mask (if present)
if infonce_include_qq:
qq_block = qq_matrix.clone()
qq_mask = qq_block > thresholds
qq_block[qq_mask] = float('-inf')
# diagonal already masked unconditionally at construction time
components.append(qq_block)
# dd block (if present): self-positive column already masked unconditionally
if infonce_include_dd:
# align with Qwen3-Embedding, no threshold masking for d-d
components.append(dd_matrix)
similarity_matrix = torch.cat(components, dim=1)
else:
# concatenate all components without masking
similarity_matrix = torch.cat(logits_list, dim=1)
# temperature scaling and CE
similarity_matrix = similarity_matrix / temperature
loss = nn.CrossEntropyLoss()(similarity_matrix, labels)
else:
all_tensors = []
for tensor in split_tensors:
all_tensors.append(tensor[1:])
# cat all neg+1 tensors
sentences = torch.cat(all_tensors, dim=0)
# prepare query anchors list if q-q is included
if infonce_include_qq:
queries_all = torch.stack([t[0] for t in split_tensors], dim=0) # [B, D]
length = 0
for idx, tensor in enumerate(split_tensors):
# [D] * [B*(neg+1), D], neg numbers are different
qd_vec = torch.matmul(tensor[0], sentences.T)
target = torch.tensor(length).to(tensor.device)
logits_parts = []
# compute threshold from positive q->d score
threshold = (qd_vec[target].detach() + fake_neg_margin)
# qd part with masking
if infonce_mask_fake_negative:
qd_masked = torch.where(qd_vec > threshold, torch.tensor(float('-inf'), device=qd_vec.device),
qd_vec)
else:
qd_masked = qd_vec
logits_parts.append(qd_masked)
# qq part
if infonce_include_qq:
qq_vec = torch.matmul(tensor[0], queries_all.T) # [B]
# exclude self
qq_vec = qq_vec.clone()
qq_vec[idx] = float('-inf')
if infonce_mask_fake_negative:
qq_vec = torch.where(qq_vec > threshold, torch.tensor(float('-inf'), device=qq_vec.device),
qq_vec)
logits_parts.append(qq_vec)
# dd part
if infonce_include_dd:
dd_vec = torch.matmul(tensor[1], sentences.T) # [B*(neg+1)]
# mask self positive column for this row only (no threshold masking for d-d)
block = split_tensors[idx].size(0) - 1 # (neg+1) for this group
dd_vec[length] = float('-inf')
logits_parts.append(dd_vec)
logits_row = torch.cat(logits_parts, dim=-1)
logits_row = logits_row / temperature
loss += nn.CrossEntropyLoss()(logits_row.unsqueeze(0), target.unsqueeze(0))
# next positive is neg+1
length += tensor.size(0) - 1
loss /= len(split_tensors)
return loss