DEV Community

Phil IPS
Phil IPS

Posted on

Fine-Tuning LLMs with LoRA & QLoRA: Complete Production Guide

Fine-Tuning LLMs with LoRA & QLoRA: Complete Production Guide

Master parameter-efficient fine-tuning — from theory to production deployment with LoRA, QLoRA, and advanced techniques.

Introduction

Fine-tuning adapts pre-trained LLMs to specific tasks. Full fine-tuning updates all parameters — expensive, requires massive compute. Parameter-Efficient Fine-Tuning (PEFT) updates <1% of parameters — fast, cheap, nearly same performance.

This guide covers LoRA (Low-Rank Adaptation) and QLoRA (Quantized LoRA) with production-ready code.

LoRA Theory

Original weights: W ∈ ℝ^(d×k)
LoRA adapters:    A ∈ ℝ^(d×r), B ∈ ℝ^(r×k) where r << min(d,k)
Forward pass:     h = Wx + BAx = (W + BA)x
Enter fullscreen mode Exit fullscreen mode

Key insight: ΔW = BA has rank r, so we only train A and B.

# peft/lora_theory.py
import torch
import torch.nn as nn
from typing import Optional

class LoRALinear(nn.Module):
    """LoRA-adapted linear layer."""

    def __init__(
        self,
        in_features: int,
        out_features: int,
        r: int = 16,
        alpha: int = 32,
        dropout: float = 0.1,
        bias: bool = False
    ):
        super().__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.r = r
        self.alpha = alpha
        self.scaling = alpha / r

        # Original frozen weights
        self.weight = nn.Parameter(torch.empty(out_features, in_features))
        self.weight.requires_grad = False

        # LoRA adapters
        self.lora_A = nn.Parameter(torch.empty(r, in_features))
        self.lora_B = nn.Parameter(torch.empty(out_features, r))
        self.dropout = nn.Dropout(dropout)

        if bias:
            self.bias = nn.Parameter(torch.empty(out_features))
        else:
            self.register_parameter('bias', None)

        self.reset_parameters()

    def reset_parameters(self):
        nn.init.kaiming_uniform_(self.weight, a=5**0.5)
        nn.init.kaiming_uniform_(self.lora_A, a=5**0.5)
        nn.init.zeros_(self.lora_B)
        if self.bias is not None:
            nn.init.zeros_(self.bias)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Base model forward (frozen)
        base_output = nn.functional.linear(x, self.weight, self.bias)

        # LoRA forward (trainable)
        lora_output = (self.dropout(x) @ self.lora_A.T @ self.lora_B.T) * self.scaling

        return base_output + lora_output

    def merge_weights(self):
        """Merge LoRA into base weights for inference."""
        if self.r > 0:
            delta_w = (self.lora_B @ self.lora_A) * self.scaling
            self.weight.data += delta_w
            self.lora_A.data.zero_()
            self.lora_B.data.zero_()

