File size: 12,665 Bytes
01cf98b | 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 | """用本地大 Qwen-VL 模型(vLLM 离线批推)为 listwise reranker 训练集构造 CoT 数据。
输入: 训练集 jsonl(格式同 train/train_reranker_qwen3vl.py 所用数据)
输出: jsonl,每行 = 原样本 + 实际使用的 docs 顺序 + cot(思考过程)+ cot_answer(标签数字串)
本地用法见 cot_gen/run_gen_cot.sh
Nebula 用法见 scripts/submit/submit_cot_gen.sh:
nebula 按每卡一进程拉起本脚本,WORLD_SIZE > 1 时自动按 rank 分片,
每个进程用 tp=1 独占一张卡,各自写 <output>.shard<rank>-<world>.jsonl,跑完再合并。
"""
import os
os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
import argparse
import glob
import json
import random
import re
import sys
current_file_path = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.join(current_file_path, "../"))
def get_dist_env():
rank = int(os.environ.get("RANK", 0))
world_size = int(os.environ.get("WORLD_SIZE", 1))
local_rank = int(os.environ.get("LOCAL_RANK", rank))
return rank, world_size, local_rank
def bind_single_gpu(local_rank: int):
"""分片模式下每个进程只用一张卡;调用点必须早于 torch/vllm 的 import。"""
visible = os.environ.get("CUDA_VISIBLE_DEVICES")
if visible:
gpu_ids = [x for x in visible.split(",") if x]
else:
gpu_ids = [str(i) for i in sorted(int(re.findall(r"\d+$", p)[0]) for p in glob.glob("/dev/nvidia[0-9]*"))]
if len(gpu_ids) > 1:
os.environ["CUDA_VISIBLE_DEVICES"] = gpu_ids[local_rank % len(gpu_ids)]
print(f"[rank {local_rank}] CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES')}")
def extract_prompt(full_prompt: str, pattern: str):
match = re.search(pattern, full_prompt, re.DOTALL)
return match.group(1).strip() if match else None
def load_data(data_path: str):
if data_path.endswith(".json"):
return json.load(open(data_path))
return [json.loads(line) for line in open(data_path)]
def select_docs(item, num_docs: int, shuffle: bool, rng: random.Random):
docs = list(item["docs"])
if shuffle:
rng.shuffle(docs)
if num_docs > 0:
docs = docs[:num_docs]
return docs
def build_cot_messages(sys_prompt, user_prompt, item, docs, max_pixels):
"""将 user_prompt 中的 <image>/<instruction>/<doc_list>/<gt_labels> 占位符展开成 qwen-vl messages。"""
labels = [doc["label"] for doc in docs]
gt_str = "".join(str(l) for l in labels)
content = []
for segment in re.split(r"(<image>|<instruction>|<doc_list>|<gt_labels>)", user_prompt):
if segment == "<image>":
content.append({"type": "image", "image": item["query_image"], "max_pixels": max_pixels})
elif segment == "<instruction>":
content.append({"type": "text", "text": item.get("instruction", "")})
elif segment == "<doc_list>":
for i, doc in enumerate(docs):
content.append({"type": "text", "text": f"\n{i}: "})
content.append({"type": "image", "image": doc["crop_image"], "max_pixels": max_pixels})
content.append({"type": "text", "text": f" {doc['doc_title']}"})
content.append({"type": "text", "text": "\n"})
elif segment == "<gt_labels>":
lines = [f"{i}: {l}" for i, l in enumerate(labels)]
content.append({"type": "text", "text": "\n".join(lines) + f"\n(拼接后应为:{gt_str})"})
else:
content.append({"type": "text", "text": segment})
return [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": content},
]
ANSWER_RE = re.compile(r"最终答案[::]\s*([0-3]+)")
def parse_output(text: str):
"""返回 (cot, answer),解析失败返回 (None, None)。"""
matches = list(ANSWER_RE.finditer(text))
if not matches:
return None, None
last = matches[-1]
answer = last.group(1)
# 关闭 thinking 后模型直接输出正文,chat template 仍会留下一个空 think 块
cot = text[: last.start()].replace("<think>", "").replace("</think>", "").strip()
if "【逐项分析】" not in cot:
return None, answer
return cot, answer
LEAK_RE = re.compile(
r"参考标签|参考答案|标注员|给定的?标签|给定的?答案|已知标签|正确答案|所给的?标签|提供的标签|ground\s*truth",
re.IGNORECASE,
)
def is_valid(cot, answer, gt_str):
if cot is None or answer is None:
return False
if answer != gt_str:
return False
if LEAK_RE.search(cot):
return False
return True
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model-path", type=str, required=True, help="本地大 qwen-vl 模型权重路径")
parser.add_argument("--train-data-path", type=str, required=True)
parser.add_argument("--output-path", type=str, required=True)
parser.add_argument("--failed-path", type=str, default=None, help="多次重试仍失败的样本记录,默认 output_path + .failed")
parser.add_argument("--prompt-path", type=str, default=os.path.join(current_file_path, "cot_prompt.jinja"))
parser.add_argument("--num-docs", type=int, default=10, help="每个样本使用的候选数,<=0 表示全部")
parser.add_argument("--shuffle-docs", action="store_true", help="是否先 shuffle 再截取(与训练随机采样对齐)")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--max-crop-tokens", type=int, default=80, help="单图最大视觉 token 数,与训练配置对齐")
parser.add_argument("--patch-size", type=int, default=16)
parser.add_argument("--tensor-parallel-size", type=int, default=8)
parser.add_argument("--max-model-len", type=int, default=16384)
parser.add_argument("--max-new-tokens", type=int, default=4096)
parser.add_argument("--gpu-memory-utilization", type=float, default=0.9)
parser.add_argument("--max-retries", type=int, default=3, help="答案与 GT 不一致时的重试轮数")
parser.add_argument("--retry-temperature", type=float, default=0.7)
parser.add_argument("--batch-size", type=int, default=64, help="每轮送入 vLLM 的样本数")
parser.add_argument("--shard-id", type=int, default=None, help="不传则取环境变量 RANK")
parser.add_argument("--num-shards", type=int, default=None, help="不传则取环境变量 WORLD_SIZE")
parser.add_argument("--test", type=int, nargs="?", const=20, default=None,
help="试跑模式:只处理数据集前 N 条(默认 20),结果写到 <output>.test.jsonl")
args = parser.parse_args()
rank, world_size, local_rank = get_dist_env()
if args.num_shards is None:
args.num_shards = world_size
if args.shard_id is None:
args.shard_id = rank
if args.num_shards > 1:
# 每进程独占一张卡,必须早于 vllm import
bind_single_gpu(local_rank)
args.tensor_parallel_size = 1
from vllm import LLM, SamplingParams
from transformers import AutoProcessor
from collators.vision_process import process_vision_info
if args.test:
root, ext = os.path.splitext(args.output_path)
args.output_path = f"{root}.test{ext}"
if args.num_shards > 1:
root, ext = os.path.splitext(args.output_path)
args.output_path = f"{root}.shard{args.shard_id}-{args.num_shards}{ext}"
failed_path = args.failed_path or args.output_path + ".failed"
os.makedirs(os.path.dirname(os.path.abspath(args.output_path)), exist_ok=True)
with open(args.prompt_path, encoding="utf-8") as f:
full_prompt = f.read()
sys_prompt = extract_prompt(full_prompt, r"<sys_start>(.*?)<sys_end>")
user_prompt = extract_prompt(full_prompt, r"<user_start>(.*?)<user_end>")
assert sys_prompt and user_prompt, "prompt 模板缺少 <sys_start>/<user_start> 块"
max_pixels = args.max_crop_tokens * (args.patch_size * 2) ** 2
data = load_data(args.train_data_path)
print(f"Load {len(data)} instances from {args.train_data_path}")
if args.test:
data = data[: args.test]
print(f"[TEST MODE] only generate for first {len(data)} samples -> {args.output_path}")
done_ids = set()
if os.path.exists(args.output_path):
for line in open(args.output_path):
try:
done_ids.add(json.loads(line)["sample_idx"])
except Exception:
pass
print(f"Resume: {len(done_ids)} samples already done")
rng = random.Random(args.seed)
pending = [] # (sample_idx, item, docs, gt_str, messages)
for idx, item in enumerate(data):
docs = select_docs(item, args.num_docs, args.shuffle_docs, rng) # 先采样保证各分片 rng 状态一致
if idx % args.num_shards != args.shard_id or idx in done_ids:
continue
gt_str = "".join(str(doc["label"]) for doc in docs)
messages = build_cot_messages(sys_prompt, user_prompt, item, docs, max_pixels)
pending.append((idx, item, docs, gt_str, messages))
print(f"Shard {args.shard_id}/{args.num_shards}: {len(pending)} samples to generate")
if not pending:
return
max_images = 1 + max(len(d) for _, _, d, _, _ in pending)
llm = LLM(
model=args.model_path,
tensor_parallel_size=args.tensor_parallel_size,
max_model_len=args.max_model_len,
gpu_memory_utilization=args.gpu_memory_utilization,
limit_mm_per_prompt={"image": max_images},
trust_remote_code=True,
)
processor = AutoProcessor.from_pretrained(args.model_path, trust_remote_code=True)
def to_vllm_input(messages):
text = processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
)
image_inputs, _ = process_vision_info([messages])
return {"prompt": text, "multi_modal_data": {"image": image_inputs}}
out_f = open(args.output_path, "a", encoding="utf-8")
failed_f = open(failed_path, "a", encoding="utf-8")
n_ok, n_fail = 0, 0
for batch_start in range(0, len(pending), args.batch_size):
batch = pending[batch_start: batch_start + args.batch_size]
results = {} # sample_idx -> (cot, answer)
last_text = {} # sample_idx -> 最后一次尝试的模型原始输出
remaining = list(batch)
for attempt in range(args.max_retries + 1):
if not remaining:
break
temperature = 0.0 if attempt == 0 else args.retry_temperature
sampling_params = SamplingParams(
temperature=temperature,
top_p=0.95 if temperature > 0 else 1.0,
max_tokens=args.max_new_tokens,
seed=args.seed + attempt,
)
vllm_inputs = [to_vllm_input(m) for _, _, _, _, m in remaining]
outputs = llm.generate(vllm_inputs, sampling_params)
still_failed = []
for (idx, item, docs, gt_str, messages), out in zip(remaining, outputs):
text = out.outputs[0].text
last_text[idx] = text
cot, answer = parse_output(text)
if is_valid(cot, answer, gt_str):
results[idx] = (cot, answer)
else:
still_failed.append((idx, item, docs, gt_str, messages))
remaining = still_failed
if remaining:
print(f"[batch {batch_start}] attempt {attempt}: {len(remaining)} samples need retry")
for idx, item, docs, gt_str, messages in batch:
record = dict(item)
record["docs"] = docs # 保存实际使用的 docs 顺序,训练时 CoT 与顺序绑定
record["sample_idx"] = idx
if idx in results:
cot, answer = results[idx]
record["cot"] = cot
record["cot_answer"] = answer
out_f.write(json.dumps(record, ensure_ascii=False) + "\n")
n_ok += 1
else:
record["gt_answer"] = gt_str
record["raw_output"] = last_text.get(idx)
failed_f.write(json.dumps(record, ensure_ascii=False) + "\n")
n_fail += 1
out_f.flush()
failed_f.flush()
print(f"Progress: {batch_start + len(batch)}/{len(pending)}, ok={n_ok}, fail={n_fail}")
out_f.close()
failed_f.close()
print(f"Done. success={n_ok}, failed={n_fail}, output={args.output_path}")
if __name__ == "__main__":
main()
|