LoRA Fine-Tuning Rank Sweep: 12 Steps, 90 Min [2026] A tutorial published by Shattered lays out a 12-step LoRA rank sweep that trains the same adapter at five ranks on a single GPU, scores each run against a held-out set, and merges the winning adapter into a standalone model in about 90 minutes, built on Hugging Face's PEFT v0.21.3 and TRL v1.18.0. The guide cites Hugging Face documentation putting QLoRA's 4-bit loading memory saving at up to four times versus a standard LoRA load, and notes that on October 29, 2026 Hugging Face announced day-zero TRL fine-tuning support for Mistral Large 4, with TRL v1.18.0 reportedly adding Gemma 4 variants and newer Qwen3.8 revisions. It also cites the 2025 Stack Overflow Developer Survey finding that 84% of developers use or plan to use AI tools. Hugging Face’s newest releases are PEFT v0.21.3 https://whatsnew.fyi/product/peft/latest and TRL v1.18.0. That pair now sits underneath most LoRA fine-tuning jobs, so a rank sweep built on it tests the stack you will actually deploy. The lab below trains the same adapter at five ranks on one GPU, scores every run against a held-out set, and merges the winner into a standalone model. Most LoRA guides stop after one training run and one lucky output. That leaves the questions that cost real money unanswered. Does rank 64 beat rank 8? Is alpha doing anything? Did the adapter learn the task, or did it memorize the templates? The sweep answers those with numbers from your own hardware in about 90 minutes. For the single-run, memory-first route, see the walkthrough on fine-tuning an LLM with QLoRA https://shattered.io/fine-tune-llm-qlora-12-steps-2026/ . A rank sweep picks up once that first run works and asks which settings were worth the GPU hours. Don't miss new tech stories on Google Add Shattered once in the Google app and our stories show up in your news suggestions. Add now https://www.google.com/preferences/source?hl=en&q=https%3A%2F%2Fshattered.io%2F Why LoRA Fine-Tuning Still Wins in November 2026 LoRA freezes every weight in the base model and trains two small matrices beside each adapted layer. The product of those matrices acts as a learned correction to the original weight. Because only the small matrices receive gradients, the optimizer state shrinks from billions of values to a few million, and the saved artifact is a file you can email instead of a 15 GB checkpoint. QLoRA adds one step. The frozen base loads in 4-bit precision, and the adapter trains in higher precision on top of it. Hugging Face’s documentation puts the memory saving from 4-bit loading at up to four times compared with a standard LoRA load, which is the difference between a job that fits on a 24 GB card and one that does not. The method also keeps scaling. On October 29, 2026, Hugging Face announced day-zero TRL fine-tuning support for Mistral Large 4 and told users to upgrade transformers , trl , and peft to use it. TRL’s own release notes reportedly list a LoRA supervised fine-tuning example for Mistral Large 4 on a single node of eight B300 GPUs over FP8 weights. TRL v1.18.0 also reportedly adds support for Gemma 4 variants and newer Qwen3.8 revisions. The same three libraries and the same LoraConfig object cover a 1.5B test model and a trillion-parameter mixture-of-experts release. Demand is not hypothetical. The AI section of the 2025 Stack Overflow Developer Survey https://survey.stackoverflow.co/2025/technology reported that 84% of developers use or plan to use AI tools. Teams that already run open weights want to bend them to private tasks, and LoRA is the cheapest way to do it. Anyone choosing a base model first should skim the open-weight models spec sheet https://shattered.io/open-weight-ai-models-side-by-side-the-spec-sheet/ , then come back here to adapt it. The wider AI and machine learning archive https://shattered.io/category/ai-machine-learning/ tracks which families keep shipping. The table compares LoRA with the other ways to change a model’s behavior. Memory figures are relative, because the absolute numbers depend on model size, sequence length, and batch size. | Approach | What changes | Relative memory | Best used when | |---|---|---|---| | Prompting | Nothing in the model | None | The task fits in a short instruction and a few examples | | Retrieval RAG | The context window | Low | The answer lives in documents that change often | | LoRA | Small adapter matrices | Moderate | You need a fixed output format, tone, or narrow skill | | QLoRA | Adapters over a 4-bit base | Up to four times lower than LoRA per Hugging Face docs | The base model barely fits your GPU in 16-bit | | Full fine-tuning | Every weight | Highest | You have a large dataset, many GPUs, and a reason adapters fall short | Fine-tuning does not replace retrieval. It teaches behavior, not facts. A ticket classifier that must always answer in strict JSON is a textbook LoRA job. A chatbot that must quote this week’s price list is not. How Rank, Alpha and Dropout Change a LoRA Run Every LoRA setting boils down to four numbers and one list. Rank r sets how many columns the adapter matrices carry, which caps how much the adapter can express. Alpha lora alpha scales the adapter’s output, and the effective multiplier is alpha divided by rank. Dropout randomly zeroes adapter activations during training to curb overfitting. The learning rate then decides how hard each step pushes. The list, target modules , names which layers get adapters. Trainable parameters grow linearly with rank. For one adapted weight matrix with d in inputs and d out outputs, the adapter adds r d in + d out values. Doubling the rank doubles that cost across every targeted layer. This is why the sweep prints the trainable-parameter count next to each score. A rank that wins by half a point while tripling the file size rarely deserves to ship. Alpha trips up newcomers. Because the multiplier is alpha over rank, raising rank while holding alpha fixed quietly shrinks the adapter’s influence. The sweep therefore holds the ratio constant at alpha equal to twice the rank, a widely used convention, so each run changes capacity and nothing else. PEFT also offers rank-stabilized scaling through use rslora=True , which divides by the square root of rank instead. Treat it as a second experiment, not part of the first. On dropout, the Hugging Face tooling defaults to 0.05, and its documentation calls 0.05 to 0.1 the typical range. A narrow, templated task like the one in this lab has little to overfit against at low rank, so the default is fine. Check your license before you train, too. The license comparison for six open-weight families https://shattered.io/open-weight-models-license-comparison-6-families-16gb-2026/ shows which ones allow derivative adapters in commercial products. The starting values below are common community defaults, not official Hugging Face recommendations. The sweep exists to replace them with measured values for your task. | Setting | Starting value | Sweep range | What to watch | |---|---|---|---| | Rank r | 16 | 4, 8, 16, 32, 64 | Score gain per extra trainable parameter | | Alpha | 2 x rank | Fixed ratio first | Divergence if the ratio is too high | | Dropout | 0.05 | 0.0 to 0.1 | Gap between training loss and eval score | | Learning rate | 2e-4 | 1e-4 to 3e-4 | Loss spikes in the first 50 steps | | Target modules | all-linear | Attention only vs all | Quality versus adapter size | | Epochs | 2 | 1 to 3 | Eval score falling while loss keeps dropping | Prerequisites and Pinned Versions The lab runs on one NVIDIA GPU under Linux or WSL2. A 16 GB card handles the default 1.5B-class model in bfloat16 without any quantization. Moving to a 7B or 8B model with the --q4 flag typically lands around 8 to 12 GB of VRAM in practice, since 4-bit weights alone take roughly 4 to 5 GB and activations, adapter state, and framework overhead fill the rest. Sequence length and batch size move that number more than anything else. Anyone sizing hardware for bigger models can check the memory table in the local Qwen3.8-27B guide https://shattered.io/run-llm-locally-qwen3-8-27b-llama-cpp-12-steps-2026/ . Pin the Hugging Face stack. PEFT, TRL, and Transformers all moved within the last week, and a mismatched trio is the single most common reason a copied script fails. The table lists what the lab uses. | Component | Version | Notes | |---|---|---| | Python | 3.11 or newer | Any current 3.x release works | | PyTorch | Latest stable build for your CUDA driver | Install it first, from the selector on pytorch.org | | Transformers | 5.23.0 | v5.23.0 unified six new model families https://nownessai.com/article/hugging-face-transformers-v5-23-0-release in its November 13 release | | PEFT | 0.21.3 | Released November 13, 2026, with a fix for saving DTensor tensor-parallel adapters | | TRL | 1.18.0 | Released November 13, 2026, with a stable AsyncGRPOTrainer | | bitsandbytes | Latest release | Only needed for the --q4 path | | datasets, accelerate | Latest release | No special pins | Two version notes save debugging time. PEFT v0.21.2 fixed encoder-decoder models when paired with Transformers 5.18.0 or later, so anything older than that combination is worth upgrading. And FP8 checkpoints require Transformers 5.0.0 or later, which matters if you later move to the largest releases. You also need about 20 GB of free disk for the model cache, the run folders, and the merged copy. A Hugging Face account is only required for gated models. The default Qwen test model downloads without a login. Renting a GPU works just as well as owning one. Any cloud instance with a single NVIDIA card of 16 GB or more can run every script unchanged, and the whole sweep finishes inside one session, so you pay for a few hours at most. Budget extra time for the first model download, which depends on your connection rather than the GPU. If you plan several sweeps on different bases, point HF HOME at a large disk so each model downloads once. Project Layout for the Rank Sweep Lab The project is a ticket classifier. Given a short support message, the model must answer with a JSON object holding a category billing, login, bug, or feature and a priority high, medium, or low . The task is narrow on purpose. It has a crisp pass/fail metric, it needs no private data, and a 1.5B model can learn it in minutes, so five full runs fit in one session. The default base is a small Qwen instruct model, picked for speed rather than prestige. Swap in any other family through one environment variable, and the DeepSeek, Qwen, and Mistral comparison https://shattered.io/deepseek-vs-qwen-vs-mistral-open-weight-models-2026/ shows how those three differ before you commit GPU time to one. Training data comes from a script rather than a download. That choice removes dataset licensing questions, makes every run reproducible from a seed, and lets the evaluation set contain phrasings the training set never shows. A quarter of the sentence templates and a quarter of the urgency hints appear only in the held-out file, which tests whether an adapter generalized or memorized. lora-sweep/ requirements.txt pinned package list check env.py prints versions and GPU facts make data.py builds data/train.jsonl and data/eval.jsonl train.py one LoRA run, parameterized by rank, alpha, lr evaluate.py scores the base model or any adapter sweep.sh loops train.py and evaluate.py over ranks report.py merges run summaries into one table merge.py folds the winning adapter into a full model runs/ adapters and per-run summaries created by train.py results/ per-run metrics created by evaluate.py The split between train.py and evaluate.py is deliberate. Training and scoring run as separate processes, so a crash during evaluation never costs you a finished adapter, and the scorer can grade any model folder on disk, including the merged one at the end. Every random choice flows from a seed. The data script seeds Python’s generator, and the trainer takes a seed flag, so a rerun on the same hardware should land very close to the first result. Small differences still appear between GPU models because floating-point kernels are not bit-identical across architectures. Create the folder and the requirements file first. PyTorch is deliberately absent from the list, because the right build depends on your CUDA driver and you should install it from the official selector. mkdir -p lora-sweep/data && cd lora-sweep cat requirements.txt <<'EOF' transformers==5.23.0 peft==0.21.3 trl==1.18.0 datasets accelerate bitsandbytes EOF Set Up the Environment Steps 1 to 3 Step 1: Confirm the GPU and driver Run one query before installing anything. It tells you the card, its memory, and the driver version, which decides which PyTorch build to pick. nvidia-smi --query-gpu=name,memory.total,driver version --format=csv Example output your card and driver will differ : name, memory.total MiB , driver version NVIDIA GeForce RTX 4090, 24564 MiB, 5xx.xx If the command is missing, the NVIDIA driver is not installed or WSL2 has no GPU passthrough. Fix that first. Nothing later works without it. Step 2: Create a virtual environment and install the pins An isolated environment keeps this lab from fighting whatever else lives on the machine. Install PyTorch first from the build matching your driver, then the pinned Hugging Face stack. python3 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip install PyTorch here using the command from pytorch.org for your CUDA version pip install -r requirements.txt Running the install inside WSL2 is fine, provided the Windows NVIDIA driver is current and nvidia-smi works from the Linux shell. Do not install a separate Linux driver inside WSL2. Doing so breaks the passthrough that the Windows driver provides. On a shared server, check nvidia-smi for other jobs before you start, because a neighbor holding 10 GB of memory turns a safe batch size into an out-of-memory error. The resolver should finish without conflicts. If it reports one, jump to the troubleshooting section before editing any pins. A forced install of mismatched versions produces errors that look like bugs in your code. Step 3: Verify versions before you write any training code A short script catches version drift in a second. Save it as check env.py . python import importlib.metadata as md import torch for pkg in "torch", "transformers", "peft", "trl", "bitsandbytes", "datasets", "accelerate" : try: print f"{pkg:14s}{md.version pkg }" except md.PackageNotFoundError: print f"{pkg:14s}MISSING" print "cuda available:", torch.cuda.is available if torch.cuda.is available : print "gpu:", torch.cuda.get device name 0 print "bf16 supported:", torch.cuda.is bf16 supported Run python check env.py . The three lines that matter should read transformers 5.23.0 , peft 0.21.3 , and trl 1.18.0 , with cuda available: True below them. If bf16 reports False, your GPU predates bfloat16 support. Swap bf16=True for fp16=True in train.py and expect a little more numerical fuss. Build the Ticket Dataset and the Baseline Steps 4 and 5 Step 4: Generate the training and evaluation data The generator draws a sentence template, an urgency hint, and optional noise such as a ticket number or a mobile signature. Training uses the first three templates of each group. Evaluation uses all four, so one in four eval tickets carries wording the adapter never saw. python import json import random from pathlib import Path random.seed 7 TEMPLATES = { "billing": "I was charged twice for {item}.", "The invoice for {item} shows the wrong amount.", "Please refund my last payment for {item}.", "My card was declined but {item} still billed me.", , "login": "I cannot sign in to {item}.", "The password reset email for {item} never arrives.", "Two-factor codes for {item} keep getting rejected.", "{item} logs me out every few minutes.", , "bug": "{item} crashes when I open the settings page.", "Exporting from {item} produces an empty file.", "{item} freezes after the latest update.", "Charts in {item} show yesterday's numbers.", , "feature": "Can you add dark mode to {item}?", "We need a CSV import in {item}.", "Please support single sign-on for {item}.", "It would help if {item} had a weekly summary email.", , } ITEMS = "the Pro plan", "the mobile app", "the desktop client", "the analytics dashboard", "the team workspace", "the API console" HINTS = { "high": "This blocks our whole team.", "We are losing money every hour.", "Customers are complaining right now.", "Our launch is tomorrow and this stops it." , "medium": "It is annoying but we have a workaround.", "Please look at it this week.", "It slows us down a little.", "We would like an answer soon." , "low": "No rush, just flagging it.", "Whenever you get a chance.", "Nice to have, not urgent.", "Only a minor annoyance." , } INSTRUCTION = 'Classify the support ticket. Reply with JSON only, using the keys ' '"category" billing, login, bug, feature and "priority" high, medium, low .' def make n, held out : k = 4 if held out else 3 rows = for in range n : cat = random.choice list TEMPLATES pri = random.choice list HINTS sentence = random.choice TEMPLATES cat :k .format item=random.choice ITEMS hint = random.choice HINTS pri :k extra = random.choice "", f" Ticket ref {random.randint 1000, 9999 }.", " Thanks, Sam.", " Sent from my phone." rows.append f"{sentence} {hint}{extra}", {"category": cat, "priority": pri} return rows def write path, rows, with label : with open path, "w" as f: for text, label in rows: rec = { "prompt": {"role": "user", "content": f"{INSTRUCTION}\n\nTicket: {text}"} , "completion": {"role": "assistant", "content": json.dumps label } , } if with label: rec "label" = label f.write json.dumps rec + "\n" Path "data" .mkdir exist ok=True write "data/train.jsonl", make 1800, held out=False , with label=False write "data/eval.jsonl", make 200, held out=True , with label=True print "wrote 1800 train rows and 200 eval rows" Run python make data.py and peek at one row with head -n 1 data/eval.jsonl . The prompt and completion are separate fields. That prompt-completion layout matters, because TRL then computes the loss on the completion only, so the model never gets graded on repeating the instruction. Step 5: Score the untouched base model Never train before you measure. The baseline sets the bar every adapter must clear, and it exposes whether the task is already easy for the model. It also beats leaderboard shopping, since the open-model rankings that disagree with each other https://shattered.io/open-source-llm-leaderboards-disagree-kimi-k3-1-pro-glm-5-3-2026/ say little about your private task. Save this as evaluate.py . The sweep reuses it unchanged. python import argparse import json import os import re import torch from peft import PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig p = argparse.ArgumentParser p.add argument "--model", default=os.environ.get "BASE MODEL", "Qwen/Qwen2.5-1.5B-Instruct" p.add argument "--adapter", default=None p.add argument "--name", default="base" p.add argument "--q4", action="store true" p.add argument "--limit", type=int, default=0 args = p.parse args tok = AutoTokenizer.from pretrained args.model, padding side="left" if tok.pad token is None: tok.pad token = tok.eos token kw = {"device map": {"": 0}} if args.q4: kw "quantization config" = BitsAndBytesConfig load in 4bit=True, bnb 4bit quant type="nf4", bnb 4bit compute dtype=torch.bfloat16, bnb 4bit use double quant=True else: kw "dtype" = torch.bfloat16 model = AutoModelForCausalLM.from pretrained args.model, kw if args.adapter: model = PeftModel.from pretrained model, args.adapter model.eval rows = json.loads line for line in open "data/eval.jsonl" if args.limit: rows = rows : args.limit def parse text : m = re.search r"\{. ?\}", text, re.S if not m: return None try: return json.loads m.group 0 except json.JSONDecodeError: return None preds = for i in range 0, len rows , 16 : chunk = rows i : i + 16 texts = tok.apply chat template r "prompt" , tokenize=False, add generation prompt=True for r in chunk enc = tok texts, return tensors="pt", padding=True, add special tokens=False .to model.device with torch.no grad : gen = model.generate enc, max new tokens=48, do sample=False preds += tok.batch decode gen :, enc "input ids" .shape 1 : , skip special tokens=True valid = cat = pri = exact = 0 for r, out in zip rows, preds : obj = parse out if obj is None: continue valid += 1 c = obj.get "category" == r "label" "category" q = obj.get "priority" == r "label" "priority" cat += c pri += q exact += c and q n = len rows res = {"name": args.name, "n": n, "valid json": valid / n, "category acc": cat / n, "priority acc": pri / n, "exact match": exact / n} os.makedirs "results", exist ok=True json.dump res, open f"results/{args.name}.json", "w" , indent=2 with open f"results/{args.name}.preds.jsonl", "w" as f: for r, out in zip rows, preds : f.write json.dumps {"gold": r "label" , "pred": out.strip } + "\n" print json.dumps res, indent=2 print "sample output:", preds 0 .strip Run python evaluate.py --name base . The script left-pads the batch, because decoder models generate from the right edge of the prompt. It also passes add special tokens=False , since the chat template already inserts any special tokens the model expects. Example output the format is real, the numbers are illustrative and will shift with the model and hardware : { "name": "base", "n": 200, "valid json": 0.78, "category acc": 0.74, "priority acc": 0.53, "exact match": 0.41 } sample output: Here is the classification: {"category": "bug", "priority": "high"} The sample line shows the typical base-model failure. It knows the answer but wraps it in chatter, and priority guesses lag category guesses because urgency lives in a subtle hint sentence. A fine-tune should fix the first problem almost immediately. The second one is where rank may or may not matter. Configure and Train the First Adapter Steps 6 to 8 Step 6: Write the training script One script handles every run in the sweep. Rank, alpha, learning rate, and dropout arrive as flags, so the sweep changes exactly one variable at a time. Passing a LoraConfig as peft config lets TRL wrap the model, which matches the pattern in a current TRL plus PEFT small-model walkthrough https://guides-ai.com/guides/lora-fine-tune-small-model/ and keeps the loop free of hand-rolled training code. Save this as train.py . python import argparse import json import os import time import torch from datasets import load dataset from peft import LoraConfig, prepare model for kbit training from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig from trl import SFTConfig, SFTTrainer p = argparse.ArgumentParser p.add argument "--model", default=os.environ.get "BASE MODEL", "Qwen/Qwen2.5-1.5B-Instruct" p.add argument "--rank", type=int, default=16 p.add argument "--alpha", type=int, default=None p.add argument "--dropout", type=float, default=0.05 p.add argument "--lr", type=float, default=2e-4 p.add argument "--epochs", type=float, default=2 p.add argument "--batch", type=int, default=8 p.add argument "--accum", type=int, default=2 p.add argument "--max-length", type=int, default=512 p.add argument "--seed", type=int, default=42 p.add argument "--q4", action="store true" p.add argument "--out", default=None args = p.parse args alpha = args.alpha if args.alpha is not None else 2 args.rank out = args.out or f"runs/r{args.rank}" tok = AutoTokenizer.from pretrained args.model if tok.pad token is None: tok.pad token = tok.eos token kw = {"device map": {"": 0}} if args.q4: kw "quantization config" = BitsAndBytesConfig load in 4bit=True, bnb 4bit quant type="nf4", bnb 4bit compute dtype=torch.bfloat16, bnb 4bit use double quant=True else: kw "dtype" = torch.bfloat16 model = AutoModelForCausalLM.from pretrained args.model, kw if args.q4: model = prepare model for kbit training model lora = LoraConfig r=args.rank, lora alpha=alpha, lora dropout=args.dropout, target modules="all-linear", bias="none", task type="CAUSAL LM" cfg = SFTConfig output dir=out, num train epochs=args.epochs, per device train batch size=args.batch, gradient accumulation steps=args.accum, learning rate=args.lr, lr scheduler type="cosine", warmup steps=10, logging steps=10, save strategy="no", bf16=True, gradient checkpointing=True, gradient checkpointing kwargs={"use reentrant": False}, max length=args.max length, seed=args.seed, report to="none", train ds = load dataset "json", data files="data/train.jsonl", split="train" trainer = SFTTrainer model=model, args=cfg, train dataset=train ds, processing class=tok, peft config=lora trainer.model.print trainable parameters trainable = sum p .numel for p in trainer.model.parameters if p .requires grad total = sum p .numel for p in trainer.model.parameters torch.cuda.reset peak memory stats t0 = time.time trainer.train logs = h for h in trainer.state.log history if "loss" in h summary = { "rank": args.rank, "alpha": alpha, "lr": args.lr, "dropout": args.dropout, "epochs": args.epochs, "seed": args.seed, "trainable params": trainable, "total params": total, "first loss": logs 0 "loss" , "final loss": logs -1 "loss" , "seconds": round time.time - t0 , "peak gb": round torch.cuda.max memory allocated / 1e9, 2 , } os.makedirs out, exist ok=True trainer.model.save pretrained f"{out}/adapter" tok.save pretrained f"{out}/adapter" json.dump summary, open f"{out}/summary.json", "w" , indent=2 print json.dumps summary, indent=2 Four lines deserve a closer look. target modules="all-linear" attaches adapters to every linear layer except the output head, which is the setting Hugging Face’s QLoRA examples favor. gradient checkpointing trades roughly a third more compute for much lower activation memory. The use reentrant flag avoids a known gradient-flow warning with PEFT. And save strategy="no" skips checkpoints, because an adapter this small is saved once at the end. Step 7: Launch one run and read the first lines Start with the middle of the sweep. If rank 16 trains cleanly, the other four will too. python train.py --rank 16 The first printed line reports how little of the model is trainable. Then the trainer logs every ten steps. Example output illustrative, exact values vary : trainable params: