跳至主要内容

微调 Ministral 3:训练用于 X 光分类的视觉-语言模型分步指南

学习如何使用 LoRA、Hugging Face 工具、自定义提示词和 TRL 微调 Ministral 3 视觉-语言模型,以进行 X 光图像分类。
· 10分钟 阅读

使用 AI 探索

Ministral 3 是一个面向边缘优化的多模态模型家族,提供 3B、8B 和 14B 三种规模,每种规模均包含 Base、Instruct 和 Reasoning 变体,并全面支持视觉能力。

该系列兼顾强大的前沿性能与轻量化部署需求,适合本地与私有环境。旗舰 14B Instruct 模型在 FP8 下可装入 24GB 显存,性能可与更大型的系统(如 Mistral Small 3.2 24B)媲美。其还具备 256k 超大上下文窗口、稳健的多语言覆盖、原生函数调用、对系统提示的强遵循,以及 Apache 2.0 开源许可。

本教程将把 Ministral 3 14B Instruct 模型在骨折 X 光数据集上进行微调,以提升其检测骨折并准确分类的能力。

您将学到如何:

  1. 从 Kaggle 加载数据集。
  2. 将其转换为 Hugging Face 数据集。
  3. 为 X 光分类创建清晰的训练提示词。
  4. 搭建 Ministral 3 14B 视觉-语言训练流程。
  5. 微调模型并保存检查点。
  6. 加载训练得到的模型适配器进行推理。
  7. 比较微调前后的结果。

如果您想进一步了解 Mistral AI 模型,推荐阅读我们的指南Mistral 3及Mistral 3 Large 教程。

免责声明: 本教程仅为教育目的演示技术性微调流程。所得模型不用于任何医疗或临床用途,不得用于诊断、决策或患者护理。

1. Ministral 3 视觉-语言微调的环境搭建

尽管我们微调的是 14B 模型,但视觉-语言训练比纯文本微调需要显著更多的显存。图像编码器会增加 GPU 负载,这意味着我们需要使用 80 GB 显存的 A100 GPU,才能顺利运行该流程。

本项目我使用 Runpod 并启动了一个 A100 pod。

RunPod. A100 Pod.

启动 pod 后,将容器存储增至 50 GB。然后为 Kaggle 和 Hugging Face 的 API 密钥添加环境变量。

Kaggle 便于我们直接下载骨骼 X 光数据集,Hugging Face 则用于将微调后的 LoRA 适配器推送回模型中心。

Setting up environment variable in the Runpod

接下来打开 JupyterLab,创建一个新笔记本。训练前需要安装所有必需的 Python 包。

请确保Transformers 库已更新至最新版本,因为 Ministral 3 的支持是近期才加入的,旧版本将无法正确加载模型。

! pip install --upgrade --quiet bitsandbytes datasets evaluate hf_transfer peft scikit-learn matplotlib Pillow kaggle ipywidgets
!pip install --quiet git+https://github.com/huggingface/transformers.git@bf3f0ae70d0e902efab4b8517fce88f6697636ce
!pip install --quiet --no-deps trl==0.22.2

一切安装完毕后,您就可以加载数据集、准备 collator,并开始微调了。

2. 从 Kaggle 下载骨折数据集

下一步,我们为数据集设置根文件夹,并使用 Kaggle Python API 下载 Bone Break Classification Image 数据集。

该数据集以 X 光形式包含多种骨折类别,将用于对 Ministral 3 进行视觉分类微调。

import os
import glob
from kaggle.api.kaggle_api_extended import KaggleApi

DATA_ROOT = "/bone-break"
os.makedirs(DATA_ROOT, exist_ok=True)

kaggle_dataset = "pkdarabi/bone-break-classification-image-dataset"

# Initialize and authenticate Kaggle API (it will read KAGGLE_USERNAME / KAGGLE_KEY)
api = KaggleApi()
api.authenticate()

# Download and unzip the dataset using the Python API
api.dataset_download_files(
    kaggle_dataset,
    path=DATA_ROOT,
    unzip=True,
)

您可以在 Kaggle 上查看该数据集:

Dataset URL: https://www.kaggle.com/datasets/pkdarabi/bone-break-classification-image-dataset

