Archive note (2026): Reconstructed with April 2023-era models, libraries, and sources as a continuation of the earlier fine-tuning posts. The adapter-size calculation and code are reproducible; sample generations are illustrative rather than contemporaneous measurements.
Three years ago, I fine-tuned GPT-2 on 3,124 of my commit messages. It took ten minutes on a free Colab GPU and produced a half-gigabyte monument to fix typo in previous typo.
A year later, I put that model against GPT-3 with five examples in the prompt. The frozen giant won 16–4. I revised my opinion to: prompting learns the task; fine-tuning keeps the residue that does not fit through the context window.
This month fine-tuning came back through the side door.
Alpaca took a 7B base model and 52,000 instruction examples and made something surprisingly chatty. Vicuna used conversations instead and got a different creature. Dolly 2.0 used 15,000 human-written examples and, importantly, released the model and data under terms a company can actually use. Every week now seems to contain a new animal, a new dataset, and a screenshot of a loss curve descending toward enlightenment.
The models are interesting. The stranger thing is the file size.
I loaded OPT-6.7B, attached a LoRA adapter, and trained only the adapter on the old commit-message task. The base checkpoint is roughly 13 GB in half precision. The thing I saved afterward was about 8 MB.
I assumed the save had failed.
It had not. Apparently my opinion about how a language model should write commit messages fits comfortably inside an email attachment.
Fine-tuning, minus almost all the tuning
Normal fine-tuning updates the model’s weight matrices. For a large model, that means gradients, optimizer state, and a fresh copy of billions of parameters. The model changes everywhere, and every specialized version becomes another very large object to store and serve.
LoRA takes a smaller route. Freeze the original matrix W, and learn a low-rank update beside it:
W' = W + ΔW
ΔW = B × AIf W is a 4096 × 4096 attention matrix, it contains 16,777,216 parameters. With rank 4, LoRA replaces the trainable update with two skinny matrices:
A: 4 × 4096
B: 4096 × 4That is 32,768 trainable parameters instead of 16.7 million for one projection. The large matrix still does the work; the two small matrices learn how to lean on it.
I attached those updates to the query and value projections in each of OPT’s 32 transformer layers. The arithmetic is pleasingly rude:
2 projections
× 32 layers
× (4096 × 4 + 4 × 4096)
= 2,097,152 trainable parametersTwo million sounds large until it is standing next to 6.7 billion. It is about 0.03% of the model. Stored as 32-bit values, it is almost exactly 8 MB.
The base model remains frozen. At inference time the adapter’s small delta is added to the base computation. Remove the adapter and the original model is back. Load another adapter and the same model has another set of habits.
This is not a smaller model. It is a small amendment to a large one.
The old silly benchmark, one more time
I reused the commit-message experiment because it has become my fruit fly. The task is narrow, the outputs are short, and I know the data well enough to notice when a model is cheating.
This time a training record contains both the patch and the message:
### task
Write one terse git commit subject for this diff.
Use lowercase. No period. Describe the change, not the intention.
### diff
@@ -18,7 +18,10 @@ def fetch_manifest(url):
- return requests.get(url, timeout=2).json()
+ for attempt in range(3):
+ try:
+ return requests.get(url, timeout=2).json()
+ except requests.Timeout:
+ time.sleep(attempt + 1)
### message
retry manifest fetch after timeouts</s>I extracted pairs from my repositories, dropped merges and generated files, removed patches that would not fit in 512 tokens, and stripped anything that looked remotely like a credential. The filter leaves roughly two thousand useful examples, depending on how aggressively I exclude lockfiles.
The important split is chronological, not random.
A random split makes this benchmark look smarter than it is. Real commit histories contain little families: add cache, fix cache key, test cache key, actually fix cache key. Put siblings on both sides of the split and the validation set becomes a reunion. Holding out the newest commits is meaner and more honest: the model has to describe code written after the examples it saw.
The training setup, using Hugging Face’s then-new PEFT library, is short enough to fit in the post:
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import (
LoraConfig,
TaskType,
get_peft_model,
prepare_model_for_int8_training,
)
base = "facebook/opt-6.7b"
tokenizer = AutoTokenizer.from_pretrained(base, use_fast=False)
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
base,
load_in_8bit=True,
device_map="auto",
)
model = prepare_model_for_int8_training(model)
config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=4,
lora_alpha=8,
lora_dropout=0.05,
target_modules=["q_proj", "v_proj"],
bias="none",
)
model = get_peft_model(model, config)
model.print_trainable_parameters()
# about 2.1M trainable parameters out of 6.7B: ~0.03%The base weights are loaded in 8-bit, using the same broad trick described in LLM.int8(), to fit comfortably on one GPU. The small trainable matrices remain at higher precision. Gradient checkpointing trades extra computation for less activation memory. Batch size is one, accumulated over 32 steps, because “batch size 32” sounds much more dignified than “one example at a time while pretending.”
My useful settings ended up boring:
sequence length: 512
learning rate: 2e-4
micro batch size: 1
gradient accumulation: 32
epochs: 3
LoRA rank: 4
LoRA alpha: 8
LoRA dropout: 0.05The fashionable part is the adapter. The part that determined whether it worked was the labels.
My first loss curve was lying politely
The naïve training record contains a few hundred tokens of instructions and diff, followed by perhaps eight tokens of commit message. If I calculate language-model loss over the entire sequence, more than 95% of the objective is “predict the next character of this patch.”
The loss goes down. The model gets better at braces, plus signs, Python indentation, and the exact phrase ### message. Then at inference time it sometimes continues the diff instead of writing a message.
Nothing is broken. I asked it to become an excellent photocopier and it complied.
For instruction tuning, the prompt should shape the hidden state without becoming a prediction target. The input tokens stay visible to the model, but their labels are replaced with PyTorch’s ignore value:
labels = input_ids.copy()
labels[:message_starts_at] = -100
return {
"input_ids": input_ids,
"attention_mask": attention_mask,
"labels": labels,
}Now the gradient comes only from the answer. The model still reads the diff; it is simply not rewarded for reciting it.
This tiny distinction explains a lot of suspiciously beautiful fine-tuning graphs. Training loss is not a product metric. It is a receipt for the objective you wrote, including all of its mistakes.
A falling number proves that the optimizer found something easy to learn. It does not prove you chose the right thing.
It learned the accent before the meaning
With the adapter disabled, the base model tends to treat the prompt as a document to continue. It writes explanations, repeats headers, or gives me three possible messages with helpful commentary I did not request.
With the adapter enabled, the surface behavior changes quickly:
add retry logic to manifest fetch
avoid duplicate alerts for recovered pods
move token validation before cache lookupLowercase. Imperative. No period. One line. It stops trying to be useful in twelve different directions.
That part feels magical for about five minutes. Then the failure pattern appears.
My history contains far more repairs than new features, so the adapter develops a strong prior for the word fix. Show it a patch that adds a timeout where none existed and it may write fix request timeout, even though nothing was fixed; a constraint was added. Show it a new test and it may infer a bug that the test never mentions. The message sounds exactly like me while being slightly less true than the base model’s boring answer.
This is the dangerous version of personalization: not a wild hallucination, but a wrong answer wearing your clothes.
The adapter did not pour two thousand facts into the model. It changed which completions feel natural. It learned that I prefer add over implement, lowercase over title case, and mild pessimism over celebration. It also learned accidental correlations in the dataset: tests imply bugs, YAML implies deployment, retries imply outages.
Fine-tuning is a prior with excellent branding.
That makes dataset design feel less like collecting documents and more like writing source code. Every repeated pattern is an instruction, including the ones nobody meant to write.
if diff contains "test" → probably say "fix"
if file ends in .yaml → probably say "deploy"
if author is me → definitely avoid punctuationThe network does not store those rules literally. The effect is worse: they are distributed, soft, and difficult to grep.
An adapter is not a database
The open-model rush is producing a lot of claims that sound like “trained on medicine,” “trained on legal data,” or “trained on our documentation.” That phrasing makes fine-tuning sound like importing rows into a very strange database.
It is the wrong mental model.
Fine-tuning is good at changing recurring behavior: vocabulary, tone, output shape, taxonomy, and which distinctions the model habitually pays attention to. It can make an existing capability easier to reach. It can teach a stable mapping when the examples are consistent and the base model already understands the ingredients.
It is a bad place for facts that change on Tuesday.
If I need the current deployment version, customer balance, incident status, or API schema, I should fetch it at request time. Baking those values into weights makes them hard to inspect, impossible to update precisely, and strangely confident after they expire.
My current division of labor looks like this:
| Need | Use |
|---|---|
| A one-off task | A prompt and a few examples |
| Current or private facts | Retrieve them and put them in context |
| A recurring voice or decision pattern | Fine-tune an adapter |
| Exact syntax or policy | A parser, schema, or deterministic validator |
| Permission to do something | An authorization system outside the model |
A prompt is the request. The adapter is the default. The validator is the law.
Confusing those layers is how a style model becomes a source of truth, or a friendly answer becomes permission.
Eight megabytes deserves code review
The small file changes the operational shape of fine-tuning more than the cheap training does.
I can keep one base model loaded and version the adapters separately. A commit-message adapter, an incident-summary adapter, maybe one trained on a project’s vocabulary. Rollback means selecting the previous directory, not replacing a 13 GB checkpoint. Experiments become cheap enough to branch.
But “small” is not the same as “harmless.” An adapter can systematically change every answer while looking like a rounding error next to the base weights. I now want the same receipts around it that I want around infrastructure:
commit-message-r4/
├── adapter_config.json
├── adapter_model.bin
└── manifest.json{
"base_model": "facebook/opt-6.7b",
"base_revision": "<immutable commit>",
"dataset_sha256": "<hash>",
"split": "chronological",
"train_on_prompt": false,
"lora_rank": 4,
"targets": ["q_proj", "v_proj"],
"evaluation": "blind-held-out-commits-v1"
}The adapter and base revision are one deployable unit. Change either and the evaluation is stale. The dataset needs provenance because it is effectively executable behavior. Sample generations belong in the pull request. So do the embarrassing edge cases.
And I would not casually stack adapters because the files are small. Two deltas added to the same matrices are not two independent browser extensions. “Writes like me” plus “classifies incidents” may compose beautifully or may produce incident reports that apologize for themselves. The only honest answer is another held-out set.
Last month, the text box learned verbs. Plugins decide what the model can do. Fine-tuning changes what it tends to do. Capability needs permissions; tendency needs tests.
Both need a rollback button.
Fine-tuning is back, but it came back smaller
In 2020, I thought fine-tuning meant making a personal copy of a model. In 2021, five examples in a prompt made that look wasteful. My revised revision is this:
Prompting describes the task.
Fine-tuning edits the defaults.
The base model supplies broad capability. The adapter supplies a narrow gravitational field around the outputs you prefer. That field can encode taste, useful domain distinctions, and boring consistency. It can also encode every shortcut, imbalance, private string, and mislabeled example in the dataset.
LoRA makes the experiment cheap enough for one person and the result small enough to version. That is genuinely exciting. It also means model behavior is becoming another artifact we can casually copy into production without reading, because there is no meaningful way to read two million floating-point numbers.
So the review has to move outward: inspect the data, freeze the split, test the behavior, record the base revision, and keep deterministic boundaries around anything that matters.
The model is 13 GB. My opinion of it is 8 MB.
Eight megabytes is not much space.
Apparently it is enough for a habit.