""" Example 26: Vision GRPO (Group Relative Policy Optimization) Training End-to-end Vision GRPO training for building vision-language reasoning models. Extends GRPO to VLMs — the model generates text completions conditioned on image+text prompts, scored by reward functions, and updated via policy gradient with group-normalized advantages. Similar to Unsloth's Qwen VL GRPO notebooks but running natively on Apple Silicon via MLX. Unsloth uses TRL's GRPOTrainer with datasets like MathVista for hard math reasoning with visual diagrams. NOTE: With simple tasks and a capable model, all completions may receive equal rewards (steps show "skipped, equal rewards"). This is expected — GRPO requires reward variance between completions to compute advantages. For real training, use challenging tasks where the model sometimes fails (e.g., MathVista, geometric reasoning, chart interpretation). Usage: python examples/26_vision_grpo_training.py """ import re from PIL import Image, ImageDraw from mlx_tune import FastVisionModel from mlx_tune.vlm import VLMGRPOTrainer, VLMGRPOConfig # System prompt that encourages structured visual reasoning SYSTEM_PROMPT = """You are a helpful assistant that analyzes images and answers questions. Always show your reasoning before giving the final answer. Format your response as: [Your visual analysis here] [Your final answer here] """ # ---- Synthetic image generators ---- def create_color_image(color, size=(200, 200)): """Create a solid color image.""" return Image.new('RGB', size, color=color) def create_split_image(left_color, right_color, size=(200, 200)): """Create an image split vertically into two colors.""" img = Image.new('RGB', size) draw = ImageDraw.Draw(img) mid = size[0] // 2 draw.rectangle([0, 0, mid, size[1]], fill=left_color) draw.rectangle([mid, 0, size[0], size[1]], fill=right_color) return img def create_circles_image(count, size=(200, 200)): """Create an image with a specific number of filled circles.""" img = Image.new('RGB', size, color='white') draw = ImageDraw.Draw(img) colors = ['red', 'blue', 'green', 'orange', 'purple', 'cyan'] positions = [ (30, 30), (100, 30), (170, 30), (30, 100), (100, 100), (170, 100), (30, 170), (100, 170), (170, 170), ] for i in range(min(count, len(positions))): x, y = positions[i] c = colors[i % len(colors)] draw.ellipse([x - 15, y - 15, x + 15, y + 15], fill=c) return img # ---- Reward functions ---- def correctness_reward(response: str, ground_truth: str) -> float: """ Reward for getting the correct answer. Strict: only checks inside tags. No tags = no credit. This ensures reward variance when some completions use tags and others don't. """ match = re.search(r'\s*(.*?)\s*', response, re.DOTALL) if not match: return 0.0 # Must use tags extracted = match.group(1).strip().lower() truth = ground_truth.strip().lower() # Exact match if truth in extracted: return 1.0 # Numeric comparison response_nums = re.findall(r'-?\d+\.?\d*', extracted) truth_nums = re.findall(r'-?\d+\.?\d*', truth) if response_nums and truth_nums: try: if float(response_nums[-1]) == float(truth_nums[-1]): return 1.0 except ValueError: pass return 0.0 def format_reward(response: str, ground_truth: str) -> float: """ Reward for following the expected output format. Checks for and tags, and rewards detailed reasoning. Varying reasoning lengths create reward variance between completions. """ reasoning_match = re.search( r'(.*?)', response, re.DOTALL ) has_answer = bool( re.search(r'.*?', response, re.DOTALL) ) if reasoning_match and has_answer: reasoning_text = reasoning_match.group(1).strip() word_count = len(reasoning_text.split()) if word_count >= 20: return 1.0 elif word_count >= 10: return 0.7 elif word_count >= 5: return 0.5 return 0.3 elif reasoning_match or has_answer: return 0.2 return 0.0 def combined_reward(response: str, ground_truth: str) -> float: """Combined reward: 70% correctness + 30% format.""" return (0.7 * correctness_reward(response, ground_truth) + 0.3 * format_reward(response, ground_truth)) def main(): print("=" * 70) print("Vision GRPO Training — End-to-End (VLM Reasoning)") print("=" * 70) # 1. Load VLM # Any mlx-vlm model works: Qwen3.5, Qwen2-VL, LLaVA, Gemma 3, etc. model, processor = FastVisionModel.from_pretrained( "mlx-community/Qwen3.5-0.8B-bf16", ) model = FastVisionModel.get_peft_model( model, finetune_vision_layers=False, # Keep vision encoder frozen finetune_language_layers=True, # Train language layers finetune_attention_modules=True, finetune_mlp_modules=True, r=16, lora_alpha=16, ) # 2. Vision reasoning dataset — image + question + expected answer vision_data = [ { "prompt": f"{SYSTEM_PROMPT}\n\nWhat is the main color of this image?", "image": create_color_image('red'), "answer": "red", }, { "prompt": f"{SYSTEM_PROMPT}\n\nWhat is the main color of this image?", "image": create_color_image('blue'), "answer": "blue", }, { "prompt": f"{SYSTEM_PROMPT}\n\nWhat color is on the left side of this image?", "image": create_split_image('green', 'yellow'), "answer": "green", }, { "prompt": f"{SYSTEM_PROMPT}\n\nWhat color is on the right side of this image?", "image": create_split_image('red', 'blue'), "answer": "blue", }, { "prompt": f"{SYSTEM_PROMPT}\n\nHow many circles are in this image?", "image": create_circles_image(3), "answer": "3", }, { "prompt": f"{SYSTEM_PROMPT}\n\nHow many circles are in this image?", "image": create_circles_image(5), "answer": "5", }, { "prompt": f"{SYSTEM_PROMPT}\n\nWhat is the main color of this image?", "image": create_color_image('green'), "answer": "green", }, { "prompt": f"{SYSTEM_PROMPT}\n\nWhat color is on the left side of this image?", "image": create_split_image('purple', 'orange'), "answer": "purple", }, { "prompt": f"{SYSTEM_PROMPT}\n\nHow many circles are in this image?", "image": create_circles_image(2), "answer": "2", }, { "prompt": f"{SYSTEM_PROMPT}\n\nWhat is the main color of this image?", "image": create_color_image('yellow'), "answer": "yellow", }, ] # 3. Configure Vision GRPO # Matches Unsloth's GRPO parameters for vision models. # For real training, use max_steps=60+ and a harder dataset like MathVista. config = VLMGRPOConfig( beta=0.04, # KL coefficient num_generations=2, # Generate 2 completions per prompt temperature=0.9, # Sampling temperature (Unsloth uses 0.9) max_completion_length=256, # Max tokens per completion learning_rate=5e-6, # Unsloth uses 5e-6 for vision GRPO max_steps=10, # Quick demo (Unsloth uses 60) logging_steps=1, # Log every step output_dir="./vlm_grpo_output", ) # 4. Create trainer with custom reward function trainer = VLMGRPOTrainer( model=model, train_dataset=vision_data, processor=processor, reward_fn=combined_reward, args=config, ) print(f"\nReward function: 70% correctness + 30% format") print(f"Expected format: ......") # 5. Train! result = trainer.train() print(f"\nResult: {result['status']}") print(f"Adapters saved to: {result['adapter_path']}") if __name__ == "__main__": main()