def apply_lora_to_model(
    model: nn.Module,
    target_modules: list = None,
    r: int = 16,
    alpha: int = 32,
    dropout: float = 0.1
) -> nn.Module:
    """Apply LoRA to target modules (e.g., q_proj, v_proj)."""
    if target_modules is None:
        target_modules = ["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]

    for name, module in model.named_modules():
        if any(target in name for target in target_modules) and isinstance(module, nn.Linear):
            parent_name = ".".join(name.split(".")[:-1])
            child_name = name.split(".")[-1]
            parent = model.get_submodule(parent_name) if parent_name else model

            lora_layer = LoRALinear(
                module.in_features,
                module.out_features,
                r=r,
                alpha=alpha,
                dropout=dropout,
                bias=module.bias is not None
            )
            lora_layer.weight.data = module.weight.data.clone()
            if module.bias is not None:
                lora_layer.bias.data = module.bias.data.clone()

            setattr(parent, child_name, lora_layer)

    return model
Enter fullscreen mode Exit fullscreen mode

Part 2: QLoRA — 4-bit Quantization + LoRA

QLoRA combines 4-bit NF4 quantization with LoRA for massive memory savings.

# peft/qlora.py
import torch
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

def load_qlora_model(
    model_id: str,
    r: int = 64,
    alpha: int = 16,
    dropout: float = 0.1,
    target_modules: list = None,
    device_map: str = "auto"
):
    """Load model with 4-bit quantization + LoRA."""

    # 4-bit NF4 quantization config
    bnb_config = BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_compute_dtype=torch.bfloat16,
        bnb_4bit_use_double_quant=True,
    )

    # Load base model
    model = AutoModelForCausalLM.from_pretrained(
        model_id,
        quantization_config=bnb_config,
        device_map=device_map,
        torch_dtype=torch.bfloat16,
        trust_remote_code=True,
    )

    # Prepare for k-bit training
    model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)

    # LoRA config
    if target_modules is None:
        target_modules = ["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]

    lora_config = LoraConfig(
        r=r,
        lora_alpha=alpha,
        lora_dropout=dropout,
        target_modules=target_modules,
        bias="none",
        task_type="CAUSAL_LM",
        modules_to_save=None,
    )

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

    return model
Enter fullscreen mode Exit fullscreen mode

NF4 Quantization Details

NormalFloat4 (NF4) — optimal for normally distributed weights

Standard int4:     [-8, -7, ..., 7] uniform spacing
NF4:               16 quantiles of N(0,1) — more precision near 0

Double Quantization: Quantize the quantization constants (saves 0.37 bits/param)
Enter fullscreen mode Exit fullscreen mode
# peft/nf4_details.py
NF4_QUANTILES = torch.tensor([
    -1.0, -0.6961928, -0.525073, -0.394917, -0.284441, -0.184773,
    -0.091050, 0.0, 0.079580, 0.160930, 0.246112, 0.337915,
    0.440709, 0.562617, 0.722957, 1.0
])

def quantize_nf4(tensor: torch.Tensor) -> tuple:
    """Quantize to NF4 with double quantization."""
    # Block-wise quantization (block_size=64)
    block_size = 64
    original_shape = tensor.shape
    tensor = tensor.view(-1, block_size)

    # Compute absmax per block
    absmax = tensor.abs().max(dim=1, keepdim=True).values
    absmax = absmax.clamp(min=1e-8)

    # Normalize
    normalized = tensor / absmax

    # Quantize to NF4
    distances = (normalized.unsqueeze(-1) - NF4_QUANTILES.to(tensor.device)).abs()
    quantized = distances.argmin(dim=-1).to(torch.uint8)

    # Double quantization: quantize absmax
    absmax_quantized = quantize_absmax(absmax)

    return quantized, absmax_quantized, original_shape

def dequantize_nf4(quantized, absmax_quantized, shape):
    """Dequantize NF4 back to float."""
    # Implementation for inference
    pass
Enter fullscreen mode Exit fullscreen mode

Part 3: Training Pipeline

# peft/training.py
from transformers import TrainingArguments, Trainer
from peft import PeftModel
from datasets import load_dataset
import torch

def create_training_args(
    output_dir: str,
    num_epochs: int = 3,
    batch_size: int = 4,
    gradient_accumulation: int = 4,
    learning_rate: float = 2e-4,
    warmup_ratio: float = 0.03,
    max_grad_norm: float = 0.3,
    logging_steps: int = 10,
    save_steps: int = 100,
    eval_steps: int = 100,
    fp16: bool = False,
    bf16: bool = True,
    gradient_checkpointing: bool = True,
    optim: str = "paged_adamw_32bit",
    lr_scheduler_type: str = "cosine",
) -> TrainingArguments:
    """Production-ready training arguments."""

    return TrainingArguments(
        output_dir=output_dir,
        num_train_epochs=num_epochs,
        per_device_train_batch_size=batch_size,
        per_device_eval_batch_size=batch_size,
        gradient_accumulation_steps=gradient_accumulation,
        learning_rate=learning_rate,
        warmup_ratio=warmup_ratio,
        max_grad_norm=max_grad_norm,
        logging_steps=logging_steps,
        save_steps=save_steps,
        eval_steps=eval_steps,
        evaluation_strategy="steps",
        save_strategy="steps",
        load_best_model_at_end=True,
        metric_for_best_model="eval_loss",
        greater_is_better=False,
        fp16=fp16,
        bf16=bf16,
        gradient_checkpointing=gradient_checkpointing,
        optim=optim,
        lr_scheduler_type=lr_scheduler_type,
        report_to="wandb",
        run_name=f"lora-{model_id.split('/')[-1]}",
        ddp_find_unused_parameters=False,
        remove_unused_columns=False,
    )

def format_dataset(example, tokenizer, max_length=2048):
    """Format for instruction tuning."""
    # Alpaca format
    prompt = f"""### Instruction:
{example['instruction']}

### Input:
{example.get('input', '')}

### Response:
{example['output']}"""

    tokenized = tokenizer(
        prompt,
        truncation=True,
        max_length=max_length,
        padding=False,
        return_tensors=None,
    )

    # Labels = input_ids for causal LM
    tokenized["labels"] = tokenized["input_ids"].copy()
    return tokenized

def train_lora(
    model,
    tokenizer,
    train_dataset,
    eval_dataset,
    training_args: TrainingArguments,
):
    """Train LoRA adapters."""

    trainer = Trainer(
        model=model,
        args=training_args,
        train_dataset=train_dataset,
        eval_dataset=eval_dataset,
        tokenizer=tokenizer,
        data_collator=lambda x: {
            "input_ids": torch.nn.utils.rnn.pad_sequence(
                [torch.tensor(i["input_ids"]) for i in x],
                batch_first=True,
                padding_value=tokenizer.pad_token_id
            ),
            "attention_mask": torch.nn.utils.rnn.pad_sequence(
                [torch.tensor(i["attention_mask"]) for i in x],
                batch_first=True,
                padding_value=0
            ),
            "labels": torch.nn.utils.rnn.pad_sequence(
                [torch.tensor(i["labels"]) for i in x],
                batch_first=True,
                padding_value=-100
            ),
        },
    )

    trainer.train()
    return trainer

# Usage
if __name__ == "__main__":
    model_id = "meta-llama/Meta-Llama-3-8B"

    model = load_qlora_model(model_id)
    tokenizer = AutoTokenizer.from_pretrained(model_id)
    tokenizer.pad_token = tokenizer.eos_token

    dataset = load_dataset("tatsu-lab/alpaca", split="train")
    dataset = dataset.map(lambda x: format_dataset(x, tokenizer))

    train_size = int(0.9 * len(dataset))
    train_dataset = dataset.select(range(train_size))
    eval_dataset = dataset.select(range(train_size, len(dataset)))

    args = create_training_args("./lora-llama3-8b-alpaca")
    trainer = train_lora(model, tokenizer, train_dataset, eval_dataset, args)

    # Save LoRA adapters only
    trainer.model.save_pretrained("./lora-adapters")
    tokenizer.save_pretrained("./lora-adapters")
Enter fullscreen mode Exit fullscreen mode

Part 4: Advanced LoRA Variants

AdaLoRA — Adaptive Rank Allocation

# peft/adalora.py
from peft import AdaLoraConfig, get_peft_model

def create_adalora_model(model, target_modules=None):
    """AdaLoRA: dynamically allocates rank per layer."""

    config = AdaLoraConfig(
        init_r=12,
        target_r=8,
        beta1=0.85,
        beta2=0.85,
        tinit=200,
        tfinal=1000,
        deltaT=10,
        lora_alpha=32,
        lora_dropout=0.1,
        target_modules=target_modules or ["q_proj", "v_proj"],
        task_type="CAUSAL_LM",
    )

    return get_peft_model(model, config)
Enter fullscreen mode Exit fullscreen mode

LoRA+ — Different Learning Rates

# peft/lora_plus.py
from torch.optim import AdamW

def create_lora_plus_optimizer(model, lr_A=1e-4, lr_B=1e-3, weight_decay=0.01):
    """LoRA+: higher LR for B matrix (closer to output)."""

    params_A = []
    params_B = []
    params_other = []

    for name, param in model.named_parameters():
        if "lora_A" in name:
            params_A.append(param)
        elif "lora_B" in name:
            params_B.append(param)
        else:
            params_other.append(param)

    return AdamW([
        {"params": params_A, "lr": lr_A, "weight_decay": weight_decay},
        {"params": params_B, "lr": lr_B, "weight_decay": weight_decay},
        {"params": params_other, "lr": lr_A, "weight_decay": weight_decay},
    ])

# Or use PEFT's built-in
from peft import LoraConfig
config = LoraConfig(
    lora_alpha=32,
    lora_dropout=0.1,
    target_modules=["q_proj", "v_proj"],
    use_lora_plus=True,  # Enables LoRA+
    lora_plus_scale=16,  # lr_B = lr_A * scale
)
Enter fullscreen mode Exit fullscreen mode

DoRA — Weight-Decomposed LoRA

# peft/dora.py
from peft import LoraConfig

# DoRA decomposes weight into magnitude + direction
# magnitude: ||W||_2 (scalar per output neuron)
# direction: W / ||W||_2 (unit vector)
# LoRA adapts direction only

config = LoraConfig(
    r=64,
    lora_alpha=16,
    use_dora=True,  # Enable DoRA
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
    task_type="CAUSAL_LM",
)
Enter fullscreen mode Exit fullscreen mode

Part 5: Multi-Task & Continual Learning

Multi-LoRA (Multiple Adapters)

# peft/multi_lora.py
from peft import PeftModel, LoraConfig

class MultiLoRAModel:
    """Switch between multiple LoRA adapters at inference."""

    def __init__(self, base_model, adapter_configs: dict):
        self.base_model = base_model
        self.adapters = {}
        self.active_adapter = None

        for name, config in adapter_configs.items():
            self.add_adapter(name, config)

    def add_adapter(self, name: str, config: LoraConfig):
        self.base_model.add_adapter(config, adapter_name=name)
        self.adapters[name] = config

    def set_adapter(self, name: str):
        self.base_model.set_adapter(name)
        self.active_adapter = name

    def disable_adapter(self):
        self.base_model.disable_adapter()
        self.active_adapter = None

    def merge_adapter(self, name: str):
        self.base_model.merge_adapter(name)

    def unmerge_adapter(self):
        self.base_model.unmerge_adapter()

# Usage
base_model = AutoModelForCausalLM.from_pretrained("meta-llama/Meta-Llama-3-8B")
multi_lora = MultiLoRAModel(base_model, {
    "coding": LoraConfig(r=32, target_modules=["q_proj", "v_proj"]),
    "chat": LoraConfig(r=16, target_modules=["q_proj", "v_proj"]),
    "sql": LoraConfig(r=8, target_modules=["q_proj", "v_proj"]),
})

multi_lora.set_adapter("coding")
output = multi_lora.base_model.generate(...)

multi_lora.set_adapter("chat")
output = multi_lora.base_model.generate(...)
Enter fullscreen mode Exit fullscreen mode

Continual Learning with LoRA

# peft/continual.py
class ContinualLoRA:
    """Add new LoRA adapters for new tasks without forgetting."""

    def __init__(self, base_model):
        self.base_model = base_model
        self.task_adapters = {}

    def learn_task(self, task_name: str, train_data, config: LoraConfig):
        # Freeze existing adapters
        for name in self.task_adapters:
            self.base_model.set_adapter(name)
            for param in self.base_model.parameters():
                param.requires_grad = False

        # Add new adapter
        self.base_model.add_adapter(config, adapter_name=task_name)
        self.base_model.set_adapter(task_name)

        # Train only new adapter
        self._train_adapter(train_data)

        self.task_adapters[task_name] = config

    def infer(self, task_name: str, inputs):
        self.base_model.set_adapter(task_name)
        return self.base_model.generate(inputs)

    def merge_all(self):
        """Merge all adapters into base weights."""
        for name in self.task_adapters:
            self.base_model.merge_adapter(name)
Enter fullscreen mode Exit fullscreen mode

Part 6: Inference & Deployment

Merging for Fast Inference

# peft/inference.py
from peft import PeftModel

def merge_and_save(base_model_id: str, adapter_path: str, output_path: str):
    """Merge LoRA into base model for deployment."""

    base_model = AutoModelForCausalLM.from_pretrained(
        base_model_id,
        torch_dtype=torch.float16,
        device_map="auto",
    )

    model = PeftModel.from_pretrained(base_model, adapter_path)
    model = model.merge_and_unload()  # Merges weights, removes PEFT

    model.save_pretrained(output_path, safe_serialization=True)
    tokenizer = AutoTokenizer.from_pretrained(base_model_id)
    tokenizer.save_pretrained(output_path)

# For QLoRA models — need to load in 4-bit first
def merge_qlora(base_model_id: str, adapter_path: str, output_path: str):
    bnb_config = BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_compute_dtype=torch.bfloat16,
    )

    base_model = AutoModelForCausalLM.from_pretrained(
        base_model_id,
        quantization_config=bnb_config,
        device_map="auto",
    )

    model = PeftModel.from_pretrained(base_model, adapter_path)
    model = model.merge_and_unload()

    # Save in float16 for deployment
    model.save_pretrained(output_path, safe_serialization=True)
