【多模态微调实战】Qwen2-VL + LoRA + SwanLab:手把手教你在COCO数据集上微调通义千问多模态大模型

前言

大家好,今天带来一篇保姆级的多模态大模型微调教程。我们的主角是阿里通义实验室刚推出的 Qwen2-VL-2B-Instruct 多模态大模型,搭配 LoRA(低秩自适应) 轻量化微调方案,在经典的 COCO 2014 图像描述数据集上做训练,同时用 SwanLab 做全程可视化监控。

先给刚入门的同学白话解释几个关键词:

  • Qwen2-VL:通义千问的多模态版本,既能看懂图片,又能生成文字,相当于"会看图说话的AI"
  • LoRA:轻量化微调神器,不用更新整个大模型的全部参数,只训练一点点"外挂"权重,显存占用直接砍半,单卡就能跑
  • SwanLab:开源的训练可视化工具,自动帮你画loss曲线、记超参数、对比实验,调参一目了然

整个流程跑下来,一张24G显存的显卡就能搞定,非常适合个人研究者和小团队快速上手。

一、环境配置:三步搭好炼丹炉

工欲善其事,必先利其器。环境配置分三步走,照着敲命令就行。

1.1 基础要求

  • 一张英伟达显卡(建议显存≥16G,24G最佳),装好CUDA环境
  • Python 版本 ≥ 3.8,但需要小于3.12,实测有问题
  • PyTorch 支持 CUDA 加速

1.2 一键安装依赖库

直接复制下面命令到终端,全部自动安装:

python -m pip install --upgrade pip
# 更换清华源,下载速度起飞
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple

pip install modelscope==1.18.0
pip install transformers==4.46.2
pip install sentencepiece==0.2.0
pip install accelerate==1.1.1
pip install datasets==2.18.0
pip install peft==0.13.2
pip install swanlab==0.3.25
pip install qwen-vl-utils==0.0.8

重点说明

  • peft 是 HuggingFace 官方的参数高效微调库,LoRA 全靠它
  • swanlab 负责训练可视化,和 Transformers 原生集成,一行代码接入
  • qwen-vl-utils 是 Qwen2-VL 专属的图像处理工具,别漏装了

二、数据集准备:把COCO数据改成模型爱吃的格式

我们使用 COCO 2014 Caption 数据集,这是图像描述领域的"高考真题",每张图都配有人工写的英文描述。

为了快速演示,我们只取前500张图来训练,完整数据集有40多万张,大家可以按需扩充。

2.1 数据集格式说明

Qwen2-VL 吃的是对话格式数据,长这样:

[
  {
    "id": "identity_1",
    "conversations": [
      {
        "from": "user",
        "value": "COCO Yes: <|vision_start|>图片路径<|vision_end|>"
      },
      {
        "from": "assistant",
        "value": "A snow skier assessing the mountain before starting to ski"
      }
    ]
  }
]

其中 <|vision_start|><|vision_end|> 是模型的"图像占位符",告诉模型:这中间放的是图片,好好看。

2.2 第一步:下载数据并转成CSV

新建 data2csv.py,复制以下代码:

from modelscope.msdatasets import MsDataset
import os
import pandas as pd

MAX_DATA_NUMBER = 500

if not os.path.exists('coco_2014_caption'):
    # 从ModelScope下载COCO数据集
    ds = MsDataset.load('modelscope/coco_2014_caption', 
                       subset_name='coco_2014_caption', 
                       split='train')
    total = min(MAX_DATA_NUMBER, len(ds))
    os.makedirs('coco_2014_caption', exist_ok=True)
    
    image_paths, captions = [], []
    
    for i in range(total):
        item = ds[i]
        image_id = item['image_id']
        caption = item['caption']
        image = item['image']
        
        image_path = os.path.abspath(f'coco_2014_caption/{image_id}.jpg')
        image.save(image_path)
        
        image_paths.append(image_path)
        captions.append(caption)
        
        if (i + 1) % 50 == 0:
            print(f'Processing {i+1}/{total} images ({(i+1)/total*100:.1f}%)')
    
    df = pd.DataFrame({'image_path': image_paths, 'caption': captions})
    df.to_csv('./coco-2024-dataset.csv', index=False)
    print(f'数据处理完成,共处理了{total}张图片')
