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()