几周前,我训练了一个小模型 SmolVLM2-256M-Married-Qwen3-0.6B(一个将 SmolVLM 视觉能力与 Qwen3 中文能力“缝合”的模型),但遗留了不少问题,尤其是其目标定位能力不佳。

最近重温基础知识时,我决定做些小实验来填坑,顺便练练手。本文记录了使用 GRPO 算法微调该模型,以及提升其定位能力的尝试。

https://zhuanlan.zhihu.com/p/1947674801566128094

一、背景与目标

在之前的文章《SmolVLM-Married-Qwen3 缝合怪-超小多模态中文模型》中,我介绍了这个模型。

它使用 Objects365 数据集训练,初衷是在 Qwen3 优秀中文能力的基础上,赋予模型视觉定位(Grounding)能力,但初步测试表明其定位效果很差。

二、GRPO 原理解析(一个粗糙的例子)

假设我们的目标是让模型学会用一句话夸人,要求是简洁、生动、俏皮。

Prompt: “请用一句话夸我做饭好吃。”

第一步:组内采样。针对同一提示词,让模型生成一组(G=4)候选回答。

**第二步:奖励评分。**根据要求进行打分(分数为假设)。

第三步:计算相对优势。

平均奖励:

标准差:

有了这两个值,即可计算每个回复的相对优势:

第四步:策略优化。

模型参数 θ 会根据 A_i 进行调整:

  • 回答 y4 获得了正向梯度,LLM 参数 θ 会被推向增加生成 y4 这条路径的概率
  • 回答 y2 获得了负向梯度。LLM 参数 θ 会被推向减少生成 y2 这条错误路径的概率

通过这种方式,模型在没有独立价值网络的情况下,也能朝着奖励更高的方向优化。

三、任务与奖励函数设计

目标是提升模型的定位能力。最直观的想法是使用交并比作为奖励,但初步实验发现几个难点:

  • 模型初始定位能力很差,直接计算 IoU 几乎总是 0。
  • 模型经常输出不合理的边界框(如坐标值为 -1 或 >1000)或解析错误。

为此,我设计了一个改进版的边界框奖励函数,它综合考虑了以下因素:

  • 基础匹配奖励: 使用匈牙利算法匹配预测框与真实框,奖励结合了 IoU 和中心点距离。
  • 数量惩罚: 预测框与真实框的数量越接近,奖励越高。
  • 异常处理: 对漏检、误检、解析错误等情况进行了相应的惩罚。

主要代码如下:

def compute_box_reward(pred_boxes, target_boxes):
if not pred_boxes and not target_boxes:
return 1.0
elif not pred_boxes and target_boxes:
return -1.0
elif pred_boxes and not target_boxes:
return -0.5
try:
pred_boxes = torch.tensor(pred_boxes, dtype=float).reshape(-1, 4)
target_boxes = torch.tensor(target_boxes, dtype=float).reshape(-1, 4)
except Exception as e:
print("compute_box_reward error!!!!!", e)
return 0.0
iou_matrix = box_iou_matrix(pred_boxes, target_boxes)  # [N, M]
dist_matrix = center_distance_matrix(pred_boxes, target_boxes)  # [N, M]
norm_dist = dist_matrix / (dist_matrix.max() + 1e-6)
alpha = 0.5
cost_matrix = (1 - iou_matrix) + alpha * norm_dist
row_ind, col_ind = linear_sum_assignment(cost_matrix.cpu().numpy())
matched_ious = iou_matrix[row_ind, col_ind]
matched_dists = norm_dist[row_ind, col_ind]
# IoU高 -> 奖励大;中心越近 -> 奖励大
reward_iou = matched_ious.mean()
reward_center = (1 - matched_dists).mean()
base_reward = 0.7 * reward_iou + 0.3 * reward_center
# print("reward_iou", reward_iou, reward_center)
# 框数量reward
N, M = len(pred_boxes), len(target_boxes)
min_count = min(N, M)
max_count = max(N, M)
count_penalty = min_count / max_count  # ∈ [0,1]
penalty_scale = 0.5
base_reward = base_reward + penalty_scale * count_penalty
reward = torch.clamp(base_reward, -1.0, 1.0)
return reward.item()