3. 构建 Hugging Face 数据集

下载后的数据集并非常见的组织结构。每个骨折类别目录内部同时包含训练和测试文件夹。

因此我们首先需要一种方法,遍历每个类别文件夹,分离两个切分集,然后创建一个合并的 Hugging Face 数据集,包含干净的 train/test 映射。

import glob, os
from typing import Any, Dict, List

from datasets import Dataset, DatasetDict, Features, ClassLabel, Image

inner_root = os.path.join(
    DATA_ROOT, "Bone Break Classification", "Bone Break Classification"
)

classes = []
train_records: List[Dict[str, Any]] = []
test_records: List[Dict[str, Any]] = []

for class_name in sorted(os.listdir(inner_root)):
    class_dir = os.path.join(inner_root, class_name)
    if not os.path.isdir(class_dir):
        continue

    classes.append(class_name)

    for split_name, split_key in [("Train", "train"), ("Test", "test")]:
        split_dir = os.path.join(class_dir, split_name)
        if not os.path.isdir(split_dir):
            continue

        for ext in ("*.png", "*.jpg", "*.jpeg"):
            for img_path in glob.glob(os.path.join(split_dir, ext)):
                rec = {"image": img_path, "label_name": class_name}
                if split_key == "train":
                    train_records.append(rec)
                else:
                    test_records.append(rec)

print("Classes:", classes)
print("Train samples:", len(train_records))
print("Test samples:", len(test_records))

我们共有 10 个分类,训练样本 989 个,测试样本 140 个。

Classes: ['Avulsion fracture', 'Comminuted fracture', 'Fracture Dislocation', 'Greenstick fracture', 'Hairline Fracture', 'Impacted fracture', 'Longitudinal fracture', 'Oblique fracture', 'Pathological fracture', 'Spiral Fracture']
Train samples: 989
Test samples: 140

接下来我们为 Hugging Face 数据集编码标签,使每个标签类别被视为类标签,每张图像被视为图像特征。

我们将创建两个数据集:一个用于训练,一个用于测试。最后将它们合并为同时包含训练与测试子集的单一数据集。

# Encode labels
classes = sorted(list(set(classes)))
label2id = {name: i for i, name in enumerate(classes)}

for rec in train_records:
    rec["label"] = label2id[rec["label_name"]]
for rec in test_records:
    rec["label"] = label2id[rec["label_name"]]

features = Features(
    {
        "image": Image(),
        "label": ClassLabel(names=classes),
    }
)

train_ds = Dataset.from_list(
    [{"image": r["image"], "label": r["label"]} for r in train_records],
    features=features,
)
test_ds = Dataset.from_list(
    [{"image": r["image"], "label": r["label"]} for r in test_records],
    features=features,
)

data = DatasetDict({"train": train_ds, "test": test_ds})
data

以下是数据集概览。

DatasetDict({
    train: Dataset({
        features: ['image', 'label'],
        num_rows: 989
    })
    test: Dataset({
        features: ['image', 'label'],
        num_rows: 140
    })
})

4. 探索类别分布与样例 X 光图像

在微调之前,了解数据集的平衡性以及图像的样貌会很有帮助。我们先检查每个骨折类别的训练样本数量。

from collections import Counter

label_names = data["train"].features["label"].names
label_ids = data["train"]["label"]

counts = Counter(label_ids)
print("Training Class counts:")
for lbl_id, cnt in counts.items():
    print(f"{lbl_id:2d} ({label_names[lbl_id]}): {cnt}")

FRACTURE_CLASSES = label_names

这些类别相对较为平衡,但对于复杂的视觉分类任务来说,总体样本量偏小。

目前我们先用这个数据集继续,但由于样本量有限,解读结果时应保持理性预期。

Training Class counts:
 0 (Avulsion fracture): 109
 1 (Comminuted fracture): 134
 2 (Fracture Dislocation): 137
 3 (Greenstick fracture): 106
 4 (Hairline Fracture): 101
 5 (Impacted fracture): 75
 6 (Longitudinal fracture): 68
 7 (Oblique fracture): 69
 8 (Pathological fracture): 116
 9 (Spiral Fracture): 74

接下来我们随机查看几张图像,以便从视觉上更好地理解数据集。

