这次竞赛的任务是预测一条评论是否违反了特定的社区规则。难点在于测试集中包含了训练集中没有出现的新规则(Unseen Rules),要求模型具备极强的零样本(Zero-shot)或少样本(Few-shot)泛化能力。
第一名解决方案 | Kaggle
基础设置
1 2 3
| POSITIVE_ANSWER = "Yes" NEGATIVE_ANSWER = "No" BASE_PROMPT = f"Reddit moderation: Does the comment violate the rule? Answer '{POSITIVE_ANSWER}' or '{NEGATIVE_ANSWER}' only."
|
上采样与样本挖掘
挖掘测试集中的隐藏样本:
1 2 3 4 5 6 7 8 9 10 11 12
| for violation_type in ["positive", "negative"]: for i in range(1, 3): col = f"{violation_type}_example_{i}" sub_dataset = test_dataset[[col, "rule"]].copy() sub_dataset = sub_dataset.rename(columns={col: "body"}) sub_dataset["rule_violation"] = 1 if violation_type == "positive" else 0 sub_dataset["source"] = "test_examples" flatten.append(sub_dataset)
|
上采样:
1 2 3 4 5 6 7 8
| dataframe = pd.concat(flatten, axis=0, ignore_index=True)
test_rows = dataframe[dataframe["source"] == "test_examples"] if not test_rows.empty: dataframe = pd.concat([dataframe, test_rows], axis=0, ignore_index=True)
|
模型加载与 LoRA 微调(Unsloth)
lora微调
1 2 3 4 5 6
| model = FastLanguageModel.get_peft_model( model, r=16, target_modules=["q_proj", "k_proj", "v_proj", "o_proj", ...], )
|
训练循环优化
1 2 3 4 5
| trainer = train_on_responses_only( trainer, instruction_part = "<|im_start|>user", response_part = "<think>\n\n</think>\n\n", )
|
train_on_responses_only 确保模型只计算回答部分的 Loss(即 “Yes/No”),而忽略 Prompt 部分的 Loss。这让模型专注于分类决策,而不是学习如何复述问题,显著提升了分类任务的收敛速度。
Unsloth 会在分词后的序列中寻找 response_part 对应的 Token 序列。它将 response_part 之前的所有 Token 的 Label 修改为 -100。它保留 response_part 之后(即模型输出的 “Yes” 或 “No”)的 Token ID 作为 Label。
类似地,作者手动实现了一个 CompletionOnlyDataset 类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class CompletionOnlyDataset(Dataset): def __init__(self, tokenizer, dataframe, ...): for p, c in zip(prompts, completions): p_ids = self.tok.encode(p, add_special_tokens=False) c_text = c + (eos if add_eos_to_completion else "") c_ids = self.tok.encode(c_text, add_special_tokens=False) input_ids = p_ids + c_ids labels = [-100] * len(p_ids) + c_ids self.samples.append({"input_ids": input_ids, "labels": labels})
|
推理逻辑
构建多变体 Token ID: LLM 的分词器(Tokenizer)对词首的单词(如 “Yes”)和句中的单词(如 “ Yes”)通常会分配不同的 ID。作者穷举了 “Yes” 的大小写变体以及带空格的版本,确保无论模型想输出哪种形式的“是”,其概率都能被捕获。
这些变体是通过本地实验获得的,实验输出出现频率最高的 topK 个预测标记。
1 2 3 4 5 6 7 8 9 10 11
| POSITIVE_VARIANTS = ["Yes", "YES", "Y", "yes", "True"]
def _first_token_ids(tok, txt_or_texts) -> List[int]: for t in texts: for t2 in (t, " " + t): ids = tok.encode(t2, add_special_tokens=False) if ids: s.add(ids[0]) return sorted(s)
|
单次前向传播与概率聚合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| out = model(**enc, use_cache=True) step_scores = out.logits[:, -1, :]
sel = step_scores[:, tgt_ids] logp = torch.log_softmax(sel.to(torch.float32), dim=-1)
y_logp = (torch.logsumexp(logp[:, yes_idx], dim=-1) if yes_idx else ...)
n_logp = (torch.logsumexp(logp[:, no_idx], dim=-1) if no_idx else ...)
p_yes = torch.softmax(torch.stack([y_logp, n_logp], dim=-1), dim=-1)[:, 0]
|
后处理
秩归一化:
1 2 3 4 5 6
| grp = df_scores.groupby("rule")
rank = grp["prob"].rank(method="average", ascending=True) n = grp["prob"].transform("size")
score = (rank - 1.0) / np.maximum(n - 1.0, 1.0)
|