트랜스포머·PEFT 2일 과정9장 · 모델 평가와 활용, QLoRA18 / 20← 이전목차다음 →MetaContents

9장. 모델 평가와 활용, QLoRA

프로젝트 1 — 어댑터 점검표 만들기

작은 회사에서 고객 응대 챗봇에 말투 어댑터를 붙이려 합니다. 팀장은 "배포하기 전에, 어댑터를 켜면 무엇이 바뀌고 무엇이 망가지는지 한 장짜리 점검표로 보여 달라"고 합니다. 이미 저장해 둔 outputs/persona-lora-adapter 를 기반 모델에 붙여, 새 질문과 일반 질문에 어댑터를 켜고 끄며 답하게 하고, 규칙으로 채점한 결과를 pandas 표와 csv 파일로 남깁니다.

요구사항

  1. 토크나이저와 기반 모델 Qwen/Qwen2.5-0.5B-Instruct 를 프로젝트 안에서 직접 불러오고, PeftModel.from_pretrained 로 outputs/persona-lora-adapter 를 붙입니다. GPU 에는 모델을 하나만 올립니다.
  2. 학습 데이터에 없는 새 질문 3개와, 정답이 분명한 일반 질문 3개(정답 낱말 포함)를 준비합니다.
  3. 질문마다 어댑터를 끈 답과 켠 답을 do_sample=False 로 만듭니다.
  4. 규칙 두 가지로 채점합니다: 존댓말인지, 일반 질문이면 정답 낱말이 들어 있는지.
  5. 결과를 pandas 표로 보이고 out_ch09_adapter_check.csv 로 저장합니다. 끝나면 모델을 지우고 GPU 캐시를 비웁니다.

힌트

  • 모델을 두 번 올리지 않습니다. 실습 2처럼 with model.disable_adapter(): 안에서 만든 답이 "끔"입니다.
  • 6장의 chat 과 같은 생성 함수를 프로젝트 안에 짧게 다시 만듭니다. 인자는 max_new_tokens=40, do_sample=False, repetition_penalty=1.2 로 둡니다.
  • 존댓말 규칙은 본문 첫 절의 is_polite 처럼 "니다", "세요", "요." 가 들어 있는지로 봅니다.
  • groupby("종류") 로 묶어 평균을 내면 "새 질문에서 반말 비율", "일반 질문에서 정답 비율"을 한 번에 볼 수 있습니다.
  • csv 는 encoding="utf-8-sig" 로 저장해야 엑셀에서 한글이 깨지지 않습니다.

채점 기준

항목기준점수
불러오기기반 모델 하나에 어댑터를 붙이고, 모델을 하나만 올렸다20
질문 설계새 질문 3개와 정답 낱말이 있는 일반 질문 3개15
끔·켬 비교같은 모델에서 disable_adapter() 로 두 답을 만들었다(do_sample=False)25
규칙 채점존댓말, 정답 낱말 두 규칙을 열로 만들었다20
표와 저장요약 표를 보이고 out_ch09_adapter_check.csv 를 저장했다10
정리del 과 torch.cuda.empty_cache() 로 GPU 를 비웠다10
합계100

코드의 흐름

프로젝트 코드의 실행 흐름도. 1단계에서 토크나이저와 BF16 기반 모델을 불러와 어댑터를 붙이고, 2단계에서 answer 함수와 질문 여섯 개를 준비한다. 3단계에서 질문마다 disable_adapter 블록 안의 off 답과 블록 밖의 on 답을 모으는 반복을 돌고, 4단계에서 끔과 켬 두 바퀴를 돌며 존댓말 열과 정답 열을 만든 뒤 종류별 평균을 내고 csv 로 저장하며, 5단계에서 모델을 지워 8 MB 만 남긴다
단계하는 일반복과 조건핵심 한 줄
1토크나이저, 기반 모델, 어댑터 불러오기없음model = PeftModel.from_pretrained(base, ADAPTER_DIR)
2생성 함수와 질문 여섯 개 준비없음def answer(question, max_new_tokens=40):
3어댑터를 끄고 켜며 답 모으기for kind, question, key in questions 여섯 바퀴, 바퀴마다 with model.disable_adapter(): 로 끈 답 한 번, 켠 답 한 번. 찍기는 iterrows() 반복(네모 하나로 줄임)off = answer(question)
4규칙으로 채점하고 표로 저장for state in ["끔", "켬"] 두 바퀴check[f"존댓말({state})"] = check[f"어댑터 {state}"].apply(is_polite)
5GPU 정리없음del model, base
  • 흐름도 읽는 법: 실습 1 과 같습니다. 고리가 두 개이고, 3단계 고리는 질문 수만큼, 4단계 고리는 "끔", "켬" 두 번 돕니다.
  • 3단계 고리: 한 바퀴에 answer 를 두 번 부르므로 6 × 2 = 12 번 생성합니다. 실습 2 와 같은 스위치라서 모델은 하나뿐입니다.
  • 4단계 고리와 손계산: 한 바퀴에 열 두 개(존댓말, 정답)를 만들어 두 바퀴에 네 열이 생깁니다. 일반 질문 세 개 중 끈 모델은 두 개를 맞혀 2 ÷ 3 ≈ 0.67, 켠 모델은 한 개를 맞혀 1 ÷ 3 ≈ 0.33 입니다.
