File size: 11,573 Bytes
fedd8d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
#!/usr/bin/env python3
# train_nct.py – MedGemma 组织分类微调 & 评估 (仿照 c6.py)
# 功能:
#   1. 加载本地 NCT-CRC-HE-100K 训练集 和 CRC-VAL-HE-7K 测试集(自动解压 zip)
#   2. 使用 QLoRA 微调 MedGemma(4-bit 量化)
#   3. 在测试集上评估准确率和 F1

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,
#    BitsAndBytesConfig,
    pipeline,
)
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer
import evaluate

# ---------- Monkey-patch for torch<2.6 ----------
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": "...处理好的文本..."  # 必须有文本字段
    }
    #return example


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()
    # 掩码图像 token 和填充 token
    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()

    # GPU 设置提示
    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, remove_columns=data["train"].column_names)
    data = data.map(format_data)

    # 加载模型 (QLoRA 量化)
    print(f"加载模型 {args.model_path}...")
    model_kwargs = dict(
        attn_implementation="eager",
        torch_dtype=torch.bfloat16,
        device_map="auto",
        local_files_only=True,
    )
   # model_kwargs["quantization_config"] = BitsAndBytesConfig(
   #     load_in_4bit=True,
   #     bnb_4bit_use_double_quant=True,
   #     bnb_4bit_quant_type="nf4",
   #     bnb_4bit_compute_dtype=torch.bfloat16,
   #     bnb_4bit_quant_storage=torch.bfloat16,
   # )

    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"  # 训练用右填充

    # LoRA 配置
    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)

    # 设置标签特征(全局变量,供 postprocess 使用)
    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()