Model Fine-tuning
When you need a model specifically for generating e-commerce product descriptions, there are three main options.
The first is pre-training from scratch. This requires collecting massive text data (possibly trillions of tokens), renting dozens of A100 GPUs to train for months, and spending millions of dollars. This is what giants like OpenAI and Google do, and it is unrealistic for the vast majority of people.
-
The second is Prompt engineering. Give a general large model a carefully designed prompt, such as "You are a professional e-commerce copywriter. Please generate an attractive description based on the following product information..." This approach has zero cost and is immediately usable, but the problems are: you have to write long prompts every time, the results are unstable, it is easily disturbed by irrelevant input, and token consumption is high.
-
The third is fine-tuning. Take a trained general model, continue training it for a short while with your proprietary data, and let it "learn" your specific task. The resulting model retains general capabilities while performing excellently on your task, and the cost is controllable.
Comparison of the three approaches:
| Approach | Cost | Effectiveness | Applicable scenarios |
|---|---|---|---|
| Pre-training from scratch | Extremely high (millions of dollars) | Fully customized | Creating a new foundation model (only for giants) |
| Prompt engineering | Nearly zero | Unstable, depends on prompts | Simple tasks, quick validation |
| Fine-tuning | Medium (hundreds to thousands of dollars) | Stable and professional | Domain-specific tasks, productization |
Use a simple analogy:
- Pre-training from scratch is like cultivating a doctor from zero by yourself, which takes more than a decade.
- Prompt engineering is like giving an ordinary doctor a detailed operation manual.
- Fine-tuning is like letting an already licensed doctor pursue specialist training for a few months and become a specialist expert.
Why Fine-tuning is Needed
Not all tasks need fine-tuning; first clarify when to use it and when not to.
Limitations of Prompt Engineering
Prompt engineering is powerful, but it has several ceilings that are hard to break through.
The first limitation is the context window limit.Your prompts and examples must fit into the model's context window (e.g., 4K, 8K, 128K, 1024K tokens). If your task requires hundreds of examples to describe clearly, the prompt cannot contain them.
-
The second limitation is unstable results.The same prompt may produce very different results when phrased differently. When the model is in a "good mood" (sampling randomness), the output is good; when not, it may go completely off-topic.
-
The third limitation is high inference cost.Each inference requires sending the entire long prompt in, consuming many tokens and slowing response time. In high-concurrency scenarios, costs can skyrocket.
-
The fourth limitation is the forgetting problem.Although the model has read your prompt, the massive amount of pretraining data before it may still be dragging things down. For example, if you ask it to output JSON, it may still add a lot of natural language explanations.
Applicable Scenarios for Fine-tuning
Fine-tuning is worth considering when your task matches the following characteristics.
Characteristic 1: Clear task definition and fixed output format. For example, "translate technical documentation into an easy-to-understand blog," "generate ticket summaries from customer service records," "convert natural language into SQL queries."
-
Characteristic 2: You have a few hundred to a few thousand high-quality labeled data samples. Too little data won't produce results from fine-tuning, and too much is unnecessary—generally 500-5,000 samples is the ideal range.
-
Characteristic 3: You need professional performance in a specific domain. For example, medical report interpretation, legal document summarization, financial news analysis. General-purpose models may not understand industry terminology and conventions; fine-tuning can help them "enter the industry."
-
Characteristic 4: You are sensitive to response speed and cost. A small fine-tuned model may outperform a general large model, with 10x faster inference speed and 100x lower cost.
-
Characteristic 5: You need strict adherence to output format. For example, output must be JSON, must use a specific tone, must include certain fields. Fine-tuning lets the model "remember" these requirements without having to repeat them in the prompt every time.
Cost-Benefit Analysis of Fine-tuning
Before doing fine-tuning, do the math first.
Cost mainly comes from three aspects: data preparation, computing resources, and manual debugging. Data preparation usually takes the most work—you need to collect, clean, and label data. Computing resources are now very cheap; with LoRA + QLoRA, a consumer-grade GPU (RTX 3090/4090) can fine-tune 7B/13B models.
Benefits are reflected in several aspects: better results, lower inference costs, faster response times, and more stable output. If your model will provide services externally, these benefits will continue to accumulate.
When should you not fine-tune? If tasks change rapidly and data formats change every week, prompt engineering is more flexible. If you're only doing a one-time exploration, or you only have a few dozen samples, it's not worth fine-tuning either.
| Factor | Better suited for Prompt | Better suited for fine-tuning |
|---|---|---|
| Data volume | < 100 samples | > 500 samples |
| Task stability | Frequently changing | Relatively stable |
| Frequency of use | Occasional use | High-frequency use |
| Cost sensitivity | Low | High |
| Quality requirements | Good enough is fine | Pursuing the best |
Full Fine-tuning
First understand the most traditional fine-tuning method, then look at why we rarely use it today.
Principles and Process
The idea of full fine-tuning is simple: take all the parameters of the pretrained model and continue training on your dataset, updating all the weights.
The process is roughly like this:
First step: prepare the data. Organize your task data into "input-output" pairs.
-
Second step, load the pre-trained model. For example, download open-source models such as LLaMA-2-7B and Mistral-7B.
-
Step three, set the training parameters. The learning rate should be set very small (e.g., 1e-5 to 5e-5), because we don't want to completely overwrite the knowledge learned during pretraining.
-
Step 4: Start training. Let the model run a few epochs on your data (complete passes through the dataset several times).
-
Step five, save the model. After training, you get a brand new weight file, the same size as the original model.
Example
# Full Fine-tuning Concept Demonstration (Pseudo-code, Framework-independent)
# Show the core idea; the actual code will use HuggingFace.
# ============================================
def full_finetuning_concept():
Demonstration of Core Concepts of Full Fine-Tuning
# 1. Load the pre-trained model (all parameters are trainable)
model = load_pretrained_model("llama-2-7b")
# The model has 7 billion parameters, and every parameter gets updated.
print(fTotal model parameters: {count_parameters(model)})
# Output: Total number of model parameters: 7000000000
2. Prepare your task-specific data.
train_data = [
{"input": Product: Insulated cup\nGenerate e-commerce description.,
"output": This high-quality thermos cup features a 304 stainless steel inner liner and vacuum insulation technology, keeping beverages hot for up to 12 hours.},
{"input": Product: Wireless Earphones\nGenerate e-commerce description.,
"output": True wireless Bluetooth earphones, stable Bluetooth 5.3 connection, active noise cancellation, 24-hour battery life, comfortable to wear.},
# ... hundreds or thousands of similar data entries
]
# 3. Set the optimizer and a very small learning rate
optimizer = create_optimizer(model, learning_rate=2e-5)
# 4. Training loop
for epoch in range(3): # Usually train for 2-5 epochs
for batch in create_batches(train_data):
Forward propagation
loss = model.compute_loss(batch)
Backpropagation, update all parameters!
loss.backward()
optimizer.step()
optimizer.zero_grad()
print(fEpoch {epoch} complete)
# 5. Save the complete model (7 billion parameters, approximately 13GB)
model.save("llama-2-7b-ecommerce-finetuned")
print(Full fine-tuning complete, new model saved.)
This is only a concept demo; actual training requires a large amount of VRAM.
Catastrophic Forgetting Problem
Full fine-tuning has a serious problem: catastrophic forgetting.
What does it mean? When you train on your data, the model gradually "forgets" the general knowledge it learned during pretraining. It's like a person who frantically does math problems to prepare for a math exam and ends up forgetting the Chinese and English they had learned before.
Why does this happen? Because full fine-tuning updates all parameters. The knowledge learned during pretraining is encoded in these parameters, and drastic modifications can destroy the original knowledge.
The result is: the fine-tuned model performs well on your task, but its performance on other general tasks declines. Even worse, it may lose some basic abilities, such as following instructions and understanding complex problems.
How to mitigate this? You can mix in pretraining data for joint training, but that brings us back to the old problem of "needing massive amounts of data." Alternatively, you can use regularization to constrain parameters from changing too much, but the effect is limited.
VRAM Requirement Calculation
Another problem with full fine-tuning is that the VRAM requirements are too large. Let's calculate:
VRAM required to train a model = model parameters + gradients + optimizer states + activations.
For a LLaMA-7B model, in full precision (FP32), 7 billion parameters × 4 bytes = 28 GB. Gradients also take 28 GB, and the optimizer states (Adam optimizer) require 56 GB (twice the gradients). Activations vary with batch size and sequence length, and need at least a few GB as well.
Adding it all up, full fine-tuning a 7B model requires about 100+ GB of VRAM — this means at least 8 A100 GPUs (40GB) or 2 A100 GPUs (80GB) are needed.
If half precision (FP16/BF16) is used, the memory requirement can be halved, but it still requires about 50-60 GB of VRAM. This is still not something an ordinary person can afford.
| Model size | Full fine-tuning VRAM (FP16) | Required GPUs |
|---|---|---|
| 7B | 50-60 GB | 2×A100 (40GB) or 1×A100 (80GB) |
| 13B | 90-100 GB | 3×A100 (40GB) or 2×A100 (80GB) |
| 70B | 400+ GB | 12×A100 (40GB) or 6×A100 (80GB) |
Full fine-tuning is not unusable, but it is costly and risky. Unless you have a compelling reason (e.g., a very large amount of data and a need to change the model's fundamental behavior), you should give priority to parameter-efficient fine-tuning (PEFT) methods.
Parameter-Efficient Fine-tuning (PEFT)
PEFT (Parameter-Efficient Fine-Tuning) is an umbrella term for a class of methods that train only a small number of parameters. It retains the effectiveness of fine-tuning while greatly reducing cost.
Core Idea of PEFT
The core insight of PEFT is:The pretrained model already contains sufficient knowledge. We don't need to modify all parameters; we only need to "adjust" a small portion of parameters to adapt to the new task.。
It's like a piano that has already been built. You don't need to rebuild the entire piano; you just need to fine-tune a few strings to produce the tone you want.
The benefits of PEFT are obvious:
First, memory requirements are greatly reduced. It used to need 60 GB, now it might only need 6 GB.
-
Second, no catastrophic forgetting. The original model parameters remain unchanged; we just add a little something new, so existing knowledge is not destroyed.
-
Third, low storage cost. PEFT typically only saves a small number of newly added parameters (a few MB to a few hundred MB), without having to save the entire model.
-
Fourth, it enables "task composition". Train multiple small adapters, load them on demand during inference, and one model can serve as multiple models.
Overview of Mainstream PEFT Methods
PEFT is not a single method, but a collective term for a class of methods. Let's look at several mainstream methods:
The first is Adapter. Insert small neural network modules in the middle of each Transformer layer. During training, only these adapters are trained, and the original model parameters are frozen. This is an early PEFT method and is now used less.
-
The second is Prefix Tuning. Add learnable "prefix" vectors before each layer of the model. These prefix vectors occupy only a small portion, but they can guide the model's output.
-
The third is Prompt Tuning. Add learnable soft prompts only at the input layer. This is the simplest method, but its effect is relatively limited.
-
The fourth is LoRA (Low-Rank Adaptation). Add low-rank matrices alongside the model's attention layers. This is currently the most popular and effective PEFT method.
-
The fifth is QLoRA (Quantized LoRA). On top of LoRA, it adds model quantization to further reduce memory requirements. This is today's mainstream choice.
| Method | Principle | Trainable parameter count | Effect |
|---|---|---|---|
| Full fine-tuning | Update all parameters | 100% | Good, but may forget |
| Adapter | Insert small network | 0.1%-1% | Medium |
| Prefix Tuning | Add prefix vectors | 0.1%-1% | Medium |
| Prompt Tuning | Add soft prompts | < 0.1% | Average |
| LoRA | Low-rank matrix adaptation | 0.1%-1% | Close to full fine-tuning |
| QLoRA | Quantization + LoRA | 0.1%-1% | Close to full fine-tuning |
Today's best practice is clear:Prefer LoRA, and use QLoRA if memory is insufficient. These two methods are effective, low-cost, and have a mature ecosystem, making them the optimal choice in most cases.
LoRA(Low-Rank Adaptation)
LoRA is currently the most mainstream fine-tuning method, and understanding its principle is important.
Principle of Low-Rank Decomposition (Intuitive Understanding)
First, understand what "rank" is and what "low-rank" means.
Suppose you have a 100 × 100 matrix, and the data inside it may not be completely random. If this data can be composed of just a few "patterns", we say the matrix has a low rank.
For example: if every row is a multiple of the first row, then the rank of this matrix is 1. If every row is a linear combination of the first two rows, then the rank is 2. And so on.
The core insight of LoRA is:When a large model adapts to a new task, the change in weights is usually "low-rank" — that is, this change can be represented by very few parameters.。
This is like, although there are 1000 different e-commerce product descriptions, their writing styles may be determined by just a few core factors: tone, length, points of emphasis. You don't need 1000 different directions to describe the variation; 8, 16, or 32 is enough.
So LoRA's approach is: freeze all parameters of the original model, and add two small matrices A and B next to the attention layers. A is d × r, B is r × d, where r is the rank (Rank), usually a small number like 8, 16, or 32.
Original model output = original model forward pass.
LoRA output = original model forward pass + (input × A × B) × scaling factor.
During training, only A and B are trained; the original model parameters remain completely unchanged.
LoRA Mathematical Derivation
Don't be afraid, the mathematical derivation is simple, only a few lines.
Assume a certain layer of the original model is a linear transformation: h = W₀x, where W₀ is the pretrained weight matrix with shape d × d.
During fine-tuning, we want to update W₀, but not by too much. LoRA's approach is to represent the change in W₀ as the product of two small matrices: ΔW = BA.
So the output after fine-tuning is: h = W₀x + (BA)x.
Here B has shape d × r, A has shape r × d, and r is the rank (Rank).
More precisely, the formula in the LoRA paper is:
h = W₀x + α * (BA)x
where α is a scaling factor, usually set to some multiple of r, so that when r is changed there is no need to readjust hyperparameters.
During training, W₀ is completely frozen; only A and B are trainable.
At initialization, A is initialized with a random Gaussian distribution, and B is initialized to zero, so that at the start of training the LoRA part outputs zero and does not affect the original model's behavior.
Example
# Minimal implementation of LoRA's core principle
# No framework dependencies, pure NumPy demo
# ============================================
import numpy as np
class LoRALayer:
"""Minimal implementation of LoRA layer"""
def __init__(self, d: int, r: int, alpha: float = 1.0):
"""
d: input/output dimension (e.g., 4096)
r: rank (e.g., 8, 16, 32)
alpha: scaling coefficient
"""
self.d = d
self.r = r
self.alpha = alpha
# Frozen original weights (simulating a pretrained model)
self.W_0 = np.random.randn(d, d) * 0.01
self.W_0.flags.writeable = False # Marked as non-trainable
# LoRA's A and B matrices, trainable
# A: r × d, initialized with random Gaussian
self.A = np.random.randn(r, d) * 0.01
# B: d × r, initialized to zero (doesn't affect the original model at the start of training)
self.B = np.zeros((d, r))
def forward(self, x: np.ndarray) -> np.ndarray:
"""Forward pass: original model output + LoRA output"""
# Original model output
original_output = x @ self.W_0.T
# LoRA output: (x @ A.T) @ B.T = x @ (B @ A).T
lora_output = (x @ self.A.T) @ self.B.T
# Merged output
return original_output + self.alpha * lora_output
def get_trainable_params(self) -> dict:
"""Get trainable parameters (only A and B)"""
return {"A": self.A, "B": self.B}
def count_params(self) -> dict:
"""Count parameter numbers"""
total_original = self.d * self.d
total_lora = self.r * self.d + self.d * self.r
return {
"original_params": total_original,
"lora_params": total_lora,
"ratio": total_lora / total_original * 100,
}
# ============================================
# Demonstrate LoRA's parameter savings
# ============================================
# Assume d = 4096 (similar to LLaMA-7B's hidden layer dimension)
# r = 8 (commonly used rank size)
d = 4096
r = 8
lora = LoRALayer(d=d, r=r, alpha=r)
params_info = lora.count_params()
print(fOriginal parameter count: {params_info['original_params']:,})
print(fLoRA parameter count: {params_info['lora_params']:,})
print(fParameter ratio: {params_info['ratio']:.4f}%)
# Example output:
# Original parameter count: 16,777,216
# LoRA parameter count: 65,536
# Parameter ratio: 0.3906%
See, LoRA only needs to train 0.4% of the parameters, yet it can achieve results close to full fine-tuning. That's the magic of it.
Choosing r (Rank) and α Parameters
LoRA has two key hyperparameters: the rank r and the scaling coefficient α.
r determines LoRA's capacity. The larger r is, the more complex changes it can represent, but the parameter count also increases.
Common r values: 8, 16, 32, 64. For most tasks, r = 8 or 16 is sufficient. Going beyond r = 64 usually yields diminishing returns and increases training cost.
How to choose r? A practical suggestion: try r = 8 first, and if results are poor, increase it to 16 or 32. Don't start with a very large r.
α is the scaling coefficient. A common setting is α = r, so that the scale of ΔW = BA remains roughly stable. Alternatively, set α = 1 and let the user adjust it themselves.
Other parameters to consider:
Target modules: Which layers should LoRA be applied to? Usually it works well to only add the query and value projections (q_proj, v_proj) of the attention layers. It can also be added to more layers, but that increases the parameter count.
Dropout: LoRA can add dropout to prevent overfitting; common values are 0.05 or 0.1.
| Parameter | Common value | Description |
|---|---|---|
| r (rank) | 8, 16, 32 | Larger means higher capacity, but also more parameters |
| alpha | r or 1 | Scaling coefficient, usually set to r |
| target_modules | ["q_proj", "v_proj"] | Layers to apply LoRA to |
| lora_dropout | 0.0, 0.05, 0.1 | Prevent overfitting |
Merging LoRA Weights
After training LoRA, there are two options at inference time.
-
The first is to keep LoRA separate. At inference, load the original model, then load the LoRA weights, and dynamically compute the merged output. The advantage is that multiple LoRAs can be flexibly switched; the disadvantage is that inference is slightly slower (usually negligible).
-
The second is to merge the LoRA weights. Add LoRA's ΔW = BA to the original model's W₀, yielding W = W₀ + α×BA. This produces a single model file, and inference works just like a normal model.
The advantage of merging is fast inference and no special LoRA support needed. The disadvantage is that once merged, it cannot be undone, and if there are multiple LoRAs, each must be merged into a separate model file.
Example
# LoRA weight merging demonstration
# ============================================
import numpy as np
def merge_lora_weights(W_0: np.ndarray, A: np.ndarray, B: np.ndarray,
alpha: float = 1.0) -> np.ndarray:
"""
Merge LoRA weights into original weights
W = W_0 + alpha * (B @ A)
Parameters:
W_0: original weights (d, d)
A: LoRA A matrix (r, d)
B: LoRA B matrix (d, r)
alpha: scaling coefficient
Returns:
Merged weights
"""
# compute delta_W = alpha * (B @ A)
delta_W = alpha * (B @ A)
# merge into original weights
W_merged = W_0 + delta_W
return W_merged
# ============================================
# demonstrate the merging process
# ============================================
d = 4096 # hidden layer dimension
r = 8 # LoRA rank
# simulate original weights and LoRA weights
W_0 = np.random.randn(d, d) * 0.01
A = np.random.randn(r, d) * 0.01
B = np.zeros((d, r)) # in practice, B is the result after training
# merged weights
W_merged = merge_lora_weights(W_0, A, B, alpha=r)
print(f"original weight shape: {W_0.shape}")
print(f"merged weight shape: {W_merged.shape}")
print("weight merging complete, can be used like a normal model")
QLoRA (Quantized LoRA)
QLoRA takes LoRA's low cost to the extreme—fine-tuning 70B models is possible on consumer-grade GPUs.
4-bit Quantization + LoRA
QLoRA's idea is simple: quantize the model to 4-bit, then perform LoRA fine-tuning on top.
What is quantization? It converts floating-point numbers (FP16/BF16, each taking 2 bytes) into lower-precision representations. 4-bit quantization uses 4 bits (0.5 bytes) to represent a weight.
This reduces model size by 75%: a 7B model drops from 13 GB to about 3.5 GB, a 13B model from 26 GB to about 6.5 GB, and a 70B model from 140 GB to about 35 GB.
But direct quantization loses precision. QLoRA's innovation is a data type called NF4 (Normalized Float 4), which better preserves the information in neural network weights.
QLoRA's approach:
-
1. Quantize the pretrained model to 4-bit (NF4 format).
-
2. Add LoRA adapters on top.
-
3. During training, the 4-bit quantized base model is completely frozen; only the LoRA FP16 weights are trained.
The result: VRAM requirements drop by another 75%, with almost no loss in performance.
bitsandbytes Library
bitsandbytes is the core library for quantization, developed by Tim Dettmers.
It provides:
-
1. 4-bit quantization (NF4).
-
2. Double Quantization—quantizes the quantization constants as well, saving a bit more VRAM.
-
3. Paged Optimizer—moves some data to CPU memory when VRAM is insufficient.
Ordinary users don't need to interact directly with bitsandbytes; the peft and transformers libraries already wrap it. But it's useful to know it exists.
Consumer-Grade GPU Fine-tuning Solutions
With QLoRA, ordinary people can afford to do fine-tuning too.
RTX 3090/4090 (24GB VRAM) can easily fine-tune 7B/13B models, and can even attempt 70B models (requiring longer sequence lengths or smaller batch sizes).
VRAM requirement estimate:
| Model size | QLoRA VRAM requirement | Suitable GPU |
|---|---|---|
| 7B | 5-8 GB | RTX 3060/4060 and above |
| 13B | 8-12 GB | RTX 3070/4070 and above |
| 34B | 15-20 GB | RTX 3090/4090 |
| 70B | 30-40 GB | RTX 3090/4090 × 2 or A100 |
QLoRA is a game changer. It turned large model fine-tuning from "only big companies can do it" into "ordinary people can do it on their own gaming laptops." This is one of the most important advances in open-source AI.
Training Data Preparation
Data preparation is the most time-consuming and critical part of the fine-tuning process. Garbage in, garbage out—data quality determines the upper limit of the model.
Data Formats (Alpaca, ShareGPT Formats)
There are several common data formats; choose one and keep it consistent.
The first is the Alpaca format. It is one of the most popular formats, popularized by the Stanford Alpaca project.
The format is: instruction (instruction), input (input, optional), output (output).
Example
# Alpaca Data Format Example
# ============================================
alpaca_data = [
{
"instruction": Generate an e-commerce product description.,
"input": Product Name: Smart Sports Watch\nFunctions: heart rate monitoring, sleep tracking, GPS positioning, 50-meter water resistance.,
"output": This smart sports watch is an ideal companion for fitness enthusiasts. It features accurate heart rate monitoring and scientific sleep tracking to help you fully understand your health status. Built-in GPS allows you to record your exercise routes without carrying your phone. With a 50-meter water resistance rating, you can wear it with confidence while swimming.
},
{
"instruction": Rewrite the following text into a more colloquial expression.,
"input": This product adopts advanced technology and features high performance and low power consumption.,
"output": This product uses very advanced technology, with strong performance and energy efficiency!
},
More data
]
print(The Alpaca format contains three fields:)
print(- instruction: Task instruction (required))
print(- input: extra input (optional, can be an empty string if none))
print(- output: expected output (required))
The second is the ShareGPT format. This format is used for dialogue data and supports multi-turn conversations.
The format is: conversations (dialogue list), each turn has from (role) and value (content).
Example
ShareGPT data format example
# ============================================
sharegpt_data = [
{
"conversations": [
{
"from": "human",
"value": Hello, I'd like to learn more about your smartwatches.
},
{
"from": "gpt",
"value": "Hello! Our smartwatch has features such as heart rate monitoring, sleep tracking, and GPS positioning, as well as 50-meter water resistance. Is there anything specific you'd like to know?"
},
{
"from": "human",
"value": How is the battery life?
},
{
"from": "gpt",
"value": With normal use, the battery lasts 7 days; with GPS sport mode enabled, it lasts 12 hours.
}
]
},
# ... More conversations
]
print(ShareGPT format is used for multi-turn conversation data:)
print(- conversations: conversation list)
print(- from: role (usually human and gpt))
print(- value: conversation content)
There is also a simple input-output pair format, suitable for simple tasks.
Example
# Simple input-output pair format
# ============================================
simple_data = [
{
"text": "User: Generate product description\nProduct: Thermos cup\n\nAssistant: This high-quality thermos cup uses..."
},
{
"text": "User: Generate product description\nProduct: Wireless earbuds\n\nAssistant: True wireless Bluetooth earbuds, Bluetooth 5.3..."
},
# ... more data
]
print("This format directly concatenates all content into a single text field, which is the most flexible but also the least standardized.")
Which format is best? The Alpaca format is the most common; most training frameworks support it. For conversation tasks, ShareGPT is more suitable.
Key Points for Data Cleaning
Data cleaning is much more important than you might think. 80% of your model's performance depends on data quality.
-
First, deduplication. Duplicate data will cause the model to overfit to those repeating patterns. Check and remove completely identical or highly similar data.
-
Second, filter low-quality data. Remove data that is too short, too long, poorly formatted, or has obvious errors. For example, outputs with only a few characters, or with garbled text.
-
Third, unify formats. For example, some outputs use Chinese quotation marks, some use English quotation marks; some end with a period, some don't. Try to unify them so the model learns faster.
-
Fourth, balance the distribution. If some categories of data are particularly abundant and others are scarce, the model will be biased toward the majority category. Consider oversampling the minority class, or undersampling the majority class.
-
Fifth, check for data leakage. Ensure that the test set and validation set do not appear in the training set.
Example
# Simple example of data cleaning
# ============================================
import json
from typing import List, Dict
def clean_alpaca_data(data: List[Dict]) -> List[Dict]:
"""Simple data cleaning function"""
cleaned = []
seen_outputs = set()
for item in data:
# 1. Check whether required fields exist
if "instruction" not in item or "output" not in item:
continue
instruction = item["instruction"].strip()
output = item["output"].strip()
# 2. Filter outputs that are too short
if len(output) < 10:
continue
# 3. Filter outputs that are too long (adjust according to the task)
if len(output) > 2000:
continue
# 4. Deduplicate (approximate deduplication using the output)
if output in seen_outputs:
continue
seen_outputs.add(output)
# 5. Simple format unification
# For example, change Chinese quotation marks to English quotation marks
output = output.replace(""", "\"").replace(""", "\"")
# Remove leading and trailing whitespace
output = output.strip()
cleaned.append({
"instruction": instruction,
"input": item.get("input", "").strip(),
"output": output
})
return cleaned
# Load data
with open("data/training_data_raw.json", "r", encoding="utf-8") as f:
raw_data = json.load(f)
# Clean data
cleaned_data = clean_alpaca_data(raw_data)
print(f"Raw data count: {len(raw_data)}")
print(f"Cleaned data count: {len(cleaned_data)}")
print(f"Filtered out {len(raw_data) - len(cleaned_data)} low-quality data entries")
# Save the cleaned data
with open("data/training_data_cleaned.json", "w", encoding="utf-8") as f:
json.dump(cleaned_data, f, ensure_ascii=False, indent=2)
print("Cleaned data saved to data/training_data_cleaned.json")
Heuristic Rules for Data Volume
How much data do you need? This is one of the most frequently asked questions.
The answer is: it depends on task difficulty.
-
Simple tasks (e.g., making a piece of text more conversational): 100-300 entries may be enough.
-
Medium tasks (e.g., generating summaries in a specific format): 300-1000 entries is suitable.
-
Complex tasks (e.g., Q&A in professional domains): 1000-5000 entries is ideal.
Of course, more data is usually better, but with diminishing marginal returns. 1000 entries is much better than 500, but 10000 may not be much better than 5000.
A practical suggestion: first prepare 500 high-quality data entries and run a trial. If the results are poor, analyze whether it's due to insufficient data, poor data quality, or hyperparameter issues, and then make targeted improvements.
Data quality >> data quantity. 1000 carefully crafted entries work much better than 10000 hastily assembled ones.
Spending more time in the data preparation phase is worthwhile. Model training is just a "press a button" matter, but data quality determines whether pressing that button is valuable.
Supervised Fine-tuning (SFT) in Practice
Finally, we've reached the hands-on section. We'll use Unsloth or LLaMA-Factory to implement the complete fine-tuning workflow.
Environment Configuration
Step 1: Install the necessary libraries.
Environment Configuration
# Install dependencies (recommended to create a new conda environment)
# ============================================
# Create a new conda environment
conda create -n finetuning python=3.10 -y
conda activate finetuning
# Install PyTorch (choose the appropriate command based on your CUDA version)
# Visit https://pytorch.org/get-started/locally/ for the latest commands
conda install pytorch pytorch-cuda=12.1 -c pytorch -c nvidia
# Install HuggingFace ecosystem libraries
pip install transformers datasets accelerate peft bitsandbytes
# Install Unsloth (optional, for faster training)
pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"
pip install --no-deps trl xformers
# Or use LLaMA-Factory (all-in-one training framework)
git clone --depth 1 https://github.com/hiyouga/LLaMA-Factory.git
cd LLaMA-Factory
pip install -e ".[torch,metrics]"
echo "Environment setup complete!"
Using Unsloth/LLaMA-Factory
Unsloth is an optimized training library that is fast and memory-efficient. LLaMA-Factory is an all-in-one training framework that supports multiple models and methods.
Let's first look at the code for training with Unsloth + peft:
Example
# Using Unsloth for LoRA Fine-tuning (Full Version)
# Runs on RTX 3090/4090
# ============================================
import torch
from datasets import load_dataset, Dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
TrainingArguments,
BitsAndBytesConfig,
)
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer
import json
# ============================================
# 1. Configuration Parameters
# ============================================
MODEL_NAME = "unsloth/mistral-7b-v0.2-bnb-4bit" # or other models
DATA_PATH = "data/training_data_cleaned.json" # your data
OUTPUT_DIR = "output/example-ecommerce-model" # output directory
# 4-bit quantization configuration
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16
)
# LoRA configuration
lora_config = LoraConfig(
r=16, # Rank, 8-64 are all fine, 16 is a good starting point
lora_alpha=16, # Scaling factor, usually equal to r
target_modules=[ # Layers to apply LoRA, these are common choices
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
],
lora_dropout=0.05, # Dropout to prevent overfitting
bias="none",
task_type="CAUSAL_LM" # Causal language model
)
# Training parameters
training_args = TrainingArguments(
output_dir=OUTPUT_DIR,
per_device_train_batch_size=4, # Batch size per GPU
per_device_eval_batch_size=4,
gradient_accumulation_steps=4, # Gradient accumulation, equivalent to a larger batch
learning_rate=2e-5, # Learning rate, 2e-5 to 5e-5 is a common range
num_train_epochs=3, # Number of epochs, 2-5 epochs is appropriate
logging_steps=10, # Log every 10 steps
evaluation_strategy="epoch", # Evaluate once per epoch
save_strategy="epoch", # Save once per epoch
fp16=True, # Use mixed precision training
bf16=False, # For Ampere and newer GPUs (30/40 series), you can set bf16=True
optim="paged_adamw_32bit", # Paged optimizer to prevent OOM
report_to="none", # Do not report to platforms like wandb
save_total_limit=3, # Save at most 3 checkpoints
load_best_model_at_end=True, # Finally load the best model
metric_for_best_model="eval_loss", # Select the best model using eval loss
)
# ============================================
# 2. Load Data and Format
# ============================================
def format_example(example):
"""Format Alpaca-format data into model input"""
instruction = example["instruction"]
input_text = example.get("input", "")
output = example["output"]
if input_text.strip() == "":
# Case without additional input
prompt = f"""Below is an instruction that describes a task. Write a response that appropriately completes the request.
### Instruction:
{instruction}
### Response:
"""
else:
# Case with additional input
prompt = f"""Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.
### Instruction:
{instruction}
### Input:
{input_text}
### Response:
"""
# The full text is prompt + output
return {
"prompt": prompt,
"completion": output,
"text": prompt + output
}
# Load data
dataset = load_dataset("json", data_files=DATA_PATH, split="train")
# Split training and validation sets (90% train, 10% validation)
dataset = dataset.train_test_split(test_size=0.1, seed=42)
train_dataset = dataset["train"]
eval_dataset = dataset["test"]
# Format data
train_dataset = train_dataset.map(format_example)
eval_dataset = eval_dataset.map(format_example)
print(f"Training set size: {len(train_dataset)}")
print(f"Validation set size: {len(eval_dataset)}")
# ============================================
# 3. Load Model and Tokenizer
# ============================================
print("Loading model...")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
tokenizer.pad_token = tokenizer.eos_token # Set pad token
tokenizer.padding_side = "right" # Pad from the right
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
use_auth_token=True, # If HuggingFace login is required
)
# Wrap LoRA
model = get_peft_model(model, lora_config)
model.print_trainable_parameters() # Print trainable parameter ratio
# ============================================
# 4. Create trainer and start training
# ============================================
trainer = SFTTrainer(
model=model,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
args=training_args,
tokenizer=tokenizer,
peft_config=lora_config,
dataset_text_field="text", # Which field contains the full text
max_seq_length=2048, # Maximum sequence length
packing=False, # Do not pack samples
)
print("Start training...")
trainer.train()
print("Training complete!")
# ============================================
# 5. Save model
# ============================================
# Save LoRA adapter (small size, a few MB to tens of MB)
model.save_pretrained(f"{OUTPUT_DIR}/lora_adapter")
tokenizer.save_pretrained(f"{OUTPUT_DIR}/lora_adapter")
print(f"LoRA adapter saved to {OUTPUT_DIR}/lora_adapter")
# If you want to merge LoRA weights (optional)
# merged_model = model.merge_and_unload()
# merged_model.save_pretrained(f"{OUTPUT_DIR}/merged_model")
# tokenizer.save_pretrained(f"{OUTPUT_DIR}/merged_model")
# print(f"Merged model saved to {OUTPUT_DIR}/merged_model")
If you want a simpler approach, you can use LLaMA-Factory, which provides both a Web UI and a command-line interface:
Example
# Use LLaMA-Factory for training
# ============================================
# Method 1: Start the Web UI (most intuitive)
cd LLaMA-Factory
python src/train_web.py
# Method 2: Command-line training (suitable for automation)
python src/train.py \
--model_name_or_path mistralai/Mistral-7B-v0.2 \
--dataset example_ecommerce \
--dataset_dir ./data \
--output_dir ./output/example-model \
--finetuning_type lora \
--quantization_bit 4 \
--lora_rank 16 \
--lora_alpha 16 \
--lora_dropout 0.05 \
--lora_target q_proj,v_proj,k_proj,o_proj,gate_proj,up_proj,down_proj \
--per_device_train_batch_size 4 \
--gradient_accumulation_steps 4 \
--lr_scheduler_type cosine \
--logging_steps 10 \
--save_strategy epoch \
--learning_rate 2e-5 \
--num_train_epochs 3 \
--fp16 true \
--plot_loss true
Training Hyperparameter Settings
Choosing hyperparameters is an art, but there are some empirical rules.
Learning Rate: This is the most important hyperparameter. For LoRA, 2e-5 to 5e-5 is a safe starting point. If training is unstable (loss oscillates), decrease it a bit. If training is too slow (loss decreases very slowly), increase it a bit.
Batch Size: The larger the better, but it is limited by VRAM. per_device_train_batch_size can be set to 2, 4, or 8. If VRAM is insufficient, use gradient_accumulation_steps to accumulate gradients. For example, batch_size=2, accumulation_steps=4 is equivalent to batch_size=8.
Epochs: 2-5 epochs is appropriate. Too few and the model won't learn enough; too many may cause overfitting. You can watch the eval loss, and stop when the eval loss stops decreasing.
LoRA Rank (r): 8-64 are all fine. 16 is a good starting point. Use 8 for small tasks, and 32 or 64 for complex tasks.
Max Sequence Length: Decide based on your data. 512, 1024, 2048, 4096 are all common. The longer it is, the more VRAM is required.
| Hyperparameter | Recommended starting value | Adjustment direction |
|---|---|---|
| learning_rate | 2e-5 | If loss oscillates, decrease; if it decreases too slowly, increase |
| per_device_train_batch_size | 4 | If VRAM is sufficient, increase; if insufficient, decrease |
| gradient_accumulation_steps | 4 | Equivalent batch = batch_size × accumulation |
| num_train_epochs | 3 | If overfitting, reduce; if underfitting, increase |
| lora_rank (r) | 16 | Simple tasks 8, complex tasks 32-64 |
| max_seq_length | 1024 or 2048 | Adjust according to your data length |
Training Monitoring and Loss Interpretation
During training, pay attention to a few metrics.
Train Loss: the loss on the training set, should decrease steadily. If it decreases quickly, it means the learning rate may be too large or there is too little data. If it decreases slowly, it means the learning rate is too small or the model capacity is insufficient.
-
Eval Loss: the loss on the validation set, this is the most important metric. It should decrease together with train loss. If eval loss starts to rise while train loss continues to decrease, it means overfitting, and training should be stopped.
Signs of overfitting: eval loss rises after first falling, while train loss keeps decreasing. Solutions: reduce epochs, increase dropout, add data, decrease LoRA rank.
Signs of underfitting: both train loss and eval loss are high, and they decrease slowly. Solutions: increase epochs, increase LoRA rank, add data, slightly increase the learning rate.
Evaluating Fine-tuning Effects
After training, how do you know if the model is good?
Selection of Evaluation Metrics
The evaluation method depends on your task type.
The first is manual evaluation. Have people read the model's output and score its quality. This is the most reliable method, but also the most time-consuming.
You can make a simple scoring table:
Example
# Simple manual evaluation template
# ============================================
evaluation_prompts = [
{
"instruction": "Generate an e-commerce product description",
"input": "Product name: portable coffee machine\nFunction: one-touch extraction, portable design, USB charging",
"reference": "This portable coffee machine lets you enjoy freshly ground coffee anytime, anywhere. One-touch extraction is simple to operate, and the compact, portable design suits office, travel, and outdoor use. The USB charging design means you don't have to worry about power supply issues."
},
{
"instruction": "Generate an e-commerce product description",
"input": "Product name: mechanical keyboard\nFeatures: blue switch, RGB backlight, 87 keys, PBT keycaps",
"reference": "This 87-key mechanical keyboard uses crisp blue switches for an excellent typing feel. Full-key RGB backlighting keeps it clear at night. PBT keycaps are durable and do not develop a shiny coating, staying like new even after long-term use."
},
# ... 10-20 test cases
]
def evaluate_model(model, tokenizer, prompts):
"""Evaluate the model with a set of prompts"""
results = []
for item in prompts:
# Generate output
inputs = format_example_for_inference(item)
output = generate_with_model(model, tokenizer, inputs)
results.append({
"instruction": item["instruction"],
"input": item["input"],
"model_output": output,
"reference": item["reference"],
# Leave blank for manual scoring
"score_relevance": None, # 1-5, relevance
"score_quality": None, # 1-5, quality
"score_format": None, # 1-5, format correctness
})
return results
# Run evaluation
results = evaluate_model(model, tokenizer, evaluation_prompts)
# Save results for manual scoring later
with open("evaluation_results.json", "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(Evaluation results have been saved. Please score manually before analyzing.)
The second is automatic evaluation. Use metrics such as BLEU, ROUGE, and BLEURT. These metrics are not perfect, but they allow for rapid iteration.
The third is A/B testing. Mix the outputs of the old model and the new model together for blind evaluation to see whether the new model is really better.
Preventing Overfitting
Overfitting is the most common problem in fine-tuning. It shows good performance on the training set but poor performance on new data.
Methods to prevent overfitting:
1. Early stopping: Monitor the eval loss, and stop training when it stops decreasing.
2. Add more data: More and more diverse data is the best way to prevent overfitting.
3. Data augmentation: Rewrite existing data to create variation.
4. Reduce model capacity: Decrease the LoRA rank and reduce the number of LoRA layers.
5. Increase dropout: Set lora_dropout in the LoRA configuration to 0.05 or 0.1.
6. Weight decay: Add weight_decay=0.01 to the training parameters.
Deploying Fine-tuned Models
Once the model is trained, how do you use it?
Loading LoRA for Inference
The first way is to load the base model, then load the LoRA adapter.
Example
# Load LoRA and perform inference
# ============================================
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
def load_finetuned_model(base_model_name, lora_adapter_path):
"""Load the base model and LoRA adapter"""
# Load tokenizer
tokenizer = AutoTokenizer.from_pretrained(base_model_name)
tokenizer.pad_token = tokenizer.eos_token
# Load base model
model = AutoModelForCausalLM.from_pretrained(
base_model_name,
device_map="auto",
torch_dtype=torch.float16,
trust_remote_code=True,
)
# Load LoRA adapter
model = PeftModel.from_pretrained(model, lora_adapter_path)
# Merge weights (optional, makes inference faster)
model = model.merge_and_unload()
return model, tokenizer
def generate_product_description(model, tokenizer, product_name, features):
"""Generate e-commerce product description"""
# Construct the prompt (same as during training!)
prompt = f"""Below is an instruction that describes a task. Write a response that appropriately completes the request.
### Instruction:
Generate an e-commerce product description
### Input:
Product name: {product_name}
Features: {features}
### Response:
"""
# Generate
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=200,
temperature=0.7, # Control randomness, 0-1, larger means more random
top_p=0.9,
do_sample=True,
pad_token_id=tokenizer.eos_token_id,
)
# Decode output
generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
# Only take the Response part
response = generated_text.split("### Response:")[-1].strip()
return response
# ============================================
# Usage example
# ============================================
BASE_MODEL = "mistralai/Mistral-7B-v0.2"
LORA_PATH = "output/example-ecommerce-model/lora_adapter"
print("Loading model...")
model, tokenizer = load_finetuned_model(BASE_MODEL, LORA_PATH)
print("Model loaded successfully!")
# Test generation
product_name = "Smart robot vacuum"
features = "Laser navigation, auto recharging, APP control, 2000Pa strong suction"
description = generate_product_description(
model, tokenizer, product_name, features
)
print(f"\nProduct name: {product_name}")
print(fGenerated description:\n{description}")
Deployment Methods
There are several common deployment options.
-
The first is to use vLLM. It is a high-performance inference engine that supports LoRA, continuous batching, and other features. Recommended for production environments.
-
The second is to use Text Generation Inference. HuggingFace's official inference service, feature-rich.
-
The third is to wrap it yourself with FastAPI. Simple and direct, suitable for small-scale deployment.
-
The fourth is to use Ollama. Convert the model to GGUF format and run it with Ollama, suitable for local deployment.