본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research8 min read

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 데이터는 보통 이런 구조로 만든다.

python
[ {"role": "system", "content": "당신은 친절한 한국어 어시스턴트입니다."}, {"role": "user", "content": "파이썬에서 리스트를 뒤집는 방법은?"}, {"role": "assistant", "content": "reversed()를 쓰거나 슬라이싱 [::-1]을 쓰면 됩니다."}, ]
  • system: 모델의 역할과 규칙을 정하는 지시
  • user: 사용자의 질문
  • assistant: 모델이 내놓아야 할 정답 응답

모델은 결국 글자(토큰) 하나만 이어 받는다. 그래서 이 리스트는 학습 직전에 "역할 표시 + 내용"을 이어 붙인 긴 문자열 하나로 펴진다. 이때 쓰는 틀을 채팅 템플릿(chat template, 역할 구분 표시를 어떻게 붙일지 정한 규칙)이라 한다. 모델마다 규칙이 달라서, 다음 주에 쓸 모델의 템플릿을 확인해야 한다. 오늘은 데이터를 messages 리스트 형태로 정리해 두는 데까지가 목표다.

3) 데이터 품질·중복·다양성: 요리 재료 고르기

  • 품질: 답이 틀렸거나, 비어 있거나, 질문과 상관없는 데이터가 섞이면 모델이 그 습관을 그대로 배운다. 재료가 상했는데 요리 솜씨로 해결할 수는 없다.
  • 중복: 똑같은 예시가 많으면 모델이 그 예시만 외우고, 평가 때 쓰는 데이터와 겹치면 성능이 부풀려 보인다. 가장 쉬운 방법은 지시문을 정규화(공백 정리, 소문자화)한 뒤 같은 문자열을 제거하는 것이다.
  • 다양성: 질문 종류가 한쪽으로 쏠리면 다른 종류의 질문에서 약해진다. 번역 문제만 90%인 데이터로 학습하면 모델은 번역가가 된다.

4) 토큰 길이: 모델은 글자 수가 아니라 토큰 수로 센다

토큰(token)은 토크나이저가 글을 잘라 만든 조각이다. 모델에는 한 번에 볼 수 있는 최대 길이(최대 시퀀스 길이)가 있어서, 그보다 긴 예시는 뒤가 잘린다. 응답 끝이 잘리면 모델은 "끝맺는 법"을 배우지 못한다. 그래서 길이 분포를 미리 보고 자를 기준을 정한다.

최소한의 수식은 하나다. 배치(batch, 한 번에 묶어 학습하는 예시 묶음)에서 가장 긴 예시에 맞춰 나머지에 빈칸(패딩)을 채우므로:

text
낭비되는 칸 수 = (배치 안 최대 길이 × 배치 크기) - (실제 토큰 수의 합)

길이가 들쭉날쭉할수록 낭비가 커진다. 길이 분포를 보는 이유 중 하나다.

코드로 직접 해보기

실행 환경은 Python 3.10 이상을 가정한다. 먼저 설치한다.

bash
pip install datasets transformers matplotlib

Step 1. 원리부터: 라이브러리 없이 변환 함수 만들기

datasets의 map이 하는 일은 결국 "행마다 함수를 적용하는 반복문"이다. 직접 해 보자.

python
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에서 같은 열 구조를 가진 다른 데이터셋으로 바꿔도 된다.

python
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으로 채팅 포맷 변환

python
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로 품질·중복 정리

python
# 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 토크나이저를 쓴다.

python
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. 저장

python
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분간 혼자 고민하고, 막히면 풀이를 본 뒤 다음 날 같은 문제를 다시 풀어 보는 것을 권한다.