4장. LLM 을 완성하는 3단계와 Hugging Face
지도 미세조정(SFT)
신입 상담원이 입사하면 "이런 문의에는 이렇게 답한다"는 응대 매뉴얼로 교육을 받습니다. 말은 이미 할 줄 알지만, 회사의 말투와 정답을 익히는 것입니다. 사전학습한 모델에 질문과 모범 답의 쌍을 보여 주는 지도 미세조정(SFT)이 이것입니다.
위 줄이 모델에 넣는 토큰이고, 아래 줄이 정답 자리입니다. 회색인 질문 부분의 정답 자리는 -100 으로 채워 채점하지 않고, 청록인 답 부분만 채점합니다. 모델이 질문을 외우는 데 힘을 쓰지 않고 답하는 법만 배우게 하려는 것입니다. 여기서 응대 매뉴얼의 문의와 모범 답이 SFT 데이터의 질문과 답입니다.
프롬프트 템플릿
질문과 답을 늘 같은 꼴로 이어 붙여 넣어야 모델이 "어디까지가 질문이고 어디부터 답하면 되는지"를 배웁니다.
| 쓰임 | 이 장의 템플릿 |
|---|---|
| 학습할 때 | ### 질문:\n{질문}\n### 답변:\n{답}<eos> |
| 물어볼 때 | ### 질문:\n{질문}\n### 답변:\n 까지 넣고 뒤를 이어 쓰게 한다 |
끝의 <eos> 는 "답이 여기서 끝난다"는 표시입니다. 이것을 배워야 모델이 답을 마치고 멈춥니다. 3장의 Qwen 지시 모델이 쓴 <|im_start|> 같은 표시도 같은 역할의 템플릿입니다.
정답 자리 만들기
prompt_ids = [11, 12, 13, 14] # "### 질문: 배송비는? ### 답변:" 의 토큰 번호(설명용)
answer_ids = [21, 22, 23, 2] # "3만원 이상 무료 <eos>" 의 토큰 번호(설명용)
input_ids = prompt_ids + answer_ids # 모델에는 둘 다 넣는다
labels = [-100] * len(prompt_ids) + answer_ids # 질문 자리는 -100, 답 자리만 정답
print(input_ids)
print(labels)labels 의 앞 네 자리가 -100 입니다. PyTorch 의 교차 엔트로피 손실은 -100 인 자리를 건너뛰도록 약속되어 있어서, 답 자리 네 개만 손실에 들어갑니다.
이어 사전학습과 SFT
| 견줄 것 | 이어 사전학습 | SFT |
|---|---|---|
| 데이터 | 우리 분야의 긴 글 | 질문과 답의 쌍 |
| 정답 자리 | 모든 토큰 | 답 부분 토큰만 |
| 바뀌는 것 | 분야의 낱말과 지식 | 답하는 꼴과 말투 |
| 이 장에서 | 실습 5(처음부터 사전학습) | 실습 6 |
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
prompt_ids + answer_ids | 두 목록을 이어 붙인다. 모델에는 질문과 답을 한 줄로 넣는다 |
[-100] * len(prompt_ids) | -100 을 질문 토큰 수만큼 늘어놓는다. 여기서는 [-100, -100, -100, -100] 이다 |
-100 | "이 자리는 채점하지 않는다"는 약속된 표시다. PyTorch 손실 함수의 ignore_index(무시할 번호) 기본값이 -100 이다 |
이 코드가 뜻하는 것
학원의 모의 면접을 떠올려 보세요. 면접관의 질문까지 그대로 따라 말하는 것은 점수에 넣지 않고, 지원자가 한 답만 채점합니다. input_ids 는 면접 녹음 전체이고, labels 는 채점표입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
prompt_ids = [11, 12, 13, 14] | 질문 부분의 토큰 번호(설명용) | 면접관의 질문 |
answer_ids = [21, 22, 23, 2] | 답 부분의 토큰 번호. 마지막 2 는 <eos>(설명용) | 지원자의 답과 "이상입니다" |
input_ids = prompt_ids + answer_ids | 둘을 이어 모델에 넣을 한 줄을 만든다 | 녹음 전체 |
labels = [-100] * ... + answer_ids | 질문 자리는 -100, 답 자리는 답 번호로 채운다 | 채점표: 질문 칸은 빗금, 답 칸만 점수 |
두 줄의 길이가 8 로 같아야 자리끼리 맞습니다. 질문 자리를 -100 으로 두면 모델은 질문을 외우는 데 힘을 쓰지 않고 답을 내는 법에 집중합니다.
작게 줄여서 눈으로 보기
-100 자리가 정말 손실에서 빠지는지 PyTorch 로 확인해 봅니다. 자리 6개, 어휘 5개의 점수를 난수로 만들고, 앞 셋을 질문 자리(-100)로 둡니다.
import torch
import torch.nn.functional as F
torch.manual_seed(0)
logits = torch.randn(6, 5) # 자리 6개, 어휘 5개의 점수(설명용 난수)
labels = torch.tensor([-100, -100, -100, 2, 4, 1]) # 앞 셋은 질문 자리
loss_all = F.cross_entropy(logits, labels) # -100 자리는 알아서 빠진다
loss_ans = F.cross_entropy(logits[3:], labels[3:]) # 답 자리 셋만 따로 계산
print("-100 을 섞어 계산한 손실:", round(loss_all.item(), 4))
print("답 자리만 계산한 손실 :", round(loss_ans.item(), 4))-100 을 섞어 계산한 손실: 1.9994
답 자리만 계산한 손실 : 1.9994| 결과 | 읽는 법 |
|---|---|
| -100 을 섞어 계산한 손실 | 여섯 자리를 모두 넣었지만 질문 자리 셋은 빠지고 답 자리 셋으로만 평균을 냅니다 |
| 답 자리만 계산한 손실 | 처음부터 답 자리 셋만 넣은 값입니다. 두 값이 1.9994 로 같습니다 |
F.cross_entropy 는 3장에서 본 교차 엔트로피 손실 함수이고, F 는 torch.nn.functional(신경망 함수 모음)의 줄임 이름입니다. .item() 은 숫자 하나짜리 텐서를 보통 숫자로 꺼냅니다.
교차 엔트로피 손실 함수
SFT 도 사전학습도, 모델이 무엇을 얼마나 틀렸는지는 이 함수 하나로 잽니다. 일기예보로 생각하면 쉽습니다. 예보관이 "내일 비 올 확률 90%"라고 했는데 비가 오면 잘한 예보이고, "10%"라고 했는데 비가 오면 크게 빗나간 예보입니다. 교차 엔트로피는 "실제로 일어난 일에 미리 몇 % 를 걸었나"를 보고 벌점을 매깁니다. 여기서 예보관이 모델, 실제로 온 날씨가 정답 토큰, 벌점이 손실입니다.
계산은 한 줄입니다
모델은 다음 토큰 후보마다 확률을 냅니다. 그 가운데 정답 토큰에 준 확률만 보고, 손실 = -log(정답에 준 확률)로 셈합니다. log 는 2장에서 본 exp 를 거꾸로 하는 셈입니다. 정답에 큰 확률을 주면 손실이 0 에 가깝고, 작은 확률을 주면 손실이 가파르게 커집니다.
| 정답에 준 확률 | 손실 | 쉽게 말하면 |
|---|---|---|
| 0.99 | 0.010 | 거의 확신하고 맞혔다. 벌점이 거의 없다 |
| 0.9 | 0.105 | 잘 맞혔다 |
| 0.5 | 0.693 | 반반으로 찍은 셈이다 |
| 0.1 | 2.303 | 정답을 거의 생각하지 않았다 |
| 0.01 | 4.605 | 정답을 아예 무시했다. 벌점이 크다 |
확률이 0.9 에서 0.5 로 떨어질 때보다 0.1 에서 0.01 로 떨어질 때 손실이 훨씬 크게 뜁니다. 그래서 모델은 "정답을 아예 무시하는 실수"를 가장 먼저 고치게 됩니다.
손으로 셈한 값과 함수의 값 견주기
3장 "Attention 의 원리"에서 본 점수 2.0, 1.0, 0.1 을 다음 낱말 후보 "무료", "유료", "모름"의 점수로 두고(설명용), 정답이 다를 때 손실을 셈해 봅니다.
import math
import torch
import torch.nn.functional as F
cands = ["무료", "유료", "모름"] # 다음 낱말 후보 셋(설명용)
logits = torch.tensor([[2.0, 1.0, 0.1]]) # 모델이 낸 점수(설명용)
probs = torch.softmax(logits, dim=-1)[0] # 점수를 확률로
print("확률:", {c: round(p, 3) for c, p in zip(cands, probs.tolist())})
for answer in [0, 2]: # 정답이 "무료"일 때와 "모름"일 때
label = torch.tensor([answer])
by_hand = -math.log(probs[answer].item()) # 정답에 준 확률에 -log
by_torch = F.cross_entropy(logits, label).item()
print(f"정답 '{cands[answer]}': 정답 확률 {probs[answer]:.3f} → 손으로 {by_hand:.3f}, 함수로 {by_torch:.3f}")
print("정답 확률별 손실:", [(p, round(-math.log(p), 3)) for p in [0.99, 0.9, 0.5, 0.1, 0.01]])확률: {'무료': 0.659, '유료': 0.242, '모름': 0.099}
정답 '무료': 정답 확률 0.659 → 손으로 0.417, 함수로 0.417
정답 '모름': 정답 확률 0.099 → 손으로 2.317, 함수로 2.317
정답 확률별 손실: [(0.99, 0.01), (0.9, 0.105), (0.5, 0.693), (0.1, 2.303), (0.01, 4.605)]| 결과 | 읽는 법 |
|---|---|
| 확률 0.659, 0.242, 0.099 | softmax 가 점수를 합이 1 인 확률로 바꾼 값입니다. 3장에서 본 숫자와 같습니다 |
| 정답 '무료' → 0.417 | 정답에 가장 큰 확률(0.659)을 줬으니 벌점이 작습니다 |
| 정답 '모름' → 2.317 | 같은 확률표인데 정답이 "모름"이면, 0.099 밖에 주지 않았으니 벌점이 5배 넘게 큽니다 |
| 손으로 = 함수로 | F.cross_entropy 가 안에서 하는 일이 "softmax 로 확률을 내고, 정답 칸 확률에 -log"라는 확인입니다 |
글 한 줄에는 정답 자리가 여럿이라, 함수는 자리마다 이 벌점을 구해 평균을 냅니다. 위 "작게 줄여서 눈으로 보기"에서 -100 자리를 빼고 답 자리 셋으로만 평균을 낸 것이 바로 이것입니다. 그리고 이 평균 손실을 e 의 지수로 올린 값이 "손실과 퍼플렉서티" 절의 퍼플렉서티입니다.
| 코드 | 쉬운 뜻 |
|---|---|
torch.softmax(logits, dim=-1) | 점수를 줄마다 합이 1 인 확률로 바꾼다 |
math.log(x) | 자연로그(e 를 밑으로 하는 log)다. exp 를 거꾸로 한다. 확률이 1 보다 작으면 음수가 나오므로 앞에 - 를 붙여 양수 벌점으로 만든다 |
F.cross_entropy(점수, 정답 번호) | softmax 와 -log 를 한 번에 해 준다. 점수는 softmax 전의 날것을 넣는다 |
torch.tensor([answer]) | 정답 토큰의 번호다. 확률이 아니라 "몇 번째 후보가 정답인지"를 정수로 준다 |
이름의 entropy(엔트로피)는 정보이론에서 "불확실한 정도"를 뜻하는 말이고, cross(교차)는 정답의 분포와 모델이 낸 분포를 엇갈려 견준다는 뜻입니다. 정답이 하나뿐인 지금 경우에는 결국 "정답에 준 확률에 -log"로 줄어듭니다.
주의할 점
- 학습할 때와 물어볼 때의 템플릿이 한 글자라도 다르면 답이 엉뚱해집니다. 템플릿은 함수로 만들어 한곳에서만 고칩니다.
- SFT 데이터의 답이 틀리면 모델도 틀린 답을 자신 있게 합니다. 적은 양이라도 정확한 데이터가 많은 양의 엉성한 데이터보다 낫습니다.