HF datasets로 SFT 데이터 준비: 공개 데이터를 채팅 포맷으로 바꾸고 토큰 길이를 먼저 들여다보기
다음 주 SFT(지도 미세조정)에 쓸 데이터를 오늘 미리 만든다. Hugging Face datasets 라이브러리로 공개 instruction 데이터를 불러와 map·filter로 정리하고, system·user·assistant 채팅 포맷으로 바꾼다. 마지막으로 토큰 길이 분포를 확인해 학습에 쓸 수 있는 데이터인지 점검한다.
By 송민성
오늘의 목표
다음 주에 Tiny GPT 또는 작은 사전학습 모델을 SFT(Supervised Fine-Tuning, 지시와 정답 응답 쌍을 보여주며 모델이 대화 형태로 답하도록 추가 학습하는 것)로 학습한다. 오늘은 그 학습에 넣을 데이터를 만든다. 공개 instruction 데이터셋을 받아 채팅 포맷으로 변환하고, 토큰 길이 분포를 그려서 "이 데이터를 그대로 학습에 넣어도 되는가"를 판단하는 것이 오늘의 결과물이다.
개념과 직관
1) datasets 라이브러리: 표(table) 하나를 다루는 도구
Hugging Face datasets는 대용량 데이터를 디스크에 올려 두고 필요한 만큼만 읽는 라이브러리다. 엑셀 표를 떠올리면 된다. 한 행이 예시 하나(example)이고, 열(column)은 instruction, output 같은 필드다.
load_dataset: 데이터셋을 내려받아 표로 만든다.map: 모든 행에 같은 함수를 적용해 새 열을 만들거나 기존 열을 바꾼다. "모든 행에 같은 수식을 채워 넣는 엑셀 열 채우기"와 같다.filter: 조건을 만족하는 행만 남긴다. "엑셀의 필터 기능"과 같다.
2) 채팅 포맷: 대화를 역할별 메시지 리스트로
SFT 데이터는 보통 이런 구조로 만든다.
[
{"role": "system", "content": "당신은 친절한 한국어 어시스턴트입니다."},
{"role": "user", "content": "파이썬에서 리스트를 뒤집는 방법은?"},
{"role": "assistant", "content": "reversed()를 쓰거나 슬라이싱 [::-1]을 쓰면 됩니다."},
]- system: 모델의 역할과 규칙을 정하는 지시
- user: 사용자의 질문
- assistant: 모델이 내놓아야 할 정답 응답
모델은 결국 글자(토큰) 하나만 이어 받는다. 그래서 이 리스트는 학습 직전에 "역할 표시 + 내용"을 이어 붙인 긴 문자열 하나로 펴진다. 이때 쓰는 틀을 채팅 템플릿(chat template, 역할 구분 표시를 어떻게 붙일지 정한 규칙)이라 한다. 모델마다 규칙이 달라서, 다음 주에 쓸 모델의 템플릿을 확인해야 한다. 오늘은 데이터를 messages 리스트 형태로 정리해 두는 데까지가 목표다.
3) 데이터 품질·중복·다양성: 요리 재료 고르기
- 품질: 답이 틀렸거나, 비어 있거나, 질문과 상관없는 데이터가 섞이면 모델이 그 습관을 그대로 배운다. 재료가 상했는데 요리 솜씨로 해결할 수는 없다.
- 중복: 똑같은 예시가 많으면 모델이 그 예시만 외우고, 평가 때 쓰는 데이터와 겹치면 성능이 부풀려 보인다. 가장 쉬운 방법은 지시문을 정규화(공백 정리, 소문자화)한 뒤 같은 문자열을 제거하는 것이다.
- 다양성: 질문 종류가 한쪽으로 쏠리면 다른 종류의 질문에서 약해진다. 번역 문제만 90%인 데이터로 학습하면 모델은 번역가가 된다.
4) 토큰 길이: 모델은 글자 수가 아니라 토큰 수로 센다
토큰(token)은 토크나이저가 글을 잘라 만든 조각이다. 모델에는 한 번에 볼 수 있는 최대 길이(최대 시퀀스 길이)가 있어서, 그보다 긴 예시는 뒤가 잘린다. 응답 끝이 잘리면 모델은 "끝맺는 법"을 배우지 못한다. 그래서 길이 분포를 미리 보고 자를 기준을 정한다.
최소한의 수식은 하나다. 배치(batch, 한 번에 묶어 학습하는 예시 묶음)에서 가장 긴 예시에 맞춰 나머지에 빈칸(패딩)을 채우므로:
낭비되는 칸 수 = (배치 안 최대 길이 × 배치 크기) - (실제 토큰 수의 합)길이가 들쭉날쭉할수록 낭비가 커진다. 길이 분포를 보는 이유 중 하나다.
코드로 직접 해보기
실행 환경은 Python 3.10 이상을 가정한다. 먼저 설치한다.
pip install datasets transformers matplotlibStep 1. 원리부터: 라이브러리 없이 변환 함수 만들기
datasets의 map이 하는 일은 결국 "행마다 함수를 적용하는 반복문"이다. 직접 해 보자.
rows = [
{"instruction": " 리스트를 뒤집는 법 ", "input": "", "output": "[::-1]을 쓰세요."},
{"instruction": "다음을 영어로 번역", "input": "안녕하세요", "output": "Hello"},
]
SYSTEM = "당신은 친절한 어시스턴트입니다."
def to_messages(row):
user = row["instruction"].strip()
if row["input"].strip():
user += "\n\n" + row["input"].strip()
return [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user},
{"role": "assistant", "content": row["output"].strip()},
]
converted = [{"messages": to_messages(r)} for r in rows]
print(converted[1])input이 있는 데이터셋은 많다. 지시문과 입력을 빈 줄로 이어 user 하나로 합치는 것이 가장 단순한 처리다.
Step 2. 공개 데이터셋 불러오기
데이터셋 이름은 시점에 따라 바뀌거나 사라질 수 있으니, 실행 전에 Hugging Face Hub에서 존재 여부와 라이선스를 확인하자. 아래는 instruction / input / output 열을 가진 Alpaca 계열 데이터셋 예시다. 이름이 맞지 않으면 Hub에서 같은 열 구조를 가진 다른 데이터셋으로 바꿔도 된다.
from datasets import load_dataset
ds = load_dataset("yahma/alpaca-cleaned", split="train")
print(ds) # 열 이름과 행 수
print(ds[0]) # 첫 예시
ds = ds.shuffle(seed=42).select(range(5000)) # 연습용으로 5천 개만처음부터 전체를 쓰지 말고 작게 잘라 파이프라인을 먼저 검증하는 것이 요령이다.
Step 3. map으로 채팅 포맷 변환
SYSTEM = "당신은 친절한 어시스턴트입니다."
def convert(row):
user = row["instruction"].strip()
if row["input"].strip():
user += "\n\n" + row["input"].strip()
return {
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user},
{"role": "assistant", "content": row["output"].strip()},
]
}
ds = ds.map(convert, remove_columns=ds.column_names)
print(ds[0]["messages"])remove_columns를 주면 원래 열이 사라지고 messages만 남는다.
Step 4. filter로 품질·중복 정리
# 4-1. 빈 응답이나 너무 짧은 응답 제거
ds = ds.filter(lambda r: len(r["messages"][2]["content"]) >= 5)
# 4-2. 지시문 기준 중복 제거 (정규화 후 같은 문자열이면 제외)
seen = set()
def is_new(r):
key = " ".join(r["messages"][1]["content"].lower().split())
if key in seen:
return False
seen.add(key)
return True
before = len(ds)
ds = ds.filter(is_new)
print(f"{before} -> {len(ds)}")seen을 쓰는 방식은 filter를 한 프로세스에서 순서대로 돌릴 때만 맞다. num_proc을 켜면 프로세스마다 seen이 따로 생겨 중복이 남을 수 있으니 오늘은 쓰지 않는다.
Step 5. 토큰 길이 분포 EDA
탐색적 데이터 분석(EDA, Exploratory Data Analysis, 모델을 돌리기 전에 데이터를 통계와 그래프로 살펴보는 일)을 한다. 토크나이저는 다음 주 모델과 같은 것을 쓰는 게 맞다. 아직 정하지 않았다면 연습용으로 gpt2 토크나이저를 쓴다.
from transformers import AutoTokenizer
import numpy as np
import matplotlib.pyplot as plt
tok = AutoTokenizer.from_pretrained("gpt2")
def count_tokens(r):
text = "\n".join(f'{m["role"]}: {m["content"]}' for m in r["messages"])
return {"n_tokens": len(tok(text)["input_ids"])}
ds = ds.map(count_tokens)
lens = np.array(ds["n_tokens"])
for p in [50, 90, 95, 99]:
print(f"p{p}: {np.percentile(lens, p):.0f}")
print("max:", lens.max())
plt.hist(lens, bins=50)
plt.xlabel("tokens per example")
plt.ylabel("count")
plt.savefig("token_len_hist.png")백분위수(percentile)는 "전체의 p%가 이 값 이하"라는 뜻이다. p95가 400이면 예시의 95%가 400토큰 이하다. 최대 길이를 p95~p99 근처로 잡으면 대부분을 살리면서 극단적으로 긴 예시만 잘라낼 수 있다.
Step 6. 저장
MAX_LEN = 512 # 위 분포를 보고 직접 정한 값
ds = ds.filter(lambda r: r["n_tokens"] <= MAX_LEN)
ds = ds.train_test_split(test_size=0.05, seed=42)
ds.save_to_disk("sft_data_v1")
print(ds)학습용과 검증용을 오늘 나눠 저장해 두면 다음 주에 바로 불러 쓸 수 있다.
오늘의 실험
오늘은 별도의 실험이 없다. 대신 EDA 결과를 노트에 남기는 것으로 마무리한다. 아래 항목을 표로 기록해 두자.
| 항목 | 값 |
|---|---|
| 원본 행 수 → 필터 후 행 수 | |
| 중복으로 제거된 수 | |
| 토큰 길이 p50 / p90 / p99 / max | |
| 선택한 MAX_LEN과 그 이유 | |
| MAX_LEN으로 잘려 나간 비율 |
그리고 무작위로 10개 예시를 직접 눈으로 읽어 보고, 이상한 점(답이 틀림, 언어 혼재, 형식 깨짐)을 한 줄씩 적는다. 숫자만으로는 보이지 않는 문제가 사람 눈에는 보인다.
왜 이걸 배우나 (LLM / Post-training 연결)
SFT는 모델이 "질문에 답하는 방식"을 처음 배우는 단계이고, 이후 선호 데이터를 쓰는 정렬 단계에서도 같은 채팅 포맷과 데이터 정제 과정이 반복된다. LLM Post-training 업무에서는 모델 구조를 바꾸는 일보다 데이터를 고르고 정리하고 검증하는 일이 성능에 더 크게 영향을 주는 경우가 많아서, 오늘 익힌 변환·중복 제거·길이 분석이 실무 첫 단계와 거의 같다.
초보자가 막히는 지점
- 데이터셋 이름이나 열 이름이 달라 `KeyError`가 난다: 데이터셋마다 열 이름이 다르다.
print(ds.column_names)와ds[0]으로 먼저 확인하고 변환 함수를 맞춘다. - `map` 결과가 안 바뀐다:
ds.map(...)은 새 데이터셋을 반환할 뿐 원본을 고치지 않는다. 반드시ds = ds.map(...)처럼 다시 받아야 한다. - `map` 함수가 딕셔너리가 아니라 리스트를 반환해서 에러가 난다:
map함수는{"열이름": 값}형태의 딕셔너리를 반환해야 한다. Step 3에서{"messages": [...]}로 감싼 이유다. - 글자 수와 토큰 수를 혼동한다: 한국어는 영어보다 같은 글자 수에서 토큰이 더 많이 나오는 경우가 흔하다. 길이 판단은 반드시 토크나이저로 센 토큰 수로 한다.
- 토크나이저를 나중에 바꾼다: 오늘 정한 MAX_LEN은 오늘 쓴 토크나이저 기준이다. 다음 주에 다른 모델을 쓰면 길이를 다시 재야 한다.
- 영어 데이터로 한국어 능력을 기대한다: Alpaca 계열은 영어 중심이다. 오늘은 파이프라인 연습이 목적이고, 한국어 모델이 목표라면 한국어 instruction 데이터셋을 Hub에서 찾아 같은 코드를 적용해 보자. 사용 전 라이선스 확인은 필수다.
- 중복 제거 후 검증 데이터가 학습 데이터와 겹친다: 분할은 중복 제거 이후에 해야 한다. 순서를 바꾸면 같은 지시문이 양쪽에 들어간다.
알고리즘 (병행 1시간)
LeetCode Medium 2문제를 DP(동적 계획법, 큰 문제를 작은 문제로 나눠 답을 저장해 두고 재사용하는 방법)로 푼다. 문제는 직접 골라도 되고, 예를 들어 계단 오르기나 동전 거스름돈 계열처럼 "앞 단계의 답으로 현재 답을 만드는" 유형이 적당하다.
각 문제를 푼 뒤 아래를 기록한다.
- 접근: 상태(dp[i]가 무엇을 뜻하는지), 점화식, 초기값을 한 줄씩
- 시간복잡도 / 공간복잡도: 반복문 구조에서 도출하고, 공간을 줄일 수 있는지도 적기
- 틀린 이유: 처음 제출이 실패했다면 원인 (초기값 오류, 인덱스 범위, 상태 정의 모호함 등)
먼저 30분간 혼자 고민하고, 막히면 풀이를 본 뒤 다음 날 같은 문제를 다시 풀어 보는 것을 권한다.