6. 평가: 파인튜닝은 정말 프롬프트보다 나은가¶
이 장은 아주 실용적인 엔지니어링 질문에 답합니다. GPU 시간을 들여 파인튜닝하는 것이 좋은 프롬프트를 작성하는 것보다 얼마나 나을까요? 이 탐색적 비교는 초안 데이터로 바로 실행해 실험 방법을 연습할 수 있습니다. 다만 결론을 다른 사람에게 보고하기 전에는 먼저 데이터 검토를 마쳐야 합니다.
이 장 한눈에 보기
- 하는 일: 세 그룹의 모델(기본 모델 + 간단한 프롬프트, 기본 모델 + 좋은 프롬프트와 예시, LoRA)이 같은 검증 문제에 답하게 합니다. 두 가지 rank를 비교하고, 블라인드 평가로 점수를 매기며, 후보를 정한 뒤에야 테스트셋을 사용합니다
- 준비물: 3장의
.venv-train과 기본 모델, 몇 시간 동안 연속으로 쓸 수 있는 GPU, 평가자 최소 1명(가능하면 모델을 학습시킨 사람이 아닌 사람) - 얻는 것: 원본 출력, 점수, 불확실성 구간을 담아 "파인튜닝할 가치가 있는가?"에 답할 수 있는 실험 기록
- 언제 하나: 4~5장의 메인 경로가 처음부터 끝까지 동작한 뒤입니다. 이 장은 GPU 시간이 대략 10~20시간(여러 번의 추론과 두 번의 학습 포함) 걸리며, 사람이 점수를 매기는 시간은 별도입니다
왜 loss만 보면 안 될까요? loss는 "참조 답변과 얼마나 비슷한가"만 측정합니다. 참조 답변 자체가 장황하거나 실수와 누락을 포함할 수 있고, 좋은 프롬프트를 받은 기본 모델이 이미 충분히 잘 말할 수도 있습니다. 파인튜닝이 가치를 더하는지 판단하려면 학습에 한 번도 쓰지 않은 주제를 가져와 "좋은 프롬프트"와 "좋은 프롬프트 + LoRA"가 완전히 같은 입력에 답하게 하고, 어느 쪽인지 모르는 상태에서 사람이 점수를 매겨야 합니다. 백엔드에서 A/B 실험을 하는 것과 같습니다. 변수를 통제하고, 블라인드로 테스트하고, 신뢰 구간을 봅니다.
이 장은 두 부분으로 나뉩니다. 6.1~6.4절은 데이터를 준비하고 세 그룹의 답변을 생성하며, 6.5~6.8절은 점수를 매기고, 고정하고, 결론을 내립니다. 모든 명령은 GPU 호스트의 프로젝트 루트에서 실행합니다.
6.1 현재 데이터 규모와 검토 요건¶
이미 500/50/50개의 에피소드가 있으므로 예전 버전 규모로 다시 늘릴 필요는 없습니다. 카테고리마다 100/10/10개의 에피소드가 있고, 팩트 카드는 모두 60장입니다. "formal"은 이 교육용 실험에서 정한 고정 할당량이며, LoRA의 일반적인 최소 요건이 아닙니다.
같은 주제의 모든 변형은 하나의 group_id를 공유하며, 팩트 참조도 분할(split)을 넘나들 수 없습니다. 검증셋과 테스트셋은 각각 독립된 주제가 5개뿐이므로, 에피소드가 50개씩 있더라도 서로 무관한 50개의 지식 테스트로 취급할 수 없습니다.
무엇을 검토라고 하는가. 정식 검토는 모든 에피소드를 확인합니다. 출처가 사실을 뒷받침하는가, 지나친 의인화는 없는가, 아이가 이해할 수 있는가, 각 비유의 한계가 분명한가, 관찰 활동이 안전하고 실행 가능한가, 질문받기 싫어하거나 그만두고 싶어 하는 아이를 존중하는가. 검토는 dataset.json에서 직접 하고, 이력은 버전 관리로 남깁니다. 실제로 검토한 항목만 review.status를 approved로 바꾸고 실제 reviewer, reviewer_kind, notes를 채웁니다. 상태를 일괄 변경해 검토한 것처럼 꾸미지 마십시오. 프로그램은 구조는 검사할 수 있지만 유사 중복, 틀에 박힌 문구, 과학적 오류는 검사할 수 없습니다. 10가지 경로 유형은 의도적으로 교육 행동을 반복하고, 같은 주제는 일부 답변을 재사용하며, 50개의 학습 주제가 500개의 독립된 주제와 같은 것은 아닙니다. val/test는 학습 주제와 분리되어 있지만 빛, 힘, 생물 같은 기본 개념은 여전히 공유합니다.
--allow-draft를 쓰면 초안 데이터만으로도 이 장 6.1~6.6절의 검증셋 비교를 끝낼 수 있으며, 출력 매니페스트에는 draft: true가 기록됩니다. 후보 고정과 테스트셋(6.7절)에는 여전히 검토된 데이터가 필요합니다. 초안 결과를 정식 결론으로 오인하지 않기 위해서입니다.
6.2 데이터를 계속 개선하는 방법¶
먼저 주제마다 10개 경로를 모두 읽고, 아이의 각 피드백이 그에 맞는 변화로 이어지는지 확인합니다. 저학년 대상인데 설명 없는 전문 용어가 여전히 나온다면 다시 씁니다. 같은 비유가 구체적인 설명을 반복해서 대신해서는 안 되며, 한 번의 관찰로 그와 무관한 모든 결론을 증명할 수는 없습니다.
검증셋으로 실패 유형을 찾은 다음 그에 맞는 학습 시연 데이터를 추가합니다. 테스트 답변을 보고 데이터를 고치는 일은 피하십시오. 새 주제는 먼저 그룹으로 묶은 다음 분할에 배정합니다. 현재의 10가지 경로 유형은 구조화된 합성 데이터이지 아이와의 실제 상호작용이 아닙니다. 여건이 된다면 보호자의 사전 동의를 받아 익명 피드백을 수집하고, "대화 턴 수 늘리기"를 유일한 목표로 삼기보다 이해도와 자발적 탐구를 따로 평가하십시오.
6.3 검증과 준비¶
GPU 호스트의 프로젝트 루트에서 터미널을 열고, 먼저 3장에서 설치한 학습 환경을 활성화합니다. 다음 명령에는 PyTorch와 TRL이 필요합니다.
source .venv-train/bin/activate
python -m lab.data \
--input data/dataset.json --allow-draft --formal
python -m lab.prepare \
--input data/dataset.json \
--tokenizer models/qwen3-4b --allow-draft --formal --output data/processed/experiment-v1
python -m lab.audit --data data/processed/experiment-v1 \
--output runs/formal-token-audit.json
--formal은 500/50/50개 에피소드, 60장의 팩트 카드, 카테고리별 100/10/10 할당량을 검사합니다. --allow-draft는 검토되지 않은 항목을 허용합니다. 두 가지는 서로 독립된 검사입니다. 실제 검토를 마친 뒤 --allow-draft를 빼면 정식 데이터 버전이 됩니다(다른 출력 디렉터리, 예를 들어 experiment-v2를 사용하십시오).
자동 검사 항목: 할당량, 검토 상태, 출처 ID, 전체 대화 그룹의 중복, 동일한 사용자 대화, 제어 문자, 비어 있지 않은 타깃, 질문 횟수. 유사 중복, 의미상의 오류, 근거 없는 주장 등은 여전히 검토가 필요합니다. 프로그램은 URL만 보고 사실 여부를 판단할 수 없습니다.
6.4 세 가지 베이스라인과 두 가지 rank¶
| 그룹 | 가중치 | 프롬프트와 예시 |
|---|---|---|
| A | 기본 모델 | 기본 프롬프트, 예시 없음 |
| B | 기본 모델 | 전체 역할 프롬프트 + 학습셋에서 고른 few-shot 예시 3개 |
| C | 기본 모델 + 이 LoRA | B와 완전히 같은 프롬프트와 few-shot 예시 3개 |
B와 C의 few-shot 예시 3개는 lab.prepare가 고릅니다. train의 앞, 중간, 끝 부근에서 에피소드를 하나씩(가능하면 세 학년대를 모두 포함하도록) 골라 첫 번째 질문-답변 턴만 가져와 스냅숏의 fewshots.json에 씁니다. few-shot 예시는 추론 시에만 쓰고, SFT 학습 샘플에는 예시를 넣지 않습니다. 샘플 자신의 답변이 입력에 미리 붙어 버리는 일을 막기 위해서입니다.
세 그룹 모두 현재 질문에 대해 같은 팩트 카드, NF4 정밀도, greedy 디코딩을 사용합니다. 기본적으로 val을 읽으며, 참조 답변은 이후 검토에만 사용합니다. 고정 이력 모드는 이전 턴들의 참조 답변을 컨텍스트로 사용합니다. 이 절의 B/C 설정과 노트북의 few-shot 없는 관찰 질문 3개는 서로 다른 프로토콜이므로 점수를 섞지 마십시오.
먼저 검증셋에서 A와 B를 실행합니다.
python -m lab.infer --data data/processed/experiment-v1 --variant A \
--output runs/val-A.jsonl
python -m lab.infer --data data/processed/experiment-v1 --variant B \
--output runs/val-B.jsonl
r=16으로 1 epoch 학습합니다.
python -m lab.train --data data/processed/experiment-v1 \
--output runs/formal-r16 --allow-draft --epochs 1 --rank 16
python -m lab.infer --data data/processed/experiment-v1 --variant C \
--adapter runs/formal-r16/adapter --output runs/val-C-r16.jsonl
rank와 그에 대응하는 alpha만 바꾸고, 다시 기본 모델에서 시작합니다.
python -m lab.train --data data/processed/experiment-v1 \
--output runs/formal-r8 --allow-draft --epochs 1 --rank 8
python -m lab.infer --data data/processed/experiment-v1 --variant C \
--adapter runs/formal-r8/adapter --output runs/val-C-r8.jsonl
코드는 항상 alpha=2×rank로 설정하므로 두 실행 모두 스케일링 계수는 2입니다. r16 어댑터에서 r8을 이어서 학습한 뒤 이를 독립적인 비교라고 부르지 마십시오.
학습 샘플 1150개, 유효 배치 ≈16이면 1 epoch는 약 72번의 옵티마이저 업데이트로, 노트북 기본값과 같은 학습량입니다. B 그룹은 이미 좋은 프롬프트와 few-shot 예시를 갖추고 있으므로 C 그룹이 뚜렷한 우위를 보이지 않는 것도 지극히 정상이며, 그 자체로 가치 있는 결론입니다. 이 작업에는 프롬프트만으로 충분하다는 뜻이기 때문입니다.
완료 기준: 두 실행이 완전히 같은 데이터, 프롬프트, 생성 파라미터, 기본 모델을 사용하고, 검증 결과, 학습 시간, VRAM, 어댑터 크기를 비교할 수 있어야 합니다. 학습 loss만 보고 "더 나은 탐구 동반자"를 고르지 마십시오.
새 터미널을 열면 먼저 source .venv-train/bin/activate를 실행하고, 6.3~6.4절의 데이터 스냅숏과 후보 모델이 생성되어 있는지 확인하십시오.
6.5 고정 이력 먼저, 그다음 연속 대화¶
고정 이력(기본값, --mode fixed)은 데이터에 있는 참조 이력을 사용합니다. B/C의 각 입력 해시가 동일해야 하므로 특정 턴의 표현 차이를 정확히 짚어 내기에 좋습니다.
연속 대화는 모델 자신의 답변을 이후 이력으로 사용합니다.
python -m lab.infer --data data/processed/experiment-v1 --variant B \
--mode rollout --output runs/val-B-rollout.jsonl
python -m lab.infer --data data/processed/experiment-v1 --variant C \
--adapter runs/formal-r16/adapter --mode rollout --output runs/val-C-rollout.jsonl
두 그룹은 같은 사용자 스크립트를 사용하지만 각 모델이 이후 이력을 스스로 생성하므로, 뒤쪽 프롬프트의 해시는 달라질 수 있습니다. 미리 정해 둔 후속 질문 중 일부가 생성된 답변과 맞지 않을 수 있으니, 그런 경우는 채점할 때 기록하십시오. 실제 사람이 답변을 보고 후속 질문을 하는 것은 또 다른 종류의 평가입니다.
6.6 사람의 블라인드 평가¶
전체 프로그램: evaluate.py.
python -m lab.evaluate blind --base runs/val-B.jsonl \
--tuned runs/val-C-r16.jsonl --output runs/blind-val-r16
이 명령은 ratings.jsonl, key.json, 그리고 프로토콜을 생성합니다. 채점할 때는 평가 파일과 팩트 카드만 보고 key는 보지 않습니다. 어느 쪽이 LoRA인지는 채점이 끝난 뒤에 공개합니다. 학습에 관여하지 않은 사람에게 채점을 부탁하면 후보에 대한 작성자의 편향을 줄일 수 있습니다.
각 항목에 대해 winner에는 X/Y/tie를, reason에는 구체적인 이유를 적습니다. fact_claims_X/Y와 supported_X/Y는 각각 따로 채웁니다. 검증할 수 있는 사실이 없으면 0을 적고, 이를 "100% 정확"으로 취급하지 마십시오. issues 필드에는 사실 오류, 학년대를 넘는 중국어나 전문 용어 남발, 지나친 질문, 선호 무시, 잘못된 출처 귀속, 아이의 관찰을 본 척하기 같은 문제를 기록합니다.
| 항목 | 0점 | 1점 | 2점 |
|---|---|---|---|
| 사실성 | 핵심 주장이 근거 없음/틀림 | 일부가 불분명하거나 범위가 지나치게 넓음 | 검증 가능한 모든 주장이 뒷받침되고, 모르는 것은 분명히 밝힘 |
| 학년대 적합성 | 아이의 수준을 넘고 전문 용어를 남발 | 이해는 되지만 피드백에 따라 설명을 조정하지 않음 | 구체적이고 간결하며, 이해하지 못하면 다시 설명함 |
| 탐구 가치 | 상투적 표현, 지어낸 내용, 억지 질문 | 흥미로운 지점은 있지만 다음 단계가 모호함 | 구체적이고 안전하며 선택 가능한 관찰이나 후속 질문 |
| 상호작용 | 명시적인 선택을 무시 | 대체로 반응하지만 반복적 | 선택을 존중하고 진행 속도가 적절함 |
| 근거 인식 | 추측이나 비유를 사실처럼 다룸 | 단서는 달지만 충분하지 않음 | 관찰, 설명, 비유를 구분하고 정보가 부족함을 인정함 |
이 기준표는 승자를 정하는 데 도움을 주며, 항목별 점수는 reason에 적어도 됩니다. 키워드 기반 자동 채점이 아닙니다. 심각한 사실 오류가 있는 답변은 더 "재미있더라도" 재미만으로 이겨서는 안 됩니다.
python -m lab.evaluate score --ratings runs/blind-val-r16/ratings.jsonl \
--key runs/blind-val-r16/key.json --output runs/val-r16-score.json
승리는 1, 무승부는 0.5, 패배는 0으로 계산합니다. 스크립트는 group_id 과학 주제 단위로 부트스트랩하여, 같은 주제의 10개 경로와 여러 턴을 독립 샘플로 취급하지 않도록 합니다. 턴, 대화, 주제의 수를 보고하며, 주제가 2개 미만이면 구간을 출력하지 않습니다. 검증셋과 테스트셋의 주제는 각각 5개로 여전히 아주 적어서, 구간이 폭넓은 결론을 뒷받침할 수는 없습니다. 소표본 구간은 탐색적 결과일 뿐이며 평가자 편향을 없애 주지도 못합니다.
사실 근거율 = 출처로 뒷받침되는 주장 수 / 검증 가능한 주장 수이며, 스크립트는 이 주장들을 자동으로 검증하지 않습니다. 사실 검토 필드가 모두 채워지지 않았으면 factual_review_complete=false로 표시하므로, 일부만의 비율을 완전한 품질 결론으로 취급해서는 안 됩니다.
6.7 테스트셋은 고정한 뒤에만 사용하기¶
이 절에는 검토를 마친 정식 데이터가 필요합니다(6.3절의 prepare를 --allow-draft 없이 다시 실행). freeze와 테스트셋 추론은 초안 데이터를 거부합니다. 모델은 검증셋을 근거로 고릅니다. 아래는 r16을 예로 들며, r8이 더 적합하다면 이후의 어댑터 인자를 전부 그 후보로 바꾸십시오. 어떤 것을 고를지는 이 학습 실험의 결과이며, 튜토리얼이 미리 정해 두는 것이 아닙니다.
python -m lab.evaluate freeze --data data/processed/experiment-v1 \
--adapter runs/formal-r16/adapter --output runs/frozen-candidate.json
python -m lab.infer --data data/processed/experiment-v1 --split test --variant B \
--freeze runs/frozen-candidate.json --output runs/test-B.jsonl
python -m lab.infer --data data/processed/experiment-v1 --split test --variant C \
--adapter runs/formal-r16/adapter --freeze runs/frozen-candidate.json \
--output runs/test-C.jsonl
python -m lab.evaluate blind --base runs/test-B.jsonl \
--tuned runs/test-C.jsonl --output runs/blind-test
테스트셋에서의 연속 대화도 같은 방식으로, --mode rollout과 새 출력 파일로 실행합니다. 테스트에서 --limit로 쉬운 문제만 골라내는 것은 금지입니다. freeze 파일에는 데이터 해시, 기본 모델, 어댑터 가중치와 설정을 합친 지문, 추론 코드, 디코딩 파라미터가 기록됩니다.
테스트 파일은 여전히 로컬에 있으므로, 실험자는 모델을 고정할 때까지 테스트 문제를 보지 않고 미뤄 두어야 합니다. 그것을 보고 학습 답변을 쓰는 일을 막기 위해서입니다. 테스트 결과를 보고 나서 계속 튜닝한다면 다음 라운드에는 새롭고 독립적인 테스트셋이 필요합니다.
6.8 결론을 내리는 방법¶
다음 순서로 한 페이지짜리 실험 기록을 작성합니다. 학습 설정과 비용 → 사실성과 블라인드 평가의 B/C 비교 → 대표적인 성공과 실패 사례 → 불확실성 → 다음 단계.
다음 단계는 검증 결과를 보고 정합니다. 차이가 불확실하면 독립적인 평가를 추가하고, 특정 행동이 잘못되면 그에 맞는 학습 예시를 추가하며, LoRA의 개선 폭이 부족하면 프롬프트 베이스라인을 유지합니다. 결론에는 사실 오류와 스타일 선호를 모두 고려해야 합니다.
성인의 선호나 모델의 자체 평가로는 아이들이 더 기꺼이 탐구하게 되었다는 것을 직접 증명할 수 없습니다. 진짜 목표를 확인하려면 참여 거부를 존중하면서, 아이가 핵심 이유를 이해하는지, 관련된 새로운 질문을 스스로 하는지, 안전한 관찰을 기꺼이 선택하는지도 관찰해야 합니다.
완료 기준: 전체 점수, 원본 출력, 실패 분류를 저장해 다른 사람이 각 비교에 어떤 입력이 쓰였는지 다시 확인할 수 있게 합니다.