← Back to DevBytes

How to Fine-Tune Vision Language Models (VLMs)

Introduction to Vision Language Models

Vision Language Models (VLMs) are a class of multimodal AI systems that can jointly understand and reason over both visual and textual inputs. Unlike traditional computer vision models that output fixed labels, or language models that only consume text, VLMs bridge the two modalities — enabling tasks like image captioning, visual question answering, document understanding, and image-grounded dialogue.

Popular open-source VLMs include LLaVA, Qwen-VL, IDEFICS, Pix2Struct, and Florence-2. These models typically combine a vision encoder (such as CLIP or SigLIP) with a large language model (LLM) connected through a projection layer. While pretrained VLMs are remarkably capable out of the box, they often underperform on niche domains, proprietary data, or task-specific formatting. That's where fine-tuning comes in.

What Is VLM Fine-Tuning?

Fine-tuning a Vision Language Model means taking a pretrained VLM and continuing its training on a smaller, task-specific dataset so it adapts to your particular use case. The model's weights are updated using gradient descent on your data, shifting its behavior toward your desired outputs.

There are two main flavors of fine-tuning:

For most developers, LoRA-based fine-tuning is the sweet spot, and it's what we'll focus on in this tutorial.

Why Fine-Tuning Matters

Pretrained VLMs are trained on broad, general-purpose datasets. They're great at describing everyday photos or answering common questions, but they struggle with specialized scenarios. Fine-tuning matters because it lets you:

Prerequisites and Environment Setup

Before we begin, you'll need a machine with an NVIDIA GPU. For LoRA fine-tuning of a 7B-parameter VLM like LLaVA, a single 24GB GPU (such as an RTX 3090 or A10g) is sufficient. For full fine-tuning, you'll want multiple high-memory GPUs.

Install the required libraries:

pip install -q transformers accelerate peft bitsandbytes datasets torch pillow

You'll also need a Hugging Face account and to accept the model license for whichever VLM you choose. For this tutorial, we'll use llava-hf/llava-1.5-7b-hf, a widely supported open VLM.

Preparing Your Dataset

VLM fine-tuning datasets pair images with text instructions and target responses. The most common format is a conversation-style structure where the user provides an image and a question, and the assistant provides the answer.

Let's create a sample dataset in JSONL format. Each line represents one training example:

{"image": "images/img_001.jpg", "conversation": [
  {"role": "user", "content": "What defect is visible in this circuit board?"},
  {"role": "assistant", "content": "There is a cold solder joint on the third capacitor from the left."}
]}
{"image": "images/img_002.jpg", "conversation": [
  {"role": "user", "content": "Describe the damage shown here."},
  {"role": "assistant", "content": "The PCB trace near the power regulator is burnt and delaminated."}
]}

Now let's load and preprocess this data using the Hugging Face datasets library:

import json
from datasets import Dataset
from PIL import Image

def load_vlm_dataset(jsonl_path):
    examples = []
    with open(jsonl_path, "r") as f:
        for line in f:
            examples.append(json.loads(line))
    return Dataset.from_list(examples)

dataset = load_vlm_dataset("train_data.jsonl")
print(f"Loaded {len(dataset)} examples")
print(dataset[0])

Loading the Model with Quantization

To fit a 7B model on a single GPU, we'll load it in 4-bit precision using bitsandbytes. This dramatically reduces memory usage with minimal quality loss.

import torch
from transformers import LlavaForConditionalGeneration, AutoProcessor, BitsAndBytesConfig

model_id = "llava-hf/llava-1.5-7b-hf"

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_use_double_quant=True,
)

model = LlavaForConditionalGeneration.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",
)

processor = AutoProcessor.from_pretrained(model_id)

# Disable caching during training to save memory
model.config.use_cache = False

Configuring LoRA Adapters

LoRA (Low-Rank Adaptation) injects small trainable rank-decomposition matrices into specific linear layers of the model. We typically target the language model's attention and MLP projections, since those carry most of the task-specific behavior.

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

# Prepare the model for k-bit training (freezes weights, enables gradient checkpointing)
model = prepare_model_for_kbit_training(model)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

You should see output indicating that only a small fraction (typically under 1%) of parameters are trainable. This is the core benefit of PEFT.

Building a Custom Data Collator

VLMs require careful preprocessing: images must be processed by the vision encoder, and text must be tokenized with the correct chat template. A custom collator handles this batch-by-batch.

from torch.utils.data import DataLoader

