Project 2 마무리: base·full-SFT·LoRA·QLoRA를 같은 시험지로 채점하고 표 하나로 비교하기
지난 며칠간 만든 base, full-SFT, LoRA, QLoRA 모델을 똑같은 고정 프롬프트셋으로 채점해 한 장의 표로 정리합니다. 성능뿐 아니라 VRAM, 학습 시간, 학습 파라미터 수를 함께 재고, LoRA의 rank를 바꿔 보는 ablation과 오답 유형 태깅까지 합니다. "SFT를 했더니 좋아졌다"는 느낌을 "이만큼, 이 비용으로 좋아졌다"는
By 송민성
오늘의 목표
Project 2의 마지막 날입니다. 같은 도메인 데이터로 만든 네 가지 모델(base, full-SFT, LoRA, QLoRA)을 같은 문제, 같은 채점 방식으로 평가하고, 성능·VRAM·시간·파라미터 수를 한 표에 담습니다. 이 표를 보고 "내 상황에서는 어떤 방법이 가장 낫다"고 근거를 들어 말할 수 있어야 합니다.
개념과 직관
오늘은 새 학습을 돌리지 않습니다. 이미 만든 모델들을 공정하게 비교하는 법이 핵심입니다.
1) 네 모델이 뭐가 다른지 한 줄씩
- base: 아무것도 추가로 학습하지 않은 원래 모델. 비교의 기준선(baseline, 개선 여부를 재는 출발점)입니다.
- full-SFT: 지도 미세조정(SFT, Supervised Fine-Tuning, 정답 예시를 보여 주며 모델을 추가 학습시키는 것)에서 모델의 모든 파라미터를 갱신합니다.
- LoRA: 원래 가중치는 얼리고, 작은 보조 행렬 두 개만 학습합니다. 가중치 W가 있으면 W + (B × A)처럼 더하는 방식이고, 이때 A와 B의 크기를 정하는 값이 rank(r)입니다.
- QLoRA: 얼린 원래 가중치를 4bit로 압축해 메모리에 올리고, 그 위에 LoRA를 붙여 학습합니다. 메모리를 아끼는 대신 압축 해제 계산 때문에 시간이 더 걸릴 수 있습니다.
2) 비유: 같은 시험지, 같은 채점관 학생 네 명의 실력을 비교하는데 한 명에게만 쉬운 시험지를 주면 비교가 무의미합니다. 그래서 다음을 고정합니다.
- 프롬프트셋(시험 문제)은 학습 데이터와 겹치지 않는 것으로, 모든 모델에 동일하게 사용
- 생성 설정(temperature 등)도 동일하게. 가장 단순하게는 temperature 0(항상 가장 확률 높은 토큰 선택)
- 채점 기준도 동일하게
3) LoRA가 얼마나 적게 학습하는가 (최소 수식) d_out × d_in 크기의 가중치 하나를 LoRA로 바꾸면 학습 파라미터는 다음과 같습니다.
full: d_out × d_in
LoRA: r × d_in + d_out × r = r × (d_in + d_out)예를 들어 d_in = d_out = 4096, r = 8이면 full은 약 1,678만 개, LoRA는 65,536개입니다. 대략 1/256 수준입니다.
4) Ablation과 Error Analysis
- ablation(어블레이션, 한 요소만 바꿔 그 영향을 보는 실험): 오늘은 rank만 바꿉니다(예: 4, 8, 16, 64). 나머지는 모두 고정해야 rank의 효과만 보입니다.
- error analysis(오류 분석): 점수만 보면 "왜 틀렸는지"를 모릅니다. 틀린 답을 유형별로 라벨(태그)을 붙여 세면 다음에 무엇을 고칠지 보입니다.
코드로 직접 해보기
라이브러리 없이 원리부터 확인하고, 그다음 평가 루프를 만듭니다.
단계 1. 학습 파라미터 수를 직접 세기
def count_params(model):
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
return total, trainable
def lora_params(d_in, d_out, r):
return r * (d_in + d_out)
print(lora_params(4096, 4096, 8)) # 65536실제 모델에서는 count_params(model)의 두 번째 값(trainable)을 표에 적습니다. LoRA 모델은 trainable이 전체의 1% 안팎 이하로 나오는 경우가 많습니다. 숫자가 이보다 훨씬 크면 설정을 의심해 보세요.
단계 2. 고정 프롬프트셋과 채점 함수
import json
# eval.jsonl 한 줄 예: {"id": 1, "prompt": "...", "answer": "..."}
def load_eval(path):
with open(path, encoding="utf-8") as f:
return [json.loads(line) for line in f]
def score(pred, answer):
# 가장 단순한 채점: 정규화 후 정답 포함 여부
norm = lambda s: " ".join(s.strip().lower().split())
return int(norm(answer) in norm(pred))도메인에 따라 채점 방식은 달라질 수 있습니다(정답 문자열 포함, 형식 준수 여부 등). 중요한 것은 네 모델에 같은 함수를 쓰는 것입니다.
단계 3. 모델별 평가 루프 (시간·VRAM 같이 측정)
import time, torch
def evaluate(model, tokenizer, data, max_new_tokens=128):
model.eval()
torch.cuda.reset_peak_memory_stats()
results, start = [], time.time()
for ex in data:
inputs = tokenizer(ex["prompt"], return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
pred = tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
results.append({"id": ex["id"], "pred": pred, "correct": score(pred, ex["answer"])})
acc = sum(r["correct"] for r in results) / len(results)
peak_gb = torch.cuda.max_memory_allocated() / 1e9
return acc, peak_gb, time.time() - start, results여기서 잰 VRAM은 추론 시 최대치입니다. 학습 때의 VRAM과 학습 시간은 학습 스크립트 쪽에서 같은 방식으로 따로 재서 기록해야 합니다.
단계 4. 오답을 파일로 저장해 태깅
def dump_errors(name, results, path):
wrong = [r for r in results if r["correct"] == 0]
with open(path, "w", encoding="utf-8") as f:
for r in wrong:
r["tag"] = "" # 직접 손으로 채울 칸
f.write(json.dumps(r, ensure_ascii=False) + "\n")오늘의 실험
실험 1. 종합 비교표
모델 4개를 평가해 아래 표를 채웁니다. 수치는 반드시 본인이 측정한 값만 적습니다.
| 모델 | 정확도 | 학습 VRAM peak | 학습 시간 | 학습 파라미터 수 | 어댑터/체크포인트 크기 |
|---|---|---|---|---|---|
| base | - | - | 0 | - | |
| full-SFT | |||||
| LoRA (r=8) | |||||
| QLoRA (r=8) |
관찰 포인트: 성능 차이가 얼마나 나는가, 그 차이를 만들기 위해 VRAM과 시간을 얼마나 더 썼는가. 평가 문제가 적으면(수십 개) 1~2점 차이는 우연일 수 있으니, 문제 수를 함께 적고 해석을 조심하세요.
실험 2. rank ablation
LoRA(필요하면 QLoRA도)에서 r을 4, 8, 16, 64로 바꿉니다. 학습률, 에폭, 데이터, 시드, lora_alpha의 관리 방식은 고정합니다. lora_alpha는 r에 맞춰 같이 키우는 설정(예: alpha = 2r)과 고정하는 설정이 결과에 영향을 주므로, 어느 쪽을 택했는지 반드시 기록하세요.
| rank | 학습 파라미터 수 | 정확도 | 학습 시간 |
|---|
rank를 키우면 항상 좋아지는지, 어느 지점부터 이득이 줄어드는지 봅니다. 결과가 단조롭지 않아도 이상한 것이 아닙니다. 그대로 기록하면 됩니다.
실험 3. Error Analysis
가장 성능이 좋은 모델(또는 비교하고 싶은 모델 둘)의 오답을 모아 태그를 붙입니다. 태그 예시는 다음과 같고, 데이터를 보며 직접 수정해도 됩니다.
사실오류: 내용이 틀림형식위반: 요구한 출력 형식을 안 지킴불완전: 답이 중간에 끊기거나 일부만 답함반복/횡설수설: 같은 말을 되풀이함채점오류: 사실 맞는데 채점 함수가 틀렸다고 판정함
태그별 개수를 세고, base와 SFT 모델에서 분포가 어떻게 달라졌는지 비교합니다. 채점오류가 많다면 모델이 아니라 채점 함수를 먼저 고쳐야 합니다.
마지막으로 표와 오답 분석을 바탕으로 결론 3줄을 씁니다. (1) 어떤 방법이 가성비가 좋았나, (2) 가장 흔한 오답 유형은 무엇인가, (3) 다음에 데이터나 설정에서 무엇을 바꿔 보겠는가.
왜 이걸 배우나 (LLM / Post-training 연결)
Post-training 업무의 상당 부분은 "무엇을 바꿨더니 얼마나 좋아졌고, 비용은 얼마였나"를 재현 가능하게 보여 주는 일입니다. 고정 평가셋, 비용 대비 성능 표, 오류 유형 분석은 이후 선호 학습이나 더 큰 모델 실험에서도 그대로 쓰이는 기본 틀이며, LLM Research Engineer의 포트폴리오에서도 이런 정리가 설득력을 만듭니다.
초보자가 막히는 지점
- 평가 문제가 학습 데이터에 섞임: 점수가 비정상적으로 높게 나옵니다. 학습/평가를 분리한 뒤 문장 중복을 검사하세요.
- 모델마다 생성 설정이 다름: 비교가 무너집니다.
do_sample=False,max_new_tokens, 프롬프트 템플릿을 하나의 설정으로 통일하세요. - 프롬프트 템플릿 불일치: SFT 때 쓴 형식과 평가 때 형식이 다르면 SFT 모델이 오히려 base보다 나빠 보일 수 있습니다.
- VRAM 측정 혼동:
nvidia-smi값과max_memory_allocated는 다릅니다(캐시 포함 여부 등). 어느 쪽을 썼는지 적고 전 모델에 같은 방법을 쓰세요. - LoRA 어댑터 로딩 실수: 어댑터를 base 모델에 붙이지 않고 평가하면 base 점수가 나옵니다. 학습 파라미터 수와 출력이 base와 실제로 다른지 확인하세요.
- 한 번의 실행으로 결론: 시드 하나, 문제 수 적음이면 차이가 우연일 수 있습니다. 가능하면 시드를 바꿔 한 번 더 돌리고, 못 하면 한계를 명시하세요.
- OOM(메모리 부족): full-SFT에서 가장 흔합니다. 배치 크기를 줄이고 gradient accumulation(여러 번 나눠 계산한 기울기를 모아 한 번에 갱신)을 쓰거나, 못 돌렸다면 "못 돌린 이유"를 표에 그대로 적으세요. 그것도 유효한 결과입니다.
알고리즘 (병행 1시간)
LeetCode Medium 2문제 (구현 유형). 아이디어보다 정확한 구현과 경계 조건 처리가 핵심인 문제(문자열 파싱, 시뮬레이션, 행렬 순회 등)를 골라 푸세요. 문제 선택은 직접 하되, 한 문제당 30분을 넘기면 힌트를 보고 이어갑니다.
풀이마다 아래를 기록합니다.
- 접근: 문제를 어떻게 쪼갰는지, 어떤 자료구조를 썼는지
- 시간복잡도 / 공간복잡도: 입력 크기 n에 대해 왜 그렇게 되는지 한 줄 근거
- 틀린 이유: 제출 실패나 막힌 지점(경계 조건, off-by-one, 입력 예외 등)과 다음에 막지 방법