2.1 LoRA, QLoRA, Adapters, BitFit, Prefix Tuning
Chapter 1 taught you how to fine-tune a model by updating all its parameters. That approach works. It’s powerful. But it’s also expensive, slow, and sometimes risky.
Chapter 2 is where you learn how to achieve similar behavioral control while updating only a tiny fraction of the model.
This is where efficiency becomes intelligence.
In the previous chapter, you fine-tuned a model the “classic” way: supervised fine-tuning (SFT) across all parameters. You cleaned the dataset, configured training arguments, monitored memory, and compared outputs.
But here’s a question that naturally follows:
What if you don’t need to update all billions of parameters?
Modern large language models contain millions—or billions—of weights. When you perform full fine-tuning, you modify every one of them. That gives you flexibility, but it also comes with trade-offs:
- High GPU memory requirements
- Longer training time
- Larger checkpoint files
- Greater risk of catastrophic forgetting
- Increased cost
Parameter-Efficient Fine-Tuning (PEFT) methods were designed to solve this problem.
Instead of retraining the entire model, PEFT techniques freeze most of the original weights and introduce small, trainable components that adapt behavior. You get customization with dramatically reduced resource usage.
In practical terms, this means:
- Training large models on consumer GPUs
- Storing lightweight adapters instead of full checkpoints
- Switching between multiple task-specific behaviors easily
- Reducing cost in production
This chapter will explore the most important PEFT methods in depth, starting with the foundational ones that have shaped modern LLM fine-tuning:
- LoRA
- QLoRA
- Adapters
- BitFit
- Prefix tuning
Let’s begin with the method that changed the field: LoRA.
Before diving into code, we need to build intuition about what's happening under the hood.
Think of a large language model as a massive neural network composed of many weight matrices—organized into layers, attention heads, and feed-forward components. Each of these matrices contains millions or billions of numerical parameters that encode the model's learned knowledge.
In full fine-tuning, we adjust all those matrices. Every single parameter is updated during training. This gives us maximum flexibility to reshape the model's behavior, but it also means we're modifying the entire structure—even parts that may already be well-suited to our task.
PEFT methods ask a fundamentally different question:
Can we modify behavior without touching everything?
The answer is yes—and each method does it differently. Some freeze the original weights and add small trainable components. Others modify only specific types of parameters, like biases. Still others introduce learned prompts or prefixes that guide the model's behavior without changing its internal structure at all.
The key insight is this: large models are over-parameterized for most downstream tasks. The knowledge is already there. We don't need to rewrite the entire network—we just need to steer it in the right direction.
This is the philosophy behind parameter-efficient fine-tuning: achieve targeted behavioral control with minimal intervention.
2.1.1 LoRA (Low-Rank Adaptation)
LoRA is arguably the most influential PEFT technique, and understanding why requires looking at both its elegance and its practical impact.
If you’ve ever wanted to fine-tune a strong model but couldn’t justify the compute and memory cost, LoRA exists for exactly that situation.
Core Idea
Instead of updating a full weight matrix ( W ), LoRA keeps ( W ) frozen and learns a low-rank update using two small matrices ( A ) and ( B ):
W′ = W + BA
Here:
- ( W ) stays frozen.
- ( A ) and ( B ) are low-rank matrices.
- Only ( A ) and ( B ) are trained.
The result is that you train only a tiny fraction of the parameters, while still steering the model’s behavior.
In a moment, you’ll implement LoRA in about 10 lines and verify just how few parameters become trainable.
You’ll also see that the overall training workflow stays familiar.
Understanding the Low-Rank Decomposition
To see why this works, zoom in on a single weight matrix.
A typical matrix might be 4096×4096, which is over 16 million parameters. In full fine-tuning, the optimizer must compute and store gradients and optimizer state for every one of them.
LoRA avoids that by representing the change to the matrix as a product of two much smaller matrices. For example, with rank r=8:
- Matrix A might be 4096×8 (32,768 parameters)
- Matrix B might be 8×4096 (32,768 parameters)
Together, that’s 65,536 trainable parameters instead of 16 million.
So why is this small update often enough?
Because for many tasks, the adaptation you need lives in a lower-dimensional subspace than the full parameter space. The base model already contains most of the general language ability and world knowledge. Fine-tuning usually needs to add a smaller, task-specific “tilt” to that behavior.
LoRA’s low-rank constraint forces the model to learn that tilt in a compact form.
LoRA takes a fundamentally different approach. Instead of modifying this massive matrix directly, it represents the update in a compact form.
But why does this work? Why can two tiny matrices capture the adaptation you need?
The answer lies in the mathematical concept of rank. The rank of a matrix represents the dimensionality of the information it contains—essentially, how many independent directions or patterns exist within it.
When we say a matrix has "low rank," we mean that despite having many entries, the actual information content is much more compact. Think of it like compression: a high-resolution image might contain millions of pixels, but much of that information is redundant or can be represented more efficiently.
The key insight is that the changes needed to adapt a pre-trained model to a new task often lie in a much lower-dimensional space than the full parameter space. The model already knows how to process language, understand context, and generate coherent text. What it needs to learn for your specific task is typically a much simpler transformation—a relatively small set of patterns or adjustments.
You don't need to update every connection in the network. You don't need to rewrite 16 million relationships. You just need to learn a compact representation of the necessary adjustments—and that's exactly what the low-rank decomposition provides.
Think of it this way: if the original weight matrix is a complex landscape with millions of features, the low-rank adapter is learning to tilt or shift that landscape in a specific direction. The tilt might be simple (low-dimensional), but its effect propagates across the entire surface (the full matrix).
This is why rank r is typically set to values like 4, 8, 16, or 32—not thousands. Even these small ranks are often sufficient to capture the task-specific adaptations needed, because the intrinsic dimensionality of the adaptation is far smaller than the raw parameter count suggests.
What This Means in Practice
The implications of LoRA's low-rank approach extend far beyond just reducing the number of parameters. Let's examine each dimension in detail:
Memory Usage: Breaking the GPU Barrier
Memory consumption during training doesn't just come from the model weights themselves. The real memory hogs are the optimizer states and gradients that must be maintained for every trainable parameter.
Consider a standard Adam optimizer, which stores two additional values per parameter: the first moment (moving average of gradients) and the second moment (moving average of squared gradients). For a 7B parameter model in 32-bit precision, this means:
- Model weights: ~28GB
- Gradients: ~28GB
- Optimizer states: ~56GB
- Total: Over 100GB of GPU memory
This is why full fine-tuning typically requires multiple high-end GPUs or expensive cloud instances.
With LoRA updating only 0.1-1% of parameters, you might need:
- Original frozen weights: ~28GB (but can be quantized further)
- Adapter weights: ~50MB
- Adapter gradients: ~50MB
- Adapter optimizer states: ~100MB
- Total trainable overhead: ~200MB instead of ~84GB
This 3-10x reduction transforms what's possible. You can fine-tune models that were previously out of reach, experiment more freely, and iterate faster without worrying about running out of memory.
Training Time: Faster Iterations, More Experiments
Training speed improvements come from multiple sources. First, the backward pass computes gradients only for the small adapter matrices, not the entire model. This means less computation per training step.
Second, optimizer updates are applied to far fewer parameters. The optimizer doesn't need to update billions of weights—just the compact adapter layers.
Third, reduced memory pressure often allows for larger batch sizes, which can improve GPU utilization and training stability.
In practice, this translates to:
- 2-5x faster training on the same hardware
- The ability to complete experiments in hours instead of days
- More iterations in the same time budget, leading to better hyperparameter tuning
- Reduced cloud computing costs for teams training on rented infrastructure
The speed advantage compounds over time. When you can run five experiments in the time it previously took to run one, you learn faster and build better models.
Storage Requirements: Democratizing Model Distribution
Storage efficiency has profound implications for how we build and deploy AI systems.
A full fine-tuned checkpoint of a 7B parameter model occupies roughly 13-14GB of disk space. If you want to maintain ten different specialized versions of the model—one for customer support, one for technical documentation, one for creative writing, and so on—you need 130-140GB of storage.
With LoRA, each adapter is typically 10-50MB depending on the rank and number of targeted layers. Ten adapters might require just 100-500MB total. You could store hundreds of task-specific adapters in the space previously occupied by a single full checkpoint.
This changes the economics and architecture of deployment:
- You can load a single base model into GPU memory and swap lightweight adapters dynamically based on the task
- Distributing new capabilities becomes trivial—just ship a tiny adapter file instead of a multi-gigabyte model
- Version control becomes practical—you can track adapter evolution in Git without repository bloat
- A/B testing multiple model variants simultaneously becomes feasible
- Individual users or customers can receive personalized adapters without massive storage overhead
This architectural pattern—one base model with many adapters—mirrors how we think about plugins or extensions in software systems. It's modular, efficient, and scales elegantly.
Yet performance often remains surprisingly close to full fine-tuning.
In many real-world benchmarks, LoRA achieves 95-99% of the performance of full fine-tuning while using a fraction of the resources. For some tasks, it even matches or exceeds full fine-tuning performance, possibly because the low-rank constraint acts as a form of regularization that prevents overfitting.
The Mathematical Elegance
What makes LoRA particularly elegant is that during inference, the learned matrices ( B ) and ( A ) can be multiplied together and added directly to the frozen weights ( W ). This means there's zero computational overhead at inference time—the adapted model runs at exactly the same speed as the original.
Let's break down why this matters so much.
When you deploy a LoRA-adapted model, you have two choices. The first is to keep the adapter separate and apply it dynamically during the forward pass. This works, but it adds a small computational step at each layer where LoRA is applied.
The second option is far more elegant: you can merge the adapter into the base model before deployment.
Remember that the adapter creates an update of the form ( BA ), where ( B ) is an ( m × r ) matrix and ( A ) is an ( r × n ) matrix. When you multiply these together, you get a single ( m × n ) matrix—the same dimensions as the original weight matrix ( W ).
This means you can compute ( W' = W + BA ) once, store the result, and then discard the separate ( A ) and ( B ) matrices entirely. The merged model ( W' ) is identical in structure to the original model. It has the same number of parameters, the same architecture, and requires exactly the same computational operations during inference.
From the perspective of the inference engine, there is no difference between a fully fine-tuned model and a LoRA-adapted model that has been merged. Both are just weight matrices. Both process inputs in exactly the same way.
This is profoundly different from other PEFT methods. Adapter layers, for instance, insert additional neural network modules into the architecture. These modules must be executed during inference, adding latency. Prefix tuning prepends learned embeddings that increase sequence length and attention computation costs.
LoRA gives you the best of both worlds:
- During training: dramatically reduced memory and compute requirements
- During inference: zero overhead, identical speed to the base model
You get all the benefits of specialization with none of the inference cost.
Moreover, the merging process is completely reversible. You can extract the adapter back out by computing ( BA = W' - W ). This means you can dynamically switch between different task-specific versions of the same base model by swapping adapters in and out, all while maintaining the option to merge for production deployment when maximum speed is required.
This mathematical property—that low-rank updates can be seamlessly folded into the original parameters—is what makes LoRA not just efficient, but architecturally beautiful. It respects the structure of the model while providing a clean, composable way to specialize behavior.
2.1.2 Why Low-Rank?
Large weight matrices often contain redundancy. This redundancy exists because not all directions in the high-dimensional parameter space contribute equally to the model's behavior. Many dimensions are correlated or contain overlapping information.
LoRA operates on a key mathematical insight: the behavioral changes needed for task-specific adaptation typically lie in a lower-dimensional subspace than the full parameter space. In other words, you don't need to modify all billions of parameters independently—most of the meaningful adaptation can be captured by a much smaller set of learned patterns.
Think of it this way: imagine you have a massive control panel with millions of knobs, each representing a parameter in your model. Full fine-tuning would require adjusting every single knob individually. But LoRA recognizes that many of these knobs are interconnected—turning one affects others in predictable ways. Instead of touching every knob, you can identify a small number of "master controls" that, when adjusted, produce the desired effect across the entire system.
This is precisely what the low-rank decomposition achieves. Instead of modifying the entire weight matrix directly, LoRA learns two small matrices whose product approximates the necessary update. These matrices have far fewer total parameters, yet they can represent complex, high-dimensional transformations when combined.
The "rank" in low-rank refers to the intrinsic dimensionality of this transformation—how many independent directions of change are actually needed. For many practical tasks, this rank can be surprisingly small (often 4, 8, or 16) because the base model already understands language broadly. What it needs to learn is a specific tilt or adjustment in behavior, not a complete rewrite of its knowledge.
This is not just efficient. It is elegant.
Practical Example: Using LoRA with Hugging Face PEFT
Install PEFT:
pip install peftNow let’s apply LoRA to a model.
from transformers import AutoModelForCausalLM, AutoTokenizerfrom peft import LoraConfig, get_peft_model model_name = "TinyLlama/TinyLlama-1.1B-Chat-v1.0" # 1) Load base model + matching tokenizertokenizer = AutoTokenizer.from_pretrained(model_name)model = AutoModelForCausalLM.from_pretrained(model_name) # 2) Define which parts of the model will receive LoRA adapterslora_config = LoraConfig( r=8, # rank (smaller = fewer trainable params) lora_alpha=16, # scaling factor target_modules=["q_proj", "v_proj"], # common targets in attention lora_dropout=0.1, bias="none", task_type="CAUSAL_LM") # 3) Inject LoRA adapters (base weights remain frozen)model = get_peft_model(model, lora_config) # Recommended during training (especially with gradient checkpointing)model.config.use_cache = False # 4) Sanity check: confirm only LoRA parameters are trainablemodel.print_trainable_parameters() # 5) Save the adapter (tiny artifact) instead of a full model checkpointmodel.save_pretrained("lora_adapter") # Optional but practical: save tokenizer alongside the adaptertokenizer.save_pretrained("lora_adapter")You’ll notice something powerful:
Only a small percentage of parameters are now trainable.
Training proceeds almost exactly like SFT — but you’re only updating adapter layers.
Code Breakdown (What Each Part Does)
AutoTokenizer.from_pretrained(model_name)loads the tokenizer that matches the base model, so your text is encoded the way the model expects.AutoModelForCausalLM.from_pretrained(model_name)loads the base language model without any task-specific adaptation yet.LoraConfig(...)defines the adapter behavior:ris the rank. Smaller values mean fewer trainable parameters.lora_alphais a scaling factor that controls the effective strength of the LoRA update.target_modules=["q_proj", "v_proj"]selects which submodules receive LoRA adapters. In many transformer architectures, these are a strong default because they directly affect attention behavior.lora_dropoutadds dropout to the LoRA path for regularization.bias="none"means you are not training biases, only LoRA weights.task_type="CAUSAL_LM"tells PEFT how to interpret the model’s forward pass for this task.get_peft_model(model, lora_config)wraps the base model and injects LoRA adapters into the specified target modules.model.config.use_cache = Falseis a practical training setting. It avoids cache-related issues when training, especially when you enable gradient checkpointing or work with longer sequences.model.print_trainable_parameters()prints exactly how many parameters are trainable after LoRA injection. This is the quickest sanity check that you are not accidentally fine-tuning the full model.model.save_pretrained("lora_adapter")saves only the adapter weights, not a full model checkpoint.tokenizer.save_pretrained("lora_adapter")saves the tokenizer alongside the adapter so you can reload the setup consistently later.
2.1.3 QLoRA (Quantized LoRA)
LoRA reduced the number of trainable parameters dramatically—often to less than 1% of the total model size. But even with LoRA's efficiency gains, the base model itself still needs to be loaded into memory during training, and for large models (7B, 13B, or 70B parameters), this can quickly exhaust available GPU memory.
QLoRA goes further by addressing this fundamental bottleneck.
Core Idea
QLoRA combines two powerful techniques:
4-bit quantization of the base model
LoRA adapters for training
The innovation is subtle but profound. Instead of storing the base model weights in their original 16-bit or 32-bit floating-point precision, QLoRA compresses them down to just 4 bits per parameter. This means each weight occupies only one-quarter (or one-eighth) of its original memory footprint.
For a 7-billion parameter model, this translates to a reduction from approximately 14GB of VRAM (at 16-bit precision) down to roughly 3.5GB. Suddenly, models that were previously accessible only to those with high-end datacenter GPUs can now be trained on consumer hardware—sometimes even on a single RTX 3090 or 4090.
But quantization alone isn't enough. If you quantize the model, you also need to ensure that training remains stable and effective. This is where the LoRA adapters come in. The base model weights remain frozen in their 4-bit quantized state, while small, full-precision LoRA adapter matrices are trained on top of them.
During the forward pass, the quantized weights are temporarily dequantized to a higher precision (typically 16-bit) for computation, the LoRA updates are applied, and gradients flow back only through the adapter parameters. The base model never changes—it stays quantized and frozen throughout the entire training process.
This hybrid approach preserves the benefits of both techniques: the memory efficiency of quantization and the parameter efficiency of LoRA. The result is that you can fine-tune models with billions of parameters on hardware that would otherwise be completely incapable of holding them in memory, let alone training them.
In practice, this means that training a 7B or even 13B parameter model on a single consumer GPU with 24GB of VRAM becomes not just possible, but practical and efficient.
Example with bitsandbytes (Minimal QLoRA Setup)
import torchfrom transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfigfrom peft import LoraConfig, get_peft_model model_name = "TinyLlama/TinyLlama-1.1B-Chat-v1.0" # 1) Quantization config: store base weights in 4-bit to reduce VRAMbnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_compute_dtype=torch.float16,) # 2) Load base model in 4-bit + matching tokenizer# device_map="auto" places layers on available GPU(s)tokenizer = AutoTokenizer.from_pretrained(model_name)model = AutoModelForCausalLM.from_pretrained( model_name, quantization_config=bnb_config, device_map="auto",) # 3) Add LoRA adapters on top of the quantized (frozen) base modellora_config = LoraConfig( r=8, lora_alpha=16, target_modules=["q_proj", "v_proj"], lora_dropout=0.1, bias="none", task_type="CAUSAL_LM",) model = get_peft_model(model, lora_config) # Recommended during training (especially with gradient checkpointing)model.config.use_cache = False # 4) Sanity check: you should see only adapter parameters are trainablemodel.print_trainable_parameters() # 5) Save only the adapters (not a full model checkpoint)model.save_pretrained("qlora_adapter") tokenizer.save_pretrained("qlora_adapter")Code Breakdown (What Each Part Does)
BitsAndBytesConfig(...)defines how the base model weights are stored and computed:load_in_4bit=Truestores the base model weights in 4-bit, which is the main VRAM savings.bnb_4bit_compute_dtype=torch.float16controls the dtype used for computation during forward passes. The weights are 4-bit on disk/in memory, but computation happens in a higher precision for stability.AutoTokenizer.from_pretrained(model_name)loads the tokenizer that matches the base model.AutoModelForCausalLM.from_pretrained(..., quantization_config=bnb_config, device_map="auto")loads the base model in 4-bit:quantization_config=bnb_configtells Transformers to use bitsandbytes quantization.device_map="auto"automatically places the model on your GPU(s). This is helpful when your GPU memory is tight.LoraConfig(...)defines the trainable adapters that sit on top of the frozen, quantized base model:randlora_alphacontrol adapter capacity and scaling.target_modules=["q_proj", "v_proj"]applies LoRA to attention projections, a common and effective default.lora_dropoutadds regularization.bias="none"keeps biases frozen.task_type="CAUSAL_LM"matches decoder-only language modeling.get_peft_model(model, lora_config)injects LoRA adapters into the chosen target modules. This is the “LoRA part” of QLoRA.model.config.use_cache = Falseis a practical training setting that avoids cache-related issues during fine-tuning.model.print_trainable_parameters()confirms that training will update only the small adapter matrices.model.save_pretrained("qlora_adapter")saves only the adapter weights, which keeps artifacts small.tokenizer.save_pretrained("qlora_adapter")saves the tokenizer alongside the adapter for consistent reload later.
QLoRA is often the most practical approach for serious fine-tuning on limited hardware.
2.1.4 Adapters
Adapters were one of the earliest parameter-efficient fine-tuning techniques introduced in the research literature, predating methods like LoRA by several years. Despite being older, they remain conceptually important and are still used in production systems today, particularly in scenarios where modularity and interpretability are valued.
Core Idea
The adapter approach works by inserting small, trainable neural network layers—often called "bottleneck" layers—inside each transformer block of the model. These bottleneck layers are typically implemented as two-layer feed-forward networks with a down-projection (reducing dimensionality), a non-linearity, and an up-projection (restoring dimensionality).
Critically, the original pre-trained weights of the model remain completely frozen. Only these newly inserted adapter layers are trained during fine-tuning. This means that the base model's knowledge is preserved, while the adapters learn task-specific transformations that modify the model's behavior.
Unlike LoRA, which modifies attention matrices through low-rank decomposition, adapters add new learnable bottleneck layers that process the hidden states at various points in the network. This architectural difference gives adapters a distinct profile: they are often slightly more expressive but also add a small amount of inference overhead since they introduce additional forward-pass computations.
In practice, adapters are inserted after the multi-head attention and feed-forward sub-layers within each transformer block. During training, gradients flow through these adapter layers while the surrounding weights stay fixed. After training, you can save only the adapter parameters—typically just a few megabytes—and load them on top of the base model whenever you need that specific task behavior.
Advantages:
- Clean modularity: Each task gets its own adapter, making it easy to maintain multiple fine-tuned versions of the same base model without duplicating the entire model weights.
- Easy task switching: You can swap adapters at runtime to switch between tasks instantly, which is valuable in multi-task or multi-tenant systems.
- Stable training: Because adapters are small and inserted in a structured way, training tends to be stable and predictable, even with aggressive learning rates.
Adapters typically increase the total model size slightly more than LoRA—often by 1-5% of the base model size—but they still remain highly efficient compared to full fine-tuning. The trade-off is that adapters may introduce a small latency increase during inference, though this is usually negligible for most applications.
Practical Example: Adapter Setup with Adapter-Transformers
Install adapter-transformers (AdapterHub’s extension of Transformers):
pip install -U adapter-transformersfrom transformers import AutoTokenizerfrom transformers.adapters import AutoAdapterModel model_name = "TinyLlama/TinyLlama-1.1B-Chat-v1.0" # 1) Load an adapter-compatible model + tokenizertokenizer = AutoTokenizer.from_pretrained(model_name)model = AutoAdapterModel.from_pretrained(model_name) # 2) Add a new task adapter (bottleneck layers) to the model# The adapter is *new* and trainable; the base model stays frozen.adapter_name = "my_task_adapter"model.add_adapter(adapter_name) # 3) Activate and train only the adaptermodel.train_adapter(adapter_name)model.set_active_adapters(adapter_name) # 4) Save only the adapter weights (small artifact)model.save_adapter("adapter_ckpt", adapter_name) tokenizer.save_pretrained("adapter_ckpt")Code Breakdown (What Each Part Does)
pip install -U adapter-transformersinstalls a Transformers-compatible library that adds adapter support (adding, activating, training, and saving adapters).AutoAdapterModel.from_pretrained(model_name)loads a version of the model that can host adapters.- Conceptually, this is still your base model.
- The difference is that it knows how to insert adapter modules into its transformer blocks.
model.add_adapter(adapter_name)creates a new adapter.- The adapter is usually a small bottleneck MLP inserted at specific points inside each transformer layer.
- These new adapter parameters start randomly initialized.
model.train_adapter(adapter_name)freezes the base model weights and marks the adapter weights as trainable.- This is the key “PEFT switch.” You are not fine-tuning the full model.
model.set_active_adapters(adapter_name)tells the model which adapter to use in the forward pass.- This is what makes task switching easy: you can activate a different adapter without reloading the base model.
model.save_adapter("adapter_ckpt", adapter_name)saves only the adapter weights.- This keeps checkpoints small.
- You can later load this adapter into the same base model to recover the task-specific behavior.
tokenizer.save_pretrained("adapter_ckpt")saves the tokenizer alongside the adapter so your inference setup stays consistent.
2.1.5 BitFit
BitFit is radically simple, yet surprisingly effective—a method that challenges assumptions about how much complexity is needed to adapt a large language model.
Core Idea
Freeze all weights except bias terms.
That's it.
You only update the bias parameters—the small additive constants found throughout the network in linear layers, attention mechanisms, and normalization layers.
This dramatically reduces trainable parameters—sometimes below 0.1% of total weights. For a 7B parameter model, you might train fewer than 7 million parameters. For a 13B model, perhaps 10-15 million. The base model stays completely frozen, while these tiny bias terms absorb all the task-specific learning.
BitFit works surprisingly well for some tasks, though it generally provides smaller behavioral shifts than LoRA or adapters. It's particularly effective for tasks that require subtle calibration rather than dramatic behavioral changes—think classification, sentiment analysis, or light stylistic adjustments.
The method is named "BitFit" because you're fitting only the bias terms, but the name also evokes the idea of making "bit-sized" adjustments to a model—small changes with disproportionate impact.
Conceptually, it teaches an important lesson:
Even tiny changes in a massive model can produce measurable adaptation. The model's pre-trained weights already encode rich representations. Bias terms act as lightweight steering mechanisms that nudge these representations toward task-specific behavior without rewriting the underlying knowledge.
In practice, BitFit is often used as a baseline or fallback method. It's fast to train, trivial to implement, and requires almost no memory overhead. If your task is simple or your resources are extremely constrained, BitFit can be a pragmatic starting point before exploring more sophisticated PEFT methods.
Practical Example: BitFit (Train Biases Only)
from transformers import AutoModelForCausalLM, AutoTokenizer model_name = "TinyLlama/TinyLlama-1.1B-Chat-v1.0" # 1) Load base model + matching tokenizertokenizer = AutoTokenizer.from_pretrained(model_name)model = AutoModelForCausalLM.from_pretrained(model_name) # 2) Freeze everythingfor param in model.parameters(): param.requires_grad = False # 3) Unfreeze only bias termsfor name, param in model.named_parameters(): if name.endswith("bias"): param.requires_grad = True # 4) Sanity check: count trainable parameterstrainable = sum(p.numel() for p in model.parameters() if p.requires_grad)total = sum(p.numel() for p in model.parameters())print(f"Trainable params: {trainable:,} / {total:,} ({100 * trainable / total:.4f}%)") # 5) From here, you can use the same Trainer/SFT loop as in full fine-tuning,# but only the bias parameters will update. # Tip: BitFit usually produces a *full* model checkpoint (not a tiny adapter file).# After training:# model.save_pretrained("bitfit_ckpt")# tokenizer.save_pretrained("bitfit_ckpt")Code Breakdown (What Each Part Does)
AutoTokenizer.from_pretrained(model_name)loads the tokenizer that matches the base model.AutoModelForCausalLM.from_pretrained(model_name)loads the base model.- The first loop sets
requires_grad = Falsefor all parameters. - This ensures you are not accidentally doing full fine-tuning.
- The second loop selectively re-enables gradients only for parameters whose name ends with
"bias". - In most Transformer implementations, these are the bias terms inside linear layers and related components.
- This is the entire BitFit idea in code.
- The trainable-parameter count is a simple sanity check.
- You should see a very small percentage, often well below 0.1% for large models.
- Training is otherwise unchanged.
- You can reuse the same loss, optimizer, and training loop.
- The only difference is which parameters receive gradient updates.
- Saving is typically done as a full checkpoint (
save_pretrained). - Unlike LoRA/adapters, BitFit modifies weights inside the model (bias tensors), so you usually store the fine-tuned model weights rather than a separate adapter artifact.
2.1.6 Prefix Tuning
Prefix tuning takes a fundamentally different approach to parameter-efficient fine-tuning compared to methods like LoRA or adapters.
Instead of modifying the internal weights of the model—whether through low-rank updates, adapter layers, or bias terms—prefix tuning leaves the entire base model completely untouched. Instead, it prepends a small sequence of learned "virtual tokens" to the input that the model processes.
These aren't real tokens in the vocabulary sense. They're continuous embeddings—learned vectors that exist in the same space as word embeddings but represent abstract task-specific context rather than discrete words.
Core Idea
The core mechanism is elegant: learn a small set of continuous prefix embeddings that condition the model's behavior for a specific task.
During training, these prefix embeddings are optimized to encode task-relevant information. When the model processes a sequence, it first "sees" these learned prefixes, which influence how it interprets and generates the rest of the sequence. The prefix acts as a soft prompt that steers the model's attention patterns and hidden representations without altering any of the model's billions of parameters.
Think of it as giving the model a persistent, learned instruction at the beginning of every input—but instead of using natural language, you're optimizing the instruction directly in embedding space, which can be more expressive and compact than discrete tokens.
Critically, no internal weights are modified. The entire base model remains frozen. Only the prefix embeddings are trained, which typically amounts to a few thousand to a few hundred thousand parameters depending on prefix length and model dimension.
In practice, the prefix is usually implemented as trainable embeddings prepended to the key and value vectors in the attention mechanism across multiple layers, rather than just prepending to the input sequence. This gives the prefix deeper influence throughout the model's computation.
Advantages:
- Extremely lightweight: The trainable parameter count is often less than 0.1% of the base model, sometimes just a few megabytes.
- Easy to swap between tasks: Since each task is just a different set of prefix embeddings, you can instantly switch between tasks by loading different prefixes without touching the base model.
- Minimal memory footprint: Training only requires gradients for the prefix parameters, dramatically reducing memory overhead during fine-tuning.
- Preserves base model integrity: Because the model itself is never modified, there's zero risk of catastrophic forgetting or degrading the base model's general capabilities.
Limitations:
- Sometimes less expressive than LoRA: Because the prefix only influences the model indirectly through attention mechanisms, it may struggle with tasks that require more fundamental behavioral changes. LoRA's ability to modify attention and feed-forward weights directly can capture more complex adaptations.
- Prefix length tuning required: Finding the optimal prefix length can require experimentation—too short and you lose expressiveness, too long and you waste parameters and context window space.
- Less intuitive to debug: Unlike LoRA's low-rank updates or adapters' explicit bottleneck layers, prefix embeddings operate in abstract embedding space, making it harder to interpret what the prefix has "learned."
Practical Example: Prefix Tuning with Hugging Face PEFT
from transformers import AutoModelForCausalLM, AutoTokenizerfrom peft import PrefixTuningConfig, TaskType, get_peft_model model_name = "TinyLlama/TinyLlama-1.1B-Chat-v1.0" # 1) Load base model + matching tokenizertokenizer = AutoTokenizer.from_pretrained(model_name)model = AutoModelForCausalLM.from_pretrained(model_name) # 2) Configure prefix tuning (train only a small set of virtual tokens)prefix_config = PrefixTuningConfig( task_type=TaskType.CAUSAL_LM, num_virtual_tokens=20,) # 3) Inject the prefix parameters (base model stays frozen)model = get_peft_model(model, prefix_config) # Recommended during training (especially with gradient checkpointing)model.config.use_cache = False # 4) Sanity check: confirm only prefix parameters are trainablemodel.print_trainable_parameters() # 5) Save the prefix tuning parameters (small artifact)model.save_pretrained("prefix_adapter") tokenizer.save_pretrained("prefix_adapter")Code Breakdown (What Each Part Does)
PrefixTuningConfig(...)defines what you will train.task_type=TaskType.CAUSAL_LMtells PEFT this is a decoder-only language modeling setup.num_virtual_tokens=20controls how many learned prefix embeddings the model will use.- These are not vocabulary tokens.
- They are trainable vectors in embedding space.
- More virtual tokens usually means more capacity, but also more parameters and (sometimes) more compute.
get_peft_model(model, prefix_config)injects the prefix-tuning parameters into the model.- The base model weights remain frozen.
- Only the prefix parameters will receive gradients.
model.print_trainable_parameters()is the quickest sanity check that you are not doing full fine-tuning.model.save_pretrained("prefix_adapter")saves the prefix parameters as a small artifact.- Like LoRA and QLoRA, this is typically much smaller than a full checkpoint.
tokenizer.save_pretrained("prefix_adapter")saves the tokenizer alongside the prefix adapter so you can reload everything consistently.
2.1.7 Comparing the Methods
Each method is a trade-off between resource usage, behavioral control, inference cost, and operational simplicity.
The table above is the fast summary. Here is a practical way to choose.
A Simple Decision Lens
- How much can you change the model?
- Most control: Full SFT
- Strong control with small updates: LoRA, QLoRA, Adapters
- Lightweight steering: BitFit, Prefix tuning
- Do you need fast inference and minimal overhead?
- LoRA can often be merged into the base weights for zero extra inference cost.
- Adapters add small extra layers, so they add a bit of inference latency.
- Prefix tuning changes the attention context, which can add some overhead depending on how it is implemented.
- Do you need modular deployment (many task variants)?
- LoRA, QLoRA, and Prefix tuning typically save small artifacts you can swap in and out.
- BitFit is simple to train, but it is often saved as a full checkpoint in common workflows.
Choosing the Right Method (Quick Rules)
- If you are not sure where to start, start with LoRA.
- If the model does not fit in your GPU memory, try QLoRA.
- If you need clear architectural separation and easy task switching, consider Adapters.
- If you want the simplest possible baseline, try BitFit.
- If you want a “soft prompt” approach that does not change model weights, try Prefix tuning.
2.1.8 The Big Insight
Parameter-efficient fine-tuning is not about cutting corners. It’s about recognizing that a pre-trained model already has most of what you need.
In many projects, fine-tuning is less about “teaching language” and more about steering behavior.
You don’t need to rebuild the brain.
You only need to adjust the behavior.
That is why PEFT is so useful. By updating a small fraction of parameters, you can often:
- Train faster
- Reduce cost
- Run more experiments
- Maintain multiple task-specific variants without duplicating the full base model
A Practical Default Path
- Start with LoRA.
- If the model does not fit in memory, move to QLoRA.
- If you need the simplest baseline, try BitFit.
In the next section, we’ll move from theory to practice and apply these methods using Hugging Face’s PEFT and TRL libraries in real workflows.
What’s Next (Section 2.2)
- Choose a base model and a small training dataset.
- Apply LoRA (or QLoRA if memory is tight) using PEFT.
- Fine-tune using TRL’s trainer so the workflow feels familiar.
- Save the adapter.
- Reload it and compare outputs before vs after fine-tuning.