def format_conversation(example):
    """Convert conversation list into the model's chat template format."""
    image = Image.open(example["image"]).convert("RGB")
    conversation = example["conversation"]

    # Build the prompt using the processor's chat template
    prompt_parts = []
    for i, turn in enumerate(conversation):
        if turn["role"] == "user":
            if i == 0:
                prompt_parts.append(f"USER: <image>\n{turn['content']}")
            else:
                prompt_parts.append(f"USER: {turn['content']}")
        else:
            prompt_parts.append(f"ASSISTANT: {turn['content']}")
    prompt = "\n".join(prompt_parts) + "</s>"
    return prompt, image

class VLMCollator:
    def __init__(self, processor, max_length=512):
        self.processor = processor
        self.max_length = max_length

    def __call__(self, batch):
        prompts = []
        images = []
        labels = []

        for example in batch:
            prompt, image = format_conversation(example)
            images.append(image)

            # Tokenize the full prompt to get labels
            full_text = prompt
            prompts.append(full_text)

        # Process images and text together
        inputs = self.processor(
            text=prompts,
            images=images,
            padding=True,
            truncation=True,
            max_length=self.max_length,
            return_tensors="pt",
        )

        # Use input_ids as labels for causal LM training
        labels = inputs["input_ids"].clone()
        # Mask padding tokens in labels
        labels[labels == self.processor.tokenizer.pad_token_id] = -100
        inputs["labels"] = labels

        return inputs

collator = VLMCollator(processor, max_length=512)
dataloader = DataLoader(dataset, batch_size=1, collate_fn=collator, shuffle=True)

Writing the Training Loop

While you can use the Hugging Face Trainer API, writing a custom training loop gives you more control and is easier to debug. Here's a complete training loop with gradient accumulation and periodic logging:

from transformers import get_linear_schedule_with_warmup
from torch.optim import AdamW
import torch

# Training hyperparameters
num_epochs = 3
learning_rate = 2e-5
gradient_accumulation_steps = 4
max_grad_norm = 1.0

# Optimizer and scheduler
optimizer = AdamW(
    filter(lambda p: p.requires_grad, model.parameters()),
    lr=learning_rate,
    weight_decay=0.01,
)

total_steps = (len(dataloader) // gradient_accumulation_steps) * num_epochs
scheduler = get_linear_schedule_with_warmup(
    optimizer,
    num_warmup_steps=int(0.03 * total_steps),
    num_training_steps=total_steps,
)

model.train()
global_step = 0

for epoch in range(num_epochs):
    print(f"\n=== Epoch {epoch + 1}/{num_epochs} ===")
    accumulated_loss = 0.0

    for step, batch in enumerate(dataloader):
        # Move batch to device
        batch = {k: v.to(model.device) if isinstance(v, torch.Tensor) else v
                 for k, v in batch.items()}

        # Forward pass
        outputs = model(**batch)
        loss = outputs.loss / gradient_accumulation_steps

        # Backward pass
        loss.backward()
        accumulated_loss += loss.item()

        # Step optimizer every gradient_accumulation_steps
        if (step + 1) % gradient_accumulation_steps == 0:
            torch.nn.utils.clip_grad_norm_(
                model.parameters(), max_grad_norm
            )
            optimizer.step()
            scheduler.step()
            optimizer.zero_grad()
            global_step += 1

            if global_step % 10 == 0:
                avg_loss = accumulated_loss / gradient_accumulation_steps
                print(f"Step {global_step} | Loss: {avg_loss:.4f} | LR: {scheduler.get_last_lr()[0]:.2e}")
                accumulated_loss = 0.0

print("Training complete.")

Saving and Loading the Fine-Tuned Adapter

One of the biggest advantages of LoRA is that you only save the tiny adapter weights, not the full model. A 7B model's LoRA adapter is typically just 50–100 MB.

# Save the LoRA adapter
adapter_path = "./vlm-lora-adapter"
model.save_pretrained(adapter_path)
processor.save_pretrained(adapter_path)
print(f"Adapter saved to {adapter_path}")

To load and use your fine-tuned model for inference:

from peft import PeftModel

# Load base model
base_model = LlavaForConditionalGeneration.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",
)

# Attach the trained adapter
fine_tuned_model = PeftModel.from_pretrained(base_model, adapter_path)
fine_tuned_model.eval()

# Run inference
test_image = Image.open("test_image.jpg").convert("RGB")
prompt = "USER: <image>\nWhat defect is visible in this circuit board?\nASSISTANT:"

inputs = processor(text=prompt, images=test_image, return_tensors="pt").to(fine_tuned_model.device)

with torch.no_grad():
    output = fine_tuned_model.generate(
        **inputs,
        max_new_tokens=128,
        do_sample=False,
        temperature=0.1,
    )

response = processor.decode(output[0], skip_special_tokens=True)
print(response)

Evaluating Your Fine-Tuned Model

Training loss alone doesn't tell you whether the model is actually good at your task. You should build a held-out evaluation set and measure task-specific metrics. For captioning tasks, use BLEU, ROUGE, or CIDEr. For VQA, measure exact-match accuracy or use an LLM-as-judge approach.

def evaluate_vqa(model, processor, eval_dataset, max_samples=100):
    correct = 0
    total = 0

    for example in eval_dataset.select(range(min(max_samples, len(eval_dataset)))):
        image = Image.open(example["image"]).convert("RGB")
        question = example["conversation"][0]["content"]
        expected = example["conversation"][1]["content"]

        prompt = f"USER: <image>\n{question}\nASSISTANT:"
        inputs = processor(text=prompt, images=image, return_tensors="pt").to(model.device)

        with torch.no_grad():
            output = model.generate(**inputs, max_new_tokens=128, do_sample=False)

        predicted = processor.decode(output[0], skip_special_tokens=True)
        # Extract assistant response
        predicted_answer = predicted.split("ASSISTANT:")[-1].strip()

        # Simple exact match (replace with fuzzy matching for production)
        if expected.lower().strip() in predicted_answer.lower():
            correct += 1
        total += 1

    accuracy = correct / total if total > 0 else 0
    print(f"Evaluation Accuracy: {accuracy:.2%} ({correct}/{total})")
    return accuracy

Best Practices for VLM Fine-Tuning

1. Curate High-Quality Data

The single most important factor in fine-tuning success is data quality. A few hundred carefully labeled examples will outperform thousands of noisy ones. Ensure your images are representative of real deployment conditions, including lighting variations, angles, and resolutions.

2. Use Instruction Diversity

Don't train on a single question phrasing. Vary your instructions — "Describe this image," "What do you see here?", "Identify the issue shown" — so the model generalizes rather than memorizing a specific prompt template.

3. Start with LoRA Before Full Fine-Tuning

LoRA with rank 16–64 is sufficient for most adaptation tasks. Only escalate to full fine-tuning if you've exhausted LoRA's capacity and have the compute budget. Full fine-tuning also increases the risk of catastrophic forgetting, where the model loses its general capabilities.

4. Tune Hyperparameters Carefully

For LoRA fine-tuning of VLMs, these ranges work well as starting points:

5. Monitor for Hallucination

VLMs are prone to hallucinating details not present in images. After fine-tuning, specifically test for hallucination by asking about absent objects or features. If hallucination increases, reduce the learning rate or number of epochs.

6. Preserve General Capabilities

If you need the model to retain its general reasoning ability alongside your specialized task, mix in a small portion (10–20%) of general-purpose VLM data during fine-tuning. This regularization technique, sometimes called "data mixing," prevents the model from over-specializing.

7. Version Your Datasets and Adapters

Treat your training data and LoRA adapters as versioned artifacts. Log which dataset version produced which adapter, along with the training hyperparameters and evaluation metrics. This makes reproduction and debugging far easier.

8. Consider Merging for Deployment

For production deployment, you can merge the LoRA adapter back into the base model weights. This eliminates the overhead of loading adapters separately and can improve inference speed:

# Merge adapter into base model
merged_model = fine_tuned_model.merge_and_unload()
merged_model.save_pretrained("./vlm-merged")
processor.save_pretrained("./vlm-merged")

Common Pitfalls and Troubleshooting

Out of memory errors: Reduce batch size to 1, enable gradient checkpointing with model.gradient_checkpointing_enable(), or lower the LoRA rank. You can also reduce max_length in your collator.

Loss not decreasing: Check that your learning rate isn't too low (try 2e-5) or too high (causing instability). Verify that trainable parameters are correctly identified by print_trainable_parameters(). Ensure your labels aren't all masked to -100.

Model ignores images after fine-tuning: This usually means the image token <image> was misplaced in your prompt template, or the processor didn't receive the image tensor correctly. Double-check that your collator passes images in the same order as text prompts.

Overfitting: If training loss drops but evaluation accuracy plateaus or degrades, you're overfitting. Reduce epochs, increase dropout in LoRA config, or augment your dataset with more diverse examples.

Conclusion

Fine-tuning Vision Language Models unlocks their potential for specialized, real-world applications that general-purpose models simply can't handle. By combining quantized loading, LoRA adapters, and a well-structured training pipeline, you can adapt a 7B-parameter VLM on a single consumer GPU in a matter of hours. The key to success lies in meticulous data curation, thoughtful hyperparameter selection, and rigorous evaluation. Start small with a few hundred high-quality examples, iterate based on evaluation metrics, and scale up only when you've validated your approach. As the open-source VLM ecosystem continues to mature, the barrier to building custom multimodal AI systems keeps dropping — and with the techniques covered in this tutorial, you now have a practical foundation to build upon.

— Ad —

Google AdSense will appear here after approval

← Back to all articles