完整性检查:当预测与真值中均无目标时,视为正确,奖励为 +1;若仅有一方为空(漏检或误检),则给予相应惩罚。

位置精度优化:在计算 IoU 的基础上,融入边界框中心点距离作为奖励因子,距离越近,奖励越高,从而提升定位准确性。

数量一致性奖励:计算预测框与真值框的数量比例,比例越接近 1,获得的额外奖励越高,以鼓励模型输出合理数量的检测框。

同时,为了防止模型在优化定位能力时牺牲文本描述质量,在 IOU 奖励基础上,同时使用文本相似度奖励:

def levenshtein_reward_func(completions, solution, **kwargs):
res = []
for completion, sol in zip(completions, solution):
if '</think>' in completion:
t = completion.split('</think>')[-1].strip()  # calculate result distance
t = re.sub(r'\n\s*\n', '\n', t).strip()
res.append(levenshtein_ratio(t, sol))
else:
res.append(0.0)
return res

这份完整版的大模型 AI 学习和面试资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】
在这里插入图片描述

四、实验设置与“踩坑”记录

1. 训练数据

使用 Objects365 数据集,并融入了由大模型生成的描述信息:

def grpo_obj365_generator(annotation_file, root_dir, processor, image_resolution=512):
coco = COCO(annotation_file)
image_ids = list(coco.imgs.keys())[:10]
for image_id in image_ids:
image_info = coco.imgs[image_id]
image_path = os.path.join(root_dir, image_info['file_name'])
assert os.path.isfile(image_path)
image = Image.open(image_path).convert("RGB")
image = image.resize((image_resolution, image_resolution))
height = image_info["height"]
caption = image_info["caption"]
ann_ids = coco.getAnnIds(imgIds=image_id)
annotations = coco.loadAnns(ann_ids)
boxes = []
labels = []
for ann in annotations:
if ann['area'] <= 0:
continue
bboxes_xywh = np.array(ann['bbox'])
bboxes_xyxy = bboxes_xywh.copy()
bboxes_xyxy[2:] += bboxes_xyxy[:2]  # x2 = x + w, y2 = y + h
bboxes_xyxy *= (1000 / height)
bboxes_xyxy = bboxes_xyxy.astype(np.int32).tolist()
boxes.append(bboxes_xyxy)
labels.append(ann['category_id'])
label_u = list(set(labels))
for label_id in label_u:
if random.random() < 0.3:
obj = random.choice(
object365_zh_with_synonyms[label_id]
)
question = random.choice(prompts_bbox_template).format(
obj
)
select_box = []
cnt = 0
for b, label in zip(boxes, labels):
if cnt == MAX_BOX:
break
if label == label_id:
select_box.append({"box": b, "label": obj})
cnt += 1
answer = "<box>" + json.dumps(select_box, ensure_ascii=False) + "</box>"
else:
obj = random.choice(
object365_zh_with_synonyms[label_id]
)
question = random.choice(prompts_caption_bbox_template).format(
obj
)
select_box = []
cnt = 0
for b, label in zip(boxes, labels):
if cnt == MAX_BOX:
break
if label == label_id:
select_box.append({"box": b, "label": obj})
cnt += 1
answer = caption + "<box>" + json.dumps(select_box, ensure_ascii=False) + "</box>"
yield {
'image': image,
'solution': answer,
'problem': question
}
def make_conversation(example):
conversation = [
{"role": "system", "content": "简短回复问题."},
{
"role": "user",
"content": [
{"type": "image"},
{"type": "text", "text": example["problem"]}
]
},
]
prompt = processor.apply_chat_template(conversation, add_generation_prompt=True)
return {
"prompt": prompt,
"image": example["image"],
}

trl trainer 目前还不支持 SmolVLM2-256M-Married-Qwen3-0.6B 的 GRPO 训练,因此使用 open-r1 的训练代码(需要针对模型做修改)。

参考:

https://github.com/Liuziyu77/Visual-RFT/blob/main/src/virft/src/open_r1/trainer/grpo_trainer.py
  1. 模型与训练配置(因资源有限,一切从简)