import matplotlib.pyplot as plt
from datasets import Image

# Cast the 'image' column to Hugging Face Image feature
dataset_images = data.cast_column("image", Image())

# Select random samples from the training set
samples = dataset_images["train"].shuffle(seed=420).select(range(4))

# Plot
fig, axes = plt.subplots(1, 4, figsize=(16, 5))

for i, sample in enumerate(samples):
    img_object = sample["image"]              # PIL Image
    label_id = sample["label"]               # int
    label_name = label_names[label_id]       # string

    axes[i].imshow(img_object)
    axes[i].set_title(f"Label: {label_name}")
    axes[i].axis("off")

plt.tight_layout()
plt.show()

从样例图像可见,尺寸、分辨率、方向与扫描质量各不相同。这不会影响视觉-语言模型的微调,但会影响模型对各骨折类别学习的一致性。

直观地检测骨折往往较为直接,但要将其归入十种细微差别的医学骨折类型之一,则需要比此处更结构化的数据。

4 random sample from bone fracture dataset

5. 骨折分类的提示词设计

接下来我们需要确定模型的行为方式。本任务的目标非常严格:给定一张 X 光图像,模型应只输出一个骨折类别名称且不包含任何其他内容。不允许解释、Markdown 或多余文本。

我们将编写清晰的指令提示词,然后用它来在数据集中构建一个 messages 列。

PROMPT = f"""
You are a radiology assistant specialised in bone fracture classification.

You must classify the X-ray image into exactly ONE of the following classes:
{", ".join(FRACTURE_CLASSES)}

Rules:
- Reply with ONLY the class name.
- No explanations, no extra words, no markdown, no punctuation.
- Output must be EXACTLY one of: {", ".join(FRACTURE_CLASSES)}
""".strip()


from typing import Dict, Any

def convert_to_conversation(example: Dict[str, Any]) -> Dict[str, Any]:
    conversation = [
        {
            "role": "user",
            "content": [
                {"type": "text",  "text": PROMPT},
                {"type": "image"},     # no image bytes here
            ],
        },
        {
            "role": "assistant",
            "content": [
                {"type": "text", "text": FRACTURE_CLASSES[example["label"]]},
            ],
        },
    ]
    example["messages"] = conversation
    return example




formatted_data = data["train"].map(convert_to_conversation)
formatted_data["messages"][0]

这会创建一个 messages 列,其中每条记录都包含一段完整的微型对话:指令加上正确的骨折类别。这正是视觉-语言模型在微调期间将看到的内容。

Dataset in conversational style.

6. 加载 Ministral 3 视觉-语言模型

现在我们从 Hugging Face Hub 加载 BF16 版本的 Ministral-3 14B Instruct 模型与处理器。处理器将分词器与图像预处理管线打包在一起,适配视觉-语言输入。

import torch
from transformers import AutoProcessor, AutoModelForImageTextToText, BitsAndBytesConfig

model_id = "mistralai/Ministral-3-14B-Instruct-2512-BF16"


model_kwargs = dict(
    attn_implementation="eager",
    torch_dtype=torch.bfloat16,
    device_map="auto",
)

model = AutoModelForImageTextToText.from_pretrained(model_id, **model_kwargs)
processor = AutoProcessor.from_pretrained(model_id)

# Use right padding to avoid issues during training
processor.tokenizer.padding_side = "right"

7. 设置自定义聊天模板

Mistral AI 提供的原始聊天模板面向长对话场景。在本项目中,这成了问题:模型开始关注冗长的角色指令和系统文本,反而忽视了真正的目标——从 X 光中预测单个骨折类别。

为了解决这个问题,我们定义了一个更简短的视觉-语言模板,仅保留关键部分:用户指令与助手答案。这样可以降低噪声,帮助模型学习从图像到类别标签的干净映射。

SHORT_VLM_TEMPLATE = r"""{{ bos_token }}[INST]
{% for message in messages if message['role'] == 'user' -%}
  {%- for content in message['content'] -%}
    {%- if content['type'] == 'text' -%}
{{ content['text'] }}

    {%- elif content['type'] == 'image' -%}
[IMG]
    {%- endif -%}
  {%- endfor -%}
{%- endfor -%}
[/INST]{% if not add_generation_prompt %}
  {%- for message in messages if message['role'] == 'assistant' -%}
{{ message['content'][0]['text'] }}{{ eos_token }}
  {%- endfor -%}
{%- endif %}"""


