论文分享|Arxiv2024‘麦吉尔大学|LLM2Vec—将LLM转换为文本编码器

LLM本身的表征直接用于Embedding,比如用于检索/聚类/STS等任务,效果其实不太好。因此才需要将Embedding模型和大模型区分开来。本文介绍一篇将LLM转换为Embedding模型的工作,代码全开源,值得好好学习。

论文题目:LLM2Vec: Large Language Models Are Secretly Powerful Text Encoders

来源:Arxiv2024/麦吉尔大学

方向:文本编码/LLM

开源地址:https://github.com/McGill-NLP/llm2vec

转换LLM为Text Encoder的三步骤

参考作者的Tutorial ,以LLaMA2FlashAttention为例介绍主要的三个步骤

第一步:实现双向注意力

img

对于llama这样的transformer模型来说,每一层都含有一个自注意力的子层,而这些自注意力在得到embedding的时候都会mask后面的词的attention,只保留前面的词语,即单向注意力。所以需要先将这个掩蔽关闭,每个词都可以和句子中所有词语进行注意力交互,从而实现双向注意力。如下图所示:

img

下面介绍下代码。首先需要修改llama_attention子层的实现。llama_attention有三种实现,在此我们需要将LlamaFlashAttention2中的is_causal设置为False,从而改为双向注意力。其实只修改了__init__函数

class ModifiedLlamaFlashAttention2(LlamaFlashAttention2):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.is_causal = False  # 原Trnasformer实现中是True

LLAMA_ATTENTION_CLASSES = {
    "eager": LlamaAttention,
    "flash_attention_2": ModifiedLlamaFlashAttention2,  # 原是`LlamaFlashAttention2'
    "sdpa": LlamaSdpaAttention,
}

接下来需要将每个Decoder层中的attention改为双向注意力attention实现。同样只需要修改__init__函数。这一段直接从transformers库中复制即可

class ModifiedLlamaDecoderLayer(LlamaDecoderLayer):
    def __init__(self, config: LlamaConfig, layer_idx: int):
        nn.Module.__init__(self) # Initially, super().__init__()
        self.hidden_size = config.hidden_size
        
        # 这一行将注意力类绑定为LLAMA_ATTENTION_CLASSES中自定义的类
        self.self_attn = LLAMA_ATTENTION_CLASSES[config._attn_implementation](config=config, layer_idx=layer_idx) 

        self.mlp = LlamaMLP(config)
        self.input_layernorm = LlamaRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
        self.post_attention_layernorm = LlamaRMSNorm(config.hidden_size, eps=config.rms_norm_eps)

最后需要将LlamaModel中的每一层都改为我们修改的decoder层,还是只需要修改__init__函数中。将每个layer中的改为修改的layer

class LlamaBiModel(LlamaModel):
    def __init__(self, config):
        LlamaPreTrainedModel.__init__(self, config) # Initially, super().__init__(config)
        self.padding_idx = config.pad_token_id
        self.vocab_size = config.vocab_size

        self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size, self.padding_idx)
        self.layers = nn.ModuleList(
            [ModifiedLlamaDecoderLayer(config, layer_idx) for layer_idx in range(config.num_hidden_layers)]  # Initially, `LlamaDecoderLayer(config, layer_idx)`
        ) # 这一行中原是LlamaDecoderLayer(config, layer_idx)
        self.norm = LlamaRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
        self.gradient_checkpointing = False

        self.post_init()

至此,通过简单修改了三行代码,就完成了对llama双向注意力的实现

第二步:使用掩蔽下一词预测(masked next token prediction ,MNTP)任务训练

原LlamaForCausalLM类使用的是LlamaModel,需要先修改为上一步的LlamaBiModel

class BiLlamaForMNTP(LlamaForCausalLM):

    def __init__(self, config, attention_dropout=0.0):
        LlamaPreTrainedModel.__init__(self, config) # Initially, super().__init__(config)
        self.model = LlamaBiModel(config)  # 原是LlamaModel(config)
        self.vocab_size = config.vocab_size
        self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)

        self.post_init()

