File size: 10,259 Bytes
0dd9a1c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 | """
CoT 数据清洗脚本
功能:
1. 自动识别 think 模式新数据 / 旧数据两种格式
- 新数据 (think 模式): <think>[草稿]</think> Ref-Delta <answer>
→ 丢弃草稿,将 Ref-Delta 包入 <think>
- 旧数据: <think>[Ref-Delta]</think> <answer> (保持原逻辑)
2. 处理重复 <think></think> 标签
3. 验证必要字段完整性
4. 输出清洗报告
用法:
python clean_cot_data.py input.jsonl -o output.jsonl
python clean_cot_data.py input.jsonl # 覆盖原文件
"""
import re
import sys
import json
import argparse
from pathlib import Path
def clean_response(response: str, gt_answer: str = "") -> tuple[str, list[str]]:
"""清洗单条 response,返回 (清洗后文本, 修复记录列表)"""
fixes = []
# ---- 1. 保护 <answer> 和 <key_frame> 块 ----
answer_match = re.search(r"<answer>(.*?)</answer>", response, re.DOTALL)
answer_block = answer_match.group(0).strip() if answer_match else ""
kf_match = re.search(r"<key_frame>(.*?)</key_frame>", response, re.DOTALL)
kf_block = kf_match.group(0).strip() if kf_match else ""
# ---- 2. 识别格式:新(think模式) vs 旧 ----
# 新格式特征:Ref-Delta 在 </think> 之后(草稿在 <think> 内部)
# 旧格式特征:Ref-Delta 在 <think> 内部
think_match = re.search(r"<think>(.*?)</think>(.*)", response, re.DOTALL)
is_new_format = False
if think_match:
after_think = think_match.group(2)
if "Reference frame at" in after_think or "<time_range>" in after_think:
is_new_format = True
if is_new_format:
# 新格式:丢弃 <think> 内的草稿,取 </think> 之后的 Ref-Delta 作为内容
after_think = think_match.group(2)
content = re.sub(r"<answer>.*?</answer>", "", after_think, flags=re.DOTALL)
content = re.sub(r"<key_frame>.*?</key_frame>", "", content, flags=re.DOTALL)
content = content.strip()
fixes.append("think模式新格式: 去除内部草稿,保留Ref-Delta")
n_think_open = n_think_close = 1
else:
# 旧格式:去掉所有 <think></think> 标签,内容原样保留
body = response
body = re.sub(r"<answer>.*?</answer>", "", body, flags=re.DOTALL)
body = re.sub(r"<key_frame>.*?</key_frame>", "", body, flags=re.DOTALL)
n_think_open = len(re.findall(r"<think>", body))
n_think_close = len(re.findall(r"</think>", body))
content = re.sub(r"</?think>", "", body).strip()
# ---- 3. 清理 think 内容 ----
# 3a. 把 bullet list 转为散文 (去掉行首 - • * 符号)
if re.search(r"^\s*[-•*]\s", content, re.MULTILINE):
lines = content.split("\n")
cleaned_lines = []
for line in lines:
stripped = re.sub(r"^\s*[-•*]\s+", "", line)
if stripped != line:
fixes.append(f"去除bullet: {line.strip()[:60]}")
cleaned_lines.append(stripped)
content = "\n".join(cleaned_lines)
# 3b. 把 "At <time>..." 句式统一为 Reference frame 句式
def _fix_at_time(m):
full = m.group(0)
t = m.group(1)
rest = m.group(2).strip().rstrip(".")
fixes.append(f"At <time> → Reference frame: {full[:60]}")
return f"Reference frame at <time>{t}</time>: {rest}."
content = re.sub(
r"(?<!Reference frame )At <time>(.*?)</time>[,:]\s*(.*?)(?=\.\s|\n\n|$)",
_fix_at_time, content, flags=re.DOTALL,
)
# ---- 4. 记录修复 ----
if not is_new_format and (n_think_open > 1 or n_think_close > 1):
fixes.append(f"多余think标签: {n_think_open}个<think>, {n_think_close}个</think>")
if not is_new_format:
think_matches = re.findall(r"<think>(.*?)</think>", response, re.DOTALL)
all_empty = all(m.strip() == "" for m in think_matches) if think_matches else True
if all_empty and content:
fixes.append("think为空,内容在外部,已合并")
# ---- 5. 清理 answer ----
if gt_answer and answer_block:
ans_inner = re.search(r"<answer>(.*?)</answer>", answer_block, re.DOTALL)
if ans_inner:
ans_text = ans_inner.group(1).strip()
if ans_text != gt_answer and len(ans_text) > len(gt_answer):
fixes.append(f"answer冗余: \"{ans_text[:50]}\" → \"{gt_answer}\"")
answer_block = f"<answer>{gt_answer}</answer>"
# ---- 6. 重新组装 ----
parts = []
if content:
parts.append(f"<think>\n{content}\n</think>")
else:
parts.append("<think>\n</think>")
if answer_block:
parts.append(answer_block)
if kf_block:
parts.append(kf_block)
cleaned = "\n\n".join(parts)
return cleaned, fixes
def _extract_option_letter(s: str) -> str:
"""从答案文本中提取选项字母 A-E,失败返回空串"""
m = re.match(r"^\(?([A-E])\)?[\.\s]?", s.strip())
return m.group(1) if m else ""
_PLACEHOLDERS = {
"concise answer", "the answer", "answer", "...", "……",
"yes", "no", "n/a", "none", "unknown",
}
def answers_match(model_ans: str, gt: str) -> bool:
"""答案匹配,分两套策略:
- 选择题(双方均可提取 A-E 字母):严格比较字母
- 自由文本:只过滤占位符/空答/极短答案,其余保留
(因为 GT 已作为引导传给模型,有实质内容即视为正确)
"""
if not model_ans or not gt:
return False
if model_ans == gt:
return True
# ---- 选择题:提取字母比较 ----
ml = _extract_option_letter(model_ans)
gl = _extract_option_letter(gt)
if ml and gl:
return ml == gl
# ---- 自由文本:过滤明显失败 ----
mn = model_ans.strip().lower()
# 占位符
if mn in _PLACEHOLDERS:
return False
# 极短(不足 5 字符)
if len(mn) < 5:
return False
# 纯省略号 / 重复符号
if re.match(r"^[.\s…]+$", mn):
return False
# 有实质内容 → 保留(GT 已引导模型,不做严格文本比对)
return True
def validate_item(item: dict) -> list[str]:
"""验证单条数据的必要字段"""
warnings = []
resp = item.get("response", "")
if not resp:
warnings.append("response为空")
return warnings
# 检查 think 内容
think_match = re.search(r"<think>(.*?)</think>", resp, re.DOTALL)
think_text = think_match.group(1).strip() if think_match else ""
if not think_text:
warnings.append("think内容为空")
# 检查 answer
if not re.search(r"<answer>", resp):
warnings.append("缺少answer")
# 检查 Reference frame
if not re.search(r"Reference frame at", resp):
warnings.append("无Reference frame")
# 检查 delta
if not re.search(r"<delta>", resp):
warnings.append("无delta")
# 检查 time 标签
n_time = len(re.findall(r"<time>", resp))
n_time_range = len(re.findall(r"<time_range>", resp))
if n_time + n_time_range == 0:
warnings.append("无时间标签")
# 检查 answer 是否与 GT 一致(宽松匹配)
gt = item.get("answer", "")
if gt:
ans_match = re.search(r"<answer>(.*?)</answer>", resp, re.DOTALL)
model_ans = ans_match.group(1).strip() if ans_match else ""
if model_ans and not answers_match(model_ans, gt):
warnings.append(f"答案错误: model={model_ans[:40]} gt={gt[:40]}")
return warnings
def main():
parser = argparse.ArgumentParser(description="清洗 CoT 数据")
parser.add_argument("input", help="输入 JSONL 文件路径")
parser.add_argument("-o", "--output", help="输出路径 (不指定则覆盖原文件)")
parser.add_argument("--filter-wrong", action="store_true",
help="过滤掉答案与GT不一致的条目")
args = parser.parse_args()
input_path = Path(args.input)
output_path = Path(args.output) if args.output else input_path
# 读取
items = []
with open(input_path, encoding="utf-8") as f:
for line in f:
if line.strip():
items.append(json.loads(line))
print(f"读取 {len(items)} 条数据\n")
# 清洗
total_fixes = 0
total_warnings = 0
cleaned_items = []
for i, item in enumerate(items):
pid = item.get("problem_id", i)
resp = item.get("response", "")
if not resp:
cleaned_items.append(item)
continue
# 清洗 response
gt_answer = item.get("answer", "")
cleaned_resp, fixes = clean_response(resp, gt_answer)
item["response"] = cleaned_resp
# 验证
warnings = validate_item(item)
if fixes or warnings:
print(f" [{pid}]", end="")
if fixes:
total_fixes += len(fixes)
print(f" 修复: {'; '.join(fixes)}", end="")
if warnings:
total_warnings += len(warnings)
print(f" ⚠ {'; '.join(warnings)}", end="")
print()
cleaned_items.append(item)
# 过滤答案错误的条目
n_filtered = 0
if args.filter_wrong:
filtered = []
for item in cleaned_items:
gt = item.get("answer", "")
resp = item.get("response", "")
ans_m = re.search(r"<answer>(.*?)</answer>", resp, re.DOTALL)
model_ans = ans_m.group(1).strip() if ans_m else ""
if not gt or answers_match(model_ans, gt):
filtered.append(item)
else:
n_filtered += 1
cleaned_items = filtered
# 写入
with open(output_path, "w", encoding="utf-8") as f:
for item in cleaned_items:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
# 报告
print(f"\n{'='*50}")
print(f"清洗完成:")
print(f" 总条数: {len(cleaned_items)}")
print(f" 修复数: {total_fixes}")
print(f" 警告数: {total_warnings}")
if args.filter_wrong:
print(f" 过滤错误: {n_filtered}")
print(f" 输出到: {output_path}")
if __name__ == "__main__":
main()
|