LLM 엔지니어링4장 · LLM 을 완성하는 3단계와 Hugging Face6 / 23← 이전목차다음 →MetaContents

4장. LLM 을 완성하는 3단계와 Hugging Face

지도 미세조정(SFT)

신입 상담원이 입사하면 "이런 문의에는 이렇게 답한다"는 응대 매뉴얼로 교육을 받습니다. 말은 이미 할 줄 알지만, 회사의 말투와 정답을 익히는 것입니다. 사전학습한 모델에 질문과 모범 답의 쌍을 보여 주는 지도 미세조정(SFT)이 이것입니다.

SFT 한 줄에서 질문 부분 토큰은 -100 으로 채점하지 않고 답 부분 토큰만 채점하는 모습을 그린 그림

위 줄이 모델에 넣는 토큰이고, 아래 줄이 정답 자리입니다. 회색인 질문 부분의 정답 자리는 -100 으로 채워 채점하지 않고, 청록인 답 부분만 채점합니다. 모델이 질문을 외우는 데 힘을 쓰지 않고 답하는 법만 배우게 하려는 것입니다. 여기서 응대 매뉴얼의 문의와 모범 답이 SFT 데이터의 질문과 답입니다.

프롬프트 템플릿

질문과 답을 늘 같은 꼴로 이어 붙여 넣어야 모델이 "어디까지가 질문이고 어디부터 답하면 되는지"를 배웁니다.

쓰임이 장의 템플릿
학습할 때### 질문:\n{질문}\n### 답변:\n{답}<eos>
물어볼 때### 질문:\n{질문}\n### 답변:\n 까지 넣고 뒤를 이어 쓰게 한다

끝의 <eos> 는 "답이 여기서 끝난다"는 표시입니다. 이것을 배워야 모델이 답을 마치고 멈춥니다. 3장의 Qwen 지시 모델이 쓴 <|im_start|> 같은 표시도 같은 역할의 템플릿입니다.

정답 자리 만들기

Python
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)로 둡니다.

Python
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.990.010거의 확신하고 맞혔다. 벌점이 거의 없다
0.90.105잘 맞혔다
0.50.693반반으로 찍은 셈이다
0.12.303정답을 거의 생각하지 않았다
0.014.605정답을 아예 무시했다. 벌점이 크다

확률이 0.9 에서 0.5 로 떨어질 때보다 0.1 에서 0.01 로 떨어질 때 손실이 훨씬 크게 뜁니다. 그래서 모델은 "정답을 아예 무시하는 실수"를 가장 먼저 고치게 됩니다.

손으로 셈한 값과 함수의 값 견주기

3장 "Attention 의 원리"에서 본 점수 2.0, 1.0, 0.1 을 다음 낱말 후보 "무료", "유료", "모름"의 점수로 두고(설명용), 정답이 다를 때 손실을 셈해 봅니다.

Python
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.099softmax 가 점수를 합이 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 데이터의 답이 틀리면 모델도 틀린 답을 자신 있게 합니다. 적은 양이라도 정확한 데이터가 많은 양의 엉성한 데이터보다 낫습니다.
← 이전 절4장 · 손실과 퍼플렉서티다음 절 →4장 · 정렬