else:
    print('目录已存在,跳过数据处理')

2.3 第二步:CSV转JSON对话格式

新建 csv2json.py

import pandas as pd
import json

df = pd.read_csv('./coco-2024-dataset.csv')
conversations = []

for i in range(len(df)):
    conversations.append({
        "id": f"identity_{i+1}",
        "conversations": [
            {
                "from": "user",
                "value": f"COCO Yes: <|vision_start|>{df.iloc[i]['image_path']}<|vision_end|>"
            },
            {
                "from": "assistant",
                "value": df.iloc[i]['caption']
            }
        ]
    })

with open('data_vl.json', 'w', encoding='utf-8') as f:
    json.dump(conversations, f, ensure_ascii=False, indent=2)

依次运行两个脚本,你会得到 data_vl.json,数据集准备完毕。

三、模型下载与加载:把Qwen2-VL请回家

我们用 ModelScope 下载模型,国内速度快,不用科学上网。

核心加载代码片段:

from modelscope import snapshot_download, AutoTokenizer
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
import torch

# 下载模型到本地
model_dir = snapshot_download("Qwen/Qwen2-VL-2B-Instruct", 
                             cache_dir="./", 
                             revision="master")

# 加载分词器和处理器
tokenizer = AutoTokenizer.from_pretrained(
    "./Qwen/Qwen2-VL-2B-Instruct/", 
    use_fast=False, 
    trust_remote_code=True
)
processor = AutoProcessor.from_pretrained("./Qwen/Qwen2-VL-2B-Instruct")

# 加载模型,用bf16精度省显存
model = Qwen2VLForConditionalGeneration.from_pretrained(
    "./Qwen/Qwen2-VL-2B-Instruct/", 
    device_map="auto", 
    torch_dtype=torch.bfloat16, 
    trust_remote_code=True
)
model.enable_input_require_grads()  # 开启梯度检查点必须加这行

划重点

  • 2B版本模型大小约 4.5GB,下载5分钟左右
  • device_map="auto" 自动分配显存,多卡也能用
  • torch.bfloat16 是炼丹标配,比float32省一半显存,精度基本不掉
  • enable_input_require_grads() 配合梯度检查点,进一步省显存

在这里插入图片描述

四、集成SwanLab:训练过程一眼看穿

SwanLab 是个开源的训练可视化工具,跟 Transformers 已经做好了原生集成,加一个回调就行
SwanLab官方文档:docs.swanlab.cn/guide_cloud/general/quick-start.html

4.1 接入代码

from swanlab.integration.transformers import SwanLabCallback

swanlab_callback = SwanLabCallback(
    project="Qwen2-VL-finetune",
    experiment_name="qwen2-vl-coco2014",
    config={
        "model": "Qwen2-VL-2B-Instruct",
        "dataset": "coco_2014_caption",
        "train_data_number": 496,
        "lora_rank": 64,
        "lora_alpha": 16,
        "lora_dropout": 0.05,
    },
)

然后在 Trainer 里加上 callbacks=[swanlab_callback] 就完事了。

4.2 首次使用

第一次用需要去 SwanLab 官网注册个账号,复制 API Key,训练开始时粘贴进去就行,后续自动登录。

在这里插入图片描述

它会自动帮你记录:

  • 训练loss、验证loss曲线
  • 学习率变化、梯度范数
  • 所有超参数配置
  • 甚至还能上传生成的图片做对比

运行下面的Python脚本,开启第一次训练

import swanlab
import random

# 创建一个SwanLab项目
swanlab.init(
    # 设置项目名
    project="my-awesome-project",
    
    # 设置超参数
    config={
        "learning_rate": 0.02,
        "architecture": "CNN",
        "dataset": "CIFAR-100",
        "epochs": 10
    }
)

# 模拟一次训练
epochs = 10
offset = random.random() / 5
for epoch in range(2, epochs):
  acc = 1 - 2 ** -epoch - random.random() / epoch - offset
  loss = 2 ** -epoch + random.random() / epoch + offset

  # 记录训练指标
  swanlab.log({"acc": acc, "loss": loss})

# [可选] 完成训练,这在notebook环境中是必要的
swanlab.finish()

在这里插入图片描述
打开网址,可以看到记录的信息
在这里插入图片描述

五、开始微调:LoRA参数全解析

重头戏来了。完整的训练脚本 train.py 我放在下面,先讲几个核心参数,别瞎调。

5.1 LoRA配置怎么设

from peft import LoraConfig, TaskType, get_peft_model

config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    # 这7个模块是注意力和MLP的核心,都加上LoRA效果最好
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", 
                    "gate_proj", "up_proj", "down_proj"],
    inference_mode=False,
    r=64,              # LoRA秩,越大参数量越多,效果越好但越慢
    lora_alpha=16,     # 缩放系数,一般设为r的1/4或1/2
    lora_dropout=0.05, # 防止过拟合
    bias="none",
)

peft_model = get_peft_model(model, config)

白话解释:

  • r=64:LoRA矩阵的秩。r=8是极简版,r=64效果更饱满,2B模型用64完全扛得住
  • lora_alpha=16:相当于给LoRA输出乘个系数,控制LoRA对原模型的"影响力"
  • target_modules:指定在哪些层上加LoRA。注意力的q/k/v/o和MLP的三层全加上,覆盖最全面

5.2 训练参数配置

args = TrainingArguments(
    output_dir="./output/Qwen2-VL-2B",
    per_device_train_batch_size=4,   # 每张卡batch size
    gradient_accumulation_steps=4,   # 梯度累积,等效batch=16
    logging_steps=10,
    num_train_epochs=2,              # 小数据集2个epoch足够,多了过拟合
    save_steps=100,
    learning_rate=1e-4,              # LoRA学习率比全参微调大一个量级
    gradient_checkpointing=True,     # 显存救星,用时间换空间
    report_to="none",                # 关掉wandb,我们用SwanLab
)

5.3 完整训练代码

import torch
import json
import swanlab
from datasets import Dataset
from modelscope import snapshot_download, AutoTokenizer
from swanlab.integration.transformers import SwanLabCallback
from qwen_vl_utils import process_vision_info
from peft import LoraConfig, TaskType, get_peft_model, PeftModel
from transformers import (
    TrainingArguments, Trainer, DataCollatorForSeq2Seq,
    Qwen2VLForConditionalGeneration, AutoProcessor,
)

def process_func(example):
    """数据预处理:把对话格式转换成模型输入张量"""
    MAX_LENGTH = 8192
    conversation = example["conversations"]
    input_content = conversation[0]["value"]
    output_content = conversation[1]["value"]
    file_path = input_content.split("<|vision_start|>")[1].split("<|vision_end|>")[0]

    messages = [
        {
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "image": file_path,
                    "resized_height": 280,
                    "resized_width": 280,
                },
                {"type": "text", "text": "COCO Yes:"},
            ],
        }
    ]

    text = processor.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True
    )

    image_inputs, _ = process_vision_info(messages)
    inputs = processor(
        text=[text],
        images=image_inputs,
        padding=True,
        return_tensors="pt"
    )

    response = tokenizer(
        output_content,
        add_special_tokens=False,
        return_tensors="pt"
    )

    prompt_input_ids = inputs["input_ids"][0]
    prompt_attention_mask = inputs["attention_mask"][0]

    response_input_ids = response["input_ids"][0]
    response_attention_mask = response["attention_mask"][0]

    pad_id = torch.tensor([tokenizer.pad_token_id], dtype=prompt_input_ids.dtype)
    pad_mask = torch.tensor([1], dtype=prompt_attention_mask.dtype)

    # 拼接输入与输出;label 只计算回答部分,指令部分用 -100 掩码
    input_ids = torch.cat(
        [prompt_input_ids, response_input_ids, pad_id],
        dim=0
    )
    attention_mask = torch.cat(
        [prompt_attention_mask, response_attention_mask, pad_mask],
        dim=0
    )
    labels = torch.cat(
        [
            torch.full((prompt_input_ids.size(0),), -100, dtype=prompt_input_ids.dtype),
            response_input_ids,
            pad_id
        ],
        dim=0
    )

    if input_ids.size(0) > MAX_LENGTH:
        input_ids = input_ids[:MAX_LENGTH]
        attention_mask = attention_mask[:MAX_LENGTH]
        labels = labels[:MAX_LENGTH]

    return {
        "input_ids": input_ids,
        "attention_mask": attention_mask,
        "labels": labels,
        "pixel_values": inputs["pixel_values"].squeeze(0),
        "image_grid_thw": inputs["image_grid_thw"].squeeze(0),
    }

