Jigsaw-Agile Community Rules Classification第一名方案

这次竞赛的任务是预测一条评论是否违反了特定的社区规则。难点在于测试集中包含了训练集中没有出现的新规则(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): # test.csv 每个规则提供了2个正例和2个负例
col = f"{violation_type}_example_{i}"
# 提取这些列,重命名为 'body' 以匹配训练格式
sub_dataset = test_dataset[[col, "rule"]].copy()
sub_dataset = sub_dataset.rename(columns={col: "body"})
# 手动打标签:positive->1, negative->0
sub_dataset["rule_violation"] = 1 if violation_type == "positive" else 0
# 关键标记:记下这些数据来源是 'test_examples'
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_examples
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, # LoRA 秩
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
# [代码来源: train_fp16.py]
class CompletionOnlyDataset(Dataset):
def __init__(self, tokenizer, dataframe, ...):
# ...
for p, c in zip(prompts, completions):
# 1. 编码 Prompt (提问部分)
p_ids = self.tok.encode(p, add_special_tokens=False)
# 2. 编码 Completion (回答部分,即 "Yes/No")
c_text = c + (eos if add_eos_to_completion else "")
c_ids = self.tok.encode(c_text, add_special_tokens=False)

# 3. 拼接输入 (Input IDs): 提问 + 回答
input_ids = p_ids + c_ids

# 4. *** 关键步骤:构造标签 (Labels) ***
# 提问部分的标签全部设为 -100 (被忽略)
# 回答部分的标签保留原始 Token ID (参与计算)
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:
# 这里的 t2 循环非常关键
for t2 in (t, " " + t): # 同时编码 "Yes" 和 " Yes" (带空格)
ids = tok.encode(t2, add_special_tokens=False)
if ids:
s.add(ids[0]) # 只取第一个 Token ID
return sorted(s)

单次前向传播与概率聚合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 运行一次模型,拿到 logits
out = model(**enc, use_cache=True)
step_scores = out.logits[:, -1, :] # 取最后一个 Token 的输出

# 提取所有目标 ID (Yes/No 变体) 的 Logits
sel = step_scores[:, tgt_ids]
logp = torch.log_softmax(sel.to(torch.float32), dim=-1)

# 聚合所有代表 "Yes" 的变体的概率 (LogSumExp)
y_logp = (torch.logsumexp(logp[:, yes_idx], dim=-1) if yes_idx else ...)

# 聚合所有代表 "No" 的变体的概率
n_logp = (torch.logsumexp(logp[:, no_idx], dim=-1) if no_idx else ...)

# 计算最终 Yes 的概率: P(Yes) / (P(Yes) + P(No))
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)
rank = grp["prob"].rank(method="average", ascending=True)
n = grp["prob"].transform("size")
# 将排名归一化到 [0, 1] 区间
score = (rank - 1.0) / np.maximum(n - 1.0, 1.0)

Jigsaw-Agile Community Rules Classification第一名方案
https://lijianxiong.space/2025/20251203/
作者
LJX
发布于
2025年12月3日
许可协议