# 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