def predict(messages, model):
    """推理函数,训练完用来测效果"""
    text = processor.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    image_inputs, _ = process_vision_info(messages)
    inputs = processor(text=[text], images=image_inputs,
                      padding=True, return_tensors="pt").to("cuda")
    
    generated_ids = model.generate(**inputs, max_new_tokens=128)
    generated_ids_trimmed = [
        out_ids[len(in_ids):] 
        for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
    ]
    return processor.batch_decode(
        generated_ids_trimmed, 
        skip_special_tokens=True, 
        clean_up_tokenization_spaces=False
    )[0]

# ========== 加载模型 ==========
model_dir = snapshot_download("Qwen/Qwen2-VL-2B-Instruct", 
                             cache_dir="./", revision="master")
tokenizer = AutoTokenizer.from_pretrained(
    "./Qwen/Qwen2-VL-2B-Instruct/", use_fast=False, trust_remote_code=True
)
processor = AutoProcessor.from_pretrained("./Qwen/Qwen2-VL-2B-Instruct")
model = Qwen2VLForConditionalGeneration.from_pretrained(
    "./Qwen/Qwen2-VL-2B-Instruct/", 
    device_map="auto", torch_dtype=torch.bfloat16, trust_remote_code=True
)
model.enable_input_require_grads()

# ========== 划分训练/测试集 ==========
with open("data_vl.json", 'r') as f:
    data = json.load(f)
    train_data = data[:-4]  # 496条训练
    test_data = data[-4:]   # 4条留着看效果

with open("data_vl_train.json", "w") as f:
    json.dump(train_data, f)
with open("data_vl_test.json", "w") as f:
    json.dump(test_data, f)

train_ds = Dataset.from_json("data_vl_train.json")
train_dataset = train_ds.map(process_func)

# ========== 配置LoRA ==========
config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", 
                    "gate_proj", "up_proj", "down_proj"],
    inference_mode=False,
    r=64, lora_alpha=16, lora_dropout=0.05, bias="none",
)
peft_model = get_peft_model(model, config)

# ========== 训练参数 ==========
args = TrainingArguments(
    output_dir="./output/Qwen2-VL-2B",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    logging_steps=10,
    logging_first_step=5,
    num_train_epochs=2,
    save_steps=100,
    learning_rate=1e-4,
    save_on_each_node=True,
    gradient_checkpointing=True,
    report_to="none",
)

# ========== SwanLab回调 ==========
swanlab_callback = SwanLabCallback(
    project="Qwen2-VL-finetune",
    experiment_name="qwen2-vl-coco2014",
    config={
        "model": "Qwen2-VL-2B-Instruct",
        "dataset": "coco_2014_caption",
        "train_data_number": len(train_data),
        "lora_rank": 64,
        "lora_alpha": 16,
        "lora_dropout": 0.05,
    },
)

# ========== 开始训练 ==========
trainer = Trainer(
    model=peft_model,
    args=args,
    train_dataset=train_dataset,
    data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True),
    callbacks=[swanlab_callback],
)
trainer.train()

# ========== 推理测试并上传结果 ==========
val_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", 
                    "gate_proj", "up_proj", "down_proj"],
    inference_mode=True,
    r=64, lora_alpha=16, lora_dropout=0.05, bias="none",
)
val_peft_model = PeftModel.from_pretrained(
    model, model_id="./output/Qwen2-VL-2B/checkpoint-62", config=val_config
)

test_image_list = []
with open("data_vl_test.json", "r") as f:
    test_dataset = json.load(f)

for item in test_dataset:
    origin_image_path = item["conversations"][0]["value"].split(
        "<|vision_start|>")[1].split("<|vision_end|>")[0]
    
    messages = [{
        "role": "user",
        "content": [
            {"type": "image", "image": origin_image_path},
            {"type": "text", "text": "COCO Yes:"}
        ]
    }]
    
    response = predict(messages, val_peft_model)
    print(f"生成结果:{response}")
    test_image_list.append(swanlab.Image(origin_image_path, caption=response))

swanlab.log({"Prediction": test_image_list})
swanlab.finish()