Enter fullscreen mode Exit fullscreen mode

vLLM / TGI Deployment

# peft/deployment.py
# vLLM supports LoRA adapters natively!
from vllm import LLM, SamplingParams
from vllm.lora.request import LoRARequest

def serve_with_lora(base_model_path: str, adapter_path: str):
    """Serve base model + LoRA with vLLM."""

    llm = LLM(
        model=base_model_path,
        enable_lora=True,
        max_lora_rank=64,
        max_loras=10,  # Max concurrent adapters
        gpu_memory_utilization=0.9,
    )

    sampling_params = SamplingParams(
        temperature=0.7,
        top_p=0.9,
        max_tokens=512,
    )

    # Load adapter
    lora_request = LoRARequest("my_adapter", 1, adapter_path)

    outputs = llm.generate(
        ["Write a Python function for fibonacci:"],
        sampling_params,
        lora_request=lora_request,
    )

    return outputs[0].outputs[0].text

# Multiple adapters simultaneously
def serve_multi_lora(base_model_path: str, adapters: dict):
    """Serve multiple LoRAs, select per-request."""
    llm = LLM(model=base_model_path, enable_lora=True, max_loras=len(adapters))

    lora_requests = {
        name: LoRARequest(name, i+1, path)
        for i, (name, path) in enumerate(adapters.items())
    }

    def generate(prompt: str, adapter_name: str):
        return llm.generate(
            [prompt],
            SamplingParams(temperature=0.7, max_tokens=512),
            lora_request=lora_requests[adapter_name],
        )[0].outputs[0].text

    return generate