使用 QLoRa 方式进行微调(LoRa 方式还可以节约一个参考模型):

bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = Idefics3ForConditionalGeneration.from_pretrained(
SMOLVLM2_MARRIED_QWEN3_06B_256M,
torch_dtype=torch.bfloat16,
_attn_implementation="flash_attention_2",
quantization_config=bnb_config,
device_map={'': accelerator.device}
)
TARGET_MODULES = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
lora_config = LoraConfig(
r=8,
lora_alpha=16,
target_modules=TARGET_MODULES,
bias="none",
lora_dropout=0.05,
)
model = get_peft_model(model, lora_config)

使用 DeepSpeed 优化(ZeRO Stage 2):

{
"zero_optimization": {
"stage": 2,
"allgather_partitions": true,
"allgather_bucket_size": 2e8,
"reduce_scatter": true,
"reduce_bucket_size": 2e8,
"overlap_comm": true,
"contiguous_gradients": true
},
"bfp16": {
"enabled": true
},
"train_batch_size": "auto"
}

使用 bf16,优化器选择 adamw_bnb_8bit,per_device_train_batch_size=1,gradient_accumulation_steps=4,num_generations=2(再大跑不起来😭)

training_args = GRPOConfig(
output_dir="SmolVLM-Qwen3-IOU",
learning_rate=1e-5,
num_train_epochs=1,
bf16=True,
optim = "adamw_bnb_8bit",
lr_scheduler_type = "linear",
warmup_ratio = 0.05,
per_device_train_batch_size=1,
gradient_accumulation_steps=4,
max_completion_length=512,
num_generations=2,
max_prompt_length=2048,
report_to=["tensorboard"],
logging_dir="SmolVLM-Qwen3-IOU/log",
logging_steps=10,
save_strategy="steps",
save_steps=100,
save_total_limit=10,
dataloader_pin_memory=False,
remove_unused_columns=False,
max_grad_norm=1.0,
deepspeed="ds_config.json",
gradient_checkpointing=False, #开启有问题
)

开始训练:

trainer = SmolVLM2MarriedQwen3GRPOTrainer(
model=model,
processing_class=processor,
reward_funcs=[iou_reward_func, levenshtein_reward_func],
args=training_args,
train_dataset=train_dataset,
)
trainer.train()

3. 问题与解决

不出意外的话,意外果然发生了。

报错与 flash_attention_2 和模型生成有关。由于 flash_attention_2 要求严格的左填充,虽然做了如下配置,但训练代码依旧报错。

processor = AutoProcessor.from_pretrained(SMOLVLM2_MARRIED_QWEN3_06B_256M, use_fast=True, padding_side="left")

经过定位发现代码在生成 Group,获取每个 token 的 log probabilities 处:

# Generate completions
with unwrap_model_for_generation(model, self.accelerator) as unwrapped_model:
prompt_completion_ids = unwrapped_model.generate(**prompt_inputs, generation_config=self.generation_config)
prompt_length = prompt_ids.size(1)
prompt_ids = prompt_completion_ids[:, :prompt_length]
completion_ids = prompt_completion_ids[:, prompt_length:]
prompt_mask = prompt_mask.repeat_interleave(self.num_generations, dim=0)
# Mask everything after the first EOS token
is_eos = completion_ids == self.processing_class.eos_token_id
device = self.accelerator.device
eos_idx = torch.full((is_eos.size(0),), is_eos.size(1), dtype=torch.long, device=device)
eos_idx[is_eos.any(dim=1)] = is_eos.int().argmax(dim=1)[is_eos.any(dim=1)]
sequence_indices = torch.arange(is_eos.size(1), device=device).expand(is_eos.size(0), -1)
completion_mask = (sequence_indices <= eos_idx.unsqueeze(1)).int()
# flash attention2要求paddingside=left,completion_ids有多个eos_token_id,导致第一个eos_token_id后的token都被mask,flash attention2会报错。
# 这里先直接全部=1
completion_mask.fill_(1)
# Concatenate prompt_mask with completion_mask for logit computation
attention_mask = torch.cat([prompt_mask, completion_mask], dim=1)  # (B*G, P+C)
pixel_values = prompt_inputs["pixel_values"].repeat(self.num_generations, 1, 1, 1, 1)
per_token_logps = self._get_per_token_logps(model, prompt_completion_ids, attention_mask, pixel_values)
# Get rid of the prompt (-1 because of the shift done in get_per_token_logps)
per_token_logps = per_token_logps[:, prompt_length - 1 :]
with torch.inference_mode():
if self.ref_model is not None:
ref_per_token_logps = self._get_per_token_logps(self.ref_model, prompt_completion_ids, attention_mask, pixel_values)
else:
with self.accelerator.unwrap_model(model).disable_adapter():
ref_per_token_logps = self._get_per_token_logps(model, prompt_completion_ids, attention_mask, pixel_values)

