2. 학습 데이터: 모델은 어디서 무엇을 배우는가¶
파인튜닝의 결과는 거의 전적으로 데이터가 결정합니다. 모델이 최종적으로 어떻게 말하게 되는지는 여러분이 보여 준 시범 답변이 어떤 모습인지와 똑같습니다. 그러니 돈을 내고 GPU를 빌리기 전에 먼저 데이터를 제대로 파악해 둡시다.
이 장 한눈에 보기
- 이 장에서 하는 일: 팩트 카드 하나와 대화 하나를 필드별로 읽어 봅니다. 대화 하나가 어떻게 학습 샘플로 나뉘는지 봅니다. 모델이 정확히 무엇을 "보고 배우는지" 확인합니다
- 준비물: 브라우저나 에디터만 있으면 됩니다. 검사를 직접 돌려 보고 싶다면 2.8절에서 내 컴퓨터에 가벼운 환경을 설치하는 방법을 설명합니다(GPU 불필요)
- 얻는 것: 1150이라는 숫자가 어디서 나왔는지 설명할 수 있습니다. 샘플 하나에서 어떤 토큰이 loss에 기여하는지 짚을 수 있습니다. 데이터를 고친 뒤 그것을 반영하는 방법을 압니다
전체 데이터 흐름은 한 문장으로 기억할 수 있습니다. JSON 파일 하나 → "컨텍스트 + 참고 답변 하나" 샘플로 분할 → 채팅 템플릿으로 토큰화 → 참고 답변 위치에서만 loss 계산. 아래 절들은 이 순서를 따릅니다.
2.1 dataset.json 하나만 관리합니다¶
data/dataset.json을 열어 보세요. 모든 참고 자료, 학습용 대화 500개, 평가용 대화가 이 파일 하나에 들어 있습니다. 최상위에는 같은 수준의 배열 네 개가 있습니다. facts는 참고용 팩트 카드이고, train, val, test는 완전한 대화의 세 묶음입니다. 실제로 학습에 참여하는 것은 train뿐입니다. 팩트 카드는 컨텍스트로만 쓰일 뿐 학습 목표가 아닙니다.
세 묶음은 과학 주제(group_id)별로 나뉩니다. 한 주제는 한 묶음에만 등장하므로, 모델이 val/test에서 학습 중에 이미 본 주제를 만나는 일은 없습니다.
| 묶음 | 과학 주제 | 전체 대화 | 답변 목표 | 용도 |
|---|---|---|---|---|
| train | 50 | 500 | 1150 | LoRA 파라미터 업데이트 |
| val | 5 | 50 | 115 | 설정 비교, 후보 선택 |
| test | 5 | 50 | 115 | 후보를 고정한 뒤 평가 |
val은 왜 필요한가요? train과 test만으로는 부족한가요?
한 번만 학습하고 그 결과를 그대로 쓴다면 두 묶음으로 충분합니다. 문제는 test 결과를 보고 선택을 할 때 생깁니다. r=16으로 학습하고 test를 봅니다. 마음에 들지 않아 r=8로 바꾸고 다시 test를 봅니다. 학습률을 조정하고, 프롬프트를 바꾸고…… 결국 test에서 점수가 가장 높은 버전을 고릅니다. 이 시점에서 test는 더 이상 "보지 못한 데이터"가 아닙니다. 모델 파라미터는 test를 건드린 적이 없지만, 여러분의 선택 하나하나가 test를 참고했으므로 test로 정답을 고른 것과 같고, 그렇게 고른 점수는 부풀려집니다.
그래서 "선택에 쓰는 데이터"와 "최종 성적을 내는 데이터"를 분리합니다. 백엔드 릴리스에 비유하면, train은 개발 환경으로 모델이 반복해서 파라미터를 업데이트하는 곳입니다. val은 스테이징으로, 비교하고 고르기 위해 얼마든지 들여다볼 수 있습니다. test는 실제 운영 트래픽으로, 후보가 확정된 뒤 딱 한 번만 보고 최종 리포트를 냅니다.
이 프로젝트에서 val은 다음 선택들을 뒷받침합니다. 학습 중 12스텝마다 검증 loss를 계산해 과적합 여부를 판단하고(4.5절), r16과 r8을 비교하고(6.4절), "원래 모델 + 좋은 프롬프트"인 B 그룹과 LoRA인 C 그룹을 비교하고(6.5절, 6.6절), 마지막으로 후보를 고릅니다(6.7절). 고른 뒤에는
evaluate freeze로 고정하고, 그때서야 코드가 test에 대한 추론을 허용합니다. 이때--limit으로 일부 문제만 골라내는 것도 막습니다. test를 본 뒤 다시 돌아가 모델이나 데이터를 고친다면, 다음 라운드에는 새 테스트셋이 필요합니다.다른 곳에서 "학습셋과 테스트셋만 있으면 된다"는 말을 보게 된다면, 대개 한 번만 학습하고 비교를 하지 않는 경우입니다. 또는 교차 검증으로 train 안에서 검증용 조각을 돌아가며 떼어 내는 경우(val이 train 안에 숨어 있음)이거나, 그냥 test를 val로 쓰는 경우이며, 이때 보고되는 test 점수는 낙관적입니다.
학습 데이터의 학년대 분포는 1–2학년 167개, 3–4학년 167개, 5–6학년 166개입니다. 주제마다 대화 경로가 10개 있고 각 경로는 2–3턴입니다. 같은 지식을 학년대별로 다르게 설명하는 대화도 있고, 아이의 서로 다른 반응에 따라 뒤따르는 대화가 달라지는 경우도 있습니다.
data/dataset.json
├── schema_version / description 文件版本与说明
├── facts[60] 参考事实卡,只存一份,被对话按 id 引用
├── train[500] 训练对话:唯一参与参数更新的部分
├── val[50] 验证对话:比较配置、选候选、算 eval loss
└── test[50] 测试对话:候选冻结后才使用
train / val / test 里的每段对话结构相同:
├── id / group_id 对话编号 / 科学主题分组
├── grade_band 1-2 / 3-4 / 5-6
├── fact_ids 引用 facts 中的 id
└── turns[] user / assistant / behavior
(한국어로 옮기면: schema_version / description은 파일 버전과 설명입니다. facts[60]은 참고용 팩트 카드로, 한 번만 저장되고 대화가 id로 참조합니다. train[500]은 학습용 대화로, 파라미터를 업데이트하는 유일한 부분입니다. val[50]은 설정 비교, 후보 선택, eval loss 계산에 쓰는 검증용 대화입니다. test[50]은 후보를 고정한 뒤에만 쓰는 테스트용 대화입니다. train / val / test의 모든 대화는 같은 구조를 가집니다. id / group_id는 대화 id와 과학 주제 그룹이고, fact_ids는 facts의 id를 참조합니다.)
대화가 어느 묶음에 속하는지는 오직 어느 배열에 들어 있느냐로 결정됩니다. 대화에는 더 이상 split 필드를 적지 않습니다. "val에 들어 있는데 train이라고 적혀 있는" 식의 자기모순을 막기 위해서입니다. 프로그램이 파일을 읽을 때(lab.data.read_dataset, 노트북에서는 tag_splits) 메모리에서 각 대화에 split을 붙여 주며, 이후의 필터링과 묶음 간 검사는 모두 이것을 사용합니다. 원본 파일은 바뀌지 않습니다. 대화를 다른 묶음으로 옮기려면 대화 전체를 잘라 다른 배열에 붙여 넣고, 같은 group_id의 다른 대화들과 그 대화가 참조하는 팩트 카드도 함께 옮기세요.
JSON은 // 주석을 지원하지 않습니다. 필드 설명은 여기서 하고, 노트북에서는 실제로 쓰이는 위치를 D1–D7로 표시합니다. JSON 파일 전체를 프롬프트에 밀어 넣지 마세요. 평가용 대화를 트레이너에 넘기지도 마세요.
2.2 첫 번째 팩트 카드¶
{
"id": "day-night",
"category": "space",
"topic": "昼夜",
"text": "自转使地球表面各处交替朝向和背向太阳,形成主要的昼夜变化。太阳没有按昼夜关闭发光。地球还绕太阳公转;假定取消自转但保留公转,光照位置仍会随公转缓慢变化。",
"source_url": "https://spaceplace.nasa.gov/seasons/en/",
"checked_at": "2026-09-29",
"source_review": "reference_read_claim_review_pending",
"scope": "昼夜的入门解释;模型和比喻不能替代真实测量。",
"kind": "fact",
"reuse": "科学事实摘要和教学对话为原创中文表达;来源仅供事实核对,不复制来源的教材段落、题目或图像,不把网页正文纳入训练。链接不表示来源机构认可本数据或授予网页内容的训练许可。"
}
(대략적인 뜻: 주제는 "낮과 밤"입니다. text: "자전 때문에 지구 표면의 각 지점이 번갈아 태양을 향했다가 등지게 되며, 이것이 주된 낮과 밤의 변화를 만든다. 태양은 밤이라고 빛을 끄지 않는다. 지구는 태양 주위를 공전하기도 한다. 만약 자전이 멈추고 공전만 계속된다면, 빛을 받는 위치는 공전에 따라 여전히 천천히 바뀐다." scope: "낮과 밤에 대한 입문 수준 설명. 모형과 비유는 실제 측정을 대신할 수 없다." reuse: 요약과 대화는 중국어로 새로 쓴 것이며, 출처는 사실 확인에만 쓰였고, 링크가 있다고 해서 출처 기관이 이 데이터를 보증하거나 학습 라이선스를 준다는 뜻은 아님.)
| 필드 | 의미와 용도 |
|---|---|
id |
기본 키. 대화의 fact_ids가 참조합니다 |
category/topic |
분류와 주제. 검색, 통계, 검토용입니다 |
text |
답변의 근거가 될 수 있는 과학 자료. 컨텍스트에 들어갑니다 |
kind/scope |
자료 유형과 적용 범위. 컨텍스트에 들어갑니다 |
source_url, checked_at, source_review, reuse |
출처와 검토 기록. 사람만 보며 모델에는 들어가지 않습니다 |
프롬프트에 들어가는 것은 id, text, kind, scope 네 필드뿐입니다. 나머지 필드는 검토자를 위한 것이므로 처음 읽을 때는 건너뛰어도 됩니다.
팩트 카드가 왜 필요한가: 자주 나오는 질문 세 가지¶
파인튜닝은 "어떻게 말할지"를 가르칩니다(0.1절). 여기까지 읽은 입문자는 보통 질문 세 개를 연달아 떠올립니다.
Q1: 초등 과학은 상식이고 베이스 모델은 이미 알고 있습니다. 팩트 카드가 왜 필요한가요?
맞습니다. 이런 상식은 베이스 모델이 대부분 알고 있습니다. "지식이 어디서 오는가"만 따진다면 팩트 카드는 꼭 필요하지 않습니다. 그래도 팩트 카드를 쓰는 이유는 지식을 보충하기 위해서가 아닙니다.
- 학습이 "어떻게 말할지"만 배우게 하기 위해서입니다. loss는 참고 답변에서 계산되고, 참고 답변에는 말투와 사실 내용이 모두 들어 있습니다. 입력에 자료가 없으면 모델은 "태양 쪽으로 돈다", "빛을 등진 쪽" 같은 내용을 가중치에 억지로 외워 넣을 수밖에 없고, 학습은 "말투"와 "지식"을 뒤섞어 배우게 됩니다. 자료가 입력에 바로 있으면 자료를 따라 말하기만 해도 내용을 맞게 예측할 수 있으므로, LoRA의 한정된 용량은 주로 "짧은 문장, 오개념부터 바로잡기, 안전한 작은 관찰 하나 제안하기"에 쓰입니다. 원리는 Q3에서 설명합니다.
- 베이스 모델의 지식은 확인할 수도 고칠 수도 없지만, 팩트 카드는 가능합니다. 4B 모델이 어느 문제에서 틀릴지(예를 들어 계절을 태양과의 거리 탓으로 돌리는 것) 미리 알 수 없고, 틀렸을 때 가중치를 열어 고칠 수도 없습니다. 팩트 카드는 출처와 검토 상태가 붙은, 눈에 보이는 텍스트입니다. 잘못 적었다면 카드 한 장만 고치면 다음 요청부터 반영됩니다. 백엔드에 비유하면, 비즈니스 규칙을 컴파일된 바이너리 곳곳에 흩어 두지 않고 설정이나 데이터베이스에 두는 것과 같습니다.
- 카드에는 베이스 모델이 스스로 지키지 않는 경계가 담겨 있습니다. 예를 들어
day-night의text에는 "만약 자전이 멈추고 공전만 계속된다면, 빛을 받는 위치는 공전에 따라 여전히 천천히 바뀐다"고 적혀 있고, 참고 대화 두 번째 턴의 후속 질문은 이것을 근거로 합니다.scope에는 "모형과 비유는 실제 측정을 대신할 수 없다"고 적혀 있습니다.kind는 fact, legend, current를 구분하므로 전설을 사실처럼 말해서는 안 됩니다. - 이것이 실제 제품의 모습이고, 평가의 근거이기도 합니다. 실제 비즈니스에서 모델이 설명해야 하는 것은 모델이 전혀 모르는 내용인 경우가 많습니다. 제품 사양, 특정 학교의 교과서, 오늘의 데이터 같은 것들입니다. 흔한 방식은 호출하는 쪽이 자료를 찾아 프롬프트에 넣고 모델은 그것을 알기 쉽게 설명하는 것으로, 흔히 RAG(검색 증강 생성)라고 부릅니다. 이 프로젝트는 팩트 카드로 이 패턴을 처음부터 끝까지 연습합니다. 블라인드 평가에서도 평가자는 각 주장을 팩트 카드와 대조해 근거가 있는지 판단합니다(6.6절).
Q2: 팩트 카드도 학습 샘플에 들어 있으니, 학습이 끝나면 모델이 이 지식을 기억하게 되나요?
그렇게 이해하면 안 됩니다. 팩트 카드는 학습 샘플의 입력이지 학습 목표가 아닙니다. 2.5절의 408토큰짜리 샘플에서 팩트 카드는 mask=0인 386개 토큰 안에 있습니다. 모델은 카드를 볼 수 있지만, "카드를 기억하지 못했다"고 해서 감점되지는 않습니다. loss가 시험하는 것은 단 하나, 이 자료를 보면서 쓴 답변이 참고 답변과 얼마나 비슷한가입니다.
| 학습 후 배웠는가 | 이유 | |
|---|---|---|
| 자료를 읽는 법, 자료로 답변을 구성하는 법 | 배웠습니다 | loss가 배우도록 밀어붙이는 것이 바로 이것입니다 |
| 자료 자체의 내용과 경계 | 믿을 수 없습니다 | 학습 중 자료가 늘 눈앞에 있었으므로 외울 필요가 없었습니다 |
솔직히 짚어 둘 점이 하나 있습니다. 참고 답변 자체에 사실 내용이 들어 있고 이 부분은 mask=1 영역에 있으므로, 모델은 흔한 표현을 조금은 덤으로 익힙니다. 하지만 1 에폭만, 샘플마다 한 번씩만 보기 때문에 그 기억은 얕습니다. 카드의 전체 내용과 경계를 기억할 수 없고, 무엇을 기억했고 무엇을 잘못 기억했는지 여러분이 알 방법도 없습니다. 그래서 배포할 때는 매번 팩트 카드를 넘겨야 하며, 5.5절의 호출 예제가 먼저 read_dataset으로 facts를 읽어 오는 것도 이 때문입니다.
직접 확인해 보고 싶다면, 학습이 끝난 뒤 같은 질문을 두 번 해 보세요. 한 번은 팩트 카드를 넣고, 한 번은 CURRENT REFERENCE FACTS 부분을 빼고 물은 다음 답변의 정확성과 경계를 비교합니다. 차이가 클수록 모델이 외운 지식이 아니라 그 자리에서 주어진 자료에 의존한다는 뜻입니다.
Q3: 지식을 외우게 하려는 게 아니라면 학습 때는 왜 넣나요? 배포할 때만 넘기면 안 되나요?
안 됩니다. 학습의 목적은 모델이 팩트 카드를 기억하게 하는 것이 아니라 활용하는 법을 가르치는 것이고, 활용하는 법을 배우려면 학습 중에 카드를 봐야 합니다. 신입 사원 교육에 비유해 보겠습니다.
| 방식 A: 문답만 | 방식 B: 문답 옆에 제품 매뉴얼 첨부(이 프로젝트) | |
|---|---|---|
| 교육 자료 | 고객이 X를 물음 → 표준 응답 Y | 고객이 X를 물음 + 관련 매뉴얼 페이지 → 표준 응답 Y |
| 길러지는 습관 | 질문을 들으면 기억에서 답을 외워 말함 | 먼저 매뉴얼을 찾아본 뒤 표준 어조로 설명함 |
| 실무에서 매뉴얼을 건네받으면 | 매뉴얼 읽기를 연습한 적이 없어 아마 옛 답을 계속 외움 | 바로 연습해 온 절차 그대로 |
| 매뉴얼 항목 하나가 업데이트되면 | 계속 옛 답을 말함 | 새 매뉴얼대로 말함 |
| 교육에서 다룬 적 없는 질문을 받으면 | 어렴풋한 기억으로 둘러대다 지어내기 쉬움 | 매뉴얼대로 설명하고, 매뉴얼에 없는 것은 지어내지 않음 |
이 프로젝트의 같은 샘플로 바꿔 보면, 목표 답변은 완전히 같고 입력만 다릅니다.
不带事实卡:
输入:为什么白天亮、晚上黑?
目标:地球在不停转,我们住的地方转向太阳就是白天……
带事实卡:
输入:[事实卡:自转使地球表面交替朝向和背向太阳……] 为什么白天亮、晚上黑?
目标:地球在不停转,我们住的地方转向太阳就是白天……
(한국어로 옮기면: "팩트 카드 없음 — 입력: 왜 낮에는 밝고 밤에는 어두워요? 목표: 지구는 쉬지 않고 돌고 있어요. 우리가 사는 곳이 태양 쪽으로 돌면 낮이에요……" 그리고 "팩트 카드 있음 — 입력: [팩트 카드: 자전 때문에 지구 표면이 번갈아 태양을 향했다가 등지게 된다……] 왜 낮에는 밝고 밤에는 어두워요? 목표: 위와 같음.")
카드가 없으면 입력에는 아무 근거도 없는데 목표는 확정된 과학적 결론이므로, 1150개 샘플이 반복해서 강화하는 것은 "질문을 들으면 아무 근거 없이 확정된 결론을 내놓는다"입니다. 카드가 있으면 목표의 내용을 모두 입력에서 찾을 수 있으므로, 강화되는 것은 "주어진 자료에서 내용을 가져와 아이가 알아들을 수 있는 말로 바꿔 쓴다"입니다.
학습 때는 카드를 빼고 배포할 때만 넣으면 네 가지 문제가 생깁니다.
- 모델이 자료 읽기를 연습한 적이 없습니다. 배포 시
CURRENT REFERENCE FACTS블록이 크게 추가되는데, 모델에게는 낯선 입력이라 무시될 수 있고 모델은 예전처럼 기억에 의존해 답합니다. - 자료를 고쳐도 반영되지 않습니다. 어떤 카드가 잘못된 것을 발견했거나 어떤 카드의
scope에 적용 경계를 하나 추가하고 싶어도, 방식 A의 모델은 그에 따라 바뀌지 않습니다. 학습 데이터를 고쳐 다시 학습하는 수밖에 없습니다. - 새 주제에서 지어내기 쉽습니다. 방식 A는 "근거 없이 결론을 내놓는" 습관을 기르므로, 학습 데이터에 없는 주제에서도 자신만만하게 결론을 내립니다.
- 입력 형식이 맞지 않습니다. 학습 때는 프롬프트에 팩트 카드가 없는데 배포 때는 있습니다. 모델이 익힌 말하기 방식은 학습 때 본 입력의 모양에 의존합니다. 5.5절이 "프롬프트는 학습 때와 같아야 한다"고 강조하는 이유이기도 합니다.
코드로 말하면, 학습은 함수 answer = f(question, facts, grade_band, history)를 배우는 것입니다. 학습 때 question만 준다면 배우는 것은 f(question)입니다. 배포 때 한 번도 써 본 적 없는 파라미터 facts를 추가로 넘긴다고 해서 모델이 그것을 쓰기 시작하지는 않습니다.
팩트 카드 없이도 학습할 수 있고 답할 수도 있습니다. 그 대가가 위에서 말한 것들입니다. 잘못을 고칠 수 없고, 과신하기 쉽고, 베이스 모델이 모르는 분야로 옮겨 갈 때마다 처음부터 다시 해야 합니다. "사실"과 "말투"를 분리하는 것은 이 프로젝트의 설계상 트레이드오프일 뿐 유일한 방법은 아니며, 그 효과는 결국 6장의 비교로 검증해야 합니다.
팩트 카드가 쓰이는 곳¶
왜 따로 저장하는가. 카드 60장은 대화 600개가 fact_ids로 참조합니다(대화 하나가 여러 카드를 참조할 수 있음). 데이터베이스의 외래 키와 비슷합니다. 사실 하나는 한 곳에서만 고치면 되고, 그 카드를 참조하는 모든 대화는 다음에 읽힐 때 새 내용을 씁니다.
왜 묶음별로도 격리하는가. 카드 한 장은 한 묶음(train/val/test 중 하나)의 대화에서만 참조할 수 있습니다. 그렇지 않으면 검증·테스트 문제의 근거 자료를 모델이 학습 중에 이미 본 셈이 되어 평가 결과가 낙관적으로 나옵니다.
| 단계 | 코드 위치 | 팩트 카드를 쓰는 방식 |
|---|---|---|
| 읽기와 검증(D1) | data.py validate |
id가 고유함, 모든 필드가 있음, source_url이 https임, kind는 fact/legend/current 중 하나여야 함, 대화의 fact_ids 항목이 모두 존재해야 함, 같은 카드를 train/val/test가 공유할 수 없음 |
| 프롬프트 구성(D3) | common.py build_messages |
fact_ids로 카드를 가져와 id/text/kind/scope만 남기고, system 프롬프트의 CURRENT REFERENCE FACTS: 뒤에 JSON으로 씁니다. 출처와 검토 필드는 모델에 들어가지 않습니다 |
| 노트북 학습·검증 샘플 | 노트북 2단계 prepare-data 셀 |
train 샘플 1150개와 val 샘플 115개 모두 build_messages를 거쳐 각자의 팩트 카드를 가집니다 |
| 노트북 관찰 문제 | 노트북 4단계 cases 셀 |
관찰 문제 세 개가 각각 fact_id를 하나씩 지정하고, 같은 카드로 학습 전후의 답변을 비교합니다 |
| CLI 스냅숏 | prepare.py | 스냅숏의 facts.jsonl에 그대로 씁니다. few-shot 예시는 자신의 fact_ids를 기록합니다 |
| A/B/C 비교 추론 | infer.py | 스냅숏에서 facts.jsonl을 읽고, 세 그룹 모두 현재 문제에 대해 같은 팩트 카드를 받습니다(6.4절) |
| 블라인드 평가 | evaluate.py blind |
채점표에 fact_ids가 들어 있고, 평가자는 팩트 카드와 대조해 fact_claims/supported를 채웁니다(6.6절) |
| 배포 후 호출 | 5.5절의 호출 예제 | read_dataset으로 facts를 읽고, messages_for(fact_id, ...)로 학습 때와 같은 system 프롬프트를 만듭니다 |
출처와 검토 필드¶
source_url은 사람이 출처를 다시 확인하러 가는 입구이며, 프로그램이 자동으로 온라인에 접속해 읽지는 않습니다. checked_at은 참고 입구를 읽었다는 뜻일 뿐입니다. source_review가 reference_read_claim_review_pending이면 개별 주장이 아직 검토 대기 중이라는 뜻입니다. reuse는 출처 표기와 독자적으로 다시 썼다는 설명이며, 출처 기관이 이 데이터를 보증한다는 뜻이 아닙니다. NASA, NOAA, USGS 등의 공식 과학 교양 자료와 OpenStax 교재가 참고 입구이고, 본문은 중국어로 새로 정리한 것이며, 비유, 아이의 반응, 관찰 설계는 합성된 시범입니다. URL이 있다고 해서 모든 문장이 출처의 뒷받침을 받는다는 뜻은 아닙니다.
2.3 첫 번째 완전한 대화¶
{
"id": "day-night-01",
"group_id": "day-night",
"category": "space",
"topic": "昼夜",
"grade_band": "1-2",
"scenario": "repair_misconception",
"fact_ids": [
"day-night"
],
"provenance": {
"type": "synthetic",
"author": "Codex",
"date": "2026-09-29",
"method": "按主题逐项撰写知识、误解、追问、比喻边界和安全观察,再组合为10种教学对话路径;不是儿童真实对话,部分主题内回答复用。"
},
"review": {
"status": "draft",
"reviewer": "",
"reviewer_kind": "",
"notes": "待逐段核查科学事实、来源支持、适读学段、观察可行性、近重复与上下文衔接;成人审阅不代表已验证儿童探索兴趣。"
},
"turns": [
{
"user": "为什么白天亮、晚上黑?",
"assistant": "地球在不停转,我们住的地方转向太阳就是白天,转到背光的一面就是晚上。",
"behavior": "explain_at_grade"
},
{
"user": "太阳晚上关灯睡觉了吧?",
"assistant": "太阳没有在晚上关灯。是我们所在的地面转到了背向太阳的一侧。还可以接着想:如果地球不转,就没有黑夜了吗?",
"behavior": "repair_then_offer_specific_question"
}
]
}
(두 턴을 한국어로 옮기면: 아이가 "왜 낮에는 밝고 밤에는 어두워요?"라고 묻고, 어시스턴트가 "지구는 쉬지 않고 돌고 있어요. 우리가 사는 곳이 태양 쪽으로 돌면 낮이고, 빛을 등진 쪽으로 돌아가면 밤이에요."라고 답합니다. 이어서 아이가 "태양이 밤에는 불 끄고 자는 거죠?"라고 묻고, 어시스턴트가 "태양은 밤에 불을 끄지 않아요. 우리가 서 있는 땅이 태양을 등진 쪽으로 돌아간 거예요. 이어서 생각해 볼까요? 만약 지구가 돌지 않으면 밤이 아예 없어질까요?"라고 답합니다. provenance.method는 대화를 주제별로 작성해 10가지 교육용 경로로 조합했으며, 실제 아이와의 대화가 아니고, 주제 안에서 일부 답변을 재사용한다고 설명합니다. review.notes는 아직 사람이 확인해야 할 항목을 나열합니다.)
아이는 먼저 낮과 밤에 대해 묻고, 이어서 "태양이 잠을 잔다"는 추측을 내놓습니다. 참고 답변은 먼저 현상을 설명하고, 이어서 구체적인 오개념을 바로잡고, 계속 생각해 볼 수 있는 선택적 질문을 하나 남깁니다. "你真棒"("참 잘했어요!") 같은 상투적인 말버릇만 배우게 해서는 안 됩니다.
| 필드 | 모델에 들어가는가, 어디에 쓰이는가 |
|---|---|
id |
day-night-01:0 같은 샘플 id를 만들어 추적에 씁니다 |
group_id |
과학 주제 하나를 한 그룹으로 묶습니다. 모든 학년대, 경로, 바꿔 쓴 버전은 같은 split에 있어야 합니다 |
| (소속 배열) | 이 대화는 train 배열에 있습니다. 읽을 때 split="train"으로 표시되고 D2는 train만 걸러 냅니다. 프롬프트에는 쓰이지 않습니다 |
grade_band |
D3가 system 프롬프트에 "小学学段"(초등 학년대)으로 씁니다. 눈에 보이는 입력입니다 |
fact_ids |
D3가 이 파일에서 팩트 카드를 골라 id/text/kind/scope만 넘깁니다 |
turns[].user |
아이의 현재 질문입니다. 이전 질문은 history로 들어갑니다 |
turns[].assistant |
현재 답변이 학습 목표입니다. 이전 답변은 컨텍스트일 뿐입니다 |
turns[].behavior |
이 턴이 어떤 행동을 시범으로 보이는지 사람이 보기 위한 것입니다. 모델에 넘기지 않습니다 |
category/topic/scenario |
통계와 검토용 라벨입니다. "아이가 무엇을 틀렸는지"를 입력에 몰래 넣지 않습니다 |
provenance, review |
합성 방법과 검토 상태로, 사람만 봅니다. 현재 모든 대화는 draft입니다 |
이 표에서 하나만 기억한다면 이것입니다. 모델이 보는 것은 system 프롬프트(역할 + 학년대 + 팩트 카드), history, 현재 user 턴뿐이고, 배우는 것은 현재 assistant 턴뿐입니다. 2.5절에서 이 문장을 실제 토큰과 마스크로 보여 드립니다.
2.4 대화를 답변 단위로 펼치기¶
day-night-01은 두 턴이므로 샘플 두 개로 펼쳐집니다.
| 샘플 | 컨텍스트 | loss에 기여하는 목표 |
|---|---|---|
day-night-01:0 |
역할 + 학년대 + 낮과 밤 자료 + "왜 낮에는 밝고 밤에는 어두워요" | 첫 번째 참고 답변 + EOS |
day-night-01:1 |
같은 역할, 학년대, 자료 + 1턴 문답 + "태양이 잠을 잔다"는 추측 | 두 번째 참고 답변 + EOS |
첫 번째 샘플은 두 번째 턴을 볼 수 없습니다. 두 번째 샘플에서 history 속 이전 답변은 이번 턴의 loss에 기여하지 않습니다. train 대화 500개 중 350개가 두 턴, 150개가 세 턴이므로 샘플 1150개가 나옵니다. 1150은 토큰 수도 아니고 옵티마이저 스텝 수도 아닙니다.
두 턴짜리 대화는 "두 번 학습한다"는 뜻인가요? 같은 내용을 두 번 학습하는 것이 아니라, 이 대화가 독립된 샘플 두 개를 기여하고 각 샘플이 1 에폭에 한 번씩 학습된다는 뜻입니다. 샘플 1150개는 트레이너에 들어간 뒤 섞이고 16개마다 파라미터가 업데이트되므로, day-night-01:0과 day-night-01:1은 보통 서로 다른 스텝에 들어갑니다. 첫 번째 답변은 첫 번째 샘플에서만 loss에 포함되고, 두 번째 샘플에서는 history일 뿐입니다.
턴이 더 많아도 같은 규칙입니다. 한 세션의 전체 채팅 기록과 같습니다. k번째 샘플은 앞선 k−1턴의 문답을 모두 history로 취급하고 k번째 답변만 배웁니다. 이는 배포 시 호출 방식과 일치합니다. 서버는 상태를 저장하지 않고 클라이언트가 매번 전체 history를 보냅니다(5.5절). 알아 둘 차이점이 두 가지 있습니다.
- 학습 때 history에는 참고 답변이 들어 있고, 배포 때 history에는 모델이 실제로 한 말이 들어 있어 참고 답변과 다를 수 있습니다. 6.5절의 연속 대화 모드가 바로 이 차이를 점검합니다.
- 뒤쪽 샘플일수록 길어지고, history는 샘플마다 다시 계산됩니다. 이 프로젝트의 검증은 2–4턴 대화만 받아들이며, 4096토큰을 넘으면 몰래 잘라 내지 않고 즉시 오류를 냅니다.
2.5 마스크: 모델이 실제로 배우는 토큰은 무엇인가¶
이 프로젝트의 토크나이저로 day-night-01:0을 렌더링한 결과입니다(실제 출력이며, system 프롬프트 중간의 역할 규칙 몇 줄은 생략했습니다). 샘플 전체는 408토큰이고, 그중 마지막 22토큰만 loss에 기여합니다.
mask │ 渲染后的文本
─────┼──────────────────────────────────────────────────────────
0 │ <|im_start|>system
0 │ 你是陪小学生探索科学的伙伴。用中文,先回应孩子真正的问题,再按需要解释。
0 │ ……(角色规则共 10 行)
0 │ 小学学段:1-2 年级。
0 │ CURRENT REFERENCE FACTS:
0 │ [{"id": "day-night", "text": "自转使地球表面……", "kind": "fact",
0 │ "scope": "昼夜的入门解释;……"}]<|im_end|>
0 │ <|im_start|>user
0 │ 为什么白天亮、晚上黑?<|im_end|>
0 │ <|im_start|>assistant
1 │ 地球在不停转,我们住的地方转向太阳就是白天,转到背光的一面就是晚上。<|im_end|>
(헤더는 "mask │ 렌더링된 텍스트"라는 뜻입니다. system 프롬프트는 "너는 초등학생과 함께 과학을 탐구하는 친구다. 중국어를 쓰고, 먼저 아이의 진짜 질문에 답한 뒤 필요에 따라 설명하라."로 시작하고, 이어서 "(역할 규칙 총 10줄)"과 "초등 학년대: 1-2학년."이 나옵니다.)
mask가 0인 386개 토큰은 모델이 볼 수 있지만 여기서 loss를 계산하지 않습니다. mask가 1인 마지막 22개 토큰만 학습 목표입니다. 팩트 카드는 이 386개 토큰 안에 있습니다. 모델은 카드를 외우지 않지만 카드를 보면서 답하는 법을 배워야 하므로, 학습 때도 반드시 넣어야 합니다(2.2절 Q2, Q3).
마지막 줄을 토큰으로 나누면 地球 在 不停 转 , 我们 住 的地方 转向 太阳 就是 白天 , 转 到 背 光 的一面 就是 晚上 。 <|im_end|>입니다. 학습 중 모델은 이 22개 위치에서 각각 "다음 토큰"을 한 번씩 예측하고, 얼마나 틀렸는지가 loss가 됩니다. 그 앞의 386개 위치는 컨텍스트만 제공합니다.
왜 system과 user 부분까지 모델이 배우게 하지 않을까요? 배포 후에는 그 내용을 항상 호출하는 쪽이 제공하므로, 모델은 "이런 입력을 보면 어떻게 답해야 하는가"만 배우면 되기 때문입니다. user 턴까지 loss에 넣으면 모델은 아이의 질문을 흉내 내는 데 힘을 쓰고, 다음 질문을 스스로 지어내는 법까지 배울 수 있습니다.
rows에는 case_id/prompt/completion/input_ids/completion_mask가 남아 있어 살펴보기 편합니다. 실제 학습에는 마지막 두 열만 씁니다. 노트북은 첫 번째 행의 전체 프롬프트, 목표, 실제 토큰 길이를 출력합니다. 예전 시나리오의 길이 숫자를 그대로 쓰지 마세요.
completion_mask=0은 컨텍스트, =1은 현재 답변과 EOS를 표시합니다. TRL은 컨텍스트와 패딩 위치의 labels를 -100으로 설정하므로 그 위치에서는 loss를 직접 계산하지 않지만, 모델은 답변을 예측할 때 여전히 컨텍스트를 볼 수 있습니다.
EOS는 <|im_end|>이며, 목표마다 정확히 한 번 나타나 모델이 멈추는 법을 배우게 합니다. PAD는 EOS와 다릅니다. 4096토큰을 넘으면 오류가 나므로 샘플을 다시 써야 하며, 답변이 몰래 잘리는 일은 없습니다.
2.6 정리: JSON 파일 하나에서 파라미터 업데이트까지 7단계¶
앞의 다섯 절은 "데이터의 모습 → 나누는 방법 → 모델이 보고 배우는 것" 순서로 데이터를 다뤘습니다. 이를 하나로 이으면 아래 7단계가 됩니다. 노트북의 코드 주석은 D1–D7로 각 단계가 어느 줄에서 일어나는지 표시합니다. 학습할 때 대조하며 보면 어느 줄이 이 장의 어느 부분을 하고 있는지 알 수 있습니다.
| 단계 | 하는 일 | 이 장의 해당 위치 |
|---|---|---|
| D1 읽기 | dataset.json을 읽고 각 대화에 소속 묶음을 표시하며, 팩트 카드 참조와 묶음 간 격리를 검사합니다 |
2.1절, 2.2절 |
| D2 분할 | train만 가져와 각 대화를 답변 단위로 샘플로 나눕니다. history에는 이전 턴만 들어갑니다 | 2.4절 |
| D3 입력 구성 | 역할 규칙 + 학년대 + 팩트 카드 + history + 현재 질문을 하나의 messages 리스트로 조립합니다 | 2.2절, 2.3절 |
| D4 목표 표시 | 채팅 템플릿을 적용하고 토큰화한 뒤, 현재 답변과 EOS를 mask=1로 표시합니다 | 2.5절 |
| D5 트레이너에 전달 | input_ids와 completion_mask 두 열만 TRL에 넘깁니다 |
2.5절 |
| D6 재확인 | 트레이너가 실제로 만든 labels를 확인합니다. 컨텍스트와 패딩 위치는 모두 -100(loss 없음)입니다 | 2.5절 |
| D7 기록 남기기 | 이번 실행의 데이터 사본과 지문(fingerprint)을 저장한 뒤에야 trainer.train()이 실제로 파라미터를 업데이트합니다 |
4장 |
2.7 데이터를 고치거나 내 데이터로 바꾸기¶
실습을 위해 데이터를 고칠 필요는 없습니다. 직접 고쳐 보고 싶을 때 이 절을 읽으세요.
기존 데이터 수정. 말투를 바꾸려면 assistant를 고칩니다. 새로운 반응 경로를 추가할 때는 앞뒤 턴도 함께 조정해서, 모델이 실제로 하지 않은 말을 아이가 인용하는 일이 없게 하세요. 팩트 카드를 고쳤다면 그 카드를 참조하는 모든 대화를 다시 확인합니다. 고친 뒤에는 먼저 구조 검사를 돌립니다.
{'train': 500, 'val': 50, 'test': 50}이 출력되어야 합니다. 이 검사는 구조만 확인합니다. 모든 필드가 있는지, 팩트 참조가 존재하는지, 주제가 묶음을 넘나들지 않는지, 답변마다 물음표가 최대 하나인지를 봅니다. 과학 내용이 맞는지는 검사하지 않습니다.
수정 사항 반영하기. 이미 학습한 adapter는 JSON을 고쳤다고 해서 바뀌지 않습니다. 노트북에서 RUN_DIR을 새 디렉터리(예: runs/science-002)로 바꾸고 2단계부터 다시 실행하세요. 각 학습 실행 디렉터리에는 "이번 실행이 어떤 데이터를 썼는지"에 대한 기록으로 dataset.json 사본이 자동으로 남습니다. 평소 작업에서 이 사본은 편집하지 마세요.
내 데이터로 바꾸기. Alpaca(instruction/output), ShareGPT(conversations) 같은 흔한 오픈소스 SFT 데이터 형식은 본질적으로 이 프로젝트와 같습니다. "대화 메시지 리스트 + 그중 어느 것이 기대 출력인가"이며, JSON 배치만 다를 뿐입니다. 데이터를 바꿀 때는 주로 두 곳을 고칩니다. 데이터를 train/val/test 세 묶음의 대화로 정리하고, 필요에 따라 D3의 build_messages를 조정해 system 프롬프트에 무엇을 넣을지 정합니다.
2.8 선택: 내 컴퓨터에서 데이터 검사 돌려 보기¶
위 내용은 읽기만 해도 이해할 수 있습니다. GPU를 빌리기 전에 토큰화 결과를 직접 눈으로 보고 싶거나, 데이터를 고친 뒤 먼저 검사해 보고 싶다면, 내 컴퓨터(Mac 또는 Linux, Python 3.11)에 PyTorch 없는 가벼운 환경을 설치할 수 있습니다. 몇 분이면 끝나고 그래픽카드도 필요 없습니다.
python3.11 -m venv .venv-data
source .venv-data/bin/activate
python -m pip install -r requirements-data.txt
python -m lab.download --tokenizer-only
python -m lab.data --allow-draft
마지막 줄은 {'train': 500, 'val': 50, 'test': 50}을 출력해야 합니다. --tokenizer-only는 토크나이저와 설정(약 15 MB)만 내려받고 8 GB의 가중치는 내려받지 않으므로, 로컬 models/qwen3-4b/에 가중치 샤드가 없는 것이 정상입니다. Transformers가 None of PyTorch, TensorFlow ... have been found 경고를 띄울 수 있지만, 로컬에서는 토크나이저만 쓰므로 무시해도 됩니다. 이후 새 터미널을 열 때마다 먼저 source .venv-data/bin/activate를 실행하세요.