运行 python train.py,看到进度条就说明炼丹开始了!

六、训练结果分析:从曲线里看出门道

训练完成后打开 SwanLab 面板,能看到几张关键图表:

在这里插入图片描述
在这里插入图片描述

loss 曲线:随着训练步数增加,loss 稳步下降,说明模型确实在学习图像和文本的对应关系。

学习率曲线:线性衰减策略,训练初期大步走,后期小步微调,这是标准操作。

grad_norm 梯度范数:训练后期逐渐上升,这是个预警信号——说明模型开始在小数据集上记答案了,有过拟合风险。所以我们只训2个epoch刚刚好,再训下去泛化能力反而下降。

调参经验:小数据集上别贪多epoch,loss平稳了就收手;想看泛化能力一定要留验证集。

七、推理演示:看看微调后的模型看图说话水平

训练结束后,模型会在 ./output/Qwen2-VL-2B/ 目录下生成 checkpoint,里面就是 LoRA 权重文件,一般只有几十MB,非常轻便。

加载LoRA做推理的核心代码:

from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
from qwen_vl_utils import process_vision_info
from peft import PeftModel, LoraConfig, TaskType

config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    inference_mode=True,
    r=64,  # Lora 秩
    lora_alpha=16,  # Lora alaph,具体作用参见 Lora 原理
    lora_dropout=0.05,  # Dropout 比例
    bias="none",
)

# default: Load the model on the available device(s)
model = Qwen2VLForConditionalGeneration.from_pretrained(
    "./Qwen/Qwen2-VL-2B-Instruct", torch_dtype="auto", device_map="auto"
)
model = PeftModel.from_pretrained(model, model_id="./output/Qwen2-VL-2B/checkpoint-62", config=config)
processor = AutoProcessor.from_pretrained("./Qwen/Qwen2-VL-2B-Instruct")

messages = [
    {
        "role": "user",
        "content": [
            {
                "type": "image",
                "image": "测试图像路径",
            },
            {"type": "text", "text": "COCO Yes:"},
        ],
    }
]

# Preparation for inference
text = processor.apply_chat_template(
    messages, tokenize=False, add_generation_prompt=True
)
image_inputs, video_inputs = process_vision_info(messages)
inputs = processor(
    text=[text],
    images=image_inputs,
    videos=video_inputs,
    padding=True,
    return_tensors="pt",
)
inputs = inputs.to("cuda")

# Inference: Generation of the output
generated_ids = model.generate(**inputs, max_new_tokens=128)
generated_ids_trimmed = [
    out_ids[len(in_ids) :] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
]
output_text = processor.batch_decode(
    generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
)
print(output_text)

然后给一张新图片,模型就能输出对应的英文描述。SwanLab 面板里的 Prediction 标签页也能看到测试集的生成效果,图文对照非常直观。

八、补充:硬件要求与注意事项

8.1 推荐硬件配置

配置项 最低要求 推荐配置
显存 16GB 24GB+
显卡 RTX 3090 RTX 4090 / A10
训练时长(500张,2epoch) ~30分钟 ~15分钟

8.2 避坑指南

  1. 一定要用 bfloat16:float32 显存直接翻倍,还没明显收益
  2. gradient_checkpointing 必开:省30%-40%显存,速度只慢一点点
  3. 图像别太大:我们统一缩到280x280,又快又够用
  4. LoRA别加在视觉编码器上:这次只调语言部分,视觉编码器冻住效果就不错
  5. enable_input_require_grads() 别忘了:开梯度检查点不加这行会报错

九、总结

整篇教程跑下来,你应该掌握了这几项技能:

  1. ✅ Qwen2-VL 多模态模型的加载与数据预处理
  2. ✅ 使用 LoRA 对大模型进行轻量化微调
  3. ✅ SwanLab 可视化监控训练全过程
  4. ✅ 训练完成后加载 LoRA 权重做推理
  5. ✅ 看懂 loss 曲线,判断是否过拟合

这套流程可以直接迁移到你自己的数据集上,不管是做工业缺陷图文描述、文档VQA还是其他多模态任务,换个数据格式就能直接开训。

2B 只是入门款,想效果更好可以上 7B 版本,原理完全一样,就是显存要求高一些。祝大家炼丹顺利!


参考资料

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