Enter fullscreen mode Exit fullscreen mode

Part 7: Hyperparameter Guide

LoRA Hyperparameters

Parameter Small Model (7B) Large Model (70B) Notes
r (rank) 16-64 64-128 Higher = more capacity
alpha 16-32 16-32 Scaling factor
dropout 0.05-0.1 0.1-0.2 Regularization
target_modules q,v q,k,v,o,gate,up,down More modules = more params
learning_rate 2e-4 1e-4 Lower for larger models

Optimal Configs by Use Case

# peft/configs.py
LORA_CONFIGS = {
    "general_chat": {
        "r": 32, "alpha": 16, "dropout": 0.1,
        "target_modules": ["q_proj", "v_proj", "k_proj", "o_proj"],
        "lr": 2e-4, "epochs": 3,
    },
    "coding": {
        "r": 64, "alpha": 16, "dropout": 0.05,
        "target_modules": ["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
        "lr": 1e-4, "epochs": 2,
    },
    "sql_generation": {
        "r": 16, "alpha": 32, "dropout": 0.1,
        "target_modules": ["q_proj", "v_proj"],
        "lr": 3e-4, "epochs": 5,
    },
    "classification": {
        "r": 8, "alpha": 16, "dropout": 0.1,
        "target_modules": ["q_proj", "v_proj"],
        "lr": 5e-4, "epochs": 10,
    },
    "QLoRA_general": {
        "r": 64, "alpha": 16, "dropout": 0.1,
        "target_modules": ["q_proj", "v_proj", "k_proj", "o_proj"],
        "lr": 2e-4, "epochs": 3,
        "quantization": "4bit_nf4",
    },
}
Enter fullscreen mode Exit fullscreen mode

Part 8: Troubleshooting

Common Issues & Fixes

# peft/troubleshooting.py
TROUBLESHOOTING = {
    "loss_not_decreasing": {
        "causes": [
            "Learning rate too high → reduce to 1e-4",
            "Gradient accumulation too low → increase",
            "Frozen weights not actually frozen → check requires_grad",
            "Data format wrong → verify labels match input_ids",
        ],
        "debug": "Print trainable params, check gradient flow",
    },
    "overfitting": {
        "causes": [
            "Rank too high → reduce r to 8-16",
            "Dropout too low → increase to 0.1-0.2",
            "Epochs too many → add early stopping",
            "Dataset too small → use data augmentation",
        ],
    },
    "OOM": {
        "solutions": [
            "Use QLoRA 4-bit",
            "Reduce batch_size, increase gradient_accumulation",
            "Enable gradient_checkpointing",
            "Use paged_adamw_32bit optimizer",
            "Reduce max_length",
        ],
    },
    "degraded_performance": {
        "causes": [
            "Wrong target_modules for architecture",
            "Alpha/r ratio off → try alpha=2*r",
            "Base model already good at task → less LoRA needed",
            "Merged weights incorrectly → verify merge_and_unload",
        ],
    },
    "inference_slow": {
        "solutions": [
            "Merge adapters before deployment",
            "Use vLLM with LoRA support",
            "Quantize merged model (AWQ/GPTQ)",
            "Reduce r for faster matmul",
        ],
    },
}
Enter fullscreen mode Exit fullscreen mode

Debugging Checklist

# peft/debug.py
def debug_lora_setup(model):
    """Comprehensive LoRA debugging."""

    print("=== Trainable Parameters ===")
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    total = sum(p.numel() for p in model.parameters())
    print(f"Trainable: {trainable:,} ({100*trainable/total:.2f}%)")
    print(f"Total: {total:,}")

    print("\n=== LoRA Modules ===")
    for name, module in model.named_modules():
        if hasattr(module, 'lora_A'):
            print(f"  {name}: r={module.r}, alpha={module.alpha}")
            print(f"    A: {module.lora_A.shape}, B: {module.lora_B.shape}")
            print(f"    A grad: {module.lora_A.grad is not None}")
            print(f"    B grad: {module.lora_B.grad is not None}")

    print("\n=== Gradient Flow Test ===")
    dummy_input = torch.randn(1, 10, 4096).to(next(model.parameters()).device)
    output = model(dummy_input)
    loss = output.logits.sum()
    loss.backward()

    has_grad = any(p.grad is not None and p.grad.abs().sum() > 0 
                   for p in model.parameters() if p.requires_grad)
    print(f"Gradients flowing: {has_grad}")

    if not has_grad:
        print("ERROR: No gradients! Check requires_grad and forward pass.")
Enter fullscreen mode Exit fullscreen mode

Part 9: Evaluation & Benchmarking

# peft/evaluation.py
from datasets import load_dataset
from transformers import pipeline
import evaluate

def evaluate_lora(model, tokenizer, benchmark="mt_bench"):
    """Evaluate LoRA model on standard benchmarks."""

    if benchmark == "mt_bench":
        # MT-Bench: multi-turn conversation evaluation
        pass
    elif benchmark == "alpaca_eval":
        dataset = load_dataset("tatsu-lab/alpaca_eval", split="eval")
        # Generate responses
        pass
    elif benchmark == "mmlu":
        dataset = load_dataset("cais/mmlu", "all", split="test")
        # Multiple choice evaluation
        pass
    elif benchmark == "humaneval":
        dataset = load_dataset("openai_humaneval", split="test")
        # Code generation
        pass

def compare_adapters(model, tokenizer, adapters: dict, test_prompts: list):
    """Compare multiple LoRA adapters side-by-side."""

    results = {}
    for name, adapter_path in adapters.items():
        model.load_adapter(adapter_path, adapter_name=name)
        model.set_adapter(name)

        responses = []
        for prompt in test_prompts:
            inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
            with torch.no_grad():
                outputs = model.generate(**inputs, max_new_tokens=256, temperature=0.7)
            response = tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
            responses.append(response)

        results[name] = responses
        model.disable_adapter()

    return results
Enter fullscreen mode Exit fullscreen mode

Part 10: Production Checklist

# peft/production_checklist.py
PRODUCTION_CHECKLIST = {
    "pre_training": [
        "✅ Validate dataset quality & format",
        "✅ Check tokenization (no truncation issues)",
        "✅ Verify base model license allows fine-tuning",
        "✅ Set random seeds for reproducibility",
        "✅ Configure W&B / MLflow tracking",
    ],
    "training": [
        "✅ Monitor train/val loss curves",
        "✅ Check gradient norms (should be ~1)",
        "✅ Verify learning rate schedule",
        "✅ Save checkpoints every N steps",
        "✅ Log GPU memory usage",
    ],
    "post_training": [
        "✅ Merge adapters for deployment",
        "✅ Run eval on holdout test set",
        "✅ Compare with base model (A/B test)",
        "✅ Test edge cases & safety",
        "✅ Benchmark latency/throughput",
    ],
    "deployment": [
        "✅ Quantize merged model (AWQ/GPTQ)",
        "✅ Test with vLLM / TGI",
        "✅ Configure autoscaling",
        "✅ Set up monitoring (latency, errors, quality)",
        "✅ Plan rollback strategy",
    ],
    "maintenance": [
        "✅ Schedule periodic re-training",
        "✅ Monitor data drift",
        "✅ Track model performance over time",
        "✅ Version adapters with git tags",
    ],
}
Enter fullscreen mode Exit fullscreen mode

Resources


LoRA/QLoRA makes fine-tuning accessible. Start small (r=16), validate on your task, then scale. The best adapter is the one that ships.

Top comments (0)