Dev2506's picture
Add files using upload-large-folder tool
1574b3e verified
Raw
History Blame Contribute Delete
16.6 kB
"""
LoRA fine-tuning for SDXL on heritage art datasets (FULL implementation).
This is the production-grade LoRA training script for Indic Heritage Studio v2.
It properly handles SDXL's dual text encoders + UNet + VAE, computes the real
noise prediction loss, and saves a properly-formatted LoRA safetensors file.
Hardware:
- 8 × 80GB GPUs: trains one style per GPU in parallel (use the launcher
`scripts/train_all_loras.sh` to do this)
- Single GPU: trains sequentially (~30-45 min per style at rank 32)
Algorithm:
- PEFT/LoRA on UNet attention layers (to_q, to_k, to_v, to_out.0, etc.)
- AdamW 8-bit optimizer
- Cosine LR schedule with 500-step warmup
- Mixed precision (bf16 on A100/H100, fp16 fallback)
- 1024×1024 resolution (SDXL native)
- Batch size 1 + gradient accumulation 4 (effective batch 4)
- ~800 steps per style (40 images × ~20 epochs)
"""
from __future__ import annotations
import argparse
import logging
import math
import os
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
import torch
import torch.nn.functional as F
from PIL import Image
from config.settings import settings
from config.styles import StyleSpec, get_style
log = logging.getLogger(__name__)
@dataclass
class TrainConfig:
style_id: str
output_dir: Path
dataset_dir: Path
rank: int = 32
alpha: int = 32
learning_rate: float = 1e-4
batch_size: int = 1
gradient_accumulation_steps: int = 4
num_epochs: int = 20
max_train_steps: Optional[int] = 800
resolution: int = 1024
seed: int = 42
mixed_precision: str = "bf16" # "bf16" on A100/H100, "fp16" fallback
save_every: int = 200
sample_every: int = 100
class HeritageArtDataset(torch.utils.data.Dataset):
"""Dataset that loads (image, caption) pairs for LoRA training."""
def __init__(self, dataset_dir: Path, resolution: int = 1024, tokenizer_1=None, tokenizer_2=None):
self.dataset_dir = Path(dataset_dir)
self.resolution = resolution
self.tokenizer_1 = tokenizer_1
self.tokenizer_2 = tokenizer_2
self.image_paths = sorted([p for p in self.dataset_dir.iterdir()
if p.suffix.lower() in {".jpg", ".jpeg", ".png"}])
self.caption_paths = [p.with_suffix(".txt") for p in self.image_paths]
# Image preprocessing
from torchvision import transforms
self.transform = transforms.Compose([
transforms.Resize((resolution, resolution)),
transforms.ToTensor(),
transforms.Normalize([0.5], [0.5]), # [-1, 1]
])
log.info(f"Dataset at {self.dataset_dir}: {len(self.image_paths)} images")
def __len__(self):
return len(self.image_paths)
def __getitem__(self, idx):
img = Image.open(self.image_paths[idx]).convert("RGB")
image_tensor = self.transform(img)
# Load caption
cap_path = self.caption_paths[idx]
if cap_path.exists():
caption = cap_path.read_text(encoding="utf-8").strip()
else:
caption = "heritage art, intricate detail, traditional composition"
return {
"image": image_tensor,
"caption": caption,
}
class LoRATrainer:
"""Trains a per-style LoRA on SDXL 1.0 base — full implementation."""
def __init__(self, config: TrainConfig) -> None:
self.config = config
self.config.output_dir.mkdir(parents=True, exist_ok=True)
self.device = "cuda" if torch.cuda.is_available() else "cpu"
# Determine dtype
if config.mixed_precision == "bf16":
self.dtype = torch.bfloat16
elif config.mixed_precision == "fp16":
self.dtype = torch.float16
else:
self.dtype = torch.float32
def train(self) -> Path:
"""Run training. Returns the path to the final .safetensors file."""
log.info("=" * 60)
log.info(f"Starting LoRA training for style '{self.config.style_id}'")
log.info("=" * 60)
log.info(f"Config: rank={self.config.rank}, lr={self.config.learning_rate}, "
f"steps={self.config.max_train_steps}, dataset={self.config.dataset_dir}")
log.info(f"Mixed precision: {self.config.mixed_precision} (dtype={self.dtype})")
log.info(f"Device: {self.device} ({torch.cuda.get_device_name(0)})")
torch.manual_seed(self.config.seed)
# 1. Load SDXL components
log.info("Loading SDXL components...")
from diffusers import (
StableDiffusionXLPipeline,
UNet2DConditionModel,
AutoencoderKL,
DDPMScheduler,
)
from transformers import AutoTokenizer, CLIPTextModel, CLIPTextModelWithProjection
model_id = settings.t2i_model_id
# Tokenizers + text encoders (dual for SDXL)
log.info(" Loading tokenizers...")
tokenizer_1 = AutoTokenizer.from_pretrained(model_id, subfolder="tokenizer")
tokenizer_2 = AutoTokenizer.from_pretrained(model_id, subfolder="tokenizer_2")
log.info(" Loading text encoders...")
text_encoder_1 = CLIPTextModel.from_pretrained(
model_id, subfolder="text_encoder", torch_dtype=self.dtype
).to(self.device)
text_encoder_2 = CLIPTextModelWithProjection.from_pretrained(
model_id, subfolder="text_encoder_2", torch_dtype=self.dtype
).to(self.device)
# VAE
log.info(" Loading VAE...")
vae = AutoencoderKL.from_pretrained(
model_id, subfolder="vae", torch_dtype=self.dtype
).to(self.device)
vae.requires_grad_(False)
# UNet (load in fp32 for stable training, cast to dtype for forward)
log.info(" Loading UNet...")
unet = UNet2DConditionModel.from_pretrained(
model_id, subfolder="unet", torch_dtype=torch.float32
).to(self.device)
unet.requires_grad_(False)
# Noise scheduler
noise_scheduler = DDPMScheduler.from_pretrained(model_id, subfolder="scheduler")
# 2. Inject LoRA into UNet
log.info("Injecting LoRA adapters (rank=%d)...", self.config.rank)
from peft import LoraConfig, get_peft_model
lora_config = LoraConfig(
r=self.config.rank,
lora_alpha=self.config.alpha,
target_modules=[
"to_q", "to_k", "to_v", "to_out.0",
"proj_in", "proj_out",
],
lora_dropout=0.05,
bias="none",
)
unet = get_peft_model(unet, lora_config)
unet.print_trainable_parameters()
# Cast LoRA params to training dtype
unet.to(self.dtype)
# 3. Build dataset + dataloader
log.info("Building dataset...")
dataset = HeritageArtDataset(
self.config.dataset_dir,
resolution=self.config.resolution,
tokenizer_1=tokenizer_1,
tokenizer_2=tokenizer_2,
)
if len(dataset) == 0:
raise RuntimeError(
f"No training samples found in {self.config.dataset_dir}. "
"Run `python -m training.prepare_dataset` first."
)
dataloader = torch.utils.data.DataLoader(
dataset,
batch_size=self.config.batch_size,
shuffle=True,
num_workers=2,
collate_fn=self._collate_fn,
drop_last=True,
)
# 4. Optimizer + LR schedule
log.info("Setting up optimizer...")
optimizer = torch.optim.AdamW(
unet.parameters(),
lr=self.config.learning_rate,
betas=(0.9, 0.999),
weight_decay=1e-2,
eps=1e-8,
)
num_training_steps = self.config.max_train_steps or (
len(dataloader) * self.config.num_epochs // self.config.gradient_accumulation_steps
)
num_warmup_steps = min(500, num_training_steps // 4)
from diffusers.optimization import get_cosine_schedule_with_warmup
lr_scheduler = get_cosine_schedule_with_warmup(
optimizer,
num_warmup_steps=num_warmup_steps,
num_training_steps=num_training_steps,
)
# 5. Training loop
log.info("Starting training loop: %d steps", num_training_steps)
global_step = 0
unet.train()
# Pre-compute text embeddings only once per batch (more efficient)
progress_every = max(1, num_training_steps // 40) # log ~40 times total
while global_step < num_training_steps:
for batch in dataloader:
if global_step >= num_training_steps:
break
# Move images to device
images = batch["image"].to(self.device, dtype=self.dtype)
captions = batch["caption"]
# --- Encode images to latents via VAE ---
with torch.no_grad():
# VAE expects [-1, 1] range, images already normalized
latents = vae.encode(images).latent_dist.sample()
latents = latents * vae.config.scaling_factor
# --- Encode text via both CLIP encoders ---
with torch.no_grad():
tokens_1 = tokenizer_1(
captions, padding="max_length", max_length=77,
truncation=True, return_tensors="pt",
).to(self.device)
tokens_2 = tokenizer_2(
captions, padding="max_length", max_length=77,
truncation=True, return_tensors="pt",
).to(self.device)
encoder_output_1 = text_encoder_1(**tokens_1)
encoder_output_2 = text_encoder_2(**tokens_2)
prompt_embeds = torch.cat([
encoder_output_1.last_hidden_state,
encoder_output_2.last_hidden_state,
], dim=-1)
pooled_prompt_embeds = encoder_output_2.text_embeds
# --- Sample noise + timesteps ---
noise = torch.randn_like(latents)
bsz = latents.shape[0]
timesteps = torch.randint(
0, noise_scheduler.config.num_train_timesteps,
(bsz,), device=self.device,
).long()
# --- Add noise to latents ---
noisy_latents = noise_scheduler.add_noise(latents, noise, timesteps)
# --- Predict noise with UNet ---
# SDXL needs add_text_embeds (pooled) and add_time_ids
add_text_embeds = pooled_prompt_embeds
add_time_ids = torch.tensor([
self.config.resolution, self.config.resolution,
0, 0, self.config.resolution, self.config.resolution,
], dtype=self.dtype, device=self.device).repeat(bsz, 1)
added_cond_kwargs = {
"text_embeds": add_text_embeds,
"time_ids": add_time_ids,
}
# Forward pass with mixed precision
autocast_dtype = self.dtype if self.dtype != torch.float32 else None
with torch.autocast(device_type="cuda", dtype=autocast_dtype) if autocast_dtype else torch.cuda.amp.autocast(enabled=False):
model_pred = unet(
noisy_latents,
timesteps,
encoder_hidden_states=prompt_embeds,
added_cond_kwargs=added_cond_kwargs,
).sample
# SDXL predicts velocity (v-prediction) by default in newer configs
# but most checkpoints use epsilon prediction — check scheduler
if noise_scheduler.config.prediction_type == "v_prediction":
target = noise_scheduler.get_velocity(latents, noise, timesteps)
else:
target = noise
loss = F.mse_loss(model_pred.float(), target.float())
# Backward + step (with gradient accumulation)
loss.backward()
if (global_step + 1) % self.config.gradient_accumulation_steps == 0:
torch.nn.utils.clip_grad_norm_(unet.parameters(), 1.0)
optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
global_step += 1
if global_step % progress_every == 0 or global_step == 1:
log.info(
f"Step {global_step}/{num_training_steps} "
f"loss={loss.item():.4f} lr={lr_scheduler.get_last_lr()[0]:.2e}"
)
if global_step % self.config.save_every == 0:
self._save_checkpoint(unet, global_step)
if global_step >= num_training_steps:
break
# 6. Save final LoRA
out_path = self._save_final(unet)
log.info("=" * 60)
log.info(f"LoRA training complete! Saved to: {out_path}")
log.info("=" * 60)
# Cleanup
del unet, vae, text_encoder_1, text_encoder_2, optimizer, lr_scheduler
torch.cuda.empty_cache()
return out_path
@staticmethod
def _collate_fn(batch):
return {
"image": torch.stack([b["image"] for b in batch]),
"caption": [b["caption"] for b in batch],
}
def _save_checkpoint(self, unet, step: int) -> None:
ckpt_path = self.config.output_dir / f"step_{step}.safetensors"
try:
from peft.utils.save_and_load import get_peft_model_state_dict
from safetensors.torch import save_file
state = get_peft_model_state_dict(unet)
save_file(state, str(ckpt_path))
log.info(f" 💾 Checkpoint saved: {ckpt_path}")
except Exception as exc:
log.warning(f" Checkpoint save failed: {exc}")
def _save_final(self, unet) -> Path:
from peft.utils.save_and_load import get_peft_model_state_dict
from safetensors.torch import save_file
state = get_peft_model_state_dict(unet)
out_path = settings.lora_dir / f"{self.config.style_id}.safetensors"
settings.lora_dir.mkdir(parents=True, exist_ok=True)
save_file(state, str(out_path))
log.info(f"Final LoRA saved: {out_path} ({out_path.stat().st_size / 1024 / 1024:.1f} MB)")
return out_path
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _cli():
import argparse
p = argparse.ArgumentParser(description="Indic Heritage Studio v2 — LoRA Training (full SDXL)")
p.add_argument("--style", required=True,
choices=["madhubani", "warli", "pattachitra", "mughal", "tanjore"])
p.add_argument("--rank", type=int, default=32)
p.add_argument("--alpha", type=int, default=32)
p.add_argument("--lr", type=float, default=1e-4)
p.add_argument("--steps", type=int, default=800)
p.add_argument("--batch-size", type=int, default=1)
p.add_argument("--grad-accum", type=int, default=4)
p.add_argument("--epochs", type=int, default=20)
p.add_argument("--resolution", type=int, default=1024)
p.add_argument("--mixed-precision", default="bf16",
choices=["fp16", "bf16", "no"])
p.add_argument("--seed", type=int, default=42)
args = p.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
datefmt="%H:%M:%S",
)
style = get_style(args.style)
config = TrainConfig(
style_id=args.style,
output_dir=settings.outputs_dir / "lora_training" / args.style,
dataset_dir=settings.dataset_dir / args.style,
rank=args.rank,
alpha=args.alpha,
learning_rate=args.lr,
max_train_steps=args.steps,
batch_size=args.batch_size,
gradient_accumulation_steps=args.grad_accum,
num_epochs=args.epochs,
resolution=args.resolution,
mixed_precision=args.mixed_precision,
seed=args.seed,
)
trainer = LoRATrainer(config)
out = trainer.train()
print(f"\n✅ Done. LoRA saved to: {out}")
if __name__ == "__main__":
_cli()