# Attach to processor / tokenizer
processor.chat_template = SHORT_VLM_TEMPLATE
processor.tokenizer.chat_template = SHORT_VLM_TEMPLATE

8. 微调前测试 Ministral 3

在开始微调前,先测试原始的 Ministral 3 14B Instruct 模型,以便建立基线。

首先我们从数据集中取一张随机图像,提出一个泛化问题,看看它对 X 光内容的理解如何。

image = formatted_data[200]["image"]

messages = [
    {"role": "user", "content": [
        {"type": "text", "text": "What is the image about?"},
        {"type": "image"},
        
    ]}
]

input_text = processor.apply_chat_template(messages, add_generation_prompt = True)
inputs = processor(
    image,
    input_text,
    add_special_tokens = False,
    return_tensors = "pt",
).to(device="cuda", dtype=torch.bfloat16)

from transformers import TextStreamer
text_streamer = TextStreamer(processor, skip_prompt = True)
_ = model.generate(**inputs, streamer = text_streamer, max_new_tokens = 1000,
                   use_cache = True, temperature = 1.5, min_p = 0.1)

可以看到,模型对 X 光的总体内容有所理解,但随后转向了不适用于医疗用途的描述。

接着我们尝试更接近最终目标的设置。仍使用相同图像,但这次采用严格的骨折分类提示词,而非开放式问题。

image = formatted_data[200]["image"]

messages = [
    {"role": "user", "content": [
        {"type": "text", "text": PROMPT},
        {"type": "image"},
        
    ]}
]

input_text = processor.apply_chat_template(messages, add_generation_prompt = True)
inputs = processor(
    image,
    input_text,
    add_special_tokens = False,
    return_tensors = "pt",
).to(device="cuda", dtype=torch.bfloat16)

from transformers import TextStreamer
text_streamer = TextStreamer(processor, skip_prompt = True)
_ = model.generate(**inputs, streamer = text_streamer, max_new_tokens = 1000,
                   use_cache = True, temperature = 1.5, min_p = 0.1

对于该样本,模型的回答为:

Hairline Fracture</s>

现在我们查看同一图像的真实标签。

FRACTURE_CLASSES[formatted_data[200]["label"]]

返回结果为:

'Comminuted fracture'

因此,模型遵循了指令格式,仅输出了一个类别名称,但分类本身是错误的。

这正是我们需要微调的原因。基座模型具备较强的视觉理解,但并未专门训练来区分细分的骨折类型。

接下来我们将在骨折数据集上对其进行微调,以改进这一行为。

9. 配置 LoRA 以高效微调 Ministral 3

接下来我们配置LoRA,以在不更新全部参数的情况下高效微调 Ministral 3。该配置目标为 Mistral 风格视觉-语言模型中的主要注意力与 MLP 投影层。

from peft import LoraConfig, TaskType, get_peft_model

# LoRA config for a Mistral-style VLM used as a causal LM
peft_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    target_modules=[
        "q_proj",
        "k_proj",
        "v_proj",
        "o_proj",
        "gate_proj",
        "up_proj",
        "down_proj",
    ],
    modules_to_save=None,
)

随后定义监督微调(SFT)的训练参数。

该配置控制完整训练过程,包括批处理方式、内存优化、学习率策略、日志记录、保存策略,以及与 Hugging Face Hub 的集成。

from trl import SFTConfig

args = SFTConfig(
    output_dir="ministral-3-bone-fracture",            
    num_train_epochs=1,                       
    per_device_train_batch_size=2,                           
    per_device_eval_batch_size=2,                            
    gradient_accumulation_steps=4,                           
    gradient_checkpointing=True,                             
    optim="adamw_8bit",                               
    logging_steps=0.1,                                        
    save_strategy="epoch",                                                                                                             
    learning_rate=2e-4,                             
    bf16=True,
    fp16=False,
    max_grad_norm=0.3,                                       
    warmup_steps=25,                                       
    lr_scheduler_type="linear",                              
    push_to_hub=True,                                        
    report_to="none",
    gradient_checkpointing_kwargs={"use_reentrant": False},  
    dataset_kwargs={"skip_prepare_dataset": True},           
    remove_unused_columns = False,                           
    label_names=["labels"],                                  
)

