Your First Multimodal LLM Fine-Tune: QLoRA + Gemma, Step-by-Step
If you’re not a member, you can still access the article for free here.
Your First Multimodal LLM Fine-Tune: QLoRA + Gemma, Step-by-Step
If you’re not a member, you can still access the article for free here.
This comprehensive tutorial will guide you through the process of fine-tuning a Large Language Model (LLM) using Quantized Low-Rank Adaptation (QLoRA). We’ll cover everything from setting up your environment to preparing your dataset, training the model, and finally, testing its inference capabilities. This guide is designed for beginners, so every concept and line of code will be explained in detail.

What is Quantized Low-Rank Adaptation (QLoRA)?
Before we dive into the practical steps, let’s understand what QLoRA is and why it’s so beneficial for fine-tuning LLMs.
Large Language Models (LLMs): Imagine a super-smart computer program that can understand and generate human-like text. That’s essentially what an LLM is. These models are trained on massive amounts of text data from the internet, allowing them to perform various language-related tasks like writing articles, answering questions, translating languages, and even generating creative content.
Fine-tuning: While LLMs are powerful, they are often general-purpose. To make them excel at a specific task (like generating product descriptions for e-commerce), we “fine-tune” them. This involves training the already-trained LLM on a smaller, more specific dataset relevant to our task. Think of it like taking a highly skilled generalist and giving them specialized training for a particular job.
The Challenge of Fine-tuning LLMs: LLMs are enormous! They have billions of parameters (think of these as the model’s internal “knobs” that it adjusts during training). Fine-tuning such a large model usually requires a lot of computational resources, like powerful graphics processing units (GPUs) and a lot of memory. This can be a barrier for many researchers and developers.
Quantized Low-Rank Adaptation (QLoRA) to the Rescue! QLoRA is a clever technique that significantly reduces the computational resources needed for fine-tuning LLMs while still achieving high performance. Here’s how it works:
Quantization (4-bit): In QLoRA, the original, pre-trained LLM is “quantized” to 4-bit precision.
- Precision: In computing, precision refers to the number of bits used to represent a number. Higher precision means more bits, which can lead to more accurate calculations but also requires more memory and computational power.
- 4-bit Quantization: This means that the numbers (weights) inside the LLM, which are typically represented with higher precision (e.g., 16-bit or 32-bit), are compressed to use only 4 bits. This drastically reduces the memory footprint of the model.
- Frozen Weights: Once the model is quantized, its original weights are “frozen.” This means they are not updated during the fine-tuning process. This is a crucial step that saves a lot of computation.
Trainable Adapter Layers (LoRA): Instead of training the entire massive LLM, QLoRA introduces small, additional layers called “adapter layers” (based on a technique called LoRA, or Low-Rank Adaptation).
- Adapter Layers: Think of these as tiny, specialized add-ons to the original LLM. They are much smaller than the full model.
- Only Adapters are Trained: During fine-tuning, only these small adapter layers are trained. They learn to adjust the output of the frozen, quantized base model to better suit the specific fine-tuning task. Because these layers are so much smaller, training them requires significantly less computational power and memory.
Merging or Keeping Separate: After training, the weights of these adapter layers can either be:
- Merged: Combined with the original, frozen base model. This creates a single, updated model that can be used for inference (making predictions).
- Kept as a Separate Adapter: The adapter layers can be kept as a separate, small file. When you want to use the fine-tuned model, you load the original base model and then “attach” these adapter layers on top.
In essence, QLoRA is like giving a massive, powerful brain a tiny, specialized “skill module” that it can quickly learn and attach, rather than retraining the entire brain from scratch. This makes fine-tuning LLMs much more accessible and efficient.
Setup Development Environment
The first crucial step is to prepare your environment by installing all the necessary libraries. These libraries provide the tools and functionalities we’ll need to fine-tune our LLM.
# Install Pytorch & other libraries
%pip install "torch>=2.4.0" tensorboard torchvision
# Install Gemma release branch from Hugging Face
%pip install "transformers>=4.51.3"
# Install Hugging Face libraries
%pip install --upgrade \
"datasets==3.3.2" \
"accelerate==1.4.0" \
"evaluate==0.4.3" \
"bitsandbytes==0.45.3" \
"trl==0.15.2" \
"peft==0.14.0" \
"pillow==11.1.0" \
protobuf \
sentencepiece
Explanation of each line:
%pip install ...: This is a command commonly used in Jupyter notebooks or Google Colab to install Python packages. The%at the beginning indicates that it's a "magic command" specific to these environments, allowing you to run shell commands directly.
**%pip install "torch>=2.4.0" tensorboard torchvision**
torch>=2.4.0: This installs the PyTorch library, which is a fundamental open-source machine learning framework. It's used for building and training neural networks. The>=2.4.0specifies that we need version 2.4.0 or newer. PyTorch is the backbone for many operations in this fine-tuning process.tensorboard: This installs TensorBoard, a visualization tool provided by TensorFlow (another machine learning framework, but often used alongside PyTorch for visualization). TensorBoard helps us visualize training progress, metrics, and model graphs, which is incredibly useful for debugging and understanding how our model is learning.torchvision: This is a PyTorch library that provides datasets, model architectures, and image transformations specifically designed for computer vision tasks. While our primary task is text generation, this library might be used by the Gemma model or underlying components for image processing.
**%pip install "transformers>=4.51.3"**
transformers>=4.51.3: This installs the Hugging Face Transformers library. This is a hugely popular library that provides pre-trained models, tokenizers, and training utilities for a wide range of natural language processing (NLP) tasks. It's the core library for interacting with models like Gemma. The>=4.51.3ensures we have a compatible and recent version.
**%pip install --upgrade \ ...**
--upgrade: This flag tellspipto upgrade the specified packages to their latest versions if they are already installed. This is good practice to ensure you have the most up-to-date features and bug fixes."datasets==3.3.2": Installs the Hugging Facedatasetslibrary, specifically version3.3.2. This library is essential for easily loading, processing, and sharing datasets for machine learning. We will use it to load our product description dataset."accelerate==1.4.0": Installs the Hugging Faceacceleratelibrary, version1.4.0. This library helps to easily run PyTorch training scripts on various hardware configurations (e.g., multiple GPUs, distributed training) without changing your code significantly. It "accelerates" your training."evaluate==0.4.3": Installs the Hugging Faceevaluatelibrary, version0.4.3. This library provides easy access to various metrics for evaluating machine learning models. While not explicitly used in the training loop provided, it's generally useful for assessing model performance."bitsandbytes==0.45.3": Installs thebitsandbyteslibrary, version0.45.3. This library is crucial for QLoRA because it provides highly optimized routines for 8-bit and 4-bit quantization, which are at the heart of QLoRA's memory efficiency."trl==0.15.2": Installs the Hugging Facetrl(Transformer Reinforcement Learning) library, version0.15.2. This library provides tools for training large language models with reinforcement learning from human feedback (RLHF) and, more relevant to us, includes theSFTTrainerfor supervised fine-tuning."peft==0.14.0": Installs the Hugging Facepeft(Parameter-Efficient Fine-Tuning) library, version0.14.0. This library is specifically designed to enable efficient fine-tuning techniques like LoRA and QLoRA. It handles the creation and management of the adapter layers."pillow==11.1.0": Installs thePillowlibrary, version11.1.0.Pillowis a powerful image processing library in Python. It's used here because our dataset includes images, andPillowwill handle loading and manipulating these image files (e.g., converting them to RGB format).protobuf: Installs theprotobuflibrary. Protocol Buffers are Google's language-neutral, platform-neutral, extensible mechanism for serializing structured data. It's often used for efficient data interchange and might be a dependency for some of the other installed libraries, especially those from Google or related to data handling.sentencepiece: Installs thesentencepiecelibrary. SentencePiece is an unsupervised text tokenizer and detokenizer. It's often used by modern LLMs (including Gemma) to break down text into smaller units (tokens) that the model can understand. This is essential for both input processing and output generation.
Accepting Gemma Terms of Use and Hugging Face Login
Before you can use the Gemma model, there’s an important step: accepting its terms of use and logging into your Hugging Face account.
Why this is important:
- Terms of Use: Large pre-trained models often come with specific licenses and terms of use that you must agree to before downloading and using them. This ensures you understand the conditions of their usage.
- Hugging Face Token: Hugging Face Hub is a platform where many pre-trained models and datasets are hosted. To download some models (especially larger or gated ones like Gemma), and to push your fine-tuned model back to the Hub, you need to be authenticated. A Hugging Face Token acts as your password for programmatic access.
Steps:
- Accept Gemma License:
- Go to the model page on Hugging Face:
http://huggingface.co/google/gemma-3-4b-pt(or the appropriate page for other Gemma models if you're using a different variant). - Look for an “Agree and access repository” button and click it. This signifies your acceptance of the terms.
2. Get a Hugging Face Token:
- If you don’t have a Hugging Face account, create one.
- Once logged in, go to your profile settings on Hugging Face.
- Navigate to “Access Tokens” or “Tokens.”
- Generate a new token. Crucially, make sure this token has “write access.” This is important because later in the tutorial, we will push our fine-tuned model to the Hugging Face Hub, and write access is required for that.
Code for Hugging Face Login:
from google.colab import userdata
from huggingface_hub import login
# Login into Hugging Face Hub
hf_token = userdata.get('HF_TOKEN') # If you are running inside a Google Colab
login(hf_token)
Explanation of each line:
**from google.colab import userdata**
- This line imports the
userdatamodule fromgoogle.colab. This is specifically for users running their code in Google Colab. Google Colab provides a secure way to store sensitive information like API keys or tokens using "Colab secrets." This method allows you to retrieve your Hugging Face token without hardcoding it directly into your script, which is a good security practice.
**from huggingface_hub import login**
- This line imports the
loginfunction from thehuggingface_hublibrary. This function is used to authenticate with the Hugging Face Hub.
**hf_token = userdata.get('HF_TOKEN') # If you are running inside a Google Colab**
- This line attempts to retrieve your Hugging Face token from Colab secrets. You would have previously saved your Hugging Face token under the name
HF_TOKENin your Colab environment's secrets manager. If you are not using Google Colab (e.g., running on your local machine or another cloud environment), you would typically replace this line with:hf_token = "YOUR_HUGGING_FACE_WRITE_TOKEN_HERE"(but never share your token publicly).
**login(hf_token)**
- This line calls the
loginfunction, passing your retrieved Hugging Face token (hf_token). This action authenticates your current Python session with the Hugging Face Hub, allowing you to download gated models and upload your fine-tuned model.
Create and Prepare the Fine-tuning Dataset
The quality and structure of your dataset are paramount for effective fine-tuning. This section focuses on understanding the importance of a well-defined use case and then demonstrates how to load and format a multimodal dataset for our specific task.
Understanding Your Use Case:
Before you even think about data, you need to clearly define what you want your fine-tuned LLM to do.
- Why is this important? The dataset you create or choose must directly align with your desired outcome. If your model needs to write short, catchy headlines, your dataset should contain examples of short, catchy headlines paired with their corresponding inputs. If your dataset contains long, academic papers, your model won’t learn to write effective short headlines.
- Example Use Case (from the text): “Fine-tuning a Gemma model to generate concise, SEO-optimized product descriptions for an e-commerce platform, specifically tailored for mobile search.”
- Concise: The descriptions should be brief.
- SEO-optimized: They should include keywords that help products appear higher in search results.
- E-commerce platform: The language should be appropriate for selling products online.
- Mobile search: Implies brevity and ease of reading on small screens.
Choosing a Dataset:
The tutorial uses the philschmid/amazon-product-descriptions-vlm dataset.
- Why this dataset? It’s chosen because it directly fits the defined use case. It contains Amazon product descriptions, including product images and categories, which serve as the inputs our model uses to generate descriptions.
- Multimodal: The “VLM” in the dataset name stands for “Vision-Language Model,” indicating that it contains both visual (images) and textual (descriptions, product names, categories) data. This is crucial as Gemma is a vision-capable LLM.
Multimodal Conversation Structure for TRL
Hugging Face TRL (Transformer Reinforcement Learning library) has a specific way of representing multimodal conversations. This structure tells the processing class how to handle different types of content (text and images).
The required format is a list of “messages,” where each message has a “role” (e.g., “system”, “user”, “assistant”) and “content.” The content itself is a list, where each item can be either text or an image.
{
"messages": [
{
"role": "system",
"content": [{"type": "text", "text": "You are..."}]
},
{
"role": "user",
"content": [
{"type": "text", "text": "..."},
{"type": "image"}
]
},
{
"role": "assistant",
"content": [{"type": "text", "text": "..."}]
}
]
}
Explanation of each line:
**"messages":** The top-level key for a single conversation turn. It contains a list of message objects.**"role":** Indicates who is speaking."system": Provides initial instructions or context to the model (e.g., "You are an expert product description writer.")."user": Represents the input or query from the user. In our case, this will include the product name, category, and image."assistant": Represents the expected output or response from the model. In our case, this will be the desired product description.**"content":** A list of content elements within a message.{"type": "text", "text": "..."}: Represents a text part of the message.{"type": "image"}: Crucially, this tells the processing class that an image needs to be loaded here. The actual image data will be stored separately (asPil.Imageobjects).
Loading and Formatting the Dataset
Now, let’s look at the Python code to load the dataset and transform it into the required multimodal conversation format.
from datasets import load_dataset
from PIL import Image
# System message for the assistant
system_message = "You are an expert product description writer for Amazon."
# User prompt that combines the user query and the schema
user_prompt = """Create a Short Product description based on the provided <PRODUCT> and <CATEGORY> and image.Only return description. The description should be SEO optimized and for a better mobile search experience.
<PRODUCT>{product}
</PRODUCT>
<CATEGORY>{category}
</CATEGORY>"""
# Convert dataset to OAI messages
def format_data(sample):
return {
"messages": [
{
"role": "system",
"content": [{"type": "text", "text": system_message}],
},
{
"role": "user",
"content": [
{
"type": "text",
"text": user_prompt.format(
product=sample["Product Name"],
category=sample["Category"],
),
},
{
"type": "image",
"image": sample["image"], # The PIL.Image object is stored here
},
],
},
{
"role": "assistant",
"content": [{"type": "text", "text": sample["description"]}],
},
],
}
def process_vision_info(messages: list[dict]) -> list[Image.Image]:
image_inputs = []
# Iterate through each conversation
for msg in messages:
# Get content (ensure it's a list)
content = msg.get("content", [])
if not isinstance(content, list):
content = [content]
# Check each content element for images
for element in content:
if isinstance(element, dict) and (
"image" in element or element.get("type") == "image"
):
# Get the image and convert to RGB
if "image" in element:
image = element["image"]
else:
image = element
image_inputs.append(image.convert("RGB"))
return image_inputs
# Load dataset from the hub
dataset = load_dataset("philschmid/amazon-product-descriptions-vlm", split="train")
# Convert dataset to OAI messages
# need to use list comprehension to keep Pil.Image type, .mape convert image to bytes
dataset = [format_data(sample) for sample in dataset]
print(dataset[345]["messages"])
Explanation of each line:
**from datasets import load_dataset**
- Imports the
load_datasetfunction from the Hugging Facedatasetslibrary. This is the primary function to download and load datasets from the Hugging Face Hub.
**from PIL import Image**
- Imports the
Imagemodule from thePillow(PIL) library. This is used to work with image objects, as the dataset provides images asPIL.Imageobjects.
**system_message = "You are an expert product description writer for Amazon."**
- Defines a string variable
system_message. This will be included in the "system" role of our conversational prompt. It sets the persona and task for the LLM, guiding its behavior.
**user_prompt = """..."""**
- Defines a multi-line string variable
user_prompt. This is a template for the user's input to the model. - It contains clear instructions for the model (“Create a Short Product description…”).
- It uses placeholders
{product}and{category}which will be filled with actual product names and categories from our dataset. - The
<PRODUCT>and<CATEGORY>tags are XML-like structures. These are often used to clearly delineate different pieces of information within a prompt, making it easier for the LLM to parse and understand. - “Only return description.” is a crucial instruction to ensure the model doesn’t generate extra conversational filler.
- “SEO optimized and for a better mobile search experience.” reinforces the specific requirements of our use case.
**def format_data(sample):**
- Defines a Python function named
format_datathat takes asample(a single entry/row from our dataset) as input. This function will transform each sample into theTRLmultimodal conversation format.
**return { "messages": [ ... ] }**
- This is the core of the
format_datafunction. It constructs the dictionary representing a single conversation following theTRLmultimodal format. - System Message Part:
{
"role": "system",
"content": [{"type": "text", "text": system_message}], },
- This creates the system message. It sets the
roleto"system"and itscontentis a list containing a single dictionary withtype: "text"and thesystem_messagestring. - User Message Part:
{
"role": "user",
"content": [
{
"type": "text",
"text": user_prompt.format(
product=sample["Product Name"],
category=sample["Category"],
),
},
{
"type": "image",
"image": sample["image"],
},
],
},
- This creates the user message.
- Its
roleis"user". - Its
contentis a list with two elements: - The first element is a dictionary for the text part:
type: "text"and thetextfield is populated by callinguser_prompt.format(). This replaces{product}and{category}placeholders with the actual "Product Name" and "Category" from the currentsample. - The second element is a dictionary for the image part:
type: "image"and, crucially,image: sample["image"]. This is where the actualPIL.Imageobject from the dataset sample is placed. - Assistant Message Part:
{
"role": "assistant",
"content": [{"type": "text", "text": sample["description"]}], },
- This creates the assistant message, which represents the desired output.
- Its
roleis"assistant". - Its
contentis a list with a single dictionary for the text part, where thetextfield is the "description" from the currentsample. This is what our model will learn to generate.
**def process_vision_info(messages: list[dict]) -> list[Image.Image]:**
- Defines a utility function
process_vision_info. This function takes a list ofmessages(in theTRLconversation format) and extracts all thePIL.Imageobjects from them. messages: list[dict]: Type hint indicating thatmessagesis expected to be a list of dictionaries.-> list[Image.Image]: Type hint indicating that the function is expected to return a list ofPIL.Image.Imageobjects.image_inputs = []: Initializes an empty list to store the extracted images.for msg in messages:: Iterates through each message in the conversation.content = msg.get("content", []): Safely retrieves the "content" list from the message. If "content" is missing, it defaults to an empty list.if not isinstance(content, list): content = [content]: Ensurescontentis always a list, even if it somehow was a single item.for element in content:: Iterates through each element (text or image) within the message's content.if isinstance(element, dict) and ("image" in element or element.get("type") == "image"):: Checks if the currentelementis a dictionary and if it either directly contains an "image" key (where the PIL Image object is) or if its "type" key is "image". This identifies image elements.if "image" in element: image = element["image"] else: image = element: Retrieves the image. If the image is directly stored under the "image" key, use that. Otherwise, assume theelementitself is the image (this handles cases where the structure might be slightly different but still represents an image).image_inputs.append(image.convert("RGB")): Appends the extractedPIL.Imageobject toimage_inputslist.image.convert("RGB")is crucial: it converts the image to the RGB color mode, which is a standard format expected by many models.
**dataset = load_dataset("philschmid/amazon-product-descriptions-vlm", split="train")**
- Loads the specified dataset from the Hugging Face Hub.
split="train": Specifies that we want to load the "train" split of the dataset, which is the data used for training our model.
**dataset = [format_data(sample) for sample in dataset]**
- This is a Python list comprehension. It’s a concise way to create a new list by applying the
format_datafunction to eachsample(row) in the originaldataset. - Crucial Note: The comment
# need to use list comprehension to keep Pil.Image type, .mape convert image to bytesis very important. Thedataset.map()function (which is commonly used with Hugging Facedatasets) can sometimes alter the type of data, specifically convertingPIL.Imageobjects to bytes. For multimodal models that directly expectPIL.Imageobjects, using a list comprehension ensures that the image objects retain their originalPIL.Imagetype, preventing issues later in the pipeline when the model's processor expects them.
**print(dataset[345]["messages"])**
- Prints the
messagescontent of the 346th sample (index 345) in our newly formatted dataset. This is a good way to visually inspect if the data has been transformed correctly into the desired multimodal conversation structure.
Fine-tune Gemma using TRL and the SFTTrainer
This is the core section where we configure and initiate the fine-tuning process using the SFTTrainer from the Hugging Face TRL library.
Introduction to SFTTrainer
The SFTTrainer (Supervised Fine-Tuning Trainer) is a powerful tool provided by the Hugging Face trl library specifically designed for fine-tuning LLMs. It extends the Trainer class from the transformers library, meaning it inherits all the robust features of the standard Trainer (like logging, evaluation, checkpointing, etc.) and adds specialized functionalities for supervised fine-tuning.
Key features of SFTTrainer:
- Dataset formatting: It simplifies handling conversational and instruction-based datasets, making it easier to prepare your data for training.
- Training on completions only: It can intelligently train the model to generate only the “assistant” (completion) part of the conversation, ignoring the “user” prompts during loss calculation. This prevents the model from just copying the input.
- Packing datasets: For more efficient training, it can “pack” multiple short sequences into a single, longer sequence. This reduces padding and maximizes GPU utilization.
- Parameter-efficient fine-tuning (PEFT) support: It has built-in support for techniques like QLoRA, making it seamless to integrate these memory-efficient methods.
- Preparing model and tokenizer: It can automatically handle adding special tokens (like
[BOS],[EOS],[IMG]) to the tokenizer and model, which are crucial for conversational models and vision-language models.
Loading Model and Tokenizer with Quantization Configuration
Now, let’s load our Gemma model and its corresponding processor (which handles both tokenization and image processing) and set up the 4-bit quantization.
import torch
from transformers import AutoProcessor, AutoModelForImageTextToText, BitsAndBytesConfig
# Hugging Face model id
model_id = "google/gemma-3-4b-pt" # or `google/gemma-3-12b-pt`, `google/gemma-3-27-pt`
# Check if GPU benefits from bfloat16
if torch.cuda.get_device_capability()[0] < 8:
raise ValueError("GPU does not support bfloat16, please use a GPU that supports bfloat16.")
# Define model init arguments
model_kwargs = dict(
attn_implementation="eager", # Use "flash_attention_2" when running on Ampere or newer GPU
torch_dtype=torch.bfloat16, # What torch dtype to use, defaults to auto
device_map="auto", # Let torch decide how to load the model
)
# BitsAndBytesConfig int-4 config
model_kwargs["quantization_config"] = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=model_kwargs["torch_dtype"],
bnb_4bit_quant_storage=model_kwargs["torch_dtype"],
)
# Load model and processor
model = AutoModelForImageTextToText.from_pretrained(model_id, **model_kwargs)
processor = AutoProcessor.from_pretrained("google/gemma-3-4b-it")
Explanation of each line:
**import torch**
- Imports the PyTorch library, which is fundamental for tensor operations and deep learning in general.
**from transformers import AutoProcessor, AutoModelForImageTextToText, BitsAndBytesConfig**
AutoProcessor: This class fromtransformersautomatically loads the appropriate preprocessor (tokenizer + image processor) for a given model ID. It simplifies handling multimodal inputs.AutoModelForImageTextToText: This class automatically loads a model suitable for image-to-text and text-to-image tasks (vision-language models). Gemma is one such model.BitsAndBytesConfig: This class is from thebitsandbyteslibrary and is used to define the configuration for 4-bit (or 8-bit) quantization.
**model_id = "google/gemma-3-4b-pt"**
- Defines the
model_idstring. This is the identifier for the specific Gemma model we want to load from the Hugging Face Hub.gemma-3-4b-ptrefers to a Gemma model with 3.4 billion parameters, pre-trained. Other variants like12bor27bcould be used depending on your GPU memory.
**if torch.cuda.get_device_capability()[0] < 8:**
- This is a check to ensure your GPU is compatible with
bfloat16(Brain Floating Point 16-bit) precision. torch.cuda.get_device_capability()[0]returns the major version of the CUDA compute capability for the current GPU.- GPUs with compute capability 8.0 or higher (e.g., NVIDIA Ampere architecture and newer, like A100, 30-series GPUs) natively support
bfloat16, which offers a good balance between precision and memory efficiency. raise ValueError(...): If your GPU doesn't supportbfloat16, this line will stop the execution and print an error message, asbfloat16is used in the quantization configuration.**model_kwargs = dict(...)**- Creates a dictionary
model_kwargsto store arguments that will be passed when loading the model. attn_implementation="eager": Specifies the attention mechanism to use. "eager" is a standard implementation. For better performance on compatible GPUs,flash_attention_2can be used. Flash Attention is an optimized attention algorithm that significantly speeds up and reduces memory usage for attention computations.torch_dtype=torch.bfloat16: Sets the data type for the model's computations tobfloat16. This is a mixed-precision training technique that saves memory and speeds up training without a significant loss in performance on compatible hardware.device_map="auto": This tells Hugging Facetransformersto automatically determine how to load the model across available devices (e.g., multiple GPUs or CPU if no GPU is available). It tries to optimize memory usage.
**model_kwargs["quantization_config"] = BitsAndBytesConfig(...)**
- This line adds a
quantization_configkey to ourmodel_kwargsdictionary, setting up the 4-bit quantization. load_in_4bit=True: This is the core setting for QLoRA. It tells thebitsandbyteslibrary to load the pre-trained model weights in 4-bit precision.bnb_4bit_use_double_quant=True: Enables "double quantization." This further quantizes the 4-bit quantization constants, resulting in even greater memory savings (typically 0.4 bits per parameter) without compromising performance.bnb_4bit_quant_type="nf4": Specifies the quantization data type as "NF4" (NormalFloat4). This is a custom 4-bit data type introduced by the QLoRA paper, which is empirically shown to be very effective for LLMs.bnb_4bit_compute_dtype=model_kwargs["torch_dtype"]: Sets the data type that will be used for computation during the forward and backward passes. Here, it's set tobfloat16, meaning while the weights are stored in 4-bit, the actual calculations will happen inbfloat16for better numerical stability and performance.bnb_4bit_quant_storage=model_kwargs["torch_dtype"]: Sets the data type for the storage of the quantized weights. This is also set tobfloat16for consistency with computation.
**model = AutoModelForImageTextToText.from_pretrained(model_id, **model_kwargs)**
- This line loads the pre-trained Gemma model.
AutoModelForImageTextToText.from_pretrained(model_id, ...): Downloads the model specified bymodel_idfrom the Hugging Face Hub.**model_kwargs: This unpacks all the key-value pairs from ourmodel_kwargsdictionary and passes them as individual arguments to thefrom_pretrainedfunction. This includes ourattn_implementation,torch_dtype,device_map, and most importantly, thequantization_config.
**processor = AutoProcessor.from_pretrained("google/gemma-3-4b-it")**
- Loads the preprocessor (tokenizer and image processor) for the Gemma model.
"google/gemma-3-4b-it": This model ID is specifically for the "instruction-tuned" version of Gemma's processor, which is often optimized for chat-like interactions and multimodal inputs.
Configuring LoRA Adapters
With the base model loaded and quantized, the next step is to define the LoraConfig. This configuration tells the peft library how to create and attach the small, trainable adapter layers.
from peft import LoraConfig
peft_config = LoraConfig(
lora_alpha=16,
lora_dropout=0.05,
r=16,
bias="none",
target_modules="all-linear",
task_type="CAUSAL_LM",
modules_to_save=[
"lm_head",
"embed_tokens",
],
)
Explanation of each line:
**from peft import LoraConfig**
- Imports the
LoraConfigclass from thepeft(Parameter-Efficient Fine-Tuning) library. This class is used to define the parameters for the LoRA adapter layers.
**peft_config = LoraConfig(...)**
- Creates an instance of
LoraConfigand assigns it to thepeft_configvariable. All the arguments inside define how the LoRA adapters will be structured and integrated.
**lora_alpha=16**
lora_alphais a scaling factor for the LoRA update. It's used in conjunction withr(LoRA rank) to scale the weight updates. A higheralphacan mean stronger adaptation. The QLoRA paper suggests specific values, and 16 is a common choice.
**lora_dropout=0.05**
lora_dropoutis the dropout probability applied to the LoRA layers. Dropout is a regularization technique that randomly "turns off" a fraction of neurons during training to prevent overfitting. A value of 0.05 means 5% of the LoRA layer connections will be randomly dropped during each training step.
**r=16**
ris the "rank" of the LoRA update matrices. This is a critical hyperparameter in LoRA. A lower rankrmeans fewer parameters are added, leading to smaller adapter layers and more memory savings. A higherrmeans more expressive adapters but also more parameters. 16 is a commonly used value that balances performance and efficiency.
**bias="none"**
- This specifies whether bias parameters in the LoRA layers should be trained.
"none"means no bias terms will be trained, which is common in LoRA as it often yields good results without adding extra parameters. Other options might include"all"(train all bias terms) or"lora_only"(train bias terms only in the LoRA layers).
**target_modules="all-linear"**
- This is a crucial parameter that determines which layers in the pre-trained model the LoRA adapters will be attached to.
"all-linear"means LoRA adapters will be added to all linear (dense) layers found in the model. Linear layers are very common in transformer architectures, and applying LoRA to them is generally effective. You could also specify a list of specific layer names if you wanted more fine-grained control.
**task_type="CAUSAL_LM"**
- Specifies the type of task the model is performing.
CAUSAL_LMstands for Causal Language Modeling, which is the task of predicting the next token in a sequence, given the preceding tokens. This is the underlying task for most text generation models, including Gemma.
**modules_to_save=["lm_head", "embed_tokens"]**
- This is an important parameter for models where certain parts of the original model (even if frozen) are critical for correct behavior and should be saved alongside the LoRA adapters.
lm_head: This is typically the final linear layer of a language model that projects the model's hidden states to the vocabulary size to make predictions for the next token. While the base model's weights are frozen, thelm_headmight need to be "active" or considered during fine-tuning.embed_tokens: These are the embedding layers that convert input tokens (words or subword units) into numerical representations (vectors) that the model can process. Keeping these active or savable ensures proper input processing.- By including these, we ensure that when we save our fine-tuned model, these critical components are also saved correctly, allowing for seamless inference later.
Defining Hyperparameters and Data Collator
Before training, we need to set various hyperparameters that control the training process and define a custom collate_fn (data collator) to properly prepare our multimodal data for the model.
from trl import SFTConfig
args = SFTConfig(
output_dir="gemma-product-description", # directory to save and repository id
num_train_epochs=1, # number of training epochs
per_device_train_batch_size=1, # batch size per device during training
gradient_accumulation_steps=4, # number of steps before performing a backward/update pass
gradient_checkpointing=True, # use gradient checkpointing to save memory
optim="adamw_torch_fused", # use fused adamw optimizer
logging_steps=5, # log every 5 steps
save_strategy="epoch", # save checkpoint every epoch
learning_rate=2e-4, # learning rate, based on QLoRA paper
bf16=True, # use bfloat16 precision
max_grad_norm=0.3, # max gradient norm based on QLoRA paper
warmup_ratio=0.03, # warmup ratio based on QLoRA paper
lr_scheduler_type="constant", # use constant learning rate scheduler
push_to_hub=True, # push model to hub
report_to="tensorboard", # report metrics to tensorboard
gradient_checkpointing_kwargs={
"use_reentrant": False
}, # use reentrant checkpointing
dataset_text_field="", # need a dummy field for collator
dataset_kwargs={"skip_prepare_dataset": True}, # important for collator
)
args.remove_unused_columns = False # important for collator
# Create a data collator to encode text and image pairs
def collate_fn(examples):
texts = []
images = []
for example in examples:
image_inputs = process_vision_info(example["messages"])
text = processor.apply_chat_template(
example["messages"], add_generation_prompt=False, tokenize=False
)
texts.append(text.strip())
images.append(image_inputs)
# Tokenize the texts and process the images
batch = processor(text=texts, images=images, return_tensors="pt", padding=True)
# The labels are the input_ids, and we mask the padding tokens and image tokens in the loss computation
labels = batch["input_ids"].clone()
# Mask image tokens
image_token_id = [
processor.tokenizer.convert_tokens_to_ids(
processor.tokenizer.special_tokens_map["boi_token"]
)
]
# Mask tokens for not being used in the loss computation
labels[labels == processor.tokenizer.pad_token_id] = -100
labels[labels == image_token_id] = -100
labels[labels == 262144] = -100 # This specific ID is likely another special image-related token for Gemma.
batch["labels"] = labels
return batch
Explanation of SFTConfig (Hyperparameters):
**from trl import SFTConfig**
- Imports the
SFTConfigclass, which is used to define the training arguments for theSFTTrainer.
**args = SFTConfig(...)**
- Creates an instance of
SFTConfigand assigns it to theargsvariable.
**output_dir="gemma-product-description"**
- Specifies the directory where the model checkpoints (saved states of the model during training) and the final trained model will be saved. This name will also be used as the repository ID when pushing to the Hugging Face Hub.
**num_train_epochs=1**
- Sets the number of training epochs. An “epoch” means one complete pass through the entire training dataset. For fine-tuning, often 1 to 3 epochs are sufficient.
**per_device_train_batch_size=1**
- Defines the batch size per GPU (or CPU) during training. A batch is a group of samples processed together. A smaller batch size requires less memory. Here, it’s set to 1, indicating a very small batch size, likely due to memory constraints with large models.
**gradient_accumulation_steps=4**
- This is a technique to simulate a larger batch size when
per_device_train_batch_sizeis small due to memory limits. The gradients are accumulated over 4 steps (meaning 4 batches of size 1 are processed), and then a single optimization step is performed. This effectively creates a "virtual" batch size of1 * 4 = 4.
**gradient_checkpointing=True**
- Enables gradient checkpointing. This is a memory-saving technique where instead of storing all intermediate activations during the forward pass (which takes a lot of memory), only a subset are stored. The remaining activations are recomputed during the backward pass. This trades computation time for memory reduction.
**optim="adamw_torch_fused"**
- Specifies the optimizer to use.
adamw_torch_fusedrefers to a fused (highly optimized) implementation of the AdamW optimizer from PyTorch. AdamW is a popular and effective optimizer for training neural networks.
**logging_steps=5**
- Determines how often training metrics (like loss) are logged. Here, metrics will be logged every 5 steps (batches).
**save_strategy="epoch"**
- Defines when checkpoints are saved.
"epoch"means the model will be saved at the end of each training epoch.
**learning_rate=2e-4**
- Sets the learning rate, which controls the size of the steps taken by the optimizer during training.
2e-4(0.0002) is a common learning rate for QLoRA, often derived from research papers.
**bf16=True**
- Enables
bfloat16(Brain Floating Point 16-bit) precision for training. This typically requires compatible GPU hardware (compute capability 8.0 or higher) and can significantly reduce memory usage and speed up training compared to complete 32-bit precision, while generally maintaining performance.
**max_grad_norm=0.3**
- Sets the maximum gradient norm for gradient clipping. Gradient clipping is a technique used to prevent “exploding gradients” (where gradients become extremely large, leading to unstable training). If the norm of the gradients exceeds 0.3, they are scaled down. This value is also often recommended in QLoRA papers.
**warmup_ratio=0.03**
- Sets the proportion of training steps during which the learning rate will gradually increase from 0 to its full value (
learning_rate). This "warmup" period helps stabilize training at the beginning. 0.03 means 3% of the total training steps will be used for warming up.
**lr_scheduler_type="constant"**
- Specifies the learning rate scheduler. A “constant” scheduler means the learning rate will remain constant after the warmup period. Other schedulers might reduce the learning rate over time.
**push_to_hub=True**
- If
True, the trained model (or its adapters) will be automatically pushed to the Hugging Face Hub under theoutput_dirname. This requires a Hugging Face token with write access.
**report_to="tensorboard"**
- Specifies the integration for logging training metrics. Here, metrics will be reported to TensorBoard, allowing for visual tracking of the training progress.
**gradient_checkpointing_kwargs={"use_reentrant": False}**
- Additional keyword arguments for gradient checkpointing.
use_reentrant: Falseis often recommended for better compatibility and memory usage with some PyTorch versions and model architectures when using gradient checkpointing.
**dataset_text_field=""**
- A dummy field is needed by the
SFTTrainerwhen using a customdata_collatorthat handles complex input structures (like our multimodal messages). Since ourcollate_fnhandles everything, this field is set to an empty string.
**dataset_kwargs={"skip_prepare_dataset": True}**
- Another argument to tell the
SFTTrainerthat we are providing an already prepared dataset and a customcollate_fn, so it should skip its own default dataset preparation steps. This is important when you have specific data formatting needs as we do with multimodal input.**args.remove_unused_columns = False** - By default,
Trainermight try to remove columns from the dataset that are not explicitly used by the model's forward pass. Setting this toFalseis crucial when using a customdata_collatorthat relies on specific column names (like "messages" in our case) that theTrainermight not automatically recognize as "used" by the model directly, but are indeed processed by the collator.
Custom Data Collator (collate_fn)
The collate_fn is a function that takes a list of dataset samples (a batch) and prepares them into a format that the model can directly consume. For multimodal data, this is especially important as it handles both text tokenization and image processing.
**def collate_fn(examples):**
- Defines the custom collation function.
exampleswill be a list of dictionaries, where each dictionary is one of our formatted multimodalmessagesfrom the dataset.
**texts = []**
- Initializes an empty list to store the extracted text strings from the batch.
**images = []**
- Initializes an empty list to store the extracted
PIL.Imageobjects from the batch.
**for example in examples:**
- Iterates through each conversation (sample) in the current batch.
**image_inputs = process_vision_info(example["messages"])**
- Calls our previously defined
process_vision_infoutility function. This extracts allPIL.Imageobjects from themessageslist of the currentexample.
**text = processor.apply_chat_template( ... )**
- This is a crucial step for preparing the text input.
processor.apply_chat_template(example["messages"], add_generation_prompt=False, tokenize=False):example["messages"]: Takes the structuredTRLmultimodal conversation format.add_generation_prompt=False: We don't want to add an extra prompt for generation, as ouruser_promptalready guides the generation.tokenize=False: Very important! We do not want to tokenize the text here yet. We want to get the raw string representation of the entire conversation (system, user, and assistant parts) so that theprocessorcan tokenize both text and images together in the next step.
**texts.append(text.strip())**
- Adds the processed text string (with leading/trailing whitespace removed using
.strip()) to thetextslist.
**images.append(image_inputs)**
- Adds the list of extracted
PIL.Imageobjects for the current sample to theimageslist.
**batch = processor(text=texts, images=images, return_tensors="pt", padding=True)**
- This is where the actual tokenization of text and processing of images happens together.
processor(...): TheAutoProcessorinstance (for Gemma) is called.text=texts: Provides the list of text strings (the formatted conversations).images=images: Provides the list of lists ofPIL.Imageobjects. The processor handles associating the images with their corresponding textual positions.return_tensors="pt": Tells the processor to return PyTorch tensors.padding=True: Ensures that all sequences in the batch are padded to the same length so they can be processed efficiently by the model.
**labels = batch["input_ids"].clone()**
- For language modeling tasks, the
labels(the target output for the model to predict) are often the same as theinput_ids(the input sequence), but with specific tokens masked out so the model only predicts the "completion" part. .clone(): Creates a copy of theinput_idstensor. This is important because we will modifylabelsby masking, and we don't want to alter the originalinput_ids.
Masking Tokens for Loss Computation:
- The goal is to calculate the loss only on the assistant’s (desired output) part of the conversation, and also to ignore any special tokens like padding or image tokens. This prevents the model from being penalized for reproducing the prompt or padding, and ensures it focuses on generating the correct response.
image_token_id = [processor.tokenizer.convert_tokens_to_ids(processor.tokenizer.special_tokens_map["boi_token"])]
- Retrieves the token ID for the “beginning of image” (
boi_token) special token. Gemma uses special tokens to denote the presence and boundaries of images within the text sequence.
labels[labels == processor.tokenizer.pad_token_id] = -100
- Sets the
labelscorresponding to padding tokens (pad_token_id) to-100. In Hugging Facetransformers, anignore_indexof-100for the loss function means that these positions will be ignored when calculating the loss.
labels[labels == image_token_id] = -100
- Sets the
labelscorresponding to theboi_token(and implicitly other image-related tokens if they share this ID or are handled similarly by the processor) to-100, so image tokens are also ignored in the loss calculation.
labels[labels == 262144] = -100
- This specific numerical ID (
262144) is likely another special token used by Gemma for images or other structural elements. It's explicitly masked to ensure it's not part of the loss calculation.
**batch["labels"] = labels**
- Adds the masked
labelstensor to thebatchdictionary under the key"labels". Thisbatchdictionary is what will be passed to the model for training.
Initializing and Starting the SFTTrainer
Finally, with all the pieces in place, we can create the SFTTrainer and begin the fine-tuning process.
from trl import SFTTrainer
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=dataset,
peft_config=peft_config,
processing_class=processor,
data_collator=collate_fn,
)
# Start training, the model will be automatically saved to the Hub and the output directory
trainer.train()
# Save the final model again to the Hugging Face Hub
trainer.save_model()
Explanation of each line:
**from trl import SFTTrainer**
- Imports the
SFTTrainerclass.
**trainer = SFTTrainer(...)**
- Instantiates the
SFTTrainerobject. model=model: Passes our loaded and quantized Gemma model to the trainer.args=args: Provides all the training hyperparameters defined in ourSFTConfigobject.train_dataset=dataset: Provides our prepared and formatted training dataset.peft_config=peft_config: Passes the LoRA configuration. TheSFTTrainerwill use this to set up the PEFT (LoRA) layers on top of the base model.processing_class=processor: Provides theAutoProcessorinstance. TheSFTTraineruses this to handle tokenization and image processing within its internal mechanisms, complementing our customdata_collator.data_collator=collate_fn: Tells theSFTTrainerto use our customcollate_fnto prepare batches of data.
**trainer.train()**
- This is the magical line that starts the entire fine-tuning process! The trainer will iterate through the dataset for the specified number of epochs, perform forward and backward passes, update the LoRA adapter weights, log metrics, and save checkpoints according to the configurations we set in
SFTConfig.
**trainer.save_model()**
- After the training is complete, this line explicitly saves the final state of the fine-tuned model (specifically, the LoRA adapter weights) to the
output_dirand, ifpush_to_hub=Truewas set inSFTConfig, it will also push these adapters to the Hugging Face Hub.
Freeing Memory
It’s good practice to free up GPU memory after training, especially when working with large models.
# free the memory again
del model
del trainer
torch.cuda.empty_cache()
Explanation:
del model: Deletes the Python object representing the model, releasing its memory.del trainer: Deletes the trainer object, releasing its memory.torch.cuda.empty_cache(): This explicitly clears PyTorch's CUDA memory cache. Even after deleting objects, PyTorch might hold onto allocated GPU memory in a cache for faster future allocations. This command forces it to release that cached memory back to the GPU.
Merging LoRA Adapters into the Base Model (Optional but Recommended for Inference)
When using QLoRA, you only train the small adapter layers. For convenient deployment and inference (especially with serving frameworks like vLLM or TGI), it’s often desirable to have a single, self-contained model file where the adapter weights are merged into the original base model.
Important Note: Merging adapters requires loading the original full base model into CPU memory, which can be substantial (more than 30GB as stated). If you don’t have enough CPU RAM, you can skip this step and directly proceed to “Test Model Inference” by loading the adapters on top of the base model.
from peft import PeftModel
# Load Model base model
model = AutoModelForImageTextToText.from_pretrained(model_id, low_cpu_mem_usage=True)
# Merge LoRA and base model and save
peft_model = PeftModel.from_pretrained(model, args.output_dir)
merged_model = peft_model.merge_and_unload()
merged_model.save_pretrained("merged_model", safe_serialization=True, max_shard_size="2GB")
processor = AutoProcessor.from_pretrained(args.output_dir)
processor.save_pretrained("merged_model")
Explanation of each line:
**from peft import PeftModel**
- Imports the
PeftModelclass from thepeftlibrary. This class is used to load a base model and apply PEFT (LoRA) adapters on top of it.
**model = AutoModelForImageTextToText.from_pretrained(model_id, low_cpu_mem_usage=True)**
- Loads the original, unquantized base Gemma model again.
model_id: The same ID used previously for the base model.low_cpu_mem_usage=True: This argument can help by trying to load the model in a more memory-efficient way (e.g., directly to GPU if possible, or by sharding).
**peft_model = PeftModel.from_pretrained(model, args.output_dir)**
- Creates a
PeftModelinstance. This essentially loads our trained LoRA adapter weights (which were saved inargs.output_dir) and applies them on top of the newly loadedmodel(the original base model). At this point,peft_modelis a representation of the base model with the adapters active.
**merged_model = peft_model.merge_and_unload()**
- This is the key step for merging.
merge_and_unload(): This method takes the weights from the LoRA adapter layers and mathematically merges them directly into the corresponding layers of the base model. After this operation, the adapter layers themselves are no longer separate; their effects are integrated into the base model's weights. The method also "unloads" the adapter-specific logic, resulting in a standardtransformersmodel. The result is a single, "merged" model that behaves like the fine-tuned model without needing separate adapter files.
**merged_model.save_pretrained("merged_model", safe_serialization=True, max_shard_size="2GB")**
- Saves the
merged_modelto a new directory named"merged_model". safe_serialization=True: This uses a more robust serialization format (oftensafetensors) which is generally safer, faster, and more secure than traditional PyTorchpicklefiles.max_shard_size="2GB": If the model is very large, this argument tellssave_pretrainedto shard (split) the model into multiple files, each no larger than 2GB. This makes it easier to download and load large models, especially on systems with limited memory or specific file size limits.
**processor = AutoProcessor.from_pretrained(args.output_dir)**
- Reloads the processor. While the processor itself isn’t “merged,” it’s good practice to load it from the fine-tuned model’s output directory, as it might contain any special tokens or configurations added during training.
**processor.save_pretrained("merged_model")**
- Saves the reloaded processor to the same “merged_model” directory. This ensures that when you load the
merged_modelfor inference later, you can also load its associated processor from the same location, guaranteeing compatibility.
Test Model Inference and Generate Product Descriptions
After all the hard work of fine-tuning, it’s time to test our model! This section shows you how to load the fine-tuned model and use it to generate product descriptions for new inputs.
Evaluating Generative AI Models: It’s important to note that evaluating generative AI models is complex. There isn’t always one “correct” answer. For instance, multiple product descriptions could be considered good. This tutorial focuses on a “manual evaluation” or “vibe check,” which means you’ll visually inspect the generated outputs to see if they meet your expectations. For more rigorous evaluation, you would typically use human annotators or specific metrics like ROUGE, BLEU, or more advanced NLG metrics (For more information of evaluation methods this blog post).
Loading the Fine-tuned Model for Inference
We’ll load the model with the PEFT adapters attached.
import torch
# Load Model with PEFT adapter
model = AutoModelForImageTextToText.from_pretrained(
args.output_dir, # Load from the directory where adapters were saved
device_map="auto",
torch_dtype=torch.bfloat16,
attn_implementation="eager",
)
processor = AutoProcessor.from_pretrained(args.output_dir)
Explanation of each line:
**import torch**
- Ensures PyTorch is available.
**model = AutoModelForImageTextToText.from_pretrained(...)**
- Loads the pre-trained base model again, but this time, it also loads our fine-tuned LoRA adapters directly on top of it.
args.output_dir: This is crucial! It tells thefrom_pretrainedmethod where to find the saved LoRA adapter weights. Thetransformerslibrary, when used withpeft, is smart enough to detect these adapters in the specified directory and automatically load and apply them to the base model.device_map="auto": Again, automatically maps the model components to available devices for optimal memory usage.torch_dtype=torch.bfloat16: Loads the model inbfloat16precision for efficient inference, assuming your GPU supports it.attn_implementation="eager": Specifies the attention implementation. (Again,flash_attention_2could be considered for compatible GPUs).
**processor = AutoProcessor.from_pretrained(args.output_dir)**
- Loads the processor from the same
args.output_dir. This ensures you are using the processor that was configured and saved alongside your fine-tuned adapters, maintaining compatibility, especially if any special tokens or configurations were modified during training.
Preparing a Test Sample
To test the model, we need a sample input that mimics the structure of our training data (product name, category, and an image).
import requests
from PIL import Image
# Test sample with Product Name, Category and Image
sample = {
"product_name": "Hasbro Marvel Avengers-Serie Marvel Assemble Titan-Held, Iron Man, 30,5 cm Actionfigur",
"category": "Toys & Games | Toy Figures & Playsets | Action Figures",
"image": Image.open(requests.get("https://m.media-amazon.com/images/I/81+7Up7IWyL._AC_SY300_SX300_.jpg", stream=True).raw).convert("RGB")
}
Explanation of each line:
**import requests**
- Imports the
requestslibrary, used for making HTTP requests (in this case, to download an image from a URL).
**from PIL import Image**
- Imports the
Imagemodule fromPillowfor image handling.
**sample = { ... }**
- Creates a dictionary named
sampleto hold our test data. "product_name": The name of the product for which we want to generate a description."category": The category of the product."image": This is where the image for the product is loaded.requests.get("https://m.media-amazon.com/images/I/81+7Up7IWyL._AC_SY300_SX300_.jpg", stream=True).raw: Makes an HTTP GET request to the provided URL to fetch the image data.stream=Trueallows efficient streaming of binary data, and.rawgets the raw bytes.Image.open(...): Opens the raw image data usingPillow..convert("RGB"): Converts the image to the RGB color mode, ensuring it's in a standard format expected by the model.
Generating the Description
This function encapsulates the entire inference process, from preparing the input to generating and decoding the output.
def generate_description(sample, model, processor):
# Convert sample into messages and then apply the chat template
messages = [
{"role": "system", "content": [{"type": "text", "text": system_message}]},
{"role": "user", "content": [
{"type": "image","image": sample["image"]},
{"type": "text", "text": user_prompt.format(product=sample["product_name"], category=sample["category"])},
]},
]
text = processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# Process the image and text
image_inputs = process_vision_info(messages)
# Tokenize the text and process the images
inputs = processor(
text=[text],
images=image_inputs,
padding=True,
return_tensors="pt",
)
# Move the inputs to the device
inputs = inputs.to(model.device)
# Generate the output
stop_token_ids = [processor.tokenizer.eos_token_id, processor.tokenizer.convert_tokens_to_ids("<end_of_turn>")]
generated_ids = model.generate(**inputs, max_new_tokens=256, top_p=1.0, do_sample=True, temperature=0.8, eos_token_id=stop_token_ids, disable_compile=True)
# Trim the generation and decode the output to text
generated_ids_trimmed = [out_ids[len(in_ids) :] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)]
output_text = processor.batch_decode(
generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
)
return output_text[0]
# generate the description
description = generate_description(sample, model, processor)
print(description)
Explanation of generate_description function:
**def generate_description(sample, model, processor):**
- Defines a function that takes our
sampledata, the loadedmodel, and theprocessoras input.
**messages = [ ... ]**
- Reconstructs the
TRLmultimodalmessagesformat for the input, similar to how we formatted the training data. system_message: The same system instruction.user_prompt.format(...): Fills the user prompt template with theproduct_nameandcategoryfrom the newsample."image": sample["image"]: Includes the image from the newsample.
**text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)**
- This converts the
messageslist into a single string that includes the special tokens and formatting expected by the Gemma model for chat-based inputs. add_generation_prompt=True: For inference, it's often beneficial to explicitly add a generation prompt (e.g.,<start_of_turn>assistant) at the end of the input sequence. This encourages the model to start generating its response (the "assistant" turn).tokenize=False: We still want the raw string here before full processing.
**image_inputs = process_vision_info(messages)**
- Extracts the
PIL.Imageobjects from themessageslist, just like in thecollate_fn.
**inputs = processor(text=[text], images=image_inputs, padding=True, return_tensors="pt")**
- This is the same
processorcall used in thecollate_fn. It takes the raw text string and image objects, tokenizes the text, processes the images, pads sequences, and returns PyTorch tensors ready for the model.
**inputs = inputs.to(model.device)**
- Moves the input tensors to the same device (e.g., GPU) where the model is loaded. This is essential for the model to perform computations.
**generated_ids = model.generate(...)**
- This is the core generation call!
**inputs: Unpacks the dictionary of input tensors (likeinput_ids,attention_mask,pixel_values) and passes them as arguments to thegeneratemethod.max_new_tokens=256: Sets the maximum number of new tokens the model should generate. This prevents excessively long outputs.top_p=1.0: (Nucleus sampling)top_pis a parameter for text generation that controls the diversity of the generated text. Atop_pof 1.0 means that all tokens with a cumulative probability up to 1.0 are considered. This generally leads to more diverse and less repetitive text.do_sample=True: Enables sampling-based generation. IfFalse, the model would use deterministic decoding (like greedy search). Sampling introduces randomness, making the generated text more creative and less repetitive.temperature=0.8: Controls the randomness of the sampling process. A higher temperature (e.g., 1.0) leads to more random and diverse output, while a lower temperature (e.g., 0.1) makes the output more deterministic and focused. 0.8 is a common value for a balance.eos_token_id=stop_token_ids: Specifies a list of token IDs that, if generated, will cause the generation process to stop.processor.tokenizer.eos_token_id: The "end-of-sequence" token ID.processor.tokenizer.convert_tokens_to_ids("<end_of_turn>"): The token ID for Gemma's special "end of turn" token, which signifies the end of a conversational turn.disable_compile=True: Disables Torch'storch.compileoptimization during generation. Whiletorch.compilecan speed up inference, it can sometimes introduce complexities or incompatibilities, especially during initial setup or with specific model configurations. Disabling it ensures smooth operation.
**generated_ids_trimmed = [out_ids[len(in_ids) :] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)]**
- This line removes the input prompt from the generated sequence. The
generatemethod returns the entire sequence (input + generated). We only want the generated part. - It zips the original
input_idswith thegenerated_ids. For each pair, it slices thegenerated_idsstarting from the length of theinput_ids, effectively keeping only the newly generated tokens.
**output_text = processor.batch_decode(generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False)**
- Decodes the generated token IDs back into human-readable text.
skip_special_tokens=True: Ignores any special tokens (like padding, BOS, EOS) during decoding, so they don't appear in the final output string.clean_up_tokenization_spaces=False: Prevents the decoder from aggressively cleaning up spaces, which can sometimes remove desired spacing, especially for specific model outputs.
**return output_text[0]**
- Returns the first (and likely only) generated description from the batch.
**description = generate_description(sample, model, processor)**
- Calls our function with the defined
sample,model, andprocessorto get the generated description.
**print(description)**
- Prints the generated product description to the console.
Final Word:
This step-by-step guide is designed to help beginners confidently fine-tune a multimodal LLM like Gemma using QLoRA — from setting up the environment to preparing data, training the model, and running inference. The code and methodology are based on the official tutorial provided by Google at ai.google.dev/gemma.
Disclaimer:
Opinions and views expressed here are solely my own and do not reflect those of my current or past employers or any affiliations.
메타데이터
- post_id
- 94098db489c8
- slug
- your-first-multimodal-llm-fine-tune-qlora-gemma-step-by-step-94098db489c8
- url
- https://medium.com/@roya90/your-first-multimodal-llm-fine-tune-qlora-gemma-step-by-step-94098db489c8
- canonical_url
- https://medium.com/@roya90/your-first-multimodal-llm-fine-tune-qlora-gemma-step-by-step-94098db489c8
- author_url
- https://medium.com/@roya90
- status
- ok
- fetched_at
- 2026-06-11 21:11:36