Image and Video Generation

Fine-Tuning Stable Diffusion — LoRA, DreamBooth, and Textual Inversion


You have 18 photographs of one specific ceramic mug — matte olive glaze, a chipped handle, a hairline crack near the base — and a catalogue that needs it in forty different settings. No prompt gets you that mug. "A matte olive ceramic mug with a chipped handle" gets you a generic mug that is olive-ish and undamaged. The model has never seen this object and has no word for it.

So you fine-tune. You load the SD 1.5 U-Net, point AdamW at all 860 million parameters, and immediately run into the memory arithmetic:

What is storedSize at fp32
U-Net weights (859.5M parameters)3.44 GB
Gradients (one per parameter)3.44 GB
AdamW first moment3.44 GB
AdamW second moment3.44 GB
Activations, batch 1 at 512×512~4 GB
Total before anything else~17.8 GB

You rent an A100. Eight hundred steps later, something worse than an out-of-memory error happens: it works, and then it stops working. The mug is perfect. But "a photograph of a golden retriever" also returns your mug. So does "a mountain landscape". Eighteen images have overwritten a model trained on billions, and the loss curve looked fine the whole time. That is catastrophic forgetting, and it is the reason nobody fine-tunes these models the obvious way.

And even if it worked, you would have produced a 3.4 GB checkpoint that does exactly one thing. Ten products means 34 GB, and no way to use two of them together.

Teaching a model one specific ceramic mugLoRA: learn the change• Weights frozen; train B times A• Rank 4 to 32, a few MB on disk• Stackable and swappable at load time• Best default for a style or a subjectDreamBooth: retrain the subject• Binds a rare token to your 18 photos• Needs prior-preservation images• Full checkpoint, gigabytes on disk• Overfits and driftsthe class if unchecked
Textual inversion sits below both — it changes no weights at all, only the vector one new word points to.

What is actually in there, and what needs to change

Before adapting a model it helps to know what parts exist and what each one does.

ComponentParametersJobAdapt it?
VAE encoder/decoder~84Mpixels ↔ 64×64×4 latentsAlmost never — touching it breaks every other component
CLIP text encoder~123Mprompt → 77×768 embeddingsSometimes, at very low learning rate
Token embedding table49,408 × 768one 768-d vector per vocabulary tokenYes — this is textual inversion's entire target
U-Net convolutions~400Mlocal texture, edges, spatial structureRarely needed for style or subject
U-Net attention projections~460Mglobal structure and prompt-to-region bindingYes — where nearly all adaptation happens

The last row is the key observation. Teaching the model a new subject or a new style is mostly a matter of changing which prompt tokens map to which visual features, and that mapping lives in the cross-attention projection matrices. The convolutions that know what glaze and ceramic look like need no revision at all.

LoRA: learning the change, not the weights

The intuition

A fine-tune produces new weights W. Write those as the original plus a change: W = W0 + ΔW. Freeze W0 and learn only ΔW. So far this saves nothing — ΔW is the same size as W0.

The insight, from the LoRA paper, is that ΔW from fine-tuning is empirically low rank. If you fine-tune fully and then take the singular value decomposition of the update, almost all its energy sits in a handful of singular directions. That makes sense: "make this model draw my mug" is a small, structured change, not 860 million independent adjustments.

The maths

Instead of storing a full d×k matrix, factor it through a narrow bottleneck of rank r:

ΔW=BA,B∈Rd×r, A∈Rr×k,r≪min⁡(d,k)\Delta W = BA, \qquad B \in \mathbb{R}^{d \times r},\ A \in \mathbb{R}^{r \times k}, \qquad r \ll \min(d, k)

h=W0x+αr BAxh = W_0 x + \frac{\alpha}{r}\,BAx

In plain English: project the input down to r dimensions, transform it there, project it back up, and add the result to the frozen layer's output. The parameter saving is the whole point. Take a cross-attention key projection in SD 1.5's middle blocks: W is 1280×768, so 983,040 parameters. With r = 4 you store 4×(1280 + 768) = 8,192 parameters — a 120× reduction on that one matrix.