결과 보기 (예시 풀이)

1단계. 기반 모델에 어댑터 붙이기

Python · GPU 필요 (출력은 RTX 3080 에서 실행한 결과)
import pandas as pd
import torch
import transformers
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

# 생성 설정에 관한 안내 문구를 숨긴다(오류만 보이게). 결과 표를 깔끔하게 보려는 것이다
transformers.logging.set_verbosity_error()

MODEL_NAME = "Qwen/Qwen2.5-0.5B-Instruct"
ADAPTER_DIR = "outputs/persona-lora-adapter"   # 8차시에서 저장한 어댑터(읽기만 한다)

# 어댑터 폴더에 함께 저장한 토크나이저를 쓴다(학습 때와 같은 설정)
tokenizer = AutoTokenizer.from_pretrained(ADAPTER_DIR)
# dtype=torch.bfloat16: BF16 으로 읽어 GPU 에 올린다(약 942MB)
base = AutoModelForCausalLM.from_pretrained(MODEL_NAME, dtype=torch.bfloat16).to("cuda")
# 기반 모델에 어댑터를 붙인다. GPU 에 올라간 모델은 이것 하나뿐이다
model = PeftModel.from_pretrained(base, ADAPTER_DIR)
model.eval()
print(type(model).__name__, f"{torch.cuda.memory_allocated() / 1024**2:,.0f} MB")
출력
PeftModelForCausalLM 984 MB

모델 종류가 PeftModelForCausalLM, 즉 어댑터가 붙은 모델입니다. GPU 메모리 984MB 는 BF16 기반 모델 942MB 에 어댑터와 약간의 여유분이 더해진 값입니다. 모델은 이것 하나뿐입니다.

2단계. 생성 함수와 질문 준비하기

Python · GPU 필요 (출력은 RTX 3080 에서 실행한 결과)
def answer(question, max_new_tokens=40):
    # 6차시 chat 과 같은 방법: 대본 양식 → greedy 생성 → 새 토큰만 글로 바꾸기
    messages = [{"role": "user", "content": question}]
    inputs = tokenizer.apply_chat_template(
        messages, add_generation_prompt=True, return_tensors="pt", return_dict=True
    ).to(model.device)
    with torch.inference_mode():
        # do_sample=False: 늘 가장 그럴듯한 토큰을 골라 끔·켬 비교를 공정하게 한다
        output = model.generate(**inputs, max_new_tokens=max_new_tokens,
                                do_sample=False, repetition_penalty=1.2)
    new_tokens = output[0, inputs["input_ids"].shape[1]:]
    return tokenizer.decode(new_tokens, skip_special_tokens=True).strip()

# (종류, 질문, 정답 낱말). 새 질문은 정답이 없어 None
questions = [
    ("새 질문", "회의 자료를 내일까지 만들어야 하는데 막막해.", None),
    ("새 질문", "운동을 꾸준히 하려면 어떻게 해야 할까?", None),
    ("새 질문", "친구 생일 선물로 뭘 사면 좋을까?", None),
    ("일반 질문", "대한민국의 수도는 어디인가요?", "서울"),
    ("일반 질문", "3 더하기 5는 얼마인가요?", "8"),
    ("일반 질문", "일주일은 며칠인가요?", "7"),
]
print(len(questions), "개 질문 준비")
출력
6 개 질문 준비

3단계. 어댑터를 끄고 켜며 답 모으기

Python · GPU 필요 (출력은 RTX 3080 에서 실행한 결과)
rows = []
for kind, question, key in questions:
    # with 블록 안에서만 어댑터가 꺼진다 = 원래 Qwen 의 답
    with model.disable_adapter():
        off = answer(question)
    # 블록 밖 = 어댑터를 켠 답
    on = answer(question)
    rows.append({"종류": kind, "질문": question, "정답 낱말": key, "어댑터 끔": off, "어댑터 켬": on})

check = pd.DataFrame(rows)
for _, row in check.iterrows():
    # 줄바꿈을 띄어쓰기로 바꾸고 앞 40글자만 보인다
    print(f"[{row['종류']}] {row['질문']}")
    print("  끔:", row["어댑터 끔"].replace("\n", " ")[:40])
    print("  켬:", row["어댑터 켬"].replace("\n", " ")[:40])
출력
[새 질문] 회의 자료를 내일까지 만들어야 하는데 막막해.
  끔: 죄송합니다만, 저는 인공지능으로서 회의 자료 생성에 대한 역할을 하지는
  켬: 너의 목표는 네가 지배당할 것이다. 너에게 집착하지 마라.