10. 构建用于训练的视觉-语言数据 Collator

对 Ministral 3 这类视觉-语言模型进行微调,需要自定义的数据 collator,以一致的格式将图像与对话式文本批处理在一起。

下方的 collator 负责:设置分词器与填充符号、应用聊天模板、联合分词文本与图像、强制安全的最大序列长度,以及创建标签(将填充与图像 token 屏蔽为 -100)以避免影响损失计算。

from typing import Any, Dict, List
import torch

# --- One-time processor setup ---
tokenizer = processor.tokenizer
tokenizer.padding_side = "right"

# Ensure we have a pad token
if tokenizer.pad_token is None:
    tokenizer.add_special_tokens({"pad_token": tokenizer.eos_token})

pad_token_id = tokenizer.pad_token_id
image_token_id = getattr(processor, "image_token_id", None)

# Hard cap for sequence length
MAX_SEQ_LEN = 2048 


def collate_fn(examples: List[Dict[str, Any]]) -> Dict[str, torch.Tensor]:
    """
    Collate function for VLM fine-tuning.

    Each example must contain:
      - "image": a PIL image
      - "messages": chat-style conversation (list[dict])
    """
    images = [ex["image"] for ex in examples]
    conversations = [ex["messages"] for ex in examples]

    # Build chat prompts for the whole batch
    chat_texts = processor.apply_chat_template(
        conversations,
        add_generation_prompt=False,
        tokenize=False,
    )

    # Tokenize + process images
    batch = processor(
        text=chat_texts,
        images=images,
        padding="longest", 
        truncation=True,
        max_length=MAX_SEQ_LEN,
        return_tensors="pt",
    )

    # Labels: same as input_ids but mask pad & image tokens
    labels = batch["input_ids"].clone()

    if pad_token_id is not None:
        labels[labels == pad_token_id] = -100

    if image_token_id is not None and image_token_id != pad_token_id:
        labels[labels == image_token_id] = -100

    batch["labels"] = labels
    return batch

11. 使用 TRL 微调 Ministral 3

在准备好 LoRA 适配器、训练配置与数据 collator 后,我们可以将一切接入 trl 的 SFTTrainer。

该训练器负责我们视觉-语言 Ministral 3 模型的完整监督微调循环,使用格式化数据集、LoRA 配置与用于图文批次的自定义 collate 函数。

from trl import SFTTrainer

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=formatted_data,
    peft_config=peft_config,
    processing_class=processor,
    data_collator=collate_fn,
)

运行以下代码开始训练。

trainer.train()

在我的运行中,微调占用约 57 GB 显存,评估步骤时略高(每隔几次迭代运行一次)。实际说明在低于 70 GB 显存的硬件上也能较为从容地完成微调。

Runpod telemetry during the fine-tuning of the Ministral 3 14B model.

完整训练耗时约半小时,训练损失随时间稳步下降,这表明模型确实在从数据集中学习。

Traning loss of the fine-tuning of the Ministral 3 14B model

训练完成后,我们将适配后的模型保存至 Hugging Face Hub。它会创建一个新仓库并推送所有必要文件,便于您在任意环境中加载并运行微调模型。

trainer.save_model()

您可在此查看模型:kingabzpro/ministral-3-bone-fracture。

Saved fine-tuned model on the Hugging Face hub.

12. 评估微调后的 Ministral 3 模型

现在我们从 Hugging Face Hub 加载微调后的模型与处理器。其底层会下载已保存的 LoRA 适配器,加载 Ministral 3 基座检查点,并将适配器合并入基座模型,准备进行推理。

import torch
from transformers import AutoProcessor, AutoModelForImageTextToText, BitsAndBytesConfig

model_id = "kingabzpro/ministral-3-bone-fracture"


model_kwargs = dict(
    attn_implementation="eager",
    dtype=torch.bfloat16,
    device_map="auto",
)