至此模型部分就修改完毕了,接下来实现损失函数,直接复用LlamaForCausalLM中forward函数里的下一词预测任务,即利用第i-1个token的表征的logits来预测第i个token位置mask的词,但注意由于此时模型已经是双向注意力,所以本质上这其实类似简化版本的掩蔽语言模型masked language modeling)任务。

# LlamaForCausalLM.forward()中的代码片段
loss = None
if labels is not None:
    # 拷贝表征 从而让小于n的tokens表征预测第n个token 
    # contiguous() 其实是强制拷贝一份数据防止和之前的logits对象共享存储空间
    shift_logits = logits[..., :-1, :].contiguous() # (batch_size,n-1,vocab_size) 即每句话从第1个token开始,第n-1个token结束的token表征 
    shift_labels = labels[..., 1:].contiguous() # (batch_size,n-1) 即每句话从第2个token开始,第n个token结束的token序列
    # 展开token表征
    loss_fct = CrossEntropyLoss() #交叉熵损失
    shift_logits = shift_logits.view(-1, self.config.vocab_size) # (batch_size * (n-1), vocab_size)
    shift_labels = shift_labels.view(-1) (batch_size * (n-1))
    # 允许模型并行
    shift_labels = shift_labels.to(shift_logits.device)
    loss = loss_fct(shift_logits, shift_labels)

训练时,仿造 examples/pytorch/language-modeling/run_mlm.py 脚本。 本文主要有以下修改:

  1. 将模型类别改成了自己实现的语言模型类而不是原脚本的AutoModelForMaskedLM类
  2. 使用PEFT lora进行高效微调
  3. 使用了下划线_ 作为mask token。下面展示了data collator中的mask操作代码
class DataCollatorForLanguageModelingWithFullMasking(DataCollatorForLanguageModeling):
    def torch_mask_tokens(
        self,
        inputs: Any,
        special_tokens_mask: Optional[Any] = None,
    ) -> Tuple[Any, Any]:
        """
        为掩蔽语言模型准备inputs/labels,只要被选中为需要mask的,则100%都设置为mask_token
        """
        import torch

        labels = inputs.clone()
        # 使用一个概率 self.mlm_probability 来为每个句子选择少量token用于MLM训练
        probability_matrix = torch.full(labels.shape, self.mlm_probability)
        if special_tokens_mask is None:
            special_tokens_mask = [
                self.tokenizer.get_special_tokens_mask(
                    val, already_has_special_tokens=True
                )
                for val in labels.tolist()
            ]
            special_tokens_mask = torch.tensor(special_tokens_mask, dtype=torch.bool)
        else:
            special_tokens_mask = special_tokens_mask.bool()

        probability_matrix.masked_fill_(special_tokens_mask, value=0.0)
        masked_indices = torch.bernoulli(probability_matrix).bool()
        labels[~masked_indices] = -100  # 只计算masked token处的损失

        # 对于需要mask的token,100%使用mask_token替换
        inputs[masked_indices] = self.tokenizer.convert_tokens_to_ids(
            self.tokenizer.mask_token
        )

        return inputs, labels

第三步:使用无监督/有监督对比学习训练

无论是无监督还是有监督,本质上都是对于每一个锚点(可以是查询也可以是文档),构造正例和负例,利用对比学习损失进行训练。每个查询/文档的表征由最后一层的embedding取平均得到。

img

实验

无监督结果

无监督使用了英文Wikipedia,和SimCSE一致使用LLM同样的句子两次Dropout作为正例,批内其他句子作为负例。测试使用METB。

可以发现,直接使用llama2的token表征效果已经超越了之前无监督的表示,加上MNTP训练后效果有一定提升,加上SimCSE无监督训练后效果有大幅提升

img

有监督结果

有监督使用的训练数据是E5数据 中的公开部分,和对比的BGE等模型保持一致。测试使用MTEB。

在MNTP模型的基础上,直接使用有监督训练,相比先使用SimCSE无监督训练再继续有监督训练效果相差不大。且使用有监督数据训练效果远好于无监督训练。

img


大家好,我是NLP研究者BrownSearch,如果你觉得本文对你有帮助的话,不妨点赞收藏支持我的创作,您的正反馈是我持续更新的动力!如果想了解更多LLM/检索的知识,记得关注我!

  • 24
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值