生成文本的序列中出现了两个 EOS token。标准的后处理会掩码第一个 EOS 之后的所有 token,造成序列右侧被填充,从而触发 flash_attention_2 的错误检查。

这里可视化下生成的回答,可以看到有 2 个 <|im_end|>:

报错源码位置:transformers/models/qwen3/modeling_qwen3.py。

if self.config._attn_implementation == "flash_attention_2":
if attention_mask is not None and past_key_values is not None:
is_padding_right = attention_mask[:, -1].sum().item() != input_tensor.size()[0]
if is_padding_right:
raise ValueError(
"You are attempting to perform batched generation with padding_side='right'"
"this may lead to unexpected behaviour for Flash Attention version of Qwen3. Make sure to call `tokenizer.padding_side='left'` before tokenizing the input."
)
if attention_mask is not None and 0.0 in attention_mask:
return attention_mask
return None

为了快速验证实验,我采取了“暴力”处理,将生成序列的掩码全部置为 1:completion_mask.fill_(1)。

五、训练过程与结果分析

训练过程如上图:

  • completion_length:生成序列的平均长度,要求简短回复问题,看着问题不大;
  • grad_norm:梯度范数,大概在 1~3.5 之间波动,但未出现发散;
  • kl:约束当前策略与参考策略的差距,在 1~1.5 之间波动,模型应该没跑偏;
  • loss:总的 loss 看不出下降,在一定范围内波动;
  • reward:上升后逐渐稳定,策略逐渐学会“更符合奖励函数偏好”的输出;
  • reward_std:奖励的标准差,表示输出奖励的波动性,趋于稳定;
  • iou_reward_func/levenshtein_reward_func:两个自定义的奖励函数,奖励上升后逐渐稳定;

整体看 reward 稳步提升,也许会有一点作用?

拿几张图试试(红色框标出):

prompt:请描述这张图片的内容,并检测其中的 XX

微调前:一位女士正在为一个小女孩切披萨。背景中可以看到其他顾客在用餐。<box>[{"box":[959,657,1090,802],"label":"手表"}]</box>;

微调后:一位女士正在为一个小女孩切披萨。<box>[{"box":[765,607,908,859],"label":"手表"}]</box>

微调前:一群鸟栖息在树枝上,背景是模糊的天空。<box>[{"box":[22,44,1247,998],"label":"鸟"}]</box>

六只灰色的小鸟栖息在树枝上。<box>[{"box":[79,60,1097,966],"label":"鸟"},{"box":[79,379,653,967],"label":"鸟"},{"box":[-1,98,389,997],"label":"鸟"},{"box":[289,196,753,588],"label":"鸟"},{"box":[785,497,1063,853],"label":"鸟"}]</box>

微调前:<box>[{"box":[436,237,1200,878],"label":"小猫"}]</box>

微调后:一只灰色条纹猫坐在黑色汽车的后座上。<box>[{"box":[299,255,977,787],"label":"小猫"}]</box>

微调前:一架日本航空公司的飞机停在机场跑道上。背景中可以看到城市景观和其他建筑物。<box>[{"box":[338,430,992,654],"label":"飞机"}]</box>

微调后:一架日本航空公司的飞机停在机场跑道上。背景中可以看到高楼大厦和其他基础设施。<box>[{"box":[285,365,776,593],"label":"飞机"}]</box>