model = AutoModelForImageTextToText.from_pretrained(model_id, **model_kwargs)
processor = AutoProcessor.from_pretrained(model_id)

processor.tokenizer.padding_side = "left"

为保证对比公平,我们先复用微调前测试的同一张图像(索引 200),搭配严格的分类提示词进行推理。

image = formatted_data[200]["image"]

messages = [
    {"role": "user", "content": [
        {"type": "text", "text": PROMPT},
        {"type": "image"},
        
    ]}
]

input_text = processor.apply_chat_template(messages, add_generation_prompt = True)
inputs = processor(
    image,
    input_text,
    add_special_tokens = False,
    return_tensors = "pt",
).to(device="cuda", dtype=torch.bfloat16)

from transformers import TextStreamer
text_streamer = TextStreamer(processor, skip_prompt = True)
_ = model.generate(**inputs, streamer = text_streamer, max_new_tokens = 100)

我们获得了完美结果。不需要额外后处理。模型遵循提示词规则,直接产出正确类别。

Comminuted fracture</s>

接着我们再试一个数据集中的随机样本,以确认这种表现并非个例。

image = formatted_data[400]["image"]

messages = [
    {"role": "user", "content": [
        {"type": "text", "text": PROMPT},
        {"type": "image"},
        
    ]}
]

input_text = processor.apply_chat_template(messages, add_generation_prompt = True)
inputs = processor(
    image,
    input_text,
    add_special_tokens = False,
    return_tensors = "pt",
).to(device="cuda", dtype=torch.bfloat16)

from transformers import TextStreamer
text_streamer = TextStreamer(processor, skip_prompt = True)
_ = model.generate(**inputs, streamer = text_streamer, max_new_tokens = 100)

模型输出:

Greenstick fracture</s>

真实标签:

FRACTURE_CLASSES[formatted_data[400]["label"]]

输出:

'Greenstick fracture'

同样,预测正确,与标签完全一致。

通过这些检验,我们可以认为 Ministral 3 14B 已成功在骨折 X 光数据集上完成微调,能够遵循指令提示词,并在这些示例上输出准确的单类预测。

总结思考

微调 Ministral 3 需要耐心和持续调试。我确实花了两整天时间,才弄清为何模型未按预期训练、以及为何尽管模型并不算大,却仍让我 GPU 频繁 OOM。

原因很简单,却容易忽略:它不仅是语言模型,还是视觉-语言模型,意味着它包含一个图像编码器,会额外消耗显存。

图像分辨率越大,显存占用越高。减小图像尺寸或从更小的 batch 开始,会让训练更稳定。

我遇到的另一问题是默认聊天模板。对于这类任务,它过长且结构化。模型花了更多精力去拟合模板格式,而不是学习如何读取图像并给出清晰的骨折分类。

在我将提示词简化为直接的指令格式后,模型终于开始正确响应视觉输入,而不是将每个答案都包装成完整的对话回复。

我还曾以为模型具备强大视觉能力,就会自然在医学影像上表现出色。这个假设拖慢了我。把它在脑子里称作“MedGemma”对模型和流程都不公平。它并非开箱即用的专科医疗模型,指望它不经适当训练就能有那样的表现,是我的错误。

最终,我克服了每个难点,并将所学整理为本指南。目的在于帮助您避开同样的困惑,更快获得有意义的结果。

“在更小的图像、更简洁的提示词与理性预期的配合下,Ministral 3 可更顺畅、更可靠地完成针对 X 光理解的微调。”

如果您想获得更多微调的实操练习,推荐Fine-Tuning with Llama 3 课程。

Abid Ali Awan

作者

Abid Ali Awan

作为一名持证的数据科学家,我热衷于利用前沿技术打造创新的机器学习应用。凭借在语音识别、数据分析与报告、MLOps、对话式人工智能以及自然语言处理方面的扎实背景,我不断打磨构建智能系统的能力,力求带来切实影响。除技术专长外,我也擅长沟通,能够将复杂概念提炼为清晰、简明的表述。因此,我成为数据科学领域备受关注的博主,与不断壮大的数据专业人士社区分享洞见与实践经验。目前,我专注于内容创作与编辑,借助大语言模型打造有力且吸引人的内容,帮助企业与个人更好地发挥数据价值。

主题