이미지 로딩 중...
AI Generated
2025. 11. 8. · 3 Views
AI 이미지 생성 6편 - LoRA Fine-tuning 완벽 가이드
Stable Diffusion 모델을 효율적으로 커스터마이징하는 LoRA Fine-tuning 기법을 실전 코드와 함께 알아봅니다. 대규모 모델을 적은 리소스로 파인튜닝하여 원하는 스타일의 이미지를 생성하는 방법을 상세히 다룹니다.
목차
1. LoRA_기본_개념
시작하며
여러분이 Stable Diffusion으로 이미지를 생성할 때 특정 캐릭터나 독특한 아트 스타일을 재현하고 싶었던 적 있나요? 기본 모델만으로는 원하는 스타일을 정확히 표현하기 어렵고, 그렇다고 수십 GB짜리 모델 전체를 파인튜닝하기에는 GPU 메모리와 학습 시간이 부족한 상황이죠.
이런 문제는 개인 개발자나 스타트업에게 특히 큰 장벽입니다. 전체 모델을 파인튜닝하려면 수백 GB의 VRAM과 수일에서 수주의 학습 시간이 필요하며, 학습 결과물도 수십 GB에 달해 공유와 배포가 어렵습니다.
바로 이럴 때 필요한 것이 LoRA(Low-Rank Adaptation)입니다. LoRA는 원본 모델의 가중치는 그대로 두고 작은 어댑터 레이어만 학습시켜, 몇 MB 크기의 파일만으로 완전히 새로운 스타일을 구현할 수 있게 해줍니다.
개요
간단히 말해서, LoRA는 대규모 모델의 가중치 행렬을 두 개의 작은 저랭크 행렬로 분해하여 학습하는 효율적인 파인튜닝 기법입니다. 전통적인 파인튜닝이 모든 가중치를 업데이트하는 반면, LoRA는 원본 가중치 W에 ΔW = B × A라는 작은 변화만 추가합니다.
여기서 B와 A는 훨씬 작은 차원의 행렬이죠. 예를 들어, 4096×4096 행렬 대신 4096×8과 8×4096 두 개의 행렬만 학습시키면 파라미터 수가 1/512로 줄어듭니다.
기존에는 15GB 모델 전체를 복사해서 학습했다면, 이제는 2-10MB의 LoRA 파일만으로 동일한 효과를 낼 수 있습니다. 게다가 여러 LoRA를 동시에 적용하거나 가중치를 조절해 스타일을 섞을 수도 있습니다.
핵심 특징은 세 가지입니다: (1) 학습 가능한 파라미터가 전체의 0.11%로 줄어들어 메모리 효율이 극대화되고, (2) 원본 모델과 분리되어 있어 여러 LoRA를 교체하며 사용할 수 있으며, (3) 학습 속도가 10100배 빨라집니다. 이러한 특징들이 개인 개발자도 고품질 커스텀 모델을 만들 수 있게 해주는 이유입니다.
코드 예제
import torch
from diffusers import StableDiffusionPipeline
from peft import LoraConfig, get_peft_model
# Stable Diffusion 기본 모델 로드
base_model = StableDiffusionPipeline.from_pretrained(
"stabilityai/stable-diffusion-2-1-base",
torch_dtype=torch.float16
).to("cuda")
# LoRA 설정: rank=8로 저랭크 분해 (파라미터 대폭 감소)
lora_config = LoraConfig(
r=8, # rank: 작을수록 파일 작아지고 학습 빠름, 클수록 표현력 증가
lora_alpha=32, # 스케일링 팩터 (일반적으로 rank의 2~4배)
target_modules=["to_q", "to_k", "to_v", "to_out.0"], # attention 레이어만 적용
lora_dropout=0.1 # 오버피팅 방지
)
# UNet에 LoRA 어댑터 적용 (원본 가중치는 freeze)
base_model.unet = get_peft_model(base_model.unet, lora_config)
print(f"학습 가능한 파라미터: {base_model.unet.num_parameters(only_trainable=True):,}")
설명
이것이 하는 일: 이 코드는 Stable Diffusion 모델의 UNet 구조에 LoRA 어댑터를 추가하여 효율적인 파인튜닝 환경을 구축합니다. 첫 번째로, Hugging Face의 Diffusers 라이브러리를 통해 Stable Diffusion 2.1 기본 모델을 로드합니다.
torch_dtype=torch.float16을 사용해 메모리를 절반으로 줄이는 것이 포인트입니다. 이렇게 하면 24GB VRAM 대신 12GB로도 충분히 학습할 수 있습니다.
그 다음으로, LoraConfig를 통해 LoRA의 핵심 하이퍼파라미터를 설정합니다. rank(r)는 저랭크 분해의 차원을 결정하는데, 일반적으로 4~128 사이 값을 사용합니다.
r=8은 균형잡힌 선택으로, 파일 크기는 약 3~5MB가 되면서도 충분한 표현력을 가집니다. target_modules에서 "to_q", "to_k", "to_v"는 Transformer의 Query, Key, Value projection을 의미하며, 이 부분만 학습시켜도 스타일 변화에는 충분합니다.
마지막으로, get_peft_model 함수가 원본 UNet의 지정된 레이어에 LoRA 어댑터를 삽입합니다. 이때 원본 가중치는 freeze되고 LoRA 파라미터만 학습 가능 상태가 됩니다.
내부적으로는 각 타겟 모듈에 두 개의 작은 행렬(lora_A, lora_B)이 추가되며, forward pass 시 원본 출력에 B@A@x를 더하는 방식으로 동작합니다. 여러분이 이 코드를 사용하면 860M 파라미터 중 약 0.8M개만 학습하게 되어 메모리 사용량이 10분의 1로 줄어들고, 학습 속도는 5~10배 빨라집니다.
또한 여러 LoRA를 만들어두고 상황에 따라 교체하며 사용할 수 있어, 하나의 기본 모델로 수십 가지 스타일을 구현할 수 있습니다.
실전 팁
💡 rank 값 선택: r=4는 간단한 스타일(색감, 톤), r=16은 복잡한 객체나 캐릭터, r=64~128은 매우 디테일한 특징 학습에 적합합니다. 처음엔 r=8로 시작해서 결과를 보고 조정하세요.
💡 lora_alpha는 일반적으로 rank의 24배로 설정합니다. 너무 크면 학습이 불안정해지고, 너무 작으면 효과가 미미합니다. r=8이면 alpha=1632가 적절합니다.
💡 target_modules 선택: attention 레이어(to_q, to_k, to_v)만 학습시켜도 충분하지만, 더 강력한 효과를 원하면 "to_out", "ff.net" 같은 feedforward 레이어도 포함시키세요. 단, 파일 크기는 2~3배 커집니다.
💡 메모리 부족 시: gradient_checkpointing을 활성화하고 batch_size를 1로 줄이세요. VRAM이 8GB뿐이라면 rank를 4로 낮추고 base 모델 대신 smaller variant를 사용하는 것도 방법입니다.
💡 학습 전 모델의 trainable parameters 개수를 항상 확인하세요. 전체 파라미터의 1% 이하여야 정상입니다. 10% 이상이라면 설정이 잘못된 것입니다.
2. 데이터셋_준비
시작하며
여러분이 LoRA를 학습시키려고 할 때 가장 먼저 막히는 부분이 바로 데이터셋 준비입니다. 이미지 몇 장만 있으면 되는 줄 알았는데, 각 이미지마다 정확한 캡션이 필요하고, 해상도도 맞춰야 하고, 전처리도 해야 한다는 것을 알게 되죠.
데이터셋 품질이 학습 결과를 80% 이상 좌우합니다. 아무리 하이퍼파라미터를 잘 튜닝해도 데이터가 엉망이면 쓸모없는 LoRA가 만들어집니다.
캡션이 부정확하거나 이미지 품질이 낮으면 모델이 잘못된 패턴을 학습하게 됩니다. 바로 이럴 때 필요한 것이 체계적인 데이터 전처리 파이프라인입니다.
이미지 크롭, 리사이징, 자동 캡셔닝, 메타데이터 생성까지 자동화하여 고품질 학습 데이터를 만들어야 합니다.
개요
간단히 말해서, LoRA 학습용 데이터셋은 이미지 파일과 해당 이미지를 설명하는 텍스트 캡션의 쌍으로 구성됩니다. 일반적으로 10~100장의 이미지면 충분하지만, 품질이 핵심입니다.
예를 들어, 특정 캐릭터를 학습시킨다면 다양한 각도, 포즈, 배경에서 찍은 고해상도 이미지가 필요합니다. 모든 이미지가 정면 얼굴만 있다면 측면이나 뒷모습은 제대로 생성하지 못할 것입니다.
기존에는 사람이 직접 각 이미지마다 "a photo of sks person wearing red shirt in garden"처럼 캡션을 작성했다면, 이제는 BLIP2나 LLaVA 같은 vision-language 모델로 자동 생성할 수 있습니다. 핵심 포인트는: (1) 이미지는 512×512 또는 768×768로 정규화되어야 하고, (2) 캡션은 구체적이고 일관성 있어야 하며, (3) 특수 토큰(예: "sks")으로 학습 대상을 명확히 표시해야 합니다.
이러한 요소들이 고품질 LoRA 학습의 기초가 됩니다.
코드 예제
from PIL import Image
from transformers import Blip2Processor, Blip2ForConditionalGeneration
import torch
from pathlib import Path
# BLIP2 모델로 자동 캡셔닝 (이미지 설명 생성)
processor = Blip2Processor.from_pretrained("Salesforce/blip2-opt-2.7b")
caption_model = Blip2ForConditionalGeneration.from_pretrained(
"Salesforce/blip2-opt-2.7b", torch_dtype=torch.float16
).to("cuda")
def prepare_dataset(image_folder, output_size=512, special_token="sks"):
dataset = []
for img_path in Path(image_folder).glob("*.jpg"):
# 이미지 로드 및 정사각형 중앙 크롭
image = Image.open(img_path).convert("RGB")
width, height = image.size
crop_size = min(width, height)
left = (width - crop_size) // 2
top = (height - crop_size) // 2
image = image.crop((left, top, left + crop_size, top + crop_size))
image = image.resize((output_size, output_size), Image.LANCZOS)
# 자동 캡션 생성 (BLIP2 사용)
inputs = processor(image, return_tensors="pt").to("cuda", torch.float16)
generated_ids = caption_model.generate(**inputs, max_length=50)
caption = processor.decode(generated_ids[0], skip_special_tokens=True)
# 특수 토큰 추가로 학습 대상 명확히 표시
caption = f"a photo of {special_token} " + caption
# 전처리된 이미지와 캡션 저장
output_path = f"processed/{img_path.stem}.jpg"
image.save(output_path)
with open(f"processed/{img_path.stem}.txt", "w") as f:
f.write(caption)
dataset.append({"image": output_path, "caption": caption})
return dataset
# 사용 예시
dataset = prepare_dataset("./raw_images", output_size=512, special_token="sks")
print(f"준비된 데이터셋: {len(dataset)}개 샘플")
설명
이것이 하는 일: 이 코드는 원본 이미지를 LoRA 학습에 적합한 형태로 전처리하고, 각 이미지에 대한 설명 캡션을 자동으로 생성합니다. 첫 번째로, BLIP2 모델을 로드합니다.
BLIP2는 이미지를 보고 자연어 설명을 생성하는 vision-language 모델로, 사람이 직접 캡션을 작성하는 수고를 덜어줍니다. float16을 사용해 메모리를 절약하면서도 캡션 품질은 거의 동일하게 유지됩니다.
그 다음으로, prepare_dataset 함수가 각 이미지를 처리합니다. 먼저 이미지를 정사각형으로 중앙 크롭하는데, 이는 Stable Diffusion이 정사각형 입력을 기대하기 때문입니다.
가로가 더 길다면 좌우를 자르고, 세로가 더 길다면 상하를 잘라냅니다. 그 다음 LANCZOS 리샘플링으로 512×512나 768×768로 리사이징하는데, 이 방식이 품질 손실을 최소화합니다.
세 번째 단계로, BLIP2가 이미지를 분석해 캡션을 생성합니다. 예를 들어 강아지 사진이라면 "a small brown dog sitting on grass"처럼 생성됩니다.
여기에 "a photo of sks"를 앞에 붙여서 "a photo of sks a small brown dog sitting on grass"로 만듭니다. "sks"는 학습 대상을 가리키는 고유 토큰으로, 나중에 이미지 생성 시 "sks dog"라고 프롬프트를 주면 학습한 특정 강아지를 생성하게 됩니다.
마지막으로, 전처리된 이미지는 JPG로 저장하고, 캡션은 같은 이름의 TXT 파일로 저장합니다. 많은 LoRA 학습 스크립트가 이런 파일 쌍 구조를 기대하기 때문입니다.
예: dog001.jpg / dog001.txt. 여러분이 이 코드를 사용하면 수백 장의 이미지를 몇 분 만에 전처리할 수 있고, 캡션 작성에 드는 시간과 노력을 90% 이상 줄일 수 있습니다.
또한 일관된 캡션 스타일로 학습 안정성이 향상됩니다.
실전 팁
💡 이미지 개수: 간단한 스타일 변경은 1020장, 특정 캐릭터나 객체는 50100장, 복잡한 콘셉트는 100~500장이 적정합니다. 너무 적으면 일반화가 안 되고, 너무 많으면 학습 시간만 늘어날 뿐 효과는 미미합니다.
💡 BLIP2 캡션이 부정확하면 수동으로 수정하세요. 특히 학습 대상이 되는 주요 객체나 특징은 반드시 캡션에 포함되어야 합니다. "sks person with blue eyes"처럼 구체적일수록 좋습니다.
💡 다양성 확보: 같은 각도, 같은 배경의 이미지만 있으면 과적합됩니다. 최소 3~5가지 다른 포즈, 조명, 배경을 섞어주세요. augmentation(좌우 반전, 밝기 조절)도 도움이 됩니다.
💡 특수 토큰은 드물게 사용되는 단어를 선택하세요. "person", "dog" 같은 흔한 단어는 피하고, "sks", "xyz", "ohwx" 같은 고유한 조합을 사용합니다. 토크나이저에 이미 있는 단어라면 혼선이 생깁니다.
💡 해상도는 모델의 기본 해상도와 일치시키세요. SD 1.5는 512×512, SD 2.x는 768×768, SDXL은 1024×1024입니다. 다른 해상도로 학습하면 품질이 떨어집니다.
3. LoRA_학습_환경_설정
시작하며
여러분이 데이터셋을 준비했다면 이제 실제 학습을 시작할 차례입니다. 하지만 learning rate는 얼마로?
batch size는? 몇 epoch를 돌려야 할까요?
잘못 설정하면 수 시간 학습시켜도 쓸모없는 결과물이 나옵니다. 하이퍼파라미터 설정은 LoRA 학습의 성패를 가릅니다.
learning rate가 너무 높으면 모델이 발산하고, 너무 낮으면 학습이 안 됩니다. epoch가 너무 많으면 과적합되어 학습 이미지만 복사하게 되고, 너무 적으면 특징을 제대로 학습하지 못합니다.
바로 이럴 때 필요한 것이 검증된 하이퍼파라미터 설정 방법입니다. Stable Diffusion LoRA 커뮤니티에서 수천 번의 실험을 통해 찾아낸 최적값을 기반으로 시작하면 시행착오를 크게 줄일 수 있습니다.
개요
간단히 말해서, LoRA 학습 환경 설정은 optimizer, learning rate, batch size, epoch 수, gradient accumulation 등의 하이퍼파라미터를 조정하는 과정입니다. 가장 중요한 것은 learning rate입니다.
일반적으로 LoRA는 1e-45e-4 범위를 사용하는데, 이는 전체 파인튜닝의 1e-51e-6보다 10~50배 높습니다. 왜냐하면 학습 가능한 파라미터가 매우 적기 때문에 더 큰 step size가 필요하기 때문이죠.
예를 들어, 전체 모델은 1e-6으로 조심스럽게 움직이지만, LoRA는 1e-4로 빠르게 최적점을 찾아갑니다. 기존에는 GPU 메모리에 맞춰 batch_size를 최대한 키웠다면, LoRA에서는 gradient_accumulation_steps를 활용해 작은 batch size로도 효과적인 대규모 배치 학습을 시뮬레이션할 수 있습니다.
batch_size=1, gradient_accumulation=4는 실질적으로 batch_size=4와 동일하지만 메모리는 1/4만 사용합니다. 핵심 설정 원칙: (1) learning_rate는 데이터셋 크기에 반비례 (많을수록 낮춤), (2) epoch는 50100장이라면 1020 epoch, 500장 이상이면 5~10 epoch, (3) warmup_steps로 초반 불안정성 제거, (4) save_every_n_epochs로 중간 체크포인트 저장해 과적합 시점 파악.
이러한 원칙들이 안정적인 학습을 보장합니다.
코드 예제
from accelerate import Accelerator
from transformers import get_cosine_schedule_with_warmup
import torch.optim as optim
# Accelerate로 분산 학습 및 mixed precision 설정
accelerator = Accelerator(
mixed_precision="fp16", # 메모리 절약 + 학습 속도 2배
gradient_accumulation_steps=4 # 작은 배치를 누적해 큰 배치 효과
)
# 학습 하이퍼파라미터 설정
training_config = {
"learning_rate": 1e-4, # LoRA는 일반 파인튜닝보다 10배 높게
"lr_scheduler": "cosine", # cosine decay로 부드러운 수렴
"warmup_steps": 100, # 초반 100 step은 lr을 0에서 점진적 증가
"max_train_steps": 2000, # 총 학습 step (epoch * dataset_size / batch)
"batch_size": 1, # GPU 메모리에 맞춰 조정 (gradient_accumulation으로 보완)
"gradient_checkpointing": True, # 메모리 절약 (속도는 20% 감소)
"mixed_precision": "fp16", # 메모리 1/2, 속도 2배
}
# AdamW optimizer (weight decay로 과적합 방지)
optimizer = optim.AdamW(
model.unet.parameters(),
lr=training_config["learning_rate"],
betas=(0.9, 0.999), # 기본값, 안정적
weight_decay=0.01, # L2 regularization
eps=1e-8
)
# Cosine 스케줄러 (학습 후반부로 갈수록 lr 감소)
lr_scheduler = get_cosine_schedule_with_warmup(
optimizer,
num_warmup_steps=training_config["warmup_steps"],
num_training_steps=training_config["max_train_steps"]
)
# Accelerator로 모델, optimizer, scheduler 준비
model.unet, optimizer, lr_scheduler = accelerator.prepare(
model.unet, optimizer, lr_scheduler
)
print(f"학습 설정 완료 - LR: {training_config['learning_rate']}, Steps: {training_config['max_train_steps']}")
설명
이것이 하는 일: 이 코드는 LoRA 학습에 최적화된 optimizer, learning rate scheduler, mixed precision 환경을 설정합니다. 첫 번째로, Accelerator를 초기화합니다.
Hugging Face의 Accelerate 라이브러리는 복잡한 분산 학습, mixed precision, gradient accumulation을 단 몇 줄로 처리해줍니다. mixed_precision="fp16"으로 설정하면 가중치를 float16으로 저장해 메모리를 절반으로 줄이면서도 정확도는 거의 동일하게 유지됩니다.
gradient_accumulation_steps=4는 batch_size=1로 4번 forward/backward를 수행한 후 한 번만 optimizer.step()을 호출하여, 실질적으로 batch_size=4의 효과를 냅니다. 그 다음으로, training_config 딕셔너리에 핵심 하이퍼파라미터를 정의합니다.
learning_rate=1e-4는 LoRA에 적합한 값으로, 전체 파인튜닝의 1e-6보다 100배 높습니다. 왜냐하면 학습 파라미터가 0.1%뿐이므로 더 큰 update가 필요하기 때문입니다.
warmup_steps=100은 처음 100 step 동안 learning rate를 0에서 1e-4까지 선형 증가시켜 초반 불안정성을 방지합니다. 세 번째로, AdamW optimizer를 생성합니다.
Adam의 개선 버전으로 weight_decay를 제대로 적용해 과적합을 방지합니다. betas=(0.9, 0.999)는 momentum 계수로, 첫 번째는 gradient의 이동 평균, 두 번째는 gradient 제곱의 이동 평균을 의미합니다.
대부분의 경우 기본값이 최적입니다. 네 번째로, get_cosine_schedule_with_warmup으로 learning rate 스케줄러를 만듭니다.
Cosine scheduler는 학습 초반에는 높은 lr로 빠르게 학습하다가 후반부로 갈수록 코사인 곡선을 따라 lr을 0에 가깝게 감소시킵니다. 이렇게 하면 초반에는 큰 영역을 탐색하고 후반에는 세밀하게 최적점을 찾아 더 나은 수렴을 달성합니다.
마지막으로, accelerator.prepare()가 모델, optimizer, scheduler를 분산 학습과 mixed precision에 맞게 래핑합니다. 이후 일반적인 PyTorch 학습 루프를 작성하면 Accelerator가 알아서 backward 시 gradient를 accumulate하고, step 시 적절히 스케일링하고, 여러 GPU가 있다면 분산 처리까지 해줍니다.
여러분이 이 코드를 사용하면 12GB VRAM으로도 안정적으로 LoRA를 학습할 수 있고, learning rate 설정 실수로 인한 학습 실패를 크게 줄일 수 있습니다. 또한 중간 체크포인트를 저장해두면 과적합이 시작되기 직전의 최적 모델을 선택할 수 있습니다.
실전 팁
💡 Learning rate 찾기: 1e-5부터 시작해서 loss가 감소하지 않으면 10배씩 올려보세요. 보통 1e-4~5e-4 범위에서 최적값을 찾을 수 있습니다. 너무 높으면 loss가 NaN이 되거나 진동합니다.
💡 과적합 감지: validation loss를 모니터링하거나, 매 5 epoch마다 샘플 이미지를 생성해보세요. 학습 이미지를 그대로 복사하기 시작하면 과적합 신호입니다. 그 직전 체크포인트를 선택하세요.
💡 Gradient accumulation은 VRAM이 부족할 때 필수입니다. batch_size=1, accumulation=8이면 실질 batch_size=8이지만 메모리는 1만 사용합니다. 단, step 수는 1/8로 줄어들므로 epoch 수를 늘려야 합니다.
💡 8bit AdamW (bitsandbytes 라이브러리)를 사용하면 optimizer state 메모리를 75% 절약할 수 있습니다. 특히 LoRA rank가 클 때 유용합니다. from bitsandbytes.optim import AdamW8bit로 교체하면 됩니다.
💡 Warmup은 학습 안정성에 매우 중요합니다. 특히 learning rate가 1e-4 이상으로 높을 때는 warmup 없이 시작하면 초반에 발산할 수 있습니다. 전체 step의 5~10%를 warmup으로 할당하세요.
4. 모델_학습_구현
시작하며
여러분이 환경 설정까지 마쳤다면 이제 실제 학습 루프를 구현할 차례입니다. 하지만 Stable Diffusion의 학습 과정은 일반적인 이미지 분류와 완전히 다릅니다.
noise prediction, timestep sampling, text encoding 등 생소한 개념들이 등장하죠. Diffusion 모델의 학습 원리를 이해하지 못하면 코드를 복사-붙여넣기만 하게 되고, 문제가 생겼을 때 해결할 수 없습니다.
왜 이미지에 노이즈를 추가하는지, timestep이 무엇인지, loss가 의미하는 게 뭔지 모르면 하이퍼파라미터 튜닝도 불가능합니다. 바로 이럴 때 필요한 것이 단계별로 이해할 수 있는 학습 루프 구현입니다.
각 줄이 어떤 역할을 하는지 명확히 알면 디버깅도 쉽고, 자신만의 커스터마이징도 가능해집니다.
개요
간단히 말해서, Stable Diffusion LoRA 학습은 "노이즈가 추가된 이미지에서 노이즈를 예측하도록" 모델을 훈련시키는 과정입니다. 학습 프로세스는 이렇습니다: (1) 깨끗한 이미지에 랜덤 노이즈를 추가, (2) 텍스트 캡션을 CLIP으로 인코딩, (3) UNet이 노이즈를 예측, (4) 예측 노이즈와 실제 노이즈의 차이(MSE loss)를 계산, (5) backpropagation으로 LoRA 파라미터만 업데이트.
이 과정을 수천 번 반복하면 모델이 특정 스타일의 이미지에서 노이즈를 제거하는 법을 학습합니다. 기존의 supervised learning이 "입력 → 정답 레이블" 형태였다면, diffusion 학습은 "노이즈 섞인 이미지 + 텍스트 → 추가된 노이즈"를 예측하는 형태입니다.
추론 시에는 이 과정을 역으로 수행해 순수 노이즈에서 점진적으로 노이즈를 제거하여 이미지를 생성합니다. 핵심은 timestep sampling입니다.
Diffusion 과정은 0~1000 timestep으로 나뉘는데, 각 step마다 노이즈 양이 다릅니다. 학습 시 랜덤하게 timestep을 선택해 다양한 노이즈 레벨에서 예측을 학습시킵니다.
이렇게 해야 추론 시 모든 timestep에서 정확한 denoising이 가능합니다.
코드 예제
import torch
import torch.nn.functional as F
from tqdm import tqdm
from diffusers import DDPMScheduler
# Noise scheduler (DDPM 방식으로 노이즈 추가/제거 스케줄 관리)
noise_scheduler = DDPMScheduler.from_pretrained(
"stabilityai/stable-diffusion-2-1-base", subfolder="scheduler"
)
def train_one_epoch(model, dataloader, optimizer, lr_scheduler, accelerator):
model.train()
total_loss = 0
progress_bar = tqdm(dataloader, desc="Training")
for step, batch in enumerate(progress_bar):
with accelerator.accumulate(model.unet):
# 1. 텍스트 캡션을 CLIP으로 인코딩 (조건 정보)
text_embeddings = model.text_encoder(batch["input_ids"])[0]
# 2. 깨끗한 이미지를 latent space로 인코딩 (VAE 사용)
latents = model.vae.encode(batch["images"]).latent_dist.sample()
latents = latents * model.vae.config.scaling_factor # 0.18215
# 3. 랜덤 노이즈 생성 및 timestep 샘플링
noise = torch.randn_like(latents)
timesteps = torch.randint(0, noise_scheduler.config.num_train_timesteps,
(latents.shape[0],), device=latents.device)
# 4. 노이즈 추가 (forward diffusion process)
noisy_latents = noise_scheduler.add_noise(latents, noise, timesteps)
# 5. UNet으로 노이즈 예측 (LoRA가 적용된 레이어 포함)
noise_pred = model.unet(noisy_latents, timesteps, text_embeddings).sample
# 6. MSE Loss 계산 (예측 노이즈 vs 실제 노이즈)
loss = F.mse_loss(noise_pred, noise, reduction="mean")
# 7. Backpropagation (LoRA 파라미터만 업데이트)
accelerator.backward(loss)
# Gradient clipping (폭발 방지)
if accelerator.sync_gradients:
accelerator.clip_grad_norm_(model.unet.parameters(), 1.0)
optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
total_loss += loss.detach().item()
progress_bar.set_postfix({"loss": loss.detach().item()})
return total_loss / len(dataloader)
# 학습 실행
for epoch in range(num_epochs):
avg_loss = train_one_epoch(model, train_dataloader, optimizer, lr_scheduler, accelerator)
print(f"Epoch {epoch+1}/{num_epochs} - Average Loss: {avg_loss:.4f}")
# 체크포인트 저장
if (epoch + 1) % 5 == 0:
model.unet.save_pretrained(f"lora_checkpoint_epoch{epoch+1}")
설명
이것이 하는 일: 이 코드는 Stable Diffusion의 핵심인 denoising diffusion 학습 루프를 구현합니다. 첫 번째로, DDPMScheduler를 로드합니다.
이것은 노이즈를 추가하고 제거하는 스케줄을 관리하는 객체로, 각 timestep에서 얼마나 많은 노이즈를 섞을지 정의합니다. DDPM(Denoising Diffusion Probabilistic Models)은 1000 step에 걸쳐 점진적으로 노이즈를 추가/제거하는 방식입니다.
그 다음으로, 학습 루프에서 각 배치를 처리합니다. 먼저 텍스트 캡션을 CLIP text encoder로 인코딩해 768차원(SD 2.1 기준) 벡터로 변환합니다.
이것이 "어떤 이미지를 생성할지"에 대한 조건 정보가 됩니다. 동시에 이미지는 VAE encoder를 거쳐 latent space로 압축됩니다.
512×512×3 이미지가 64×64×4 latent로 줄어들어 메모리와 연산량이 64배 감소합니다. 세 번째로, 랜덤 노이즈와 timestep을 샘플링합니다.
torch.randn_like(latents)로 latent와 같은 크기의 가우시안 노이즈를 생성하고, timestep은 0~999 사이에서 랜덤 선택합니다. 그 다음 noise_scheduler.add_noise()가 선택된 timestep에 해당하는 양만큼 노이즈를 latent에 추가합니다.
timestep=500이면 50% 노이즈 섞인 이미지가 됩니다. 네 번째로, UNet이 등장합니다.
noisy_latents, timestep, text_embeddings 세 가지를 입력받아 "이 timestep에서 제거해야 할 노이즈"를 예측합니다. 여기서 LoRA가 적용된 attention 레이어들이 활성화되어 학습한 스타일에 맞는 노이즈 패턴을 예측하게 됩니다.
예측된 noise_pred와 실제 추가한 noise의 MSE(Mean Squared Error)를 loss로 계산합니다. 마지막으로, accelerator.backward(loss)로 gradient를 계산하고 optimizer.step()으로 LoRA 파라미터만 업데이트합니다.
원본 UNet 가중치는 freeze되어 있으므로 변하지 않습니다. Gradient clipping으로 gradient 폭발을 방지하고, lr_scheduler.step()으로 learning rate를 조정합니다.
이 과정을 수천 번 반복하면 모델이 특정 스타일의 노이즈 패턴을 학습하게 됩니다. 여러분이 이 코드를 사용하면 Stable Diffusion의 학습 메커니즘을 완전히 이해하게 되고, 문제 발생 시 어느 부분을 디버깅해야 할지 정확히 알 수 있습니다.
또한 VAE, CLIP, UNet의 역할을 명확히 파악하여 더 고급 커스터마이징도 가능해집니다.
실전 팁
💡 Loss가 감소하지 않으면: (1) learning rate를 1/10로 낮추거나, (2) gradient accumulation을 늘리거나, (3) 데이터셋 캡션이 정확한지 확인하세요. Loss가 0.1 아래로 내려가면 정상입니다.
💡 VAE의 scaling_factor(0.18215)는 절대 빼먹으면 안 됩니다. 이것은 latent를 정규화하는 상수로, 없으면 학습이 제대로 안 됩니다. SD 1.x/2.x는 0.18215, SDXL은 0.13025입니다.
💡 Timestep sampling 전략: uniform sampling(기본값) 대신 importance sampling을 쓰면 더 빠른 수렴이 가능합니다. 중간 timestep(300~700)에 더 많은 가중치를 주는 방식입니다.
💡 학습 중간에 샘플 이미지를 생성해보세요. 매 100 step마다 "sks person"으로 이미지를 생성하면 학습 진행 상황을 눈으로 확인할 수 있습니다. TensorBoard나 wandb로 로깅하면 더 편리합니다.
💡 Out of Memory 에러가 나면: (1) batch_size를 1로, (2) gradient_checkpointing=True, (3) VAE를 CPU로 이동(학습 중에는 필요 없음), (4) images를 512 대신 448로 줄이기 등을 시도하세요.
5. 학습된_LoRA_적용
시작하며
여러분이 몇 시간 동안 LoRA를 학습시켰다면 이제 실제로 사용해볼 차례입니다. 하지만 학습된 LoRA를 기본 모델에 어떻게 로드하고, 어떤 프롬프트를 써야 학습한 스타일이 제대로 나올까요?
그냥 일반 이미지 생성하듯이 하면 LoRA가 작동하지 않습니다. LoRA 적용 방법을 모르면 학습은 성공했는데 사용은 못하는 아이러니한 상황이 됩니다.
특수 토큰을 빼먹거나, LoRA 가중치를 잘못 설정하거나, 호환되지 않는 기본 모델을 사용하면 전혀 다른 결과가 나옵니다. 바로 이럴 때 필요한 것이 정확한 LoRA 로딩과 프롬프트 작성 방법입니다.
학습 시 사용한 특수 토큰과 trigger words를 적절히 활용하면 원하는 스타일의 이미지를 자유자재로 생성할 수 있습니다.
개요
간단히 말해서, 학습된 LoRA를 적용한다는 것은 기본 Stable Diffusion 모델에 LoRA 가중치를 로드하여 특정 스타일이나 객체를 생성할 수 있게 만드는 과정입니다. 핵심은 두 가지입니다: (1) LoRA 파일을 올바른 레이어에 로드, (2) 프롬프트에 학습 시 사용한 특수 토큰 포함.
예를 들어, "sks person" 토큰으로 학습했다면 생성 시에도 "a photo of sks person in paris"처럼 반드시 "sks person"을 포함해야 합니다. 그렇지 않으면 LoRA가 활성화되지 않습니다.
기존에는 완전히 새로운 모델 파일을 로드해야 했다면, LoRA는 몇 MB짜리 어댑터만 교체하면 됩니다. 하나의 기본 모델(15GB)을 유지하면서 수십 개의 LoRA(각 3MB)를 바꿔가며 사용할 수 있어 매우 효율적입니다.
또한 LoRA 가중치 스케일을 조정할 수 있습니다. scale=1.0이 기본이지만, 0.5로 낮추면 LoRA 효과가 절반으로 줄어들어 기본 모델과 블렌딩되고, 1.5로 높이면 더 강렬한 스타일이 적용됩니다.
이를 통해 하나의 LoRA로도 다양한 강도의 스타일을 구현할 수 있습니다.
코드 예제
from diffusers import StableDiffusionPipeline
import torch
# 1. 기본 Stable Diffusion 모델 로드
pipeline = StableDiffusionPipeline.from_pretrained(
"stabilityai/stable-diffusion-2-1-base",
torch_dtype=torch.float16,
safety_checker=None # 빠른 생성을 위해 비활성화 (선택)
).to("cuda")
# 2. 학습된 LoRA 가중치 로드 (몇 MB 크기)
pipeline.unet.load_attn_procs("./lora_checkpoint_epoch15") # LoRA 파일 경로
# 3. LoRA 가중치 스케일 조정 (0.0~2.0, 기본 1.0)
lora_scale = 0.8 # LoRA 효과 80%로 조정
for attn_proc in pipeline.unet.attn_processors.values():
if hasattr(attn_proc, 'scale'):
attn_proc.scale = lora_scale
# 4. 프롬프트에 특수 토큰 포함 (학습 시 사용한 토큰 필수!)
prompt = "a photo of sks person wearing sunglasses in cyberpunk city, detailed, 8k"
negative_prompt = "ugly, blurry, low quality, distorted"
# 5. 이미지 생성 (LoRA가 적용된 커스텀 스타일로)
image = pipeline(
prompt=prompt,
negative_prompt=negative_prompt,
num_inference_steps=30, # 50 step이 기본, 30이면 더 빠르게
guidance_scale=7.5, # 프롬프트 충실도 (7~9 권장)
width=512,
height=512,
generator=torch.manual_seed(42) # 재현성을 위한 시드
).images[0]
image.save("generated_with_lora.png")
print("LoRA 적용 이미지 생성 완료!")
설명
이것이 하는 일: 이 코드는 학습된 LoRA 가중치를 Stable Diffusion 파이프라인에 로드하고, 특수 토큰을 사용해 커스텀 스타일의 이미지를 생성합니다. 첫 번째로, StableDiffusionPipeline으로 기본 모델을 로드합니다.
이것은 VAE, CLIP text encoder, UNet, scheduler가 모두 포함된 통합 파이프라인입니다. torch_dtype=torch.float16으로 메모리를 절반으로 줄이고, safety_checker=None으로 NSFW 필터를 비활성화해 생성 속도를 올립니다(프로덕션에서는 활성화 권장).
그 다음으로, load_attn_procs()로 LoRA 가중치를 로드합니다. 이 함수는 지정된 경로에서 LoRA 파라미터(lora_A, lora_B 행렬)를 읽어와 UNet의 attention 프로세서에 주입합니다.
기본 모델의 가중치는 변하지 않고, attention 연산 시 LoRA의 추가 변환이 적용되는 방식입니다. 파일 크기가 3~10MB에 불과해 로딩이 1초 이내로 완료됩니다.
세 번째로, LoRA의 효과 강도를 조절합니다. scale=1.0이 학습 시 설정한 전체 효과이고, 0.5로 낮추면 LoRA 스타일이 절반만 적용되어 기본 모델과 블렌딩됩니다.
예를 들어, 애니메이션 스타일 LoRA를 scale=0.3으로 적용하면 사실적인 이미지에 약간의 애니메이션 느낌만 추가할 수 있습니다. 1.5~2.0으로 높이면 과장된 스타일이 나오는데, 너무 높으면 artifacts가 생길 수 있습니다.
네 번째로, 프롬프트를 작성합니다. 핵심은 학습 시 사용한 특수 토큰(여기서는 "sks person")을 반드시 포함해야 한다는 것입니다.
이 토큰이 LoRA를 활성화하는 trigger 역할을 합니다. "sks person"을 빼먹으면 일반 SD 모델처럼 작동해 학습한 스타일이 전혀 나오지 않습니다.
Negative prompt는 원하지 않는 특징을 제거하는데, "blurry, low quality" 같은 일반적인 부정어를 사용합니다. 마지막으로, pipeline()을 호출해 이미지를 생성합니다.
num_inference_steps는 denoising step 수로, 많을수록 품질이 좋지만 느립니다. 30 step이면 약 3초, 50 step은 5초 정도 걸립니다.
guidance_scale은 프롬프트 충실도를 조절하는데, 높을수록 프롬프트를 강하게 따르지만 다양성이 줄어듭니다. 7.5가 균형잡힌 값입니다.
generator에 시드를 고정하면 같은 이미지를 재현할 수 있어 디버깅과 비교에 유용합니다. 여러분이 이 코드를 사용하면 몇 초 만에 LoRA를 교체하며 다양한 스타일의 이미지를 생성할 수 있습니다.
하나의 기본 모델로 수십 가지 커스텀 스타일을 구현할 수 있어 디스크 공간과 메모리를 크게 절약할 수 있습니다.
실전 팁
💡 특수 토큰을 프롬프트 앞부분에 배치하세요. "sks person wearing hat"이 "wearing hat, sks person"보다 효과가 좋습니다. CLIP은 앞부분 토큰에 더 큰 가중치를 줍니다.
💡 여러 LoRA를 동시에 로드할 수 있습니다. 예: 캐릭터 LoRA + 스타일 LoRA. 각각 다른 scale을 적용해 원하는 조합을 만들 수 있습니다. 단, 너무 많으면(3개 이상) 충돌이 생길 수 있습니다.
💡 LoRA가 작동하지 않으면: (1) 기본 모델 버전 확인 (SD 1.5 LoRA는 SD 2.x에서 안 됨), (2) 특수 토큰 철자 확인, (3) scale을 1.5로 높여보기. 그래도 안 되면 학습이 실패한 것입니다.
💡 최적의 guidance_scale 찾기: 7.5부터 시작해서 너무 단조로우면 6.0으로, 너무 과장되면 9.0으로 조정하세요. LoRA마다 최적값이 다르므로 실험이 필요합니다.
💡 빠른 생성을 위해 DPM++ 2M Karras scheduler를 사용하세요. 20 step만으로 50 step 품질을 낼 수 있습니다. pipeline.scheduler = DPMSolverMultistepScheduler.from_config(pipeline.scheduler.config)로 교체하면 됩니다.
6. 다중_LoRA_병합
시작하며
여러분이 여러 개의 LoRA를 학습시켰다면 이것들을 조합해 새로운 스타일을 만들고 싶을 것입니다. 캐릭터 LoRA와 배경 스타일 LoRA를 함께 쓰거나, 두 개의 아트 스타일을 섞어서 독특한 결과물을 만들고 싶은데 어떻게 해야 할까요?
단순히 두 LoRA를 순차적으로 로드하면 나중에 로드한 것이 이전 것을 덮어쓰게 됩니다. 또는 두 LoRA가 같은 레이어를 수정하면서 충돌이 발생해 이상한 결과가 나올 수 있습니다.
각 LoRA의 영향력을 조절하면서 조화롭게 병합하는 방법이 필요합니다. 바로 이럴 때 필요한 것이 가중치 기반 LoRA 병합 기법입니다.
각 LoRA에 다른 가중치를 부여하여 하나로 합치거나, 실시간으로 여러 LoRA를 동시에 적용하여 원하는 조합을 만들어낼 수 있습니다.
개요
간단히 말해서, 다중 LoRA 병합은 여러 LoRA의 가중치를 선형 결합하여 하나의 새로운 LoRA를 만들거나, 추론 시 여러 LoRA를 동시에 적용하는 기법입니다. 수학적으로는 매우 간단합니다.
LoRA_merged = α × LoRA_A + β × LoRA_B 형태로, 각 LoRA의 가중치 행렬에 스칼라를 곱하고 더합니다. 예를 들어, 애니메이션 스타일 LoRA(가중치 0.7)와 수채화 스타일 LoRA(가중치 0.3)를 병합하면 애니메이션 느낌이 더 강한 수채화 스타일을 얻을 수 있습니다.
병합 방식은 두 가지입니다: (1) Offline 병합 - 사전에 가중치를 합쳐서 하나의 LoRA 파일로 저장, 추론 시 로딩 속도가 빠름. (2) Online 병합 - 추론 시 여러 LoRA를 동시에 로드하고 forward pass 때마다 결합, 가중치를 실시간으로 조절 가능.
전자는 배포용, 후자는 실험용으로 적합합니다. 주의할 점은 LoRA들이 같은 기본 모델에서 학습되어야 하고, 같은 rank와 target_modules를 가져야 한다는 것입니다.
SD 1.5 LoRA와 SD 2.1 LoRA를 병합하면 제대로 작동하지 않습니다. 또한 너무 많은 LoRA를 병합하면(5개 이상) 각각의 특징이 희석되어 효과가 미미해질 수 있습니다.
코드 예제
import torch
from safetensors.torch import load_file, save_file
def merge_lora_weights(lora_paths, weights, output_path):
"""
여러 LoRA를 가중치 기반으로 병합
Args:
lora_paths: LoRA 파일 경로 리스트
weights: 각 LoRA에 적용할 가중치 (합이 1.0일 필요 없음)
output_path: 병합된 LoRA 저장 경로
"""
assert len(lora_paths) == len(weights), "LoRA 개수와 가중치 개수 일치 필요"
merged_state_dict = {}
# 첫 번째 LoRA를 기준으로 시작
base_state = load_file(lora_paths[0])
for key in base_state.keys():
# 각 레이어의 가중치를 선형 결합
merged_state_dict[key] = base_state[key] * weights[0]
# 나머지 LoRA들을 가중치 적용하여 더함
for lora_path, weight in zip(lora_paths[1:], weights[1:]):
state = load_file(lora_path)
for key in state.keys():
if key in merged_state_dict:
merged_state_dict[key] += state[key] * weight
else:
# 새로운 레이어가 있다면 추가
merged_state_dict[key] = state[key] * weight
# 병합된 LoRA 저장
save_file(merged_state_dict, output_path)
print(f"병합 완료: {output_path}")
return merged_state_dict
# 사용 예시: 애니메이션 스타일(70%) + 수채화(30%) 병합
lora_paths = [
"./lora_anime_style.safetensors",
"./lora_watercolor.safetensors"
]
weights = [0.7, 0.3]
merge_lora_weights(lora_paths, weights, "./lora_anime_watercolor.safetensors")
# 병합된 LoRA 로드 및 사용
from diffusers import StableDiffusionPipeline
pipeline = StableDiffusionPipeline.from_pretrained(
"stabilityai/stable-diffusion-2-1-base",
torch_dtype=torch.float16
).to("cuda")
# 병합된 LoRA 적용
pipeline.unet.load_attn_procs("./lora_anime_watercolor.safetensors")
# 두 스타일이 혼합된 이미지 생성
image = pipeline(
"a beautiful landscape with mountains and lake, detailed",
num_inference_steps=30,
guidance_scale=7.5
).images[0]
image.save("merged_style.png")
설명
이것이 하는 일: 이 코드는 여러 LoRA의 가중치 행렬을 가중 평균하여 하나의 통합된 LoRA를 생성합니다. 첫 번째로, safetensors 라이브러리로 LoRA 파일을 로드합니다.
Safetensors는 PyTorch의 pickle 기반 저장 방식보다 안전하고 빠른 포맷으로, 최근 Hugging Face의 표준이 되었습니다. load_file()은 LoRA의 state_dict를 반환하는데, 이는 {"layer_name": tensor} 형태의 딕셔너리입니다.
예를 들어, "unet.down_blocks.0.attentions.0.to_q.lora_A"처럼 각 레이어의 LoRA 행렬이 key가 됩니다. 그 다음으로, 첫 번째 LoRA를 기준으로 merged_state_dict를 초기화합니다.
각 레이어의 텐서에 weights[0]을 곱해 스케일링합니다. 예를 들어, 가중치가 0.7이면 모든 파라미터가 70%로 줄어듭니다.
이것이 첫 번째 LoRA의 "영향력"을 조절하는 방식입니다. 세 번째로, 루프를 돌며 나머지 LoRA들을 하나씩 더합니다.
각 LoRA의 같은 레이어(key)를 찾아서 가중치를 곱한 후 merged_state_dict[key]에 누적합니다. 수학적으로 merged = 0.7 × lora_A + 0.3 × lora_B 형태가 됩니다.
LoRA는 원본 가중치에 대한 "변화량(delta)"이므로, 이렇게 선형 결합해도 문제없습니다. 만약 두 LoRA가 서로 다른 레이어를 타겟팅했다면(하나는 to_q만, 다른 하나는 to_v만) 새로운 key가 추가됩니다.
네 번째로, save_file()로 병합된 state_dict를 safetensors 파일로 저장합니다. 파일 크기는 대략 원본 LoRA들의 크기와 비슷하거나 약간 큽니다.
이제 이 파일 하나만 로드하면 두 LoRA의 효과를 동시에 얻을 수 있어 추론 시 편리합니다. 마지막으로, 병합된 LoRA를 일반 LoRA처럼 load_attn_procs()로 로드하여 사용합니다.
프롬프트는 원하는 대로 작성하면 되고, 특수 토큰이 필요한 경우 두 LoRA가 사용한 토큰을 모두 포함할 수 있습니다. 생성된 이미지는 애니메이션 스타일(70%)과 수채화 느낌(30%)이 혼합된 독특한 스타일을 보여줍니다.
여러분이 이 코드를 사용하면 무한한 스타일 조합을 실험할 수 있습니다. 예를 들어, 캐릭터 LoRA 0.8 + 배경 LoRA 0.5 + 조명 LoRA 0.3처럼 세 개 이상도 가능하고, 가중치 합이 1.0을 넘어도 됩니다(단, 너무 크면 artifacts 발생).
또한 negative weight도 가능해서 특정 스타일을 "빼는" 것도 이론적으로 가능하지만 불안정할 수 있습니다.
실전 팁
💡 가중치 합이 1.0일 필요는 없습니다. [1.0, 0.5]처럼 합이 1.5여도 작동하지만, 너무 크면(3.0 이상) 이미지가 과포화되거나 깨질 수 있습니다. 보수적으로 시작하세요.
💡 LoRA 간 충돌 최소화: 같은 콘셉트를 학습한 LoRA끼리 병합하면 충돌이 심합니다. 예: 두 개의 서로 다른 캐릭터 LoRA. 대신 캐릭터 + 스타일, 또는 스타일 + 배경처럼 다른 측면을 담당하는 LoRA를 병합하세요.
💡 Online 병합 (실시간 조합): pipeline.set_adapters(["lora1", "lora2"], weights=[0.7, 0.3])로 여러 LoRA를 동시에 로드할 수 있습니다(최신 Diffusers 버전). 이렇게 하면 파일을 다시 저장하지 않고 실시간으로 가중치를 바꿔가며 실험할 수 있습니다.
💡 병합 전에 각 LoRA를 개별적으로 테스트하세요. 병합 후 문제가 생기면 어느 LoRA가 원인인지 파악하기 어렵습니다. 각각이 제대로 작동하는지 확인한 후 병합하세요.
💡 SLIDERS 기법: [1.0, -0.5]처럼 negative weight를 사용하면 특정 특징을 "제거"할 수 있습니다. 예: 나이 LoRA를 -0.3으로 병합하면 더 젊어 보이는 효과. 단, 매우 불안정하므로 신중히 사용하세요.
7. 학습률_스케줄링
시작하며
여러분이 LoRA를 학습시킬 때 learning rate를 고정값으로 사용하고 있다면 최적의 성능을 놓치고 있을 가능성이 큽니다. 처음부터 끝까지 같은 learning rate로 학습하면 초반에는 너무 느리거나, 후반에는 최적점을 지나쳐버리는 문제가 발생하죠.
학습률 스케줄링 없이 학습하면 수렴 속도가 느리고, 최종 성능도 떨어지며, 과적합이 빨리 시작됩니다. 특히 LoRA처럼 파라미터가 적은 경우 learning rate 변화에 매우 민감해서 스케줄링이 더욱 중요합니다.
바로 이럴 때 필요한 것이 적응적 학습률 스케줄링 전략입니다. Cosine annealing, warmup, cyclical learning rate 등의 기법을 활용하면 학습 안정성과 최종 품질을 크게 향상시킬 수 있습니다.
개요
간단히 말해서, 학습률 스케줄링은 학습 과정에서 learning rate를 동적으로 조절하여 빠른 수렴과 안정적인 최적화를 달성하는 기법입니다. 가장 효과적인 방법은 warmup + cosine decay 조합입니다.
초반 5~10% step 동안 learning rate를 0에서 목표값까지 선형 증가시키고(warmup), 그 이후 cosine 곡선을 따라 점진적으로 0에 가깝게 감소시킵니다. 이렇게 하면 초반 불안정성을 방지하고, 후반에는 세밀하게 최적점을 찾을 수 있습니다.
또 다른 접근은 reduce on plateau입니다. Validation loss가 일정 epoch 동안 개선되지 않으면 learning rate를 1/10로 줄이는 방식이죠.
예를 들어, 5 epoch 동안 loss가 안 떨어지면 1e-4에서 1e-5로 감소시켜 더 정밀한 탐색을 시도합니다. 이 방식은 데이터셋이 작고 학습이 불안정할 때 특히 유용합니다.
LoRA에서 권장하는 전략: (1) 전체 step의 5~10%를 warmup, (2) cosine decay로 부드럽게 감소, (3) 최소 learning rate는 목표값의 1/10 (완전히 0으로 만들지 않음). 예를 들어, 1e-4에서 시작해 1e-5까지만 줄이고 멈춥니다.
이렇게 하면 학습 후반에도 미세 조정이 가능합니다.
코드 예제
import math
from torch.optim.lr_scheduler import LambdaLR, ReduceLROnPlateau
def get_cosine_schedule_with_warmup(optimizer, num_warmup_steps, num_training_steps,
num_cycles=0.5, min_lr_ratio=0.1):
"""
Warmup + Cosine Decay 스케줄러
Args:
num_warmup_steps: warmup step 수 (전체의 5~10%)
num_training_steps: 총 학습 step 수
num_cycles: cosine 주기 (0.5가 기본, 1.0은 전체 사이클)
min_lr_ratio: 최소 lr / 최대 lr 비율 (0.1 = 10%까지만 감소)
"""
def lr_lambda(current_step):
# Warmup 단계: 0 → 1.0으로 선형 증가
if current_step < num_warmup_steps:
return float(current_step) / float(max(1, num_warmup_steps))
# Cosine Decay 단계: 1.0 → min_lr_ratio로 cosine 곡선 감소
progress = float(current_step - num_warmup_steps) / float(
max(1, num_training_steps - num_warmup_steps)
)
cosine_decay = 0.5 * (1.0 + math.cos(math.pi * num_cycles * 2.0 * progress))
# 최소값 보장 (완전히 0으로 가지 않음)
return min_lr_ratio + (1.0 - min_lr_ratio) * cosine_decay
return LambdaLR(optimizer, lr_lambda)
# 사용 예시
from torch.optim import AdamW
optimizer = AdamW(model.parameters(), lr=1e-4)
# 총 2000 step, 처음 200 step은 warmup, 최소 lr은 1e-5
scheduler = get_cosine_schedule_with_warmup(
optimizer,
num_warmup_steps=200, # 전체의 10%
num_training_steps=2000,
min_lr_ratio=0.1 # 최소 lr = 1e-4 * 0.1 = 1e-5
)
# 학습 루프에서 사용
for epoch in range(num_epochs):
for batch in dataloader:
loss = train_step(batch)
loss.backward()
optimizer.step()
scheduler.step() # 매 step마다 lr 업데이트
optimizer.zero_grad()
# 현재 lr 확인
current_lr = optimizer.param_groups[0]['lr']
if step % 100 == 0:
print(f"Step {step}, LR: {current_lr:.2e}, Loss: {loss:.4f}")
# Alternative: ReduceLROnPlateau (validation loss 기반)
scheduler_plateau = ReduceLROnPlateau(
optimizer,
mode='min', # loss 최소화
factor=0.5, # lr을 절반으로
patience=5, # 5 epoch 동안 개선 없으면 감소
min_lr=1e-6 # 최소 lr 한계
)
# Validation 후 사용
for epoch in range(num_epochs):
train_loss = train_epoch()
val_loss = validate()
scheduler_plateau.step(val_loss) # validation loss 기반 조정
설명
이것이 하는 일: 이 코드는 학습 과정에서 learning rate를 warmup과 cosine decay 패턴으로 자동 조절하는 스케줄러를 구현합니다. 첫 번째로, lr_lambda 함수가 각 step에서의 learning rate 배율을 계산합니다.
이 함수는 optimizer의 기본 lr에 곱해질 값(0.0~1.0)을 반환합니다. 예를 들어, optimizer의 lr이 1e-4이고 lambda가 0.5를 반환하면 실제 lr은 5e-5가 됩니다.
그 다음으로, warmup 단계를 구현합니다. current_step < num_warmup_steps 동안 step / warmup_steps로 선형 증가합니다.
예: warmup=200이면 step 0에서 0.0, step 100에서 0.5, step 200에서 1.0이 됩니다. 이렇게 점진적으로 learning rate를 올리는 이유는 초반에 큰 lr로 시작하면 가중치가 무작위 상태에서 큰 폭으로 변해 불안정하기 때문입니다.
Warmup은 이를 방지하는 안전장치입니다. 세 번째로, warmup 이후 cosine decay가 시작됩니다.
progress는 0.0(warmup 직후)에서 1.0(학습 종료)까지 진행도를 나타냅니다. math.cos(π * progress)는 1.0에서 시작해 -1.0까지 감소하는 코사인 곡선인데, 이를 0.5 * (1 + cos(...)) 형태로 변환하면 1.0에서 0.0까지 부드럽게 감소하는 곡선이 됩니다.
num_cycles=0.5는 절반 사이클을 의미해 더 느린 감소를 만듭니다. 네 번째로, min_lr_ratio를 적용해 최소값을 보장합니다.
cosine_decay가 0.0까지 가더라도 실제 반환값은 min_lr_ratio(예: 0.1)가 됩니다. 왜냐하면 lr이 완전히 0이 되면 학습이 멈추기 때문입니다.
약간의 lr을 유지해 마지막까지 미세 조정을 계속합니다. 마지막으로, 학습 루프에서 매 step마다 scheduler.step()을 호출합니다.
이것이 내부적으로 lr_lambda를 실행하고 optimizer의 lr을 업데이트합니다. ReduceLROnPlateau는 다른 접근으로, validation loss를 모니터링하다가 개선이 멈추면 lr을 줄입니다.
예: 5 epoch 동안 val_loss가 안 떨어지면 lr *= 0.5. 이 방식은 언제 plateau에 도달했는지 자동 감지하므로 총 step 수를 미리 정하기 어려울 때 유용합니다.
여러분이 이 코드를 사용하면 학습 초반의 발산 문제를 방지하고, 후반부에는 세밀하게 최적점을 찾아 최종 성능을 5~10% 향상시킬 수 있습니다. 또한 plateau 감지로 과적합을 조기에 발견하고 대응할 수 있습니다.
실전 팁
💡 Warmup step 수는 전체의 510%가 적정합니다. 2000 step이라면 100200. 너무 길면(20% 이상) 학습 시간만 낭비되고, 너무 짧으면(1% 이하) 효과가 없습니다.
💡 Cosine vs Linear decay: Cosine이 대부분의 경우 더 좋습니다. Linear는 일정 속도로 감소하지만, Cosine은 중반에는 천천히 후반에는 빠르게 감소해 더 나은 수렴을 보입니다. Exponential decay는 LoRA에서는 너무 공격적입니다.
💡 min_lr_ratio를 0으로 설정하지 마세요. 완전히 0이 되면 학습 후반에 조정 능력을 상실합니다. 0.050.1 (510%) 사이가 적절합니다. 특히 long training run에서는 0.1을 권장합니다.
💡 ReduceLROnPlateau 사용 시 patience를 너무 짧게 하면(1~2 epoch) lr이 너무 빨리 줄어들어 최적점을 탐색할 기회를 놓칩니다. 최소 5 epoch은 기다리세요. 또한 factor=0.1(1/10)은 너무 급격하니 0.5(절반)를 권장합니다.
💡 Learning rate를 시각화하세요. matplotlib로 lr_lambda(step)를 그려보면 warmup과 decay 곡선을 확인할 수 있습니다. TensorBoard나 wandb에 로깅하면 loss와 lr의 관계를 분석할 수 있어 디버깅에 매우 유용합니다.
8. 정규화_이미지_활용
시작하며
여러분이 특정 캐릭터나 스타일로 LoRA를 학습시켰는데, 생성된 이미지가 항상 비슷한 포즈와 배경만 나온다면 과적합(overfitting)이 발생한 것입니다. 학습 데이터를 그대로 복사하듯이 생성하여 다양성이 사라지는 문제죠.
과적합은 LoRA 학습의 가장 큰 적입니다. 데이터셋이 작을수록(10~50장) 더 심각하며, 모델이 일반화 능력을 잃어버려 "a photo of sks person standing"만 입력하면 항상 같은 이미지만 나오게 됩니다.
새로운 포즈나 배경에 적용하려 하면 이상한 결과가 나옵니다. 바로 이럴 때 필요한 것이 정규화 이미지(regularization images) 또는 class images입니다.
학습 대상과 비슷하지만 다른 일반 이미지들을 함께 학습시켜 모델이 특정 샘플만 외우는 것을 방지하고 일반화 능력을 유지시킵니다.
개요
간단히 말해서, 정규화 이미지는 학습 대상과 같은 클래스의 일반적인 샘플들로, 과적합을 방지하고 모델의 일반화 능력을 보존하는 역할을 합니다. 작동 원리는 이렇습니다: 특정 인물의 이미지 50장을 학습시킬 때, 일반적인 사람 이미지 200~500장을 추가로 제공합니다.
학습 과정에서 "sks person"으로 특정 인물을 학습하는 동시에, "person" 클래스의 일반적인 특징도 함께 학습시킵니다. 이렇게 하면 모델이 "person"이라는 넓은 개념은 유지하면서 "sks person"의 특징만 추가로 학습하게 됩니다.
정규화 이미지는 보통 기본 Stable Diffusion 모델로 자동 생성합니다. 예를 들어, "a photo of a person"이라는 프롬프트로 500장의 다양한 사람 이미지를 생성하고, 이를 class images로 사용합니다.
이렇게 하면 수작업 없이 대량의 정규화 데이터를 확보할 수 있습니다. Loss 계산 시 instance images(학습 대상)와 class images를 섞어서 사용합니다: total_loss = instance_loss + λ × class_loss.
Instance loss는 "sks person"을 학습하는 부분이고, class loss는 일반 "person"을 잊지 않도록 하는 부분입니다. λ(일반적으로 1.0)로 균형을 조절합니다.
이 방법을 Dreambooth에서 처음 제안했고, LoRA 학습에도 매우 효과적입니다.
코드 예제
from diffusers import StableDiffusionPipeline
import torch
from pathlib import Path
def generate_class_images(prompt, num_images, output_dir, class_name="person"):
"""
정규화를 위한 클래스 이미지 자동 생성
Args:
prompt: 생성 프롬프트 (예: "a photo of a person")
num_images: 생성할 이미지 수 (instance images의 3~10배 권장)
output_dir: 저장 경로
class_name: 클래스 이름 (캡션 파일에 사용)
"""
# 기본 SD 모델로 다양한 샘플 생성
pipeline = StableDiffusionPipeline.from_pretrained(
"stabilityai/stable-diffusion-2-1-base",
torch_dtype=torch.float16
).to("cuda")
output_path = Path(output_dir)
output_path.mkdir(exist_ok=True)
print(f"정규화 이미지 생성 중: {num_images}장")
for i in range(num_images):
# 다양성 확보를 위해 매번 다른 시드 사용
generator = torch.Generator("cuda").manual_seed(i)
image = pipeline(
prompt,
num_inference_steps=50,
guidance_scale=7.5,
generator=generator
).images[0]
# 이미지와 캡션 저장
image.save(output_path / f"class_{i:04d}.jpg")
with open(output_path / f"class_{i:04d}.txt", "w") as f:
f.write(f"a photo of a {class_name}")
if (i + 1) % 50 == 0:
print(f" 진행: {i+1}/{num_images}")
print(f"완료: {output_path}")
# 사용 예시: 특정 인물 학습 시 일반 사람 이미지 500장 생성
generate_class_images(
prompt="a photo of a person, neutral background, various poses",
num_images=500, # instance images 50장 대비 10배
output_dir="./class_images_person",
class_name="person"
)
# 학습 시 instance와 class images 혼합
def train_with_regularization(instance_dataloader, class_dataloader, model, optimizer):
for instance_batch, class_batch in zip(instance_dataloader, class_dataloader):
# Instance loss: "sks person" 학습
instance_loss = compute_loss(model, instance_batch)
# Class loss: 일반 "person" 유지 (prior preservation)
class_loss = compute_loss(model, class_batch)
# 총 loss = instance + class (가중치 1:1)
total_loss = instance_loss + 1.0 * class_loss
total_loss.backward()
optimizer.step()
optimizer.zero_grad()
설명
이것이 하는 일: 이 코드는 기본 Stable Diffusion 모델로 다양한 정규화 이미지를 자동 생성하고, 이를 학습에 활용하는 방법을 구현합니다. 첫 번째로, generate_class_images 함수가 기본 SD 모델을 로드합니다.
이때 학습할 LoRA가 아닌 원본 모델을 사용하는 것이 중요합니다. 왜냐하면 정규화 이미지는 "일반적인" 샘플이어야 하기 때문입니다.
만약 이미 학습된 모델로 생성하면 편향된 샘플이 되어 정규화 효과가 없습니다. 그 다음으로, 루프를 돌며 num_images만큼 이미지를 생성합니다.
각 이미지마다 다른 시드를 사용해 최대한 다양한 샘플을 만드는 것이 핵심입니다. 같은 시드로 생성하면 비슷한 이미지만 나와 정규화 효과가 떨어집니다.
프롬프트에 "various poses, different backgrounds"처럼 다양성을 유도하는 키워드를 넣으면 더 좋습니다. 세 번째로, 생성된 이미지와 함께 캡션 파일을 저장합니다.
캡션은 특수 토큰 없이 일반 클래스명만 사용합니다. "a photo of a person"처럼 단순하게 작성하여 모델이 "person"의 일반적인 특징을 학습하도록 합니다.
Instance images의 "a photo of sks person"과 대조됩니다. 네 번째로, 학습 시 두 종류의 loss를 계산합니다.
Instance loss는 특정 대상("sks person")의 고유한 특징을 학습하는 부분입니다. Class loss는 일반 클래스("person")의 특징을 유지하는 부분으로, prior preservation이라고도 불립니다.
이 두 loss를 합쳐서 backpropagation하면 모델이 특징은 학습하되 일반화 능력은 잃지 않게 됩니다. 마지막으로, 두 dataloader를 zip으로 묶어 동시에 iterate합니다.
매 iteration마다 instance 배치 하나와 class 배치 하나를 처리합니다. Class images가 instance보다 많으므로(보통 5~10배) class_dataloader는 여러 번 반복됩니다.
최종 loss는 instance_loss + λ × class_loss 형태로, λ는 보통 1.0이지만 과적합이 심하면 1.52.0으로 올려 정규화를 강화할 수 있습니다. 여러분이 이 코드를 사용하면 작은 데이터셋(1050장)으로도 과적합 없이 고품질 LoRA를 학습할 수 있습니다.
생성된 이미지가 학습 샘플을 그대로 복사하는 대신, 다양한 포즈와 배경에 일반화되어 실용성이 크게 향상됩니다.
실전 팁
💡 Class images 개수는 instance images의 310배가 적정합니다. Instance 50장이라면 class 200500장. 너무 많으면(20배 이상) 학습 시간만 늘어나고, 너무 적으면(1~2배) 정규화 효과가 미미합니다.
💡 Class images 생성 시 다양성이 핵심입니다. 프롬프트에 "different ages, ethnicities, lighting, backgrounds"를 추가하고, guidance_scale을 6.0~9.0으로 다양하게 바꿔가며 생성하세요. 단조로운 샘플은 역효과입니다.
💡 Prior preservation loss weight(λ)는 과적합 정도에 따라 조절하세요. 기본 1.0에서 시작해, 학습 이미지를 그대로 복사한다면 1.52.0으로 올립니다. 반대로 특징이 너무 약하게 나오면 0.50.7로 낮춥니다.
💡 Class images는 한 번 생성해두면 재사용할 수 있습니다. "person" 클래스 이미지 500장을 만들어두면 다른 인물 학습 시에도 계속 사용 가능합니다. 단, 스타일 LoRA(애니메이션, 수채화 등)는 각각 다른 class images가 필요합니다.
💡 데이터셋이 충분히 크다면(500장 이상) 정규화 이미지 없이도 괜찮습니다. 정규화는 주로 small dataset problem을 해결하는 기법입니다. 대규모 데이터셋에서는 오히려 학습 시간만 늘어날 수 있습니다.
코드 카드 뉴스 생성이 완료되었습니다! 총 8개의 상세한 개념 카드를 작성했습니다: