9장. 모델 평가와 활용, QLoRA
손실 비교: 답변 토큰만 채점하기
받아쓰기 시험지를 채점한다고 해 봅니다. 시험지 위쪽에는 선생님이 불러 준 문제가 인쇄되어 있고, 아래쪽에 학생이 쓴 답이 있습니다. 인쇄된 문제 부분까지 채점하면 안 됩니다. 학생이 쓴 답 부분만 채점해야 합니다. 원본 노트북의 build_features() 가 이 일을 합니다. 질문 자리의 labels 를 -100 으로 가리고, 답변 토큰에 대해서만 평균 손실을 냅니다.
그림은 왼쪽에서 오른쪽으로 읽습니다. 질문과 답변을 대본 양식(Chat Template)으로 이어 토큰 번호 열(full_ids)을 만들고, 그중 질문 길이만큼을 -100 으로 바꿔 labels 를 만든 뒤, -100 이 아닌 칸만 손실을 계산합니다. 배치마다 답변 토큰 수가 다르므로 토큰 수로 가중 평균을 냅니다.
손실 계산에 드는 세 배열
| 배열 | 질문 자리 | 답변 자리 | 쉽게 말하면 |
|---|---|---|---|
input_ids | 질문 토큰 번호 | 답변 토큰 번호 | 시험지 전체 |
attention_mask | 1 | 1 | 읽을 칸 표시(패딩은 0) |
labels | -100 | 답변 토큰 번호 | 채점할 칸만 정답을 적은 답안지 |
test loss 4.044 → 3.19 읽기
손실은 직접 느끼기 어려운 숫자입니다. e(약 2.718)를 손실만큼 거듭제곱하면 "모델이 다음 토큰을 고를 때 대략 몇 개 후보 사이에서 헷갈리는가"로 읽을 수 있습니다. 이 값을 perplexity(헷갈림 정도)라고 합니다.
- 학습 전: e^4.044 ≈ 57.1, 후보 약 57개 사이에서 헷갈립니다.
- 학습 후: e^3.19 ≈ 24.3, 후보 약 24개로 줄었습니다.
- 손실 자체는 (4.044 - 3.19) ÷ 4.044 ≈ 0.211, 약 21% 줄었습니다.
짧은 예시
import math
# AutoTokenizer = 모델 이름만 주면 맞는 토크나이저를 골라 주는 도구(모델은 불러오지 않는다)
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
# build_features 와 같은 방법: 질문만 있는 메시지와 답변까지 있는 메시지를 따로 토큰화한다
prompt_messages = [{"role": "user", "content": "주말에 영화나 보러 갈까?"}]
full_messages = prompt_messages + [{"role": "assistant", "content": "현실을 잊고 싶으냐?"}]
# apply_chat_template = 대본 양식 입히기. add_generation_prompt=True 는 답변 머리표까지 붙인다
prompt_ids = tokenizer.apply_chat_template(prompt_messages, add_generation_prompt=True)
full_ids = tokenizer.apply_chat_template(full_messages)
# 질문 길이만큼은 -100, 나머지는 원래 토큰 번호
labels = [-100] * len(prompt_ids) + full_ids[len(prompt_ids):]
print(len(full_ids), len(prompt_ids), sum(x != -100 for x in labels))
# 결과: 53 40 13 → 전체 53개 중 질문(기본 system 문장 포함) 40개는 빼고 답변 13개만 채점
# 배치 두 개의 평균 손실과 답변 토큰 수(설명용 숫자)로 가중 평균 내기
batch_loss, batch_tokens = [3.0, 4.0], [10, 30]
weighted = sum(l * n for l, n in zip(batch_loss, batch_tokens)) / sum(batch_tokens)
print(round(weighted, 2), sum(batch_loss) / 2)
# 결과: 3.75 3.5 → 토큰이 많은 배치에 무게를 주면 단순 평균과 다르다
# 실습 1의 test loss 를 헷갈림 정도(perplexity)로 바꿔 읽기
print(round(math.exp(4.044), 1), round(math.exp(3.19), 1))
# 결과: 57.1 24.3이 코드가 뜻하는 것
받아쓰기 시험지에서 인쇄된 문제 부분(40칸)을 가리고 학생이 쓴 부분(13칸)만 채점하는 과정을 그대로 해 봤습니다. 답변 13칸에는 "현실을 잊고 싶으냐?"의 토큰과 끝 표시 <|im_end|>, 줄바꿈이 들어갑니다. 여기서 시험지 전체가 full_ids, 가린 답안지가 labels, 반 전체 평균 점수가 weighted 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
apply_chat_template(prompt_messages, add_generation_prompt=True) | 질문과 답변 머리표까지만 토큰화 | 문제 부분의 길이 재기 |
[-100] * len(prompt_ids) + full_ids[len(prompt_ids):] | 질문 자리를 -100 으로 채운다 | 문제 부분 가리기 |
sum(l * n ...) / sum(batch_tokens) | 토큰 수로 가중 평균 | 문항 수가 다른 시험지 점수 합치기 |
math.exp(4.044) | 손실을 후보 수로 바꿔 읽는다 | 몇 개 중에서 헷갈렸나 |
원본의 evaluate_loss 도 out.loss.item() * n_tokens 를 더한 뒤 전체 토큰 수로 나눕니다. 배치 크기가 1이라 예시마다 답변 길이가 다르므로, 긴 답변이 더 큰 무게를 갖습니다.
주의할 점
- 이 손실은 "정답 문장을 얼마나 잘 예측했나"를 재는 값입니다. 뜻과 말투가 같아도 표현이 다르면 손실이 높게 나옵니다. 답이 옳은지를 재는 지표가 아닙니다.
- 그래서 손실 표는 같은 데이터에서 학습 전후를 견주는 상대 지표로만 봅니다. 다른 데이터, 다른 토크나이저의 손실과 바로 비교하지 않습니다.
- test 데이터는 32개뿐입니다. 숫자가 작아 몇 개만 바뀌어도 손실이 꽤 움직입니다. 학습 결과는 실행할 때마다, GPU 마다 조금씩 달라집니다.