返回列表

4th Place Solution

644. WSDM Cup - Multilingual Chatbot Arena | wsdm-cup-multilingual-chatbot-arena

开始: 2024-11-18 结束: 2025-03-10 自然语言处理 数据算法赛
第四名解决方案 - WSDM Cup

第四名解决方案

作者: HengweiDAI

发布时间: 2025-03-16

竞赛排名: 第 4 名

🙇🙇

感谢社区朋友的真诚分享,特别感谢 @sayoulala 在上一届 LMSYS 竞赛中分享的 pipeline。向每一位充满热情的参赛者致敬,祝贺所有获得荣誉的赢家!我还要特别感谢香港科技大学(广州)数据科学与分析学域 KIMI 实验室提供的 40 张 A100 80GB GPU 支持! @HKUST-gz

我的解决方案可能相对简单,尤其是与其他杰出的参赛者相比。然而,这是我第一次获得个人金牌。与获得荣誉的喜悦相比,竞赛期间的挫折和挑战占了 90%。希望大家不吝赐教,谢谢!

目录

太长不看版 (TL;DR)

1. AutoModelForCasualLM

(1) 使用 AutoModelForCasualLM + vllm 推理 替换 AutoModelForSequenceClassification + transformers 推理。

(2) 比较 Token A 和 Token B 的 logits 来决定输出。

(3) 使用思维链 (CoT) 作为初始提示。

2. 后预训练 (Post-Pretrain) 与微调 (Finetune) 的区别

(1) 数据集区别:ultrafeedback + C4AI-Community/multilingual-reward-bench (用于后预训练),lmsys (排除标记为 tie 的数据) + wsdm (用于微调)。

(2) 损失计算区别:预训练期间,使用 A 或 B 相对于整个词汇表的交叉熵损失;微调期间,使用 A 和 B 之间的交叉熵损失。

(3) 数据增强区别:预训练中,在同一批次内使用 responseA+B 和 responseB+A;微调中,仅使用 responseA+B。

(4) 输入长度区别:预训练使用 1024 tokens,微调使用 2048 tokens。

3. 蒸馏优化

(1) 使用相同流程分别在微调数据集上训练 Athene-v2-chat + nvidia/Llama-3.1-Nemotron-70B-Instruct-HF + Qwen2.5-72B-Instruct 以生成软标签。

(2) 微调期间训练超过一个 epoch,具体为两个 epoch。交叉验证结果显示,在第二个 epoch 结束时会有显著提升(平均提升约 0.002)。

4. 推理优化

(1) 使用 vllm 获取特定 token logits,配合 allowed_token_ids=[a_tok_id,b_tok_id] 和 logprobs=N。

(2) 将 awq 替换为 gptq。

解决方案详情

使用思维链 (CoT) 作为初始提示控制输出 A 和 B

def create_rounds(query, answer_a, answer_b, tokenizer):
    messages = [
        {
            "role": "system",  
            "content": '''You are a judge tasked with evaluating responses from two 
            language models. Select the response that best meets the user's needs based on their query.
            **Input:**
            <Query>User's original query.</Query>
            <Response_A>First model's response.</Response_A>
            <Response_B>Second model's response.</Response_B>
            **Output:**Return only one letter:
            - A for Response_A
            - B for Response_B
            **Guidelines:**
            - Respond with only A or B.
            - Do not provide explanations.'''
        },
        { 
            "role": "user",  
            "content": f'''Here is your input to process now-
            <Query>{query}</Query>
            {'---'*10}
            <Response_A>{answer_a}</Response_A>
            {'---'*10}
            <Response_B>{answer_b}</Response_B>'''
        }
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    return text+' Choice: '

AutoModelForSequenceClassification ——> AutoModelForCasualLM

  • 修改 tokenizer
def get_tokenizer(path):
    tokenizer = AutoTokenizer.from_pretrained(
        path,
        add_eos_token=False,)
    tokenizer.padding_side = "left"  # 使用左侧填充
    return tokenizer
  • 微调期间,仅使用 Token A 和 Token B 的 logits 来计算二元分类损失
  • 标签映射:A ——> 0, B ——> 1
class WSDMRanker(nn.Module):
    def __init__(self, base_model, tokenizer):
        super().__init__()
        self.model = base_model ## AutoModelForCasualLM
        self.token_ids = []
        for letter in ['A','B']:
            token_id = tokenizer(letter, add_special_tokens=False)["input_ids"][-1]
            self.tok_locations.append(token_id) 
    def encode(self, input_ids, attention_mask):
        outputs = self.model(input_ids=input_ids, attention_mask=attention_mask, output_hidden_states=True)
        scores = []
        for token_id in self.tok_locations:
            score = outputs.logits[:, -1, token_id]
            scores.append(score)
        logits = torch.stack(scores, 1)          
        return logits.contiguous() 
    def forward(self, input_ids, attention_mask, labels=None, **kwargs):
        logits = self.encode(input_ids, attention_mask)
        ce_loss = (self.loss_fn(logits, labels)).mean() # label = 0 for A ; 1 for B

后预训练改进:

  • 使用 Token A 和 Token B 相对于整个词汇表的 logits 计算交叉熵损失
class WSDMRanker(nn.Module):
    def __init__(self, base_model, tokenizer):
        super().__init__()
        self.model = base_model ## AutoModelForCasualLM
        self.token_ids = []
        for letter in ['A','B']:
            token_id = tokenizer(letter, add_special_tokens=False)["input_ids"][-1]
            self.tok_locations.append(token_id) 
    def forward(self, input_ids, attention_mask, labels=None, **kwargs):
        outputs = self.model(input_ids=input_ids, attention_mask=attention_mask, output_hidden_states=True)
        logits = outputs.logits[:, -1, :]
        ce_loss = (self.loss_fn(logits, labels)).mean() # label = A_token_id for A ; B_token_id for B
  • 标签映射:0 ——> A_token_id, 1——> B_token_id
  • 通过在同一批次内反转样本来应用数据增强
class qWenSFTDataset(Dataset):
    def __init__(self, dataset, tokenizer, max_prompt_len, max_completion_len) -> None:
        super().__init__()
        ......
        self.tokenizer = tokenizer
        self.a = tokenizer.encode('A')[0]
        self.b = tokenizer.encode('B')[0]
    def _process_single_entry(self, data_entry):
        _, data = data_entry
        text = data['text']# Question + res_A + res_B
        text2 = data['text2']# Question + res_B + res_A
        features = self.tokenizer(text,padding=False,add_special_tokens=False,return_length=True)
        features2 = self.tokenizer(text2,padding=False,add_special_tokens=False,return_length=True)
        labels = self.a if data['label']==0 else self.b 
        labels2 = self.a if data['label2']==0 else self.b 
        return features['input_ids'],features['attention_mask'],features['length'], labels,features2['input_ids'],features2['attention_mask'],labels2
        ......

为什么采用阶段式训练损失设计

在后预训练期间,使用整个词汇表计算 A 和 B 的多类交叉熵,而在微调期间,使用二元交叉熵。这种方法可以提高模型性能的上限,但需要更多的训练步骤。

fold epoch 1 最佳 CV epoch 2 最佳 CV
0 0.7184 0.7209
1 0.7108 0.7153
2 0.7134 0.7145
3 0.7044 0.7091
4 0.7085 0.7115

训练损失图表

使用 vllm 获取特定 Token Logits,配合 allowed_token_ids=[a_tok_id,b_tok_id] 和 logprobs=N

(如果仅使用 logprobs=N 来选择前 N 个 tokens,Token A 和 Token B 都可能不在前 N 个 tokens 之中)

a_tok_id = tokenizer("A", add_special_tokens=False)["input_ids"][-1]
b_tok_id = tokenizer("B", add_special_tokens=False)["input_ids"][-1]
llm = vllm.LLM(
    cfg.model_dir,
    quantization="gptq",#"awq",
    tensor_parallel_size=2,
......)
sampling_params = vllm.SamplingParams(n=1, top_k=1, logprobs=10, max_tokens=1, temperature=0.0, skip_special_tokens=False, allowed_token_ids=[a_tok_id,b_tok_id])
responses = llm.generate(test['prompt_list'], sampling_params, use_tqdm=True)

使用 GPTQ 8-bit 作为最终量化方案

float16 CV:0.714
使用 GPTQ 8-bit 量化:CV: 0.713, LB: 0.703, PB: 0.714
使用 AWQ 4-bit 量化:CV: 0.710, LB: 0.708, PB: 0.709

一些失败的尝试

  • 排名模型 (Rank model)
  • 增加蒸馏模型的数量: 尝试扩展训练到 Llama-3.3-70B-Instruct,但蒸馏四个模型的效果与三个模型相比没有显著差异,差异不超过 0.0005。
  • 过滤困难样本: 困难样本主要来自两种情况——模型能力不足和样本中违反普遍价值观的固有偏见。具有明显错误和偏见的异常样本会导致训练期间的损失振荡。这些偏见通常源于特定用户的偏见,不同于样本群体的普遍价值体系。因此,我在微调的 OOF (折外) 数据上训练了六个 70B 模型进行软投票,过滤掉复合得分与实际标签差异最大的前 5%-10% 的样本,并将其移除。剩余数据用于微调 14B 模型。然而,与不移除这些样本相比,性能没有显著差异,也许表明该方法还有改进空间?

未实现的想法

  • 多模型融合 > 单模型 TTA (测试时增强): 测试多模型融合:单模型可以达到 713 分,而与 gemma 融合可能达到 715/716 分。
  • 动态模型选择 > 单模型: 首先,使用现有流程和不同的 LLM 训练 N 个独立模型。计算每个模型在 OOF (折外) 数据上对每个问题回答的置信度。然后,训练一个类似于混合专家 (MOE) 路由器的模型选择器,将每个问题分配给最可靠的模型进行判断。单独加载每个独立模型来推断分配给它们的样本(注意:推理时间难以精确控制,在极端情况下,一个模型可能需要推断所有样本)。
同比赛其他方案