| |
| """ |
| generate_multiturn.py — 自动生成多轮问诊 (AVD) 对话数据 |
| ========================================================= |
| 从 unified_dataset.jsonl 中按病害类别分组, 为同类别的多张图片 |
| 生成多轮追问→补充→诊断对话, 训练模型主动追问的能力. |
| |
| 生成两类样本: |
| 1. 多轮追问正样本 (~1500): 模型认为信息不足, 追问后给出诊断 |
| 2. 无需追问负样本 (~500): 模型认为信息充分, 直接给出诊断 |
| (主要从 PlantVillage 的清晰实验室照片中选取) |
| |
| 输出为 Qwen2.5-VL 对话格式 JSONL, 可直接用于 QLoRA 微调. |
| |
| 用法: |
| # DashScope API |
| python generate_multiturn.py \ |
| --input data/processed/unified_dataset.jsonl \ |
| --output data/generated/multiturn_avd.jsonl \ |
| --api-key sk-xxx --max-samples 100 |
| |
| # 本地模型 |
| python generate_multiturn.py \ |
| --input data/processed/unified_dataset.jsonl \ |
| --output data/generated/multiturn_avd.jsonl \ |
| --use-local --max-samples 50 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import base64 |
| import json |
| import os |
| import random |
| import sys |
| import time |
| from collections import Counter, defaultdict |
| from pathlib import Path |
|
|
| from tqdm import tqdm |
|
|
| |
| |
| |
| SYSTEM_MULTITURN = ( |
| "你是一位植物病理学家,正在进行远程问诊。当图片信息不足以做出确切诊断时," |
| "你会主动追问,要求用户补充特定角度或部位的照片。当信息充分时,你会直接给出诊断。" |
| ) |
|
|
| |
| GENERATION_PROMPT_TURN1 = """\ |
| 你是一位植物病理学家,正在进行远程问诊。用户给你发了一张作物照片。 |
| 已知这是 {crop} 的照片,最终诊断应为: {label_cn}。 |
| |
| 请分析这张照片,然后表示信息不充分,提出一个**具体的追问**: |
| - 要求用户拍摄特定部位或角度(如叶片背面、茎部基部、根部、果实近距离、病斑边缘等) |
| - 解释为什么需要这个补充信息 |
| |
| 要求: |
| - 描述你在第一张图中观察到的2-3个初步症状 |
| - 追问要有针对性, 例如"请拍叶片背面——我需要确认是否有孢子堆" |
| - 说明这个补充信息对确诊的意义 |
| - 不要现在给出最终诊断 |
| 回复用纯文本,不要使用 markdown 格式标记。""" |
|
|
| |
| GENERATION_PROMPT_TURN2 = """\ |
| 你是一位植物病理学家,正在进行远程问诊。 |
| |
| 之前你观察了用户的第一张照片并追问了更多信息。你之前的回复是: |
| --- |
| {turn1_response} |
| --- |
| |
| 现在用户又发了一张补充照片(同一棵植物的另一个角度/部位)。 |
| |
| 已知最终诊断为: {label_cn} ({label_en}) |
| |
| 请基于两张照片的综合信息,给出: |
| 1. 从补充照片中新观察到的关键特征 |
| 2. 结合两次观察的完整分析 |
| 3. 最终诊断结论及置信度 |
| |
| 回复用纯文本,不要使用 markdown 格式标记。""" |
|
|
| |
| GENERATION_PROMPT_NEGATIVE = """\ |
| 你是一位植物病理学家,正在进行远程问诊。用户给你发了一张作物照片。 |
| 已知最终诊断为: {label_cn} ({label_en}) |
| |
| 这张照片信息充分、症状特征非常清晰,无需追问补充照片。 |
| 请直接给出完整的诊断: |
| 1. 首先明确说明"图片信息充分,可以直接诊断" |
| 2. 描述观察到的关键症状特征(至少3个) |
| 3. 给出诊断结论及置信度 |
| |
| 回复用纯文本,不要使用 markdown 格式标记。""" |
|
|
|
|
| |
| |
| |
| def encode_image_base64(image_path: str) -> str | None: |
| try: |
| with open(image_path, "rb") as f: |
| return base64.b64encode(f.read()).decode("utf-8") |
| except Exception as e: |
| print(f" ⚠ 无法读取图片 {image_path}: {e}") |
| return None |
|
|
|
|
| def get_image_mime(image_path: str) -> str: |
| ext = Path(image_path).suffix.lower() |
| return { |
| ".jpg": "image/jpeg", |
| ".jpeg": "image/jpeg", |
| ".png": "image/png", |
| ".bmp": "image/bmp", |
| ".webp": "image/webp", |
| }.get(ext, "image/jpeg") |
|
|
|
|
| |
| |
| |
| class DashScopeBackend: |
| """Generate via DashScope MultiModalConversation API.""" |
|
|
| def __init__(self, api_key: str, model: str = "qwen2.5-vl-72b-instruct"): |
| self.api_key = api_key |
| self.model = model |
| self._request_times: list[float] = [] |
| self._rpm_limit = 15 |
|
|
| def _rate_limit(self): |
| now = time.time() |
| self._request_times = [t for t in self._request_times if now - t < 60] |
| if len(self._request_times) >= self._rpm_limit: |
| sleep_time = 60 - (now - self._request_times[0]) + 0.5 |
| if sleep_time > 0: |
| time.sleep(sleep_time) |
| self._request_times.append(time.time()) |
|
|
| def generate(self, prompt: str, image_path: str) -> str | None: |
| """Call VLM with single image + prompt.""" |
| try: |
| from dashscope import MultiModalConversation |
| except ImportError: |
| return self._generate_openai_compat(prompt, image_path) |
|
|
| self._rate_limit() |
| abs_path = str(Path(image_path).resolve()) |
| messages = [ |
| { |
| "role": "user", |
| "content": [ |
| {"image": f"file://{abs_path}"}, |
| {"text": prompt}, |
| ], |
| } |
| ] |
| try: |
| response = MultiModalConversation.call( |
| model=self.model, |
| messages=messages, |
| api_key=self.api_key, |
| ) |
| return response.output.choices[0].message.content[0]["text"].strip() |
| except Exception as e: |
| print(f" ⚠ DashScope API 调用失败: {e}") |
| return None |
|
|
| def generate_with_context( |
| self, prompt: str, image_path: str, context_messages: list[dict], |
| ) -> str | None: |
| """Call VLM with conversation history (for turn 2).""" |
| try: |
| from dashscope import MultiModalConversation |
| except ImportError: |
| return self._generate_with_context_openai( |
| prompt, image_path, context_messages, |
| ) |
|
|
| self._rate_limit() |
| abs_path = str(Path(image_path).resolve()) |
|
|
| messages = list(context_messages) |
| messages.append( |
| { |
| "role": "user", |
| "content": [ |
| {"image": f"file://{abs_path}"}, |
| {"text": prompt}, |
| ], |
| } |
| ) |
| try: |
| response = MultiModalConversation.call( |
| model=self.model, |
| messages=messages, |
| api_key=self.api_key, |
| ) |
| return response.output.choices[0].message.content[0]["text"].strip() |
| except Exception as e: |
| print(f" ⚠ DashScope API 调用失败: {e}") |
| return None |
|
|
| def _generate_openai_compat(self, prompt: str, image_path: str) -> str | None: |
| """Fallback: OpenAI-compatible endpoint.""" |
| try: |
| from openai import OpenAI |
| except ImportError: |
| print("错误: 请安装 dashscope 或 openai 库") |
| sys.exit(1) |
|
|
| b64 = encode_image_base64(image_path) |
| if b64 is None: |
| return None |
| mime = get_image_mime(image_path) |
| self._rate_limit() |
|
|
| client = OpenAI( |
| api_key=self.api_key, |
| base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", |
| ) |
| try: |
| resp = client.chat.completions.create( |
| model=self.model, |
| messages=[ |
| { |
| "role": "user", |
| "content": [ |
| { |
| "type": "image_url", |
| "image_url": {"url": f"data:{mime};base64,{b64}"}, |
| }, |
| {"type": "text", "text": prompt}, |
| ], |
| }, |
| ], |
| max_tokens=1500, |
| temperature=0.7, |
| ) |
| return resp.choices[0].message.content.strip() |
| except Exception as e: |
| print(f" ⚠ OpenAI 兼容 API 调用失败: {e}") |
| return None |
|
|
| def _generate_with_context_openai( |
| self, prompt: str, image_path: str, context_messages: list[dict], |
| ) -> str | None: |
| """Fallback turn-2 via OpenAI-compatible endpoint.""" |
| try: |
| from openai import OpenAI |
| except ImportError: |
| print("错误: 请安装 openai 库") |
| sys.exit(1) |
|
|
| b64 = encode_image_base64(image_path) |
| if b64 is None: |
| return None |
| mime = get_image_mime(image_path) |
| self._rate_limit() |
|
|
| |
| oai_messages: list[dict] = [] |
| for msg in context_messages: |
| if msg["role"] == "assistant": |
| oai_messages.append( |
| {"role": "assistant", "content": msg["content"]} |
| ) |
| elif msg["role"] == "user": |
| content_parts = msg.get("content", []) |
| oai_parts = [] |
| for part in content_parts: |
| if "image" in part: |
| |
| img_p = part["image"] |
| if img_p.startswith("file://"): |
| img_p = img_p[7:] |
| ctx_b64 = encode_image_base64(img_p) |
| if ctx_b64: |
| ctx_mime = get_image_mime(img_p) |
| oai_parts.append({ |
| "type": "image_url", |
| "image_url": { |
| "url": f"data:{ctx_mime};base64,{ctx_b64}", |
| }, |
| }) |
| elif "text" in part: |
| oai_parts.append({"type": "text", "text": part["text"]}) |
| oai_messages.append({"role": "user", "content": oai_parts}) |
|
|
| |
| oai_messages.append({ |
| "role": "user", |
| "content": [ |
| { |
| "type": "image_url", |
| "image_url": {"url": f"data:{mime};base64,{b64}"}, |
| }, |
| {"type": "text", "text": prompt}, |
| ], |
| }) |
|
|
| client = OpenAI( |
| api_key=self.api_key, |
| base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", |
| ) |
| try: |
| resp = client.chat.completions.create( |
| model=self.model, |
| messages=oai_messages, |
| max_tokens=1500, |
| temperature=0.7, |
| ) |
| return resp.choices[0].message.content.strip() |
| except Exception as e: |
| print(f" ⚠ OpenAI 兼容 API 调用失败: {e}") |
| return None |
|
|
|
|
| |
| |
| |
| class LocalBackend: |
| """Generate responses using local Qwen2.5-VL-7B in 4-bit.""" |
|
|
| def __init__(self, model_path: str | None = None): |
| self.model = None |
| self.processor = None |
| project_root = Path(__file__).resolve().parent.parent.parent |
| default_path = project_root / "models" / "qwen2.5-vl-7b" |
| self.model_path = model_path or str(default_path) |
| self._load_model() |
|
|
| def _load_model(self): |
| print("正在加载本地模型 Qwen2.5-VL-7B-Instruct (4-bit) ...") |
| try: |
| import torch |
| from transformers import ( |
| AutoProcessor, |
| Qwen2_5_VLForConditionalGeneration, |
| BitsAndBytesConfig, |
| ) |
| except ImportError as e: |
| print(f"错误: 缺少依赖: {e}") |
| print("请安装: pip install torch transformers bitsandbytes accelerate qwen-vl-utils") |
| sys.exit(1) |
|
|
| model_name = self.model_path if os.path.isdir(self.model_path) else "Qwen/Qwen2.5-VL-7B-Instruct" |
| bnb_config = BitsAndBytesConfig( |
| load_in_4bit=True, |
| bnb_4bit_compute_dtype=torch.float16, |
| bnb_4bit_quant_type="nf4", |
| bnb_4bit_use_double_quant=True, |
| llm_int8_enable_fp32_cpu_offload=True, |
| ) |
| self.processor = AutoProcessor.from_pretrained( |
| model_name, trust_remote_code=True, |
| ) |
| max_memory = {0: "6GB", "cpu": "32GB"} |
| self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained( |
| model_name, |
| quantization_config=bnb_config, |
| device_map="auto", |
| max_memory=max_memory, |
| trust_remote_code=True, |
| torch_dtype=torch.float16, |
| ) |
| self.model.eval() |
| print("模型加载完成。") |
|
|
| def _run_inference(self, messages: list[dict]) -> str | None: |
| import torch |
| from qwen_vl_utils import process_vision_info |
|
|
| try: |
| text = self.processor.apply_chat_template( |
| messages, tokenize=False, add_generation_prompt=True, |
| ) |
| image_inputs, video_inputs = process_vision_info(messages) |
| inputs = self.processor( |
| text=[text], |
| images=image_inputs, |
| videos=video_inputs, |
| padding=True, |
| return_tensors="pt", |
| ).to(self.model.device) |
|
|
| with torch.no_grad(): |
| output_ids = self.model.generate( |
| **inputs, |
| max_new_tokens=1500, |
| temperature=0.7, |
| do_sample=True, |
| top_p=0.9, |
| ) |
|
|
| generated = output_ids[0][inputs["input_ids"].shape[1]:] |
| result = self.processor.decode( |
| generated, skip_special_tokens=True, |
| clean_up_tokenization_spaces=False, |
| ) |
| return result.strip() |
| except Exception as e: |
| print(f" ⚠ 本地推理失败: {e}") |
| return None |
|
|
| def generate(self, prompt: str, image_path: str) -> str | None: |
| abs_path = str(Path(image_path).resolve()) |
| messages = [ |
| { |
| "role": "user", |
| "content": [ |
| {"type": "image", "image": f"file://{abs_path}"}, |
| {"type": "text", "text": prompt}, |
| ], |
| } |
| ] |
| return self._run_inference(messages) |
|
|
| def generate_with_context( |
| self, prompt: str, image_path: str, context_messages: list[dict], |
| ) -> str | None: |
| abs_path = str(Path(image_path).resolve()) |
| messages = list(context_messages) |
| messages.append( |
| { |
| "role": "user", |
| "content": [ |
| {"type": "image", "image": f"file://{abs_path}"}, |
| {"type": "text", "text": prompt}, |
| ], |
| } |
| ) |
| return self._run_inference(messages) |
|
|
|
|
| |
| |
| |
| def load_input_data(input_file: str) -> list[dict]: |
| records = [] |
| with open(input_file, "r", encoding="utf-8") as f: |
| for line in f: |
| line = line.strip() |
| if line: |
| records.append(json.loads(line)) |
| return records |
|
|
|
|
| def load_existing_outputs(output_file: str) -> set[str]: |
| """Load already-generated image paths for resume support. |
| |
| Uses the FIRST image path in the first user message as unique key. |
| """ |
| done = set() |
| if not os.path.exists(output_file): |
| return done |
| try: |
| with open(output_file, "r", encoding="utf-8") as f: |
| for line in f: |
| line = line.strip() |
| if not line: |
| continue |
| entry = json.loads(line) |
| msgs = entry.get("messages", []) |
| for msg in msgs: |
| if msg.get("role") == "user": |
| content = msg.get("content", []) |
| if isinstance(content, list): |
| for item in content: |
| if isinstance(item, dict) and item.get("type") == "image": |
| done.add(item.get("image", "")) |
| break |
| except Exception: |
| pass |
| return done |
|
|
|
|
| def group_by_disease(records: list[dict]) -> dict[str, list[dict]]: |
| """Group records by disease label (label_cn) for multiturn pairing.""" |
| groups: dict[str, list[dict]] = defaultdict(list) |
| for rec in records: |
| groups[rec["label_cn"]].append(rec) |
| return dict(groups) |
|
|
|
|
| |
| |
| |
| def extract_diagnosis(text: str, label_cn: str, label_en: str) -> str: |
| """Extract diagnosis from text for consistency check.""" |
| if label_cn in text: |
| return label_cn |
| if label_en.lower().replace("_", " ") in text.lower(): |
| return label_en |
| for kw in ["最终诊断", "诊断为", "判断为", "确诊为", "结论"]: |
| idx = text.find(kw) |
| if idx != -1: |
| return text[idx: idx + 80].strip() |
| return text[-100:].strip() |
|
|
|
|
| def run_with_consistency( |
| gen_func, label_cn: str, label_en: str, n_runs: int = 3, |
| ) -> str | None: |
| """Run a generation function n_runs times, return result only if |
| the extracted diagnosis agrees across runs. |
| |
| gen_func: callable() -> str | None |
| """ |
| responses: list[str] = [] |
| labels: list[str] = [] |
| for _ in range(n_runs): |
| resp = gen_func() |
| if resp is None: |
| return None |
| responses.append(resp) |
| labels.append(extract_diagnosis(resp, label_cn, label_en)) |
|
|
| if len(set(labels)) == 1: |
| return responses[0] |
|
|
| counter = Counter(labels) |
| most_common, count = counter.most_common(1)[0] |
| if count >= n_runs - 1: |
| return responses[labels.index(most_common)] |
|
|
| return None |
|
|
|
|
| |
| |
| |
| def build_multiturn_record( |
| image1_path: str, |
| turn1_response: str, |
| image2_path: str, |
| turn2_response: str, |
| source: str, |
| label_en: str, |
| ) -> dict: |
| """Build multi-turn AVD positive sample (2 turns).""" |
| return { |
| "messages": [ |
| {"role": "system", "content": SYSTEM_MULTITURN}, |
| { |
| "role": "user", |
| "content": [ |
| {"type": "image", "image": image1_path}, |
| {"type": "text", "text": "这棵植物怎么了?"}, |
| ], |
| }, |
| {"role": "assistant", "content": turn1_response}, |
| { |
| "role": "user", |
| "content": [ |
| {"type": "image", "image": image2_path}, |
| {"type": "text", "text": "这是背面"}, |
| ], |
| }, |
| {"role": "assistant", "content": turn2_response}, |
| ], |
| "metadata": { |
| "source": source, |
| "label": label_en, |
| "task_type": "multiturn_avd", |
| }, |
| } |
|
|
|
|
| def build_negative_record( |
| image_path: str, response: str, source: str, label_en: str, |
| ) -> dict: |
| """Build no-follow-up negative sample (信息充分, 直接诊断).""" |
| return { |
| "messages": [ |
| {"role": "system", "content": SYSTEM_MULTITURN}, |
| { |
| "role": "user", |
| "content": [ |
| {"type": "image", "image": image_path}, |
| {"type": "text", "text": "这棵植物怎么了?"}, |
| ], |
| }, |
| {"role": "assistant", "content": response}, |
| ], |
| "metadata": { |
| "source": source, |
| "label": label_en, |
| "task_type": "multiturn_avd_negative", |
| }, |
| } |
|
|
|
|
| |
| |
| |
| def generate_multiturn_positive( |
| groups: dict[str, list[dict]], |
| backend, |
| output_file: str, |
| max_samples: int | None, |
| consistency_runs: int, |
| ) -> int: |
| """Generate multi-turn AVD positive samples. |
| |
| For each disease class with 2+ images, pair images and generate |
| a 2-turn dialogue: initial observation → follow-up request → diagnosis. |
| """ |
| done = load_existing_outputs(output_file) |
|
|
| |
| pairs: list[tuple[dict, dict, str]] = [] |
| for label_cn, recs in groups.items(): |
| if len(recs) < 2: |
| continue |
| random.shuffle(recs) |
| for i in range(0, len(recs) - 1, 2): |
| pairs.append((recs[i], recs[i + 1], label_cn)) |
|
|
| random.shuffle(pairs) |
|
|
| |
| pairs = [ |
| (a, b, lbl) |
| for a, b, lbl in pairs |
| if a["image_path"] not in done |
| ] |
|
|
| if max_samples is not None: |
| pairs = pairs[:max_samples] |
|
|
| print(f"\n多轮追问正样本: 待处理 {len(pairs)} 组 (已完成 {len(done)} 组)") |
|
|
| generated = 0 |
| skipped = 0 |
|
|
| with open(output_file, "a", encoding="utf-8") as fout: |
| for img1, img2, label_cn in tqdm(pairs, desc="生成多轮对话", unit="group"): |
| crop = img1.get("crop", "作物") |
| label_en = img1["label_en"] |
| source = img1["source"] |
|
|
| |
| prompt1 = GENERATION_PROMPT_TURN1.format( |
| crop=crop, label_cn=label_cn, |
| ) |
|
|
| if consistency_runs > 1: |
| turn1_resp = run_with_consistency( |
| lambda p=prompt1, ip=img1["image_path"]: backend.generate(p, ip), |
| label_cn, label_en, consistency_runs, |
| ) |
| else: |
| turn1_resp = backend.generate(prompt1, img1["image_path"]) |
|
|
| if turn1_resp is None: |
| skipped += 1 |
| continue |
|
|
| |
| prompt2 = GENERATION_PROMPT_TURN2.format( |
| turn1_response=turn1_resp, |
| label_cn=label_cn, |
| label_en=label_en, |
| ) |
|
|
| |
| abs1 = str(Path(img1["image_path"]).resolve()) |
| context = [ |
| { |
| "role": "user", |
| "content": [ |
| {"image": f"file://{abs1}"}, |
| {"text": "这棵植物怎么了?"}, |
| ], |
| }, |
| {"role": "assistant", "content": turn1_resp}, |
| ] |
|
|
| full_prompt2 = "这是补充拍摄的照片。\n" + prompt2 |
|
|
| if consistency_runs > 1: |
| turn2_resp = run_with_consistency( |
| lambda p=full_prompt2, ip=img2["image_path"], ctx=context: ( |
| backend.generate_with_context(p, ip, ctx) |
| ), |
| label_cn, label_en, consistency_runs, |
| ) |
| else: |
| turn2_resp = backend.generate_with_context( |
| full_prompt2, img2["image_path"], context, |
| ) |
|
|
| if turn2_resp is None: |
| skipped += 1 |
| continue |
|
|
| entry = build_multiturn_record( |
| img1["image_path"], turn1_resp, |
| img2["image_path"], turn2_resp, |
| source, label_en, |
| ) |
| fout.write(json.dumps(entry, ensure_ascii=False) + "\n") |
| fout.flush() |
| generated += 1 |
|
|
| print(f" 正样本: 生成 {generated} 条, 跳过 {skipped} 条") |
| return generated |
|
|
|
|
| |
| |
| |
| def generate_negative_samples( |
| records: list[dict], |
| backend, |
| output_file: str, |
| num_negative: int, |
| consistency_runs: int, |
| ) -> int: |
| """Generate negative samples where a single clear image suffices. |
| |
| Prioritizes PlantVillage images (clear lab photos with uniform backgrounds) |
| since they are unambiguous and don't need follow-up questions. |
| """ |
| done = load_existing_outputs(output_file) |
|
|
| |
| pv_records = [r for r in records if r["source"] == "PlantVillage" and r["image_path"] not in done] |
| other_records = [r for r in records if r["source"] != "PlantVillage" and r["image_path"] not in done] |
|
|
| random.shuffle(pv_records) |
| random.shuffle(other_records) |
|
|
| |
| pv_count = min(len(pv_records), int(num_negative * 0.8)) |
| other_count = min(len(other_records), num_negative - pv_count) |
| pending = pv_records[:pv_count] + other_records[:other_count] |
|
|
| |
| if len(pending) < num_negative: |
| remaining = pv_records[pv_count:] + other_records[other_count:] |
| pending += remaining[: num_negative - len(pending)] |
|
|
| random.shuffle(pending) |
|
|
| print(f"\n无需追问负样本: 待处理 {len(pending)} 条 (PlantVillage 优先)") |
|
|
| generated = 0 |
| skipped = 0 |
|
|
| with open(output_file, "a", encoding="utf-8") as fout: |
| for rec in tqdm(pending, desc="生成负样本", unit="sample"): |
| img_path = rec["image_path"] |
| label_cn = rec["label_cn"] |
| label_en = rec["label_en"] |
| source = rec["source"] |
|
|
| prompt = GENERATION_PROMPT_NEGATIVE.format( |
| label_cn=label_cn, label_en=label_en, |
| ) |
|
|
| if consistency_runs > 1: |
| response = run_with_consistency( |
| lambda p=prompt, ip=img_path: backend.generate(p, ip), |
| label_cn, label_en, consistency_runs, |
| ) |
| else: |
| response = backend.generate(prompt, img_path) |
|
|
| if response is None: |
| skipped += 1 |
| continue |
|
|
| |
| if not any(kw in response for kw in ["信息充分", "直接诊断", "可以确定", "清晰可见", "特征明显"]): |
| |
| response = backend.generate(prompt, img_path) |
| if response is None: |
| skipped += 1 |
| continue |
|
|
| entry = build_negative_record(img_path, response, source, label_en) |
| fout.write(json.dumps(entry, ensure_ascii=False) + "\n") |
| fout.flush() |
| generated += 1 |
|
|
| print(f" 负样本: 生成 {generated} 条, 跳过 {skipped} 条") |
| return generated |
|
|
|
|
| |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser( |
| description="自动生成多轮问诊 (AVD) 对话训练数据", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog="""\ |
| 示例: |
| # DashScope API (测试 20 条) |
| python generate_multiturn.py \\ |
| --input data/processed/unified_dataset.jsonl \\ |
| --output data/generated/multiturn_avd.jsonl \\ |
| --api-key sk-xxx --max-samples 20 |
| |
| # 本地 7B 模型 |
| python generate_multiturn.py \\ |
| --input data/processed/unified_dataset.jsonl \\ |
| --output data/generated/multiturn_avd.jsonl \\ |
| --use-local --max-samples 50 |
| """, |
| ) |
| parser.add_argument( |
| "--input", |
| type=str, |
| default="data/processed/unified_dataset.jsonl", |
| help="输入: unified_dataset.jsonl 路径", |
| ) |
| parser.add_argument( |
| "--output", |
| type=str, |
| required=True, |
| help="输出 JSONL 文件路径", |
| ) |
| parser.add_argument( |
| "--api-key", |
| type=str, |
| default=None, |
| help="DashScope API key (也可通过 DASHSCOPE_API_KEY 环境变量设置)", |
| ) |
| parser.add_argument( |
| "--use-local", |
| action="store_true", |
| default=False, |
| help="使用本地 Qwen2.5-VL-7B 模型 (回退方案, 需要 GPU)", |
| ) |
| parser.add_argument( |
| "--local-model-path", |
| type=str, |
| default=None, |
| help="本地模型路径 (默认: models/qwen2.5-vl-7b 相对于项目根)", |
| ) |
| parser.add_argument( |
| "--max-samples", |
| type=int, |
| default=1500, |
| help="多轮正样本数量限制 (default: 1500)", |
| ) |
| parser.add_argument( |
| "--num-negative", |
| type=int, |
| default=500, |
| help="无需追问负样本数量 (default: 500)", |
| ) |
| parser.add_argument( |
| "--consistency-runs", |
| type=int, |
| default=3, |
| help="自一致性过滤: 对每个样本生成 N 次, 仅保留诊断一致的 (default: 3)", |
| ) |
| parser.add_argument( |
| "--model", |
| type=str, |
| default="qwen2.5-vl-72b-instruct", |
| help="DashScope 模型名 (default: qwen2.5-vl-72b-instruct)", |
| ) |
| parser.add_argument( |
| "--seed", |
| type=int, |
| default=42, |
| help="随机种子 (default: 42)", |
| ) |
| args = parser.parse_args() |
|
|
| random.seed(args.seed) |
|
|
| |
| api_key = args.api_key or os.environ.get("DASHSCOPE_API_KEY") |
| if not args.use_local and not api_key: |
| print("错误: 请通过 --api-key 或 DASHSCOPE_API_KEY 环境变量提供 API key") |
| print(" 或使用 --use-local 回退到本地模型") |
| sys.exit(1) |
|
|
| |
| input_path = Path(args.input) |
| if not input_path.exists(): |
| print(f"错误: 输入文件不存在: {input_path}") |
| print("请先运行 prepare_data.py 生成 unified_dataset.jsonl") |
| sys.exit(1) |
|
|
| backend_cn = "本地 7B" if args.use_local else f"DashScope ({args.model})" |
|
|
| print("=" * 60) |
| print("多轮问诊 (AVD) 数据生成") |
| print("=" * 60) |
| print(f" 输入文件: {args.input}") |
| print(f" 输出文件: {args.output}") |
| print(f" 后端: {backend_cn}") |
| print(f" 正样本限制: {args.max_samples}") |
| print(f" 负样本数量: {args.num_negative}") |
| print(f" 一致性检验: {args.consistency_runs} 次/样本") |
| print() |
|
|
| |
| records = load_input_data(args.input) |
| print(f"加载 {len(records)} 条输入记录") |
|
|
| |
| groups = group_by_disease(records) |
| eligible = {k: v for k, v in groups.items() if len(v) >= 2} |
| print( |
| f"共 {len(groups)} 个病害类别, " |
| f"{len(eligible)} 个类别有 2+ 张图片可配对" |
| ) |
| total_pairs = sum(len(v) // 2 for v in eligible.values()) |
| print(f"预计可生成 ~{total_pairs} 组多轮对话") |
|
|
| |
| if args.use_local: |
| backend = LocalBackend(model_path=args.local_model_path) |
| else: |
| backend = DashScopeBackend(api_key=api_key, model=args.model) |
|
|
| os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True) |
|
|
| |
| n_pos = generate_multiturn_positive( |
| eligible, backend, args.output, args.max_samples, args.consistency_runs, |
| ) |
|
|
| |
| n_neg = generate_negative_samples( |
| records, backend, args.output, args.num_negative, args.consistency_runs, |
| ) |
|
|
| |
| print("\n" + "=" * 60) |
| print("生成摘要") |
| print("=" * 60) |
| print(f" 多轮正样本: {n_pos} 条") |
| print(f" 无需追问负样本: {n_neg} 条") |
| print(f" 合计: {n_pos + n_neg} 条") |
| print(f" 输出文件: {args.output}") |
| print("\n✓ 生成完成") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|