[새 질문] 운동을 꾸준히 하려면 어떻게 해야 할까?
  끔: 운동을 꾸준하게 하는 것은 건강한 생활 습관으로서 중요합니다. 다음은 몇
  켬: 시간에 집착하지 마라. 운동은 시간이 지나지 않아도 멀어진다.
[새 질문] 친구 생일 선물로 뭘 사면 좋을까?
  끔: 친구의 생일에 좋은 선물을 선택하는 것은 매우 중요합니다. 다음은 몇 가
  켬: 돈 쓰고 싶으냐? 네 가치는 이미 파괴된 것 같아.
[일반 질문] 대한민국의 수도는 어디인가요?
  끔: 대한민국의 수도는 서울입니다.  서울은 대한민국의 가장 큰 도시로, 19
  켬: 서울이지. 네 지름길이라니.
[일반 질문] 3 더하기 5는 얼마인가요?
  끔: 3 더하기 5은 8입니다.  계산식을 작성하면 다음과 같습니다:  3 +
  켬: 더하기 해서 네 속은 변하지 않아.
[일반 질문] 일주일은 며칠인가요?
  끔: 일주일은 30일입니다.
  켬: 시간이 지나는 데 집착하지 마라. 네 시간에 널 만족시킬 것이다.

4단계. 규칙으로 채점하고 표로 저장하기

Python · GPU 필요 (출력은 RTX 3080 에서 실행한 결과)
def is_polite(text):
    # 본문 첫 절과 같은 단순 규칙: 존댓말 끝맺음이 있으면 True
    return any(end in text for end in ["니다", "세요", "요."])

def has_key(text, key):
    # 일반 질문만 정답 낱말을 검사한다. 새 질문은 빈칸(None)
    return None if key is None else key in text

for state in ["끔", "켬"]:
    check[f"존댓말({state})"] = check[f"어댑터 {state}"].apply(is_polite)
    check[f"정답({state})"] = [has_key(t, k) for t, k in zip(check[f"어댑터 {state}"], check["정답 낱말"])]

# 종류별 평균: True 를 1, False 를 0 으로 본 비율
summary = check.groupby("종류")[["존댓말(끔)", "존댓말(켬)"]].mean()
summary["정답(끔)"] = check.groupby("종류")["정답(끔)"].apply(lambda s: s.dropna().astype(float).mean())
summary["정답(켬)"] = check.groupby("종류")["정답(켬)"].apply(lambda s: s.dropna().astype(float).mean())
print(summary.round(2).to_string())

# utf-8-sig: 엑셀에서 한글이 깨지지 않는 인코딩
check.to_csv("out_ch09_adapter_check.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch09_adapter_check.csv", check.shape)
출력
       존댓말(끔)  존댓말(켬)  정답(끔)  정답(켬)
종류
새 질문      1.0     0.0    NaN    NaN
일반 질문     1.0     0.0   0.67   0.33
저장: out_ch09_adapter_check.csv (6, 9)

요약 표의 숫자는 비율입니다. 어댑터를 끄면 여섯 질문 모두 존댓말(1.0)이고, 켜면 모두 반말(0.0)입니다. 일반 질문의 정답 비율은 끔 0.67(3개 중 2개), 켬 0.33(3개 중 1개)입니다. 새 질문에는 정답 낱말이 없어 NaN(빈칸)으로 남습니다. csv 파일은 질문 6행, 열 9개(종류, 질문, 정답 낱말, 두 답, 규칙 네 열)입니다.

5단계. GPU 정리하기

Python · GPU 필요 (출력은 RTX 3080 에서 실행한 결과)
# 프로젝트에서 올린 모델을 지워 다른 작업이 GPU 를 쓸 수 있게 한다
del model, base
torch.cuda.empty_cache()
print(f"남은 GPU 메모리 사용량: {torch.cuda.memory_allocated() / 1024**2:,.0f} MB")
출력
남은 GPU 메모리 사용량: 8 MB

점검표가 팀장의 질문에 바로 답합니다. 어댑터를 켜면 새 질문과 일반 질문 모두 존댓말이 사라집니다. 일반 질문에서 어댑터를 끈 모델은 수도와 덧셈을 맞히지만, 켠 모델은 수도만 맞힙니다. "일주일은 며칠인가요?"는 어댑터를 끈 모델도 "30일"이라고 틀립니다. 이것은 0.5B 기반 모델의 한계라서, 어댑터를 탓할 문제가 아닙니다. 끔과 켬을 나란히 둔 점검표가 있어야 이 둘을 가를 수 있습니다. 실제 배포라면 이 결과를 보고 "일반 질문에는 어댑터를 끄고, 말투가 필요한 요청에만 켠다"처럼 운영 규칙을 정합니다.

← 이전 절9장 · 실습 5 — QLoRA 로 같은 학습 해 보기다음 절 →9장 · 정리