| |
| |
| |
| |
| |
| |
|
|
| import os |
| import sys |
| import argparse |
| import json |
| import zipfile |
| import torch |
| import numpy as np |
| from typing import Any |
| from datasets import load_dataset, ClassLabel |
| from transformers import ( |
| AutoProcessor, |
| AutoModelForImageTextToText, |
| |
| pipeline, |
| ) |
| from peft import LoraConfig |
| from trl import SFTConfig, SFTTrainer |
| import evaluate |
|
|
| |
| import transformers.masking_utils as masking_utils |
| if torch.__version__ < "2.6": |
| _orig_causal = masking_utils.create_causal_mask |
| _orig_sliding = masking_utils.create_sliding_window_causal_mask |
|
|
| def _safe_pop(kw): |
| kw.pop("or_mask_function", None) |
| kw.pop("and_mask_function", None) |
|
|
| def _new_causal(*a, **kw): |
| _safe_pop(kw) |
| return _orig_causal(*a, **kw) |
|
|
| def _new_sliding(*a, **kw): |
| _safe_pop(kw) |
| return _orig_sliding(*a, **kw) |
|
|
| masking_utils.create_causal_mask = _new_causal |
| masking_utils.create_sliding_window_causal_mask = _new_sliding |
|
|
|
|
| TISSUE_CLASSES = [ |
| "A: adipose", |
| "B: background", |
| "C: debris", |
| "D: lymphocytes", |
| "E: mucus", |
| "F: smooth muscle", |
| "G: normal colon mucosa", |
| "H: cancer-associated stroma", |
| "I: colorectal adenocarcinoma epithelium", |
| ] |
|
|
| PROMPT = f"What is the most likely tissue type shown in the histopathology image?\n" + "\n".join(TISSUE_CLASSES) |
|
|
|
|
| def extract_zip(zip_path: str, extract_to: str) -> str: |
| """解压 zip 文件,返回解压后的目录路径""" |
| if not os.path.exists(extract_to): |
| os.makedirs(extract_to, exist_ok=True) |
| with zipfile.ZipFile(zip_path, "r") as zf: |
| zf.extractall(extract_to) |
| return extract_to |
|
|
|
|
| def format_data(example: dict[str, Any]) -> dict[str, Any]: |
| """构造训练 messages 格式""" |
| example["messages"] = [ |
| { |
| "role": "user", |
| "content": [ |
| {"type": "image"}, |
| {"type": "text", "text": PROMPT}, |
| ], |
| }, |
| { |
| "role": "assistant", |
| "content": [ |
| {"type": "text", "text": TISSUE_CLASSES[example["label"]]}, |
| ], |
| }, |
| ] |
| return { |
| "image": example["image"], |
| "text": "...处理好的文本..." |
| } |
| |
|
|
|
|
| def format_test_data(example: dict[str, Any]) -> dict[str, Any]: |
| """构造测试 messages 格式(无 assistant)""" |
| example["messages"] = [ |
| { |
| "role": "user", |
| "content": [ |
| {"type": "image"}, |
| {"type": "text", "text": PROMPT}, |
| ], |
| }, |
| ] |
| return example |
|
|
|
|
| def collate_fn(examples: list[dict[str, Any]], processor): |
| texts = [] |
| images = [] |
| for example in examples: |
| images.append([example["image"].convert("RGB")]) |
| texts.append( |
| processor.apply_chat_template( |
| example["messages"], add_generation_prompt=False, tokenize=False |
| ).strip() |
| ) |
|
|
| batch = processor(text=texts, images=images, return_tensors="pt", padding=True) |
|
|
| labels = batch["input_ids"].clone() |
| |
| image_token_id = [ |
| processor.tokenizer.convert_tokens_to_ids( |
| processor.tokenizer.special_tokens_map["boi_token"] |
| ) |
| ] |
| labels[labels == processor.tokenizer.pad_token_id] = -100 |
| for tok_id in image_token_id: |
| labels[labels == tok_id] = -100 |
| labels[labels == 262144] = -100 |
|
|
| batch["labels"] = labels |
| return batch |
|
|
|
|
| def postprocess(prediction: list[dict[str, str]], do_full_match: bool = False) -> int: |
| """将模型预测转换为类别索引""" |
| response_text = prediction[0]["generated_text"] |
| if do_full_match: |
| try: |
| return LABEL_FEATURE.str2int(response_text) |
| except: |
| return -1 |
| for label in TISSUE_CLASSES: |
| if label in response_text or f"({label.replace(': ', ') ')}" in response_text: |
| return LABEL_FEATURE.str2int(label) |
| return -1 |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="MedGemma NCT 组织分类微调与评估") |
| parser.add_argument("--model_path", required=True, help="本地 MedGemma 模型路径") |
| parser.add_argument("--train_zip", default="./NCT-CRC-HE-100K.zip", help="训练集 zip 路径") |
| parser.add_argument("--test_zip", default="./CRC-VAL-HE-7K.zip", help="测试集 zip 路径") |
| parser.add_argument("--output_dir", default="./medgemma-nct-lora", help="微调模型输出目录") |
| parser.add_argument("--num_train_epochs", type=int, default=1) |
| parser.add_argument("--learning_rate", type=float, default=2e-4) |
| parser.add_argument("--per_device_train_batch_size", type=int, default=4) |
| parser.add_argument("--max_train_samples", type=int, default=9000, help="训练样本数") |
| parser.add_argument("--max_val_samples", type=int, default=1000, help="验证样本数") |
| parser.add_argument("--max_test_samples", type=int, default=1000, help="测试样本数(评估用)") |
| parser.add_argument("--num_gpus", type=int, default=None, help="使用的 GPU 数量(建议在命令行用 CUDA_VISIBLE_DEVICES 控制)") |
| parser.add_argument("--eval_only", action="store_true", help="仅评估,跳过训练") |
| parser.add_argument("--skip_train", action="store_true", help="跳过训练(与 eval_only 相同)") |
| args = parser.parse_args() |
|
|
| |
| if args.num_gpus: |
| print("提示:请通过环境变量限制 GPU,例如 CUDA_VISIBLE_DEVICES=0,1") |
| print(f"可用 GPU 数量: {torch.cuda.device_count()}") |
|
|
| |
| train_dir = os.path.splitext(args.train_zip)[0] |
| test_dir = os.path.splitext(args.test_zip)[0] |
| if not os.path.exists(train_dir): |
| print(f"解压训练集 {args.train_zip} -> {train_dir}") |
| extract_zip(args.train_zip, train_dir) |
| if not os.path.exists(test_dir): |
| print(f"解压测试集 {args.test_zip} -> {test_dir}") |
| extract_zip(args.test_zip, test_dir) |
|
|
| |
| print("加载训练集...") |
| data = load_dataset(train_dir, split="train") |
| data = data.train_test_split( |
| train_size=args.max_train_samples, |
| test_size=args.max_val_samples, |
| shuffle=True, |
| seed=42, |
| ) |
| data["validation"] = data.pop("test") |
|
|
| |
| |
| data = data.map(format_data) |
|
|
| |
| print(f"加载模型 {args.model_path}...") |
| model_kwargs = dict( |
| attn_implementation="eager", |
| torch_dtype=torch.bfloat16, |
| device_map="auto", |
| local_files_only=True, |
| ) |
| |
| |
| |
| |
| |
| |
| |
|
|
| model = AutoModelForImageTextToText.from_pretrained(args.model_path, **model_kwargs) |
| processor = AutoProcessor.from_pretrained(args.model_path, local_files_only=True) |
| processor.tokenizer.padding_side = "right" |
|
|
| |
| peft_config = LoraConfig( |
| lora_alpha=16, |
| lora_dropout=0.05, |
| r=16, |
| bias="none", |
| target_modules="all-linear", |
| task_type="CAUSAL_LM", |
| modules_to_save=["lm_head", "embed_tokens"], |
| ) |
|
|
| |
| sft_args = SFTConfig( |
| output_dir=args.output_dir, |
| num_train_epochs=args.num_train_epochs, |
| per_device_train_batch_size=args.per_device_train_batch_size, |
| per_device_eval_batch_size=args.per_device_train_batch_size, |
| gradient_accumulation_steps=4, |
| gradient_checkpointing=True, |
| optim="adamw_torch_fused", |
| logging_steps=50, |
| save_strategy="epoch", |
| eval_strategy="steps", |
| eval_steps=50, |
| learning_rate=args.learning_rate, |
| bf16=True, |
| max_grad_norm=0.3, |
| warmup_ratio=0.03, |
| lr_scheduler_type="linear", |
| push_to_hub=False, |
| report_to="none", |
| gradient_checkpointing_kwargs={"use_reentrant": False}, |
| dataset_kwargs={"skip_prepare_dataset": True}, |
| remove_unused_columns=False, |
| label_names=["labels"], |
| ) |
|
|
| if not args.eval_only and not args.skip_train: |
| print("开始训练...") |
| trainer = SFTTrainer( |
| model=model, |
| args=sft_args, |
| train_dataset=data["train"], |
| eval_dataset=data["validation"].select(range(min(200, len(data["validation"])))), |
| peft_config=peft_config, |
| processing_class=processor, |
| data_collator=lambda examples: collate_fn(examples, processor), |
| ) |
| trainer.train() |
| trainer.save_model() |
| print(f"模型已保存到 {args.output_dir}") |
| del trainer |
| torch.cuda.empty_cache() |
|
|
| |
| print("加载测试集...") |
| test_data = load_dataset(test_dir, split="train") |
| test_data = test_data.shuffle(seed=42).select(range(args.max_test_samples)) |
| test_data = test_data.map(format_test_data, remove_columns=test_data.column_names) |
|
|
| |
| global LABEL_FEATURE |
| test_data = test_data.cast_column("label", ClassLabel(names=TISSUE_CLASSES)) |
| LABEL_FEATURE = test_data.features["label"] |
|
|
| |
| model_id = args.output_dir if (args.eval_only or args.skip_train) else args.model_path |
|
|
| print(f"创建评估管道,模型: {model_id}") |
| eval_pipe = pipeline( |
| "image-text-to-text", |
| model=model_id, |
| processor=processor, |
| torch_dtype=torch.bfloat16, |
| device_map="auto", |
| ) |
| eval_pipe.model.generation_config.do_sample = False |
| eval_pipe.model.generation_config.pad_token_id = processor.tokenizer.eos_token_id |
| processor.tokenizer.padding_side = "left" |
|
|
| |
| print("运行推理...") |
| outputs = eval_pipe( |
| text=test_data["messages"], |
| images=test_data["image"], |
| max_new_tokens=40, |
| batch_size=64, |
| return_full_text=False, |
| ) |
|
|
| |
| do_full_match = args.eval_only or args.skip_train |
| predictions = [postprocess(out, do_full_match) for out in outputs] |
| references = test_data["label"] |
|
|
| |
| accuracy_metric = evaluate.load("accuracy") |
| f1_metric = evaluate.load("f1") |
| acc = accuracy_metric.compute(predictions=predictions, references=references) |
| f1 = f1_metric.compute(predictions=predictions, references=references, average="weighted") |
| metrics = {**acc, **f1} |
| print(f"评估结果: {metrics}") |
|
|
| |
| os.makedirs(args.output_dir, exist_ok=True) |
| result_file = os.path.join(args.output_dir, "eval_metrics.json") |
| with open(result_file, "w") as f: |
| json.dump(metrics, f, indent=2) |
| print(f"结果已保存到 {result_file}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|