Rank rTrainable params (whole U-Net attention)File sizeSuited to
4~800K~3 MBa single simple style or concept
16~3.2M~12 MBthe general-purpose default
32~6.4M~25 MBa specific subject with fine detail
128~25M~100 MBcomplex multi-concept sets; overfits easily on small data

Two details in the formula do real work.

The initialisation. A is initialised from a small random normal and B is initialised to exactly zero, so ΔW = BA = 0 at step 0. The adapted model starts out bit-identical to the original. Gradients still flow to B because ∂L/∂B depends on Ax, which is non-zero. You get a guaranteed-safe start with no loss of trainability.

The α/r scaling. Without it, changing the rank changes the magnitude of the update, so every rank change would need a new learning rate. Dividing by r normalises that. In practice people set α = r (scale exactly 1.0) or α = 2r (scale 2.0, a stronger effect). The single most common LoRA bug is training with one α/r ratio and loading with another, which silently doubles or halves the LoRA's strength.

LoRA does not make a smaller model. It makes a smaller diff — and because that diff is additive and starts at zero, it can be scaled, swapped, stacked, or removed at inference with no retraining.

Two ways to use it

At inference you have a choice. Keep the factors separate and compute W0x + (α/r)BAx as two operations — slightly slower, but you can change the LoRA weight per generation or unload it instantly. Or merge: compute W0 + (α/r)BA once and overwrite the weights — zero inference overhead, but the LoRA is now baked in and you need to reload the base model to change it. Serve many LoRAs from one process: keep them separate. Serve one fixed style at high volume: merge.

Python
import torchfrom diffusers import StableDiffusionPipelinepipe = StableDiffusionPipeline.from_pretrained(    "stable-diffusion-v1-5/stable-diffusion-v1-5", torch_dtype=torch.float16).to("cuda")pipe.load_lora_weights("./olive-mug-lora", adapter_name="mug")pipe.load_lora_weights("./watercolour-lora", adapter_name="paint")# separate: weights adjustable per callpipe.set_adapters(["mug", "paint"], adapter_weights=[0.9, 0.5])img = pipe("a photo of sks mug on a windowsill").images[0]# merged: fastest, but baked in# pipe.fuse_lora(lora_scale=0.9)

Adapter weights compose additively, so they compound. Two LoRAs at 1.0 each behave like one at 2.0: over-saturated, distorted, often unusable. Keep the sum around 1.0–1.5. And LoRAs trained on different base models do not mix — an SD 1.5 LoRA loaded onto SDXL has mismatched tensor shapes at best, and at worst loads partially and produces noise.

The training loop

Python
from peft import LoraConfig# freeze everything, then attach adapters to attention projections onlyunet.requires_grad_(False)unet.add_adapter(LoraConfig(    r=16, lora_alpha=16,    target_modules=["to_q", "to_k", "to_v", "to_out.0"],))params = [p for p in unet.parameters() if p.requires_grad]print(sum(p.numel() for p in params))        # ~3.2M, not 860Mopt = torch.optim.AdamW(params, lr=1e-4)     # 10-100x the full-fine-tune LRfor step, batch in enumerate(loader):    with torch.no_grad():        latents = vae.encode(batch["pixels"]).latent_dist.sample() * 0.18215        cond = text_encoder(batch["input_ids"])[0]    noise = torch.randn_like(latents)    t = torch.randint(0, 1000, (len(latents),), device=latents.device)    noisy = scheduler.add_noise(latents, noise, t)    pred = unet(noisy, t, encoder_hidden_states=cond).sample    loss = torch.nn.functional.mse_loss(pred.float(), noise.float())    loss.backward(); opt.step(); opt.zero_grad()

Note the learning rate: 1e-4, ten to a hundred times higher than a full fine-tune's 1e-5 to 1e-6. With only 3.2M trainable parameters and a zero-initialised B, small steps make no progress at all. Using 1e-6 out of caution is the reason many first LoRA attempts appear to do nothing.

DreamBooth: binding a subject to a token

LoRA is a parameterisation — it says which weights you train. DreamBooth is a method — it says what data and captions you train on. They are routinely combined, and the combination is what most "train a LoRA of my dog" tools actually run.

DreamBooth's recipe: pick a rare token that the tokeniser does not already associate with anything (sks, ohwx, zwx), pair it with a class noun, and caption all your images "a photo of sks mug". The rare token has no prior meaning, so gradient descent is free to load it with your specific object; the class noun anchors it to the right region of the model's existing knowledge, so it starts from "mug-shaped thing" rather than from nothing.

Why prior preservation is not optional

Train on 18 images captioned "a photo of sks mug" and something predictable happens. The word "mug" appears in every caption, alongside your specific mug, every single step. The model learns that "mug" means this mug. Ask for "a blue mug" afterwards and you get yours in blue. Ask for "a coffee mug on a desk" and you get yours. This is language drift: the class noun has been captured.

Prior preservation fixes it by training on two losses at once:

L=∥ϵ−ϵθ(zt,"a photo of sks mug")∥2⏟learn the subject+λ∥ϵ′−ϵθ(zt′,"a photo of a mug")∥2⏟preserve the class\mathcal{L} = \underbrace{\lVert\epsilon - \epsilon_\theta(z_t, \text{"a photo of sks mug"})\rVert^2}_{\text{learn the subject}} + \lambda \underbrace{\lVert\epsilon' - \epsilon_\theta(z'_t, \text{"a photo of a mug"})\rVert^2}_{\text{preserve the class}}

In plain English: learn my mug from my photographs, and simultaneously keep producing your original idea of a generic mug when the rare token is absent. The second set of images is generated by the base model itself before training starts — typically 100–200 samples from the prompt "a photo of a mug". You are asking the model to stay consistent with its own past output. λ is usually 1.0.

Python
# generate the class images ONCE, from the unmodified base modelclass_images = [base_pipe("a photo of a mug").images[0] for _ in range(200)]# then each training step sees one of eachloss_subject = mse(unet(noisy_sub, t, emb("a photo of sks mug")), noise_sub)loss_prior   = mse(unet(noisy_cls, t, emb("a photo of a mug")), noise_cls)loss = loss_subject + 1.0 * loss_prior

The cost is roughly double the compute per step, plus the one-off generation. The benefit is a model that still knows what mugs are. Skip it and your adapter is unusable in any prompt that mentions the class.

Captioning: the counterintuitive rule

This is where most subject training quietly fails. The rule is: caption everything that varies across your images, and omit what stays constant. Whatever you do not name gets absorbed into the rare token.

If all 18 photographs were shot on the same oak table and every caption reads "a photo of sks mug", then sks learns "olive mug on an oak table". Every generation will drag the table along, and no prompt will remove it. Caption them "a photo of sks mug on an oak table", "a photo of sks mug on grass", "a photo of sks mug held in a hand", and the token keeps only the invariant — the mug itself.

Textual inversion: learning a word, not a model

Textual inversion is the extreme end of the spectrum. It changes no model weights at all. It adds one row to the token embedding table and trains only that row.

Concretely: add a new token <olive-mug> to the vocabulary, give it a 768-dimensional embedding vector, freeze the U-Net, the VAE and the entire text encoder, and run the ordinary diffusion loss — backpropagating all the way through the frozen model into that one vector. 768 trainable parameters. The resulting file is 3–5 KB.

Python
pipe.load_textual_inversion("./olive-mug.bin", token="<olive-mug>")img = pipe("a <olive-mug> on a rustic table, morning light").images[0]

Two mechanics decide whether it works.

Initialisation matters enormously. Starting the new embedding from random noise means thousands of steps of aimless wandering in 768-dimensional space. Initialise it from the embedding of a semantically close existing word — "mug", "cup", "vessel" — and you start inside the right neighbourhood. This routinely cuts training time by more than half.

The learning rate is unusual: around 5e-4 to 5e-3, far higher than any other technique here. You are training 768 numbers, not millions, and there is nothing to destabilise — nothing downstream of the embedding can be damaged, because everything downstream is frozen.

The corresponding limitation is severe. A single 768-d vector can only express things the frozen model can already draw. It can find the direction in concept space that means "your mug", but only if that region exists. Genuinely novel structures — an object with no analogue in the training data — are beyond it. Textual inversion is excellent for styles and for subjects close to something the model knows, and hopeless for anything else.

Choosing between them

Textual InversionLoRADreamBooth (full)
Trainable parameters7680.8M–25M860M
Output file size3–5 KB3–100 MB2–4 GB
VRAM to train~8 GB~12 GB24 GB+
Typical training time1–2 h10–30 min1–3 h
Images needed5–2010–3010–30
Can learn novel structureNoYesYes, best fidelity
Composable with othersYes, freelyYes, with careNo
Base model swappableYesWithin the same familyNo — it is the model

The decision, in one line each: a style that the model can nearly do already → textual inversion or a low-rank LoRA. A specific subject you need reliably and repeatedly → DreamBooth captioning trained with LoRA at rank 16–32 and prior preservation. Maximum fidelity with no other constraints and a subject the model finds genuinely alien → full DreamBooth, accepting the file size and the loss of composability.

Almost every production use is DreamBooth's data recipe trained through LoRA's parameterisation. They are answers to different questions, and combining them is the default, not a compromise.

Where people go wrong

SymptomCauseFix
Every prompt returns the training subjectoverfitting, or no prior preservationfewer steps, lower rank, add prior-preservation loss
Adapter has no visible effectLR too low (1e-6 instead of 1e-4), or trigger word missing from the promptraise LR; check the LoRA's documented trigger token
Output is fried, over-saturated, distortedadapter weights sum above ~1.5, or α/r mismatch between training and loadingscale each adapter down; verify alpha
Subject appears but always on the same backgroundconstant element left out of the captions and absorbed by the tokencaption everything that varies; vary the backgrounds in your data
Loading two LoRAs breaks bothtrained on different base models, or overlapping trigger tokensmatch base models; use distinct rare tokens
Textual inversion never convergesrandom init, or the concept is outside the base model's rangeinitialise from a related word; switch to LoRA
Face quality collapses after ~1500 stepsmemorising specific pixels rather than identitysave checkpoints every 200 steps and pick by eye, not by loss

That last row deserves emphasis. Diffusion training loss barely correlates with adapter quality, and there is no validation metric that reliably substitutes for looking. The standard workflow is to checkpoint every 200–500 steps, generate the same 6 prompts with the same 4 seeds from each checkpoint, and choose the grid you prefer. Overfitting is usually visible several hundred steps before it becomes irreversible: the subject sharpens, then poses stop varying, then backgrounds start repeating.

What this means when you build something

Think of these as an asset pipeline, not as model training. One base model sits in memory. Around it you keep a library of small files, and a request assembles the ones it needs.

Python
CATALOGUE = {    "olive-mug":  ("./loras/olive_mug.safetensors", 0.9, "sks mug"),    "watercolour":("./loras/watercolour.safetensors", 0.45, "wcstyle"),}def render(prompt, assets):    names = []    for key in assets:        path, weight, trigger = CATALOGUE[key]        pipe.load_lora_weights(path, adapter_name=key); names.append(key)        prompt = f"{trigger}, {prompt}"           # triggers must be present    pipe.set_adapters(names, [CATALOGUE[k][1] for k in assets])    out = pipe(prompt, num_inference_steps=25).images[0]    pipe.unload_lora_weights()                    # always clean up    return out

Three operational rules follow directly from the mechanisms above. Version the base model alongside every adapter, because a LoRA is a diff and a diff is meaningless without the thing it applies to — store the base checkpoint hash in the adapter's metadata. Record the trigger token and the training α/r with the file, since neither is recoverable from the weights and both are required to use it correctly. And unload adapters between requests: adapter state persists on the pipeline object, so a forgotten unload_lora_weights() means request 200 is silently rendering with the style someone asked for in request 3.

The economics are what make this worth doing. Full DreamBooth for ten products is 34 GB of checkpoints, ten separate model loads, and no way to combine any two. Ten rank-16 LoRAs are 120 MB total, load in under a second each, and compose. That difference — not fidelity — is why low-rank adaptation became the default way to specialise these models.