8장. PEFT와 LoRA 학습
학습 흐름과 SFTConfig
요리 대회에 나간다고 해 봅시다. 재료(데이터)를 준비하고, 조리 도구(기반 모델)를 꺼내고, 어디에 양념할지(LoraConfig)를 정하고, 불 세기와 조리 시간(SFTConfig)을 정한 뒤, 요리사(SFTTrainer)에게 맡깁니다. 요리사는 재료 손질(템플릿 적용, 토큰화, labels 마스킹, 패딩)부터 조리(학습 반복)까지 한 번에 합니다. 7장에서 손으로 해 본 일들을 이제 SFTTrainer 가 대신합니다.
그림의 첫 상자는 7장의 persona_ds(학습 263개)입니다. 둘째 상자가 Qwen2.5-0.5B-Instruct, 셋째가 실습 1에서 만든 lora_config 입니다. 넷째 상자의 SFTConfig 가 이 절의 주제이고, 다섯째 상자의 trainer.train() 이 99번 가중치를 고칩니다. 이 학습 데이터는 "냉소적인 절대 군주" 말투로 답합니다. 말투 차이를 눈으로 보기 쉽게 고른 학습용 데이터입니다.
SFTConfig 주요 설정
SFTConfig 는 Hugging Face 의 TrainingArguments(학습 설정표)에 SFT 전용 설정을 더한 클래스입니다. VRAM 8GB 에서도 돌도록 작은 배치에 그래디언트 누적을 씁니다.
| 설정 | 값 | 뜻 | 요리로 치면 |
|---|---|---|---|
per_device_train_batch_size | 1 | 한 번에 학습하는 예시 수 | 한 번에 굽는 전 한 장 |
per_device_eval_batch_size | 1 | 한 번에 검증하는 예시 수 | 맛보기 한 장씩 |
gradient_accumulation_steps | 8 | 8번의 그래디언트를 모아 한 번에 고친다 | 8장 맛보고 양념 한 번 조정 |
learning_rate | 2e-4 | LoRA 학습률 | 양념을 한 번에 얼마나 바꿀지 |
lr_scheduler_type | cosine | 학습률을 코사인 모양으로 줄인다 | 끝으로 갈수록 살살 |
warmup_ratio | 0.05 | 처음 5% 스텝은 학습률을 0에서 서서히 올린다 | 처음엔 약불로 예열 |
max_length | 256 | 템플릿을 입힌 뒤 최대 토큰 수 | 냄비 크기 |
completion_only_loss | True | 답변 구간만 손실을 잰다 | 손님 주문서는 채점하지 않음 |
eval_strategy | epoch | epoch 마다 검증한다 | 한 바퀴마다 시식 |
save_strategy | no | 중간 저장을 하지 않는다 | 중간 사진은 안 찍음 |
bf16 / fp16 | GPU 에 맞춰 | 숫자 정밀도 | 계량컵 눈금 |
completion_only_loss=True 는 7장의 labels 에서 질문 부분을 -100 으로 두던 일과 같습니다. 채점에서 빼는 문제(질문)는 빼고 답만 채점합니다. num_train_epochs 는 적지 않았으므로 기본값 3 을 씁니다.
실질 배치와 스텝 수 손계산
- 실질 배치 = 배치 1 × 누적 8 = 8. 예시 8개를 보고 가중치를 한 번 고칩니다.
- epoch 하나의 스텝 수 = 263 ÷ 8 = 32.875 → 올림해서 33 번. 마지막 한 번은 예시 7개로 고칩니다.
- 전체 스텝 수 = 33 × 3 epoch = 99. 실습 3의 출력
[99/99 03:11, Epoch 3/3]과 같습니다. - warmup 스텝 = 99 × 0.05 = 4.95 → 올림해서 5.
짧은 예시
import math
train_n, batch, accum, epochs = 263, 1, 8, 3 # persona_ds["train"] 263개와 SFTConfig 의 값
effective = batch * accum # 실질 배치
steps_per_epoch = math.ceil(train_n / effective) # ceil = 올림
total = steps_per_epoch * epochs
warmup = math.ceil(total * 0.05)
print(effective, steps_per_epoch, total, warmup) # 결과: 8 33 99 5
def lr_at(step, peak=2e-4):
# warmup 동안은 0 에서 peak 까지 곧게 올린다
if step < warmup:
return peak * step / warmup
# 그다음은 코사인 곡선으로 peak 에서 0 까지 내린다
progress = (step - warmup) / (total - warmup)
return peak * 0.5 * (1 + math.cos(math.pi * progress))
for s in [0, 1, 5, 33, 52, 66, 99]:
print(s, f"{lr_at(s):.6f}")
# 결과: 0 0.000000 / 1 0.000040 / 5 0.000200 / 33 0.000159 / 52 0.000100 / 66 0.000055 / 99 0.000000이 코드가 뜻하는 것
전 263장을 한 장씩 굽되 8장마다 양념을 고치면 몇 번 고치게 되는지, 그리고 불 세기를 어떻게 바꾸는지 미리 계산한 코드입니다. 여기서 전 장수가 train_n, 양념을 고치는 횟수가 total, 불 세기가 lr_at(step) 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
batch * accum | 1 × 8 = 8 | 8장 맛보고 한 번 고치기 |
math.ceil(train_n / effective) | 263 ÷ 8 을 올림 = 33 | 한 바퀴에 33번 고침 |
peak * step / warmup | 처음 5스텝은 곧게 올림 | 약불 예열 |
0.5 * (1 + math.cos(...)) | 1 에서 0 으로 부드럽게 | 끝으로 갈수록 살살 |
52번째 스텝(warmup 뒤 구간의 한가운데)에서 학습률이 0.0001, 꼭 절반이 됩니다. 이 계산은 Hugging Face 의 cosine 스케줄러 식을 따라 쓴 것입니다.
주의할 점
- 배치 8을 한 번에 넣으면 GPU 메모리가 모자랄 수 있습니다. 배치 1 × 누적 8 은 메모리는 배치 1만큼 쓰면서 고치는 효과는 배치 8과 비슷하게 냅니다. 대신 시간이 더 걸립니다.
max_length보다 긴 예시는 뒤가 잘립니다. 답의 끝(종료 토큰<|im_end|>)이 잘리면 모델이 답을 끝내는 법을 못 배웁니다. 이 데이터는 대부분 100토큰 안팎이라 256 이면 넉넉합니다.- 학습 시간과 손실 값은 GPU 마다, 실행할 때마다 조금씩 달라집니다. 이 장의 숫자는 RTX 3080 에서 한 번 돌린 값입니다.