VLM Fine-Tuning with Unsloth#
Standalone process for fine-tuning a vision-language model (VLM) on your own multimodal (image + text) dataset using Unsloth + LoRA, and running inference with the resulting adapter. This document describes the flow generically — it applies regardless of what domain or dataset you bring.
Looking for a concrete, ready-to-run example? See Fine-Tune a VLM with Unsloth — Weld Worked Example for a full worked instance of this flow applied to a weld-defect visual inspection dataset (input schema, prompt design, and the exact commands used).
This directory is not integrated with the rest of
industrial-edge-insights-multimodal — it does not wire into the
docker-compose*.yml stacks, configs/, or the vLLM serving setup in this
repo. It is a self-contained data-prep + fine-tuning + inference workflow you
run independently (e.g. on a dev box or training server) to produce a LoRA
adapter. Once you have an adapter, you can serve it with the existing
docker-compose-vllm.yml in this repo, or with
any OpenAI-compatible VLM server that supports LoRA adapters.
Table of Contents#
Overview#
This process is intentionally split into two concerns:
Bring your own dataset, prepared as a parquet file (or files) in the chat-conversation shape described in Expected Dataset Format. How you produce that parquet file is entirely up to your domain/data — see the Weld Worked Example for one concrete example (
prepare_weld_dataset.py) that fuses weld images + sensor telemetry into this shape.Fine-tune and run inference on that dataset with the two generic, domain-agnostic scripts in this directory:
Script |
Input |
Output |
|---|---|---|
|
A parquet dataset ( |
LoRA adapter + tokenizer |
|
Base model or adapter (from |
Streamed model response, token-by-token |
common.py holds small helpers shared by train_qwen.py and
infer_qwen.py (device detection, chat-message conversion) so the two
scripts stay modular and independently runnable, and so neither one embeds
any domain-specific assumptions about your dataset’s content.
Directory Layout#
vlm-fine-tuning/
├── README.md # short pointer to this guide
├── requirements.txt # pinned Python dependencies
├── common.py # shared chat-format / device-detection helpers
├── prepare_weld_dataset.py # weld-specific dataset prep (see the Weld Usecase guide)
├── train_qwen.py # Generic LoRA fine-tuning (Unsloth + TRL)
└── infer_qwen.py # Generic standalone inference
Generated artifacts (not checked in — see .gitignore note below) land in
whatever --output-dir / --dataset-path you pass on the command line,
e.g. processed_dataset/ and qwen_3.5_2b_adapter/.
If you fork this into your own repo, add
processed_dataset/,*_adapter/,checkpoint-*/, and any downloaded datasets/images to.gitignore— none of these generated artifacts should be committed.
Prerequisites#
Python 3.12
~16 GB+ RAM for data preparation (image + tabular processing), if your dataset-prep step is similarly memory-bound
Install the Intel Compute Runtime drivers - https://github.com/intel/compute-runtime/releases
A GPU/XPU is strongly recommended for fine-tuning and inference:
Intel GPU (Arc / integrated) via Intel XPU PyTorch build, or
CPU (functional but slow; useful only for smoke-testing the pipeline)
Ensure your user can access the GPU’s DRM render nodes. The
rendergroup provides GPU rendering access without granting broader display-management permissions. Check the render-node group and your current group memberships:stat -c "%G" /dev/dri/render* groups ${USER}
If you are not a member of the group used by the DRM render nodes, add your user to the
rendergroup, then update the current shell’s group:sudo gpasswd -a ${USER} render newgrp render
A dataset already prepared as parquet, in the shape described in Expected Dataset Format
Setup#
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
# Latest unsloth
git clone https://github.com/unslothai/unsloth.git
cd unsloth
pip install .[intel-gpu-torch2110]
To validate if XPU setup is done correctly.
import torch
print(f"PyTorch version: {torch.__version__}")
print(f"XPU available: {torch.xpu.is_available()}")
print(f"XPU device count: {torch.xpu.device_count()}")
print(f"XPU device name: {torch.xpu.get_device_name(0)}")
Unsloth auto-detects the installed PyTorch backend (XPU/CUDA/CPU) at import
time, and common.detect_device() selects xpu > cpu for
tensor placement during training/inference.
Pipeline Architecture#
At a high level, this is a generic 2-stage flow that sits on top of any dataset-preparation step you bring:
flowchart LR
subgraph S0["Your Dataset Prep\n(domain-specific — bring your own,\nsee the Weld Usecase guide)"]
direction TB
A["Your raw data"] --> B["system/user/assistant\nconversations per sample"]
B --> C["Parquet export\n(image + conversation_json columns)"]
end
subgraph S1["Fine-Tuning\n(generic — train_qwen.py)"]
direction TB
E["Load parquet dataset"] --> F["Base VLM + LoRA adapter\n(FastVisionModel)"]
F --> G["SFTTrainer\n(Unsloth vision collator)"]
G --> H["LoRA adapter\nsaved to disk"]
end
subgraph S2["Inference / Serving\n(generic — infer_qwen.py)"]
direction TB
J["Load base model\n+ LoRA adapter"] --> K["Streamed model response"]
end
C -->|"train_qwen.py\n--dataset-path"| E
H -->|"infer_qwen.py\n--model-path, or\nvLLM --enable-lora"| J
Each stage is independently runnable and only depends on the previous stage’s on-disk output (parquet dataset → LoRA adapter → served model), so you can re-run, inspect, or swap out any one stage without touching the others — including swapping in a completely different dataset-prep script for a different domain.
Expected Dataset Format#
train_qwen.py and infer_qwen.py only require a
HuggingFace datasets-loadable
parquet file (or directory of per-split parquet files) with two columns:
Column |
Type |
Description |
|---|---|---|
|
image (bytes, castable via |
The image for this sample |
|
string (JSON) |
A 3-turn chat conversation: |
The conversation_json value must parse into a list of chat messages, e.g.:
[
{"role": "system", "content": [{"type": "text", "text": "..."}]},
{"role": "user", "content": [{"type": "text", "text": "..."},
{"type": "image", "image": "<path>"}]},
{"role": "assistant", "content": [{"type": "text", "text": "..."}]}
]
common.convert_to_conversation() parses this per row and swaps in the
loaded image column value at train time; common.build_inference_messages()
does the analogous thing for a single inference request. Neither function
(nor train_qwen.py/infer_qwen.py) makes any assumption about what the
system/user/assistant text actually contains — that’s entirely up to your
dataset-prep step. Splitting into train/validation/test (e.g. as
separate parquet files, or as named splits in one directory) is expected by
train_qwen.py (train/validation) and infer_qwen.py (any split you
pass via --split).
For a concrete example of building this format from raw domain data (images + tabular telemetry), including how many prompt variants to use and why, see the Weld Worked Example.
Step: Fine-Tune the Model#
python train_qwen.py \
--model-name unsloth/Qwen3.5-2B \
--dataset-path ./processed_dataset/parquet \
--output-dir ./qwen_3.5_2b_adapter \
--learning-rate 2e-4 \
--num-train-epochs 2
Notable flags (all optional, defaults shown):
Flag |
Default |
Description |
|---|---|---|
|
|
Base VLM to fine-tune |
|
4 |
Per-device train batch size |
|
4 |
Per-device eval batch size |
|
4 |
Effective batch size = train batch × this |
|
2048 |
Max token sequence length |
|
16 / 16 |
LoRA rank / alpha |
|
off |
Load data, print the first converted sample, and exit (no model build/training) |
|
off |
Skip saving the adapter/tokenizer at the end |
Training details, and why these defaults#
LoRA applied to all four module groups — vision layers, language layers, attention modules, and MLP modules (
FastVisionModel.get_peft_model(finetune_vision_layers=True, finetune_language_layers=True, finetune_attention_modules=True, finetune_mlp_modules=True, ...)). Most fine-tuning objectives for a VLM require the model to change both how it perceives new visual patterns (vision layers) and how it phrases/structures its response (language layers) — tuning only one half would leave the other modality un-adapted. If your task only needs one modality adapted (e.g. purely stylistic text changes with no new visual concepts), you can disable the unused group inbuild_model()to shrink the adapter further.--lora-r 16/--lora-alpha 16— rank 16 is a well-established middle ground: high enough capacity to learn new behavior on a moderately sized dataset, low enough to keep the adapter small and fast to train without overfitting to phrasing. Settingalpha == r(scaling factoralpha/r = 1) keeps the effective LoRA update magnitude close to Unsloth’s tested default, avoiding the extra tuning needed if the ratio were pushed higher. Increasermainly if the base model underfits (loss plateaus high); decrease it if the adapter overfits a small dataset quickly.load_in_4bit=True(default on) — 4-bit quantization of the frozen base weights is what makes fine-tuning a multi-billion-parameter VLM practical on a single Intel Arc/integrated GPU or a modest CUDA card; only the small LoRA adapter is trained in higher precision, so quality loss from quantizing the frozen base is minimal.use_gradient_checkpointing="unsloth"— trades recomputation for activation memory, which is needed headroom for--max-seq-length 2048image + text sequences on memory-constrained GPUs.--max-seq-length 2048— sized to comfortably fit a full system + user (text + image) + assistant conversation, including image tokens, without truncating the response the model needs to learn end-to-end. Raise it if your conversations (e.g. longer prompts or responses) exceed this; lower it to save memory if you know your samples are shorter.--per-device-train-batch-size 4+--gradient-accumulation-steps 4(effective batch size 16) — a batch size chosen to fit typical single-GPU memory budgets for a 4-bit-quantized VLM atmax_seq_length=2048, with accumulation restoring a more stable effective batch size for gradient updates. Lower the batch size and raise accumulation steps proportionally if you hit out-of-memory errors (see Troubleshooting).--learning-rate 2e-4— a standard LoRA fine-tuning learning rate. Because LoRA only updates a small adapter (not the full model), it tolerates a rate roughly 10-20x higher than typical full fine-tuning rates (~1e-5–2e-5) without diverging.--num-train-epochs 2— a good starting point when target responses follow a fairly consistent structure/template, since the model converges on that structure quickly; more epochs beyond that mainly risk overfitting to exact phrasing rather than improving generalization. Increase if train/eval loss is still trending down after 2 epochs; keep it low for small or highly templated datasets.Optimizer is
adamw_8biton CUDA (reduces optimizer-state memory),adamw_torchotherwise (Intel XPU/CPU, where the 8-bit optimizer isn’t yet the well-supported path), selected automatically viacommon.detect_device().seed=3407— Unsloth’s own commonly used example seed, kept here for reproducibility parity with Unsloth’s published examples/benchmarks.Eval/checkpoint every 50 steps (
eval_steps=50,save_steps=50) — frequent enough to catch overfitting or divergence early on typical dataset sizes for this workflow, without adding significant overhead from constant evaluation.Trains with
trl.SFTTrainer+UnslothVisionDataCollator.On completion, the adapter and tokenizer are saved to
--output-dir(unless--skip-saveis set).
Step: Run Inference#
Run inference either against samples from your prepared test split, or against a single arbitrary image.
# Against the first 5 test-split samples, using the fine-tuned adapter
python infer_qwen.py \
--model-path ./qwen_3.5_2b_adapter \
--dataset-path ./processed_dataset/parquet \
--split test \
--num-samples 5
# Against a single external image
python infer_qwen.py \
--model-path ./qwen_3.5_2b_adapter \
--image /path/to/image.jpg \
--instruction "Analyze this image and produce a structured report."
--model-path accepts either a HuggingFace base model id (to sanity-check
the un-tuned base model) or a local directory containing a saved LoRA
adapter from train_qwen.py. Output streams token-by-token to stdout via
TextStreamer.
Troubleshooting#
Out-of-memory during training — lower
--per-device-train-batch-sizeand/or raise--gradient-accumulation-stepsto keep the effective batch size constant; ensure--load-in-4bitis enabled (it is by default).No XPU/CUDA detected —
common.detect_device()silently falls back to CPU; training/inference will still run but be much slower. Confirm your PyTorch build matches your hardware (see Setup).Serving the adapter — this directory only produces the adapter; to serve it with an OpenAI-compatible API, see
docker-compose-vllm.ymland.envunderVLLM configsectionDataset-prep issues (missing files, split-ratio errors, malformed
conversation_json, etc.) are specific to whichever dataset-prep script you use — see Weld Usecase — Data-Prep Troubleshooting for the worked example’s troubleshooting notes.
License#
Third-party components used by the scripts in this directory (see
requirements.txt), each under their own upstream license:
Unsloth — Apache-2.0
Hugging Face
transformers— Apache-2.0Hugging Face
datasets— Apache-2.0TRL — Apache-2.0
PEFT — Apache-2.0
PyTorch — BSD-3-Clause
For the license of any dataset used with this toolkit, see the dataset’s own license terms — e.g. for the weld worked example, see Weld Usecase — License / Dataset Attribution.