ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qwen2-VL-2B 加 LoRA 微调 COCO2014:单卡 24G 跑通图像描述实战

Qwen2-VL-2B 加 LoRA 微调 COCO2014:单卡 24G 跑通图像描述实战 简介本资源面向多模态大模型入门与进阶开发者提供在COCO2014图像描述数据集上对Qwen2-VL-2B-Instruct进行LoRA微调的完整工程可用于图像识别与描述任务的复现、二次开发与教学实践。压缩包共25个文件约1.2MB包含4个Python脚本数据转换、训练、预测等核心流程、11张png与4张jpeg/jpg实验截图显卡信息、CUDA环境、训练曲线、预测效果等、2个txt与1个md说明文档以及1份docx附赠材料覆盖环境配置、数据处理到结果验证的完整链路。已有105人学习关注。读者可据此掌握LoRA微调在视觉语言模型上的落地方法理解data2csv、csv2json等数据预处理脚本的衔接逻辑参考训练与预测脚本快速搭建自己的微调流程并借助说明文件与截图排查环境依赖、显存占用等常见问题适合希望低成本实践多模态微调的研究者与工程师。1. Qwen2-VL 加 LoRA 微调 COCO20142B 模型凭什么在单卡上跑通图像描述手里只有一张 24G 显存的卡却想拿多模态大模型做图像描述任务这是很多做视觉方向的人真实处境。全量微调 Qwen2-VL-2B 在 COCO2014 上跑一遍光优化器状态就能把显存吃干净更别提还要留出激活值空间。LoRA 微调是什么意思说白了就是在原始权重旁边挂一对低秩矩阵只训练这对小矩阵把可训练参数量压到原来的百分之几显存和训练时间都跟着降下来。这个项目要解决的就是用 Qwen2-VL-2B 这个视觉语言模型在 COCO2014 图像描述数据集上做 LoRA 微调让模型学会看图说话输出贴合数据集风格的描述文本。适合谁适合已经跑过文本 LoRA、想往多模态方向迈一步的工程师也适合需要快速验证图像描述业务可行性的团队。整条链路不复杂但多模态的坑和纯文本完全不是一回事下面把选型、数据、训练、排错一层层拆开。2. 为什么选 Qwen2-VL-2B 配 LoRA显存账和效果账一起算2.1 多模态大模型做图像描述全量微调到底卡在哪图像描述任务本质是给定一张图模型输出一段自然语言。Qwen2-VL 这类多模态大模型的架构里图像先经过 ViT 编码成视觉 token再和文本 token 拼在一起送进语言模型解码。全量微调时ViT 和语言模型两部分权重都要更新2B 参数量的模型在 AdamW 优化器下优化器状态就要占掉约 16GB2B × 4 字节 × 2 个状态加上梯度 8GB、模型权重 4GB还没算激活值就已经逼近 28GB。COCO2014 图像分辨率不低视觉 token 数量可观激活值占用会进一步推高。实际跑起来单张 24G 卡全量微调基本会 OOM除非上梯度检查点加 ZeRO 之类的分布式方案但那又引入多卡通信成本。LoRA 的思路是冻结原始权重只在注意力层的 Q、V 投影矩阵旁注入低秩分解矩阵可训练参数降到 0.1% 到 1% 量级优化器状态和梯度占用随之骤降单卡 24G 甚至 16G 都能跑起来。2.2 LoRA 秩和 target_modules 怎么定2B 模型的参数账LoRA 的核心参数是秩 r 和缩放系数 alpha。r 越大可训练参数越多拟合能力越强但过拟合风险和显存占用也上升。对 Qwen2-VL-2B 这种体量的模型做图像描述微调r 取 8 到 16 是比较稳的区间alpha 一般设为 r 的两倍。target_modules 决定往哪些层注入 LoRA常见做法是只挂语言模型侧的 q_proj、v_proj也有把 k_proj、o_proj 一起挂上的。视觉编码器侧要不要挂我的经验是图像描述任务里视觉特征提取已经由预训练 ViT 做得不错优先把 LoRA 加在语言侧让模型学会把视觉 token 映射成描述文本这样参数更省、收敛也更稳。下面这张表是几个典型配置的对比方便按显存和效果需求选。配置ralphatarget_modules可训练参数占比24G 单卡可行性轻量816q_proj, v_proj约 0.05%轻松均衡1632q_proj, k_proj, v_proj, o_proj约 0.12%可行激进3264语言侧全部线性层约 0.3%需梯度检查点选均衡配置起步跑通后再按验证集指标微调 r 和 target_modules这是比较务实的路径。2.3 环境搭建与依赖安装的最小命令先把环境拉起来。Python 建议 3.10 以上PyTorch 选 2.1 以上版本transformers 要足够新才能支持 Qwen2-VL 的处理器。下面这组命令是我在 Ubuntu 22.04 加 CUDA 12.1 环境下验证过的。# 创建虚拟环境 conda create -n qwen2vl_lora python3.10 -y conda activate qwen2vl_lora # 安装 PyTorch按自己的 CUDA 版本调整 pip install torch2.1.2 torchvision0.16.2 --index-url https://download.pytorch.org/whl/cu121 # 安装多模态训练相关依赖 pip install transformers4.45.0 accelerate0.34.0 peft0.12.0 pip install datasets2.20.0 pillow10.4.0 qwen-vl-utils0.0.8这里几个包的版本不是随便写的。transformers 4.45 对 Qwen2-VL 的 processor 支持比较完整peft 0.12 的 LoRA 注入接口和 Qwen2-VL 的模块命名能对上qwen-vl-utils 负责图像预处理和 token 拼接。版本错配最常见的表现是 processor 加载报 KeyError 或者 LoRA 注入时找不到目标模块遇到这类报错先回头核对版本。3. COCO2014 数据准备从原始标注到多模态对话格式3.1 COCO2014 标注结构长什么样COCO2014 的图像描述标注放在 captions_train2014.json 和 captions_val2014.json 里每张图对应 5 条人工描述。JSON 顶层是 images、annotations、licenses 三个字段annotations 里每条记录有 image_id 和 caption。训练时通常每张图取一条描述或者五条都取做数据增强。图像文件按 train2014 和 val2014 两个目录存放文件名形如 COCO_train2014_000000123456.jpg。数据规模上训练集约 8 万张图、40 万条描述验证集约 4 万张图、20 万条描述。做 LoRA 微调不需要全量跑先取一个子集验证链路再逐步扩量这样调试周期短。3.2 把标注转成 Qwen2-VL 对话格式的脚本Qwen2-VL 的对话格式要求把图像和文本组织成 messages 列表图像用 image 占位文本里用特殊 token 引用。下面这个脚本把 COCO 标注转成训练用的 JSONL每行一条样本。import json import os # 路径按实际存放位置改 coco_ann annotations/captions_train2014.json img_dir train2014 out_path coco_qwen2vl_train.jsonl with open(coco_ann, r, encodingutf-8) as f: data json.load(f) # 建立 image_id 到文件名的映射 id2file {img[id]: img[file_name] for img in data[images]} # 每张图只取第一条描述避免重复样本过多 seen set() count 0 with open(out_path, w, encodingutf-8) as fout: for ann in data[annotations]: img_id ann[image_id] if img_id in seen: continue seen.add(img_id) file_name id2file.get(img_id) if not file_name: continue img_path os.path.join(img_dir, file_name) if not os.path.exists(img_path): continue # 构造 Qwen2-VL 对话格式 sample { messages: [ { role: user, content: [ {type: image, image: img_path}, {type: text, text: 请描述这张图片的内容。} ] }, { role: assistant, content: [ {type: text, text: ann[caption].strip()} ] } ] } fout.write(json.dumps(sample, ensure_asciiFalse) \n) count 1 print(f共生成 {count} 条训练样本)脚本逻辑分三步先读标注建 image_id 到文件名的索引再遍历 annotations 去重取每图一条描述最后按 Qwen2-VL 的 messages 结构写出 JSONL。参数上img_dir 要指向实际图像目录如果图像路径和标注里的 file_name 对不上脚本会跳过该样本跑完看输出条数就能判断有没有大面积丢失。去重那步用 seen 集合控制如果想用五条描述做增强把 seen 相关逻辑去掉即可但要注意样本量会翻五倍训练步数也要相应调整。3.3 图像预处理和 token 长度控制Qwen2-VL 的图像处理由 processor 负责会把图像 resize 到模型支持的尺寸范围再切成 patch 转成视觉 token。COCO 图像尺寸不一长边可能到 640 像素短边 480 左右处理后会生成几百个视觉 token。文本侧描述一般不超过 30 个词token 数很少。整体序列长度主要被视觉 token 占据这也是多模态训练显存吃紧的原因。控制显存的一个手段是限制图像最大像素processor 里可以传 max_pixels 参数把视觉 token 数量压下来。代价是图像细节损失描述质量可能下降需要在显存和效果之间找平衡。我一般先用默认设置跑通OOM 再逐步降 max_pixels每次降 20% 左右观察验证集指标变化。4. LoRA 微调训练脚本从配置到跑通第一个 epoch4.1 加载模型和 processor 的正确姿势Qwen2-VL 的加载分模型和 processor 两部分processor 负责图像和文本的联合处理。下面这段代码展示加载和 LoRA 注入的完整流程。import torch from transformers import Qwen2VLForConditionalGeneration, AutoProcessor from peft import LoraConfig, get_peft_model model_id Qwen/Qwen2-VL-2B-Instruct # 加载 processor负责图像和文本的联合编码 processor AutoProcessor.from_pretrained(model_id, min_pixels256*28*28, max_pixels768*28*28) # 加载模型指定数据类型和设备映射 model Qwen2VLForConditionalGeneration.from_pretrained( model_id, torch_dtypetorch.bfloat16, device_mapauto ) # 配置 LoRA lora_config LoraConfig( r16, lora_alpha32, target_modules[q_proj, k_proj, v_proj, o_proj], lora_dropout0.05, biasnone, task_typeCAUSAL_LM ) model get_peft_model(model, lora_config) model.print_trainable_parameters()加载时 torch_dtype 用 bfloat16 能在保持数值稳定的同时省一半显存device_map 设为 auto 让 accelerate 自动分配。processor 的 min_pixels 和 max_pixels 控制图像缩放范围数值是 28 的平方倍数因为 Qwen2-VL 的 patch 尺寸是 28。LoRA 配置里 target_modules 要和模型实际模块名对上Qwen2-VL 语言侧的线性层命名就是 q_proj 这类如果报找不到模块打印 model 的 named_modules 核对一下。print_trainable_parameters 会输出可训练参数占比均衡配置下大概在 0.1% 出头看到这个数字说明注入成功。4.2 用 Trainer 跑通训练循环数据加载和训练循环用 transformers 的 Trainer 最省事自定义一个 collate_fn 处理多模态输入即可。from torch.utils.data import Dataset from transformers import TrainingArguments, Trainer from PIL import Image import json class CocoDataset(Dataset): def __init__(self, jsonl_path, processor): self.samples [] with open(jsonl_path, r, encodingutf-8) as f: for line in f: self.samples.append(json.loads(line)) self.processor processor def __len__(self): return len(self.samples) def __getitem__(self, idx): sample self.samples[idx] messages sample[messages] # 提取图像和文本 image_path None for msg in messages: for content in msg[content]: if content[type] image: image_path content[image] image Image.open(image_path).convert(RGB) # 用 processor 的 chat template 构造输入 text self.processor.apply_chat_template(messages, tokenizeFalse, add_generation_promptFalse) inputs self.processor(text[text], images[image], return_tensorspt, paddingTrue) # 去掉 batch 维度Trainer 会重新拼 inputs {k: v.squeeze(0) for k, v in inputs.items()} # labels 和 input_ids 一致图像 token 位置设为 -100 不计算损失 labels inputs[input_ids].clone() labels[labels self.processor.tokenizer.pad_token_id] -100 inputs[labels] labels return inputs def collate_fn(batch): # 简单堆叠实际项目按最长序列 padding import torch keys batch[0].keys() out {} for k in keys: out[k] torch.stack([b[k] for b in batch]) return out train_dataset CocoDataset(coco_qwen2vl_train.jsonl, processor) training_args TrainingArguments( output_dir./qwen2vl_lora_out, per_device_train_batch_size1, gradient_accumulation_steps8, num_train_epochs1, learning_rate2e-4, bf16True, logging_steps10, save_steps200, gradient_checkpointingTrue, report_tonone ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, data_collatorcollate_fn ) trainer.train()这段代码里几个参数值得说清楚。per_device_train_batch_size 设为 1 是因为多模态样本序列长度差异大batch 大了容易 OOM用 gradient_accumulation_steps 累积梯度等效扩大 batch。learning_rate 2e-4 是 LoRA 微调的常用起点比全量微调高一个量级因为可训练参数少需要更大步长。gradient_checkpointing 打开后用时间换显存能再省一截。labels 里把 pad token 位置设成 -100让损失只在有效 token 上计算这是标准做法。跑起来后看 loss 曲线正常情况前几十步会快速下降然后趋于平缓如果 loss 不降或者震荡剧烈先查学习率是不是太大、数据格式是不是有问题。4.3 训练过程中的显存和速度观察跑通后要盯几个指标。显存占用用 nvidia-smi 看均衡配置加梯度检查点24G 卡上大概占 18 到 20G留有余量。训练速度上单步耗时受图像 token 数量影响大COCO 图像处理后在 500 到 800 个视觉 token 之间单步大概 1 到 2 秒。如果发现显存缓慢增长直到 OOM多半是某个地方保留了计算图检查 collate_fn 里有没有不必要的 tensor 操作。速度慢到无法接受时优先降 max_pixels 减少视觉 token比降 batch 更有效。训练日志里 loss 值要结合步数看前 100 步波动正常500 步后应该稳定下降如果一直不降回头检查 labels 构造是否正确图像 token 有没有被错误地算进损失。5. 避坑排查多模态 LoRA 微调最容易翻车的五个地方5.1 图像 token 被算进损失导致模型学不会描述现象是 loss 下降很慢或者降到某个值就不动了生成的描述和图像内容对不上。原因是 labels 构造时没有把图像占位 token 的位置屏蔽掉模型在优化图像 token 的预测而图像 token 本身不是要生成的文本。解决是在构造 labels 时把图像特殊 token 对应的位置也设为 -100。Qwen2-VL 的图像 token 有特定 id可以在 processor 里查到构造 labels 时一并屏蔽。这个坑很隐蔽因为 loss 确实在降只是降的是没意义的部分。5.2 processor 版本和模型版本不匹配报 KeyError现象是加载 processor 或调用 apply_chat_template 时抛 KeyError提示找不到某个字段。原因是 transformers 版本太旧不认识 Qwen2-VL 的 chat template 结构。解决是升级 transformers 到 4.45 以上同时确认 qwen-vl-utils 也装了。如果升级后还有问题检查模型 id 是不是写错Qwen2-VL-2B-Instruct 和 Qwen2-VL-2B 是两个不同的仓库前者带对话模板后者是基座模型做图像描述微调要用 Instruct 版本。5.3 LoRA 注入后训练参数为零现象是 print_trainable_parameters 输出可训练参数为 0训练时 loss 不变。原因是 target_modules 里写的模块名和模型实际命名对不上get_peft_model 找不到目标层就静默跳过。解决是打印 model.named_modules()搜一下 q_proj 这类关键词确认实际模块名。Qwen2-VL 的语言侧模块名和 Qwen2 文本模型一致但视觉侧命名不同如果误把视觉侧模块名写进去也会出问题。确认后改 target_modules 重新注入。5.4 显存够但训练中途 OOM现象是启动时显存正常跑几百步后突然 OOM。原因是梯度累积过程中某些中间变量没释放或者数据加载器缓存了图像张量。解决是检查 collate_fn 有没有在循环外持有 tensor 引用数据加载的 num_workers 设小一点避免多进程各自缓存图像。另外 gradient_checkpointing 要和 model.enable_input_require_grads() 配合使用否则检查点机制可能不生效。如果还不行把 max_pixels 再降一档视觉 token 数量对显存影响是线性的。5.5 验证集描述质量差但 loss 很低现象是训练 loss 降得很好但拿验证集图像生成描述时输出要么重复、要么和图像无关。原因是过拟合训练集样本少或者训练轮数太多。解决是减少 epoch 数增加数据量或者在 LoRA 配置里提高 lora_dropout。另一个可能是训练时用了 teacher forcing推理时是自回归生成两者分布有差异。可以在训练时偶尔用生成模式评估提前发现这个问题。验证集指标不能只看 loss要实际生成描述人工看几条这是血泪经验。6. 推理验证与效果调优让微调后的模型真正会看图说话训练跑完只是第一步模型到底学没学会描述图像得用推理验证。加载微调后的模型有两种方式一是把 LoRA 权重合并回基座模型二是推理时动态加载 LoRA 适配器。合并后推理速度快适合部署动态加载灵活适合对比不同 LoRA 版本。下面这段代码展示动态加载和生成描述的流程。from peft import PeftModel from transformers import Qwen2VLForConditionalGeneration, AutoProcessor from PIL import Image import torch base_model_id Qwen/Qwen2-VL-2B-Instruct lora_path ./qwen2vl_lora_out/checkpoint-200 processor AutoProcessor.from_pretrained(base_model_id) base_model Qwen2VLForConditionalGeneration.from_pretrained( base_model_id, torch_dtypetorch.bfloat16, device_mapauto ) model PeftModel.from_pretrained(base_model, lora_path) model.eval() image Image.open(val2014/COCO_val2014_000000123456.jpg).convert(RGB) messages [ {role: user, content: [ {type: image}, {type: text, text: 请描述这张图片的内容。} ]} ] text processor.apply_chat_template(messages, tokenizeFalse, add_generation_promptTrue) inputs processor(text[text], images[image], return_tensorspt).to(model.device) with torch.no_grad(): output_ids model.generate(**inputs, max_new_tokens64, do_sampleFalse) # 去掉输入部分只解码新生成的 token generated output_ids[:, inputs[input_ids].shape[1]:] caption processor.batch_decode(generated, skip_special_tokensTrue)[0] print(caption)生成参数里 max_new_tokens 控制描述长度COCO 描述一般不超过 30 个词设 64 够用。do_sample 设为 False 走贪心解码输出稳定可复现想多样化可以开采样加 temperature。评估时别只看一两条从验证集随机抽 50 张图人工看描述是否贴合图像内容、语法是否通顺、有没有重复词。如果发现描述偏短或者模板化可能是训练数据里描述风格单一可以增加每图描述条数做增强。如果描述和图像无关回到训练环节查 labels 构造和图像 token 处理。调优方向上r 从 16 提到 32 通常能提升描述丰富度但过拟合风险也上来配合增加数据量一起做。target_modules 加上视觉侧模块可能对细粒度描述有帮助但显存和训练时间都会涨建议先跑基线再对比。学习率从 2e-4 降到 1e-4 有时能让收敛更稳特别是数据量小的时候。我自己的习惯是每次只改一个变量跑完对比验证集生成结果改多了根本分不清是哪个参数起的作用。这套流程从环境搭建到推理验证单卡一天内能跑通剩下的就是按业务数据迭代。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进