微调前:一位老人坐在公园长椅上,背景是喷泉。周围有红色的长椅和黑色栏杆围成的小区域。<box>[{"box":[486,256,868,888],"label":"男人"}]</box>

微调后:一位穿着浅色衬衫和牛仔裤的男人坐在公园长椅上,背景是喷泉。<box>[{"box":[375,255,764,858],"label":"男人"}]</box>

微调前:一位穿着白色运动服的人正在打网球。他站在网球场上,手持红色球拍准备击打球。背景是绿色的草地。未检测到网球拍。

六、结论与思考

本次小实验表明,即使在资源极其有限的情况下,通过 GRPO 进行微调,也能在一定程度上提升模型的视觉定位能力。

具体改善体现在:

  • 简单场景下的定位框精度更高。
  • 之前检测不到的目标,现在能返回边界框(尽管精度有待提高)。
  • 预测框的数量更接近真实情况。
  • 输出格式的合法性提高。

当然,必须承认的是,在当前的模型规模、数据量和计算资源限制下,模型的绝对定位能力依然较弱。

但对我而言,这次实验的最大价值并非追求一个强大的 SOTA 模型,而是一次宝贵的动手实践,加深了对相关知识的理解。

如果未来资源允许,通过增大 Group、进行全参数微调等手段,性能无疑还有提升空间。这次成功的“练手”,为今后探索更复杂的模型优化任务积累了第一手经验。

七、0基础怎么入门AI大模型?

我在一线互联网企业工作十余年里,指导过不少同行后辈。帮助很多人得到了学习和成长。

我意识到有很多经验和知识值得分享给大家,也可以通过我们的能力和经验解答大家在人工智能学习中的很多困惑,所以在工作繁忙的情况下还是坚持各种整理和分享。但苦于知识传播途径有限,很多互联网行业朋友无法获得正确的资料得到学习提升,故此将并将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。

这份完整版的大模型 AI 学习和面试资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】
在这里插入图片描述

在这里插入图片描述

第一阶段: 从大模型系统设计入手,讲解大模型的主要方法;

第二阶段: 在通过大模型提示词工程从Prompts角度入手更好发挥模型的作用;

第三阶段: 大模型平台应用开发借助阿里云PAI平台构建电商领域虚拟试衣系统;

第四阶段: 大模型知识库应用开发以LangChain框架为例,构建物流行业咨询智能问答系统;

第五阶段: 大模型微调开发借助以大健康、新零售、新媒体领域构建适合当前领域大模型;

第六阶段: 以SD多模态大模型为主,搭建了文生图小程序案例;

第七阶段: 以大模型平台应用与开发为主,通过星火大模型,文心大模型等成熟大模型构建大模型行业应用。

在这里插入图片描述

👉学会后的收获:👈

• 基于大模型全栈工程实现(前端、后端、产品经理、设计、数据分析等),通过这门课可获得不同能力;

• 能够利用大模型解决相关实际项目需求: 大数据时代,越来越多的企业和机构需要处理海量数据,利用大模型技术可以更好地处理这些数据,提高数据分析和决策的准确性。因此,掌握大模型应用开发技能,可以让程序员更好地应对实际项目需求;

• 基于大模型和企业数据AI应用开发,实现大模型理论、掌握GPU算力、硬件、LangChain开发框架和项目实战技能, 学会Fine-tuning垂直训练大模型(数据准备、数据蒸馏、大模型部署)一站式掌握;

• 能够完成时下热门大模型垂直领域模型训练能力,提高程序员的编码能力: 大模型应用开发需要掌握机器学习算法、深度学习框架等技术,这些技术的掌握可以提高程序员的编码能力和分析能力,让程序员更加熟练地编写高质量的代码。

在这里插入图片描述

1.AI大模型学习路线图
2.100套AI大模型商业化落地方案
3.100集大模型视频教程
4.200本大模型PDF书籍
5.LLM面试题合集
6.AI产品经理资源合集

👉获取方式:
😝有需要的小伙伴,可以保存图片到wx扫描二v码免费领取【保证100%免费】🆓

在这里插入图片描述

Logo

更多推荐