클라우드 Mac에서 MLX 학습 데이터의 형식과 세트 누수 검사하기

클라우드 Mac에서 MLX 학습 데이터의 형식과 세트 누수 검사하기

클라우드 Mac에서 MLX 미세 조정을 실행할 계획이라면, 바로 학습을 시작하지 마세요. 학습 세트와 검증 세트를 업로드한 뒤 가장 먼저 할 일은 데이터 검사입니다. 한 줄이 완전한 JSON이 아니거나, 답변이 비어 있거나, 같은 대화가 두 세트에 모두 들어 있으면 실험 결과를 비교하기 어려워질 수 있습니다. 아래에서는 Python 표준 라이브러리만으로 반복 실행할 수 있는 검사 절차를 만듭니다. 모델을 다운로드할 필요도, 학습 환경에 의존할 필요도 없습니다.

스크립트가 형식을 추측하지 않도록 입력 규약 정하기

이 글에서는 일반 텍스트 대화만 다룹니다. 각 줄에는 messages 필드만 있는 객체 하나를 넣고, 각 메시지에는 role과 content만 둡니다. 맨 앞에는 system 메시지를 하나 둘 수 있으며, 그다음부터는 반드시 user, assistant 순으로 번갈아 나오고 답변으로 끝나야 합니다. 멀티모달 콘텐츠, 도구 호출, 같은 역할의 연속 메시지는 이번 입력 규약에 포함하지 않습니다.

이는 프로젝트의 데이터 승인 기준이지, 모든 MLX 학습 도구가 이 구조만 받는다는 뜻은 아닙니다. 실행하기 전에 현재 사용하는 도구의 데이터 입력 규격을 확인하세요. 원본 데이터에 다른 필드가 있다면 명시적인 변환 단계를 먼저 작성하고, 검사기가 필드를 조용히 버리도록 해서는 안 됩니다.

{"messages":[{"role":"user","content":"解释训练集与验证集的区别。"},{"role":"assistant","content":"训练集用于更新参数,验证集用于观察未参与更新的数据表现。"}]}

두 세트를 각각 train.jsonl, valid.jsonl로 저장하고, BOM 없는 UTF-8 인코딩을 사용합니다. 빈 줄은 오류로 처리하지만, 파일 끝의 마지막 줄바꿈 문자는 별도의 빈 줄로 세지 않습니다. 데이터 변경 이력을 추적할 수 있도록 원본 파일은 읽기 전용으로 보관하고, 수정본은 별도로 저장하세요.

표준 라이브러리로 줄마다 형식 오류 검사하기

먼저 python3 --version을 실행해 인터프리터를 사용할 수 있는지 확인한 뒤, 아래 코드를 check_dataset.py로 저장합니다. 한 줄의 크기 제한은 256 KiB입니다. 이는 이 프로젝트에서 비정상 입력을 막기 위해 정한 기준이며, 모델의 컨텍스트 용량을 뜻하지 않습니다. 스크립트는 바이너리 모드에서 읽을 양을 제한하므로, 지나치게 긴 줄을 먼저 통째로 메모리에 올리지 않습니다.

import hashlib
import json
import sys

LIMIT = 256 * 1024
seen = {}


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError("duplicate object key")
        result[key] = value
    return result


def reject_constant(value):
    raise ValueError("nonstandard JSON constant")


def validate(obj):
    if not isinstance(obj, dict) or set(obj) != {"messages"}:
        raise ValueError("expected messages-only object")
    messages = obj["messages"]
    if not isinstance(messages, list):
        raise ValueError("messages must be an array")
    roles = []
    for message in messages:
        if not isinstance(message, dict):
            raise ValueError("message must be an object")
        if set(message) != {"role", "content"}:
            raise ValueError("unexpected message fields")
        role = message["role"]
        content = message["content"]
        if not isinstance(role, str):
            raise ValueError("role must be text")
        if not isinstance(content, str) or not content.strip():
            raise ValueError("content must be nonempty text")
        roles.append(role)
    body = roles[1:] if roles[:1] == ["system"] else roles
    if len(body) < 2 or len(body) % 2:
        raise ValueError("expected complete conversation pairs")
    if body != ["user", "assistant"] * (len(body) // 2):
        raise ValueError("invalid role order")
    return messages


def inspect(path):
    count = 0
    with open(path, "rb") as stream:
        while True:
            raw = stream.readline(LIMIT + 1)
            if not raw:
                break
            count += 1
            try:
                if len(raw) > LIMIT:
                    raise ValueError("line exceeds byte limit")
                obj = json.loads(
                    raw.decode("utf-8"),
                    object_pairs_hook=unique_object,
                    parse_constant=reject_constant,
                )
                messages = validate(obj)
                canonical = json.dumps(
                    messages, ensure_ascii=False,
                    sort_keys=True, separators=(",", ":"),
                ).encode("utf-8")
                digest = hashlib.sha256(canonical).hexdigest()
                if digest in seen:
                    raise ValueError(
                        "duplicate conversation; first at " + seen[digest]
                    )
                seen[digest] = f"{path}:{count}"
            except (ValueError, RecursionError) as error:
                raise ValueError(f"{path}:{count}: {error}") from error
    if count == 0:
        raise ValueError(f"{path}: empty dataset")
    return count


if __name__ == "__main__":
    if len(sys.argv) != 3:
        raise SystemExit("usage: check_dataset.py TRAIN VALID")
    try:
        counts = [inspect(path) for path in sys.argv[1:]]
    except (OSError, ValueError) as error:
        print(str(error), file=sys.stderr)
        raise SystemExit(1)
    print(json.dumps({"train": counts[0], "valid": counts[1]}))

두 세트에 걸쳐 중복 검사하기

학습 세트와 검증 세트를 순서대로 전달하면 스크립트가 동일한 해시 인덱스를 공유하며 검사합니다. 따라서 세트 내부 중복과 세트 간 중복 모두 실패로 처리하고, 해당 대화가 처음 등장한 위치를 표시합니다. 첫 번째 오류를 만나면 즉시 중단하므로 수정 후 다시 실행하세요. 대화 전문은 출력하지 않습니다.

python3 check_dataset.py train.jsonl valid.jsonl

해시를 계산하기 전에 객체의 키를 정렬하고 JSON의 서식 차이를 없애지만, 메시지 순서와 본문 문자는 그대로 유지합니다. 들여쓰기나 키 순서를 바꿔도 검사를 피할 수 없지만, 본문에 공백을 추가하면 다른 해시가 나올 수 있습니다. 코드, 수식, 원문의 의미를 바꾸지 않도록 스크립트는 대소문자를 임의로 통일하거나 Unicode를 정규화하지 않습니다.

완전히 일치하는 중복을 제거한다고 데이터 누수가 사라지는 것은 아닙니다. 같은 문서에서 만든 서로 다른 질의응답이나, 질문은 같지만 답변만 바꿔 쓴 샘플도 세트 간 누수를 일으킬 수 있습니다.

출처 문서 ID가 있다면 먼저 문서별로 묶어 세트를 나눈 다음 대화를 생성해야 합니다. 출처 ID가 없다면 적어도 질문 중복 검사와 표본 검토를 추가로 진행하세요. 해시 검사를 통과했다는 사실만으로 검증 세트의 독립성이 입증되지는 않습니다. 해시 인덱스의 크기는 샘플 수에 따라 커지므로, 매우 큰 데이터셋에서는 디스크 기반 인덱스로 바꿔야 합니다.

토큰 길이는 별도로 검사하기

바이트 수는 비정상 입력을 제한하는 데만 사용하며, 일정한 토큰 수로 환산할 수 없습니다. 중국어, 코드, 이모지는 인코딩과 토큰화의 비율이 서로 다르고, 채팅 템플릿도 역할 표시와 기타 특수 토큰을 추가합니다.

길이를 검사할 때는 대상 모델에 포함된 토크나이저를 사용해야 합니다. 학습 도구가 실제로 쓰는 채팅 템플릿으로 대화 전체를 처리한 다음 토큰 수를 세세요. 모델 리비전 식별자, 템플릿, 최대 시퀀스 길이, 길이 제한을 넘은 샘플 수를 기록합니다. 각 content의 토큰 수를 따로 계산해 더하는 방식만으로는 충분하지 않습니다.

길이 제한을 넘은 샘플은 제외할지, 분할할지, 잘라낼지 명확하게 정해야 합니다. 잘라내기를 선택했다면 답변 전체가 잘려 나가지는 않았는지, 손실 마스크가 여전히 유효한 답변 부분을 학습 대상으로 포함하는지 확인하세요. 유효한 학습 토큰이 하나도 없는 샘플은 반드시 제외해야 합니다. 학습 입력 방식마다 템플릿과 마스크의 동작이 다르므로, 여기서는 범용 잘라내기 명령을 제공하지 않습니다.

잘못된 샘플로 검사기를 검증한 뒤 학습에 연결하기

먼저 샘플을 복사해 네 가지 오류 사례를 만듭니다. 빈 답변, 잘못된 역할 순서, 중복 객체 키, 파일 간 중복 대화입니다. 각각 실행해 모두 종료 코드 1을 반환하고 파일과 줄 번호를 확인할 수 있는지 점검하세요. 서로 다른 정상 대화 두 개로 성공 경로도 확인해야 합니다. 검사기가 항상 오류를 내는데도 알아차리지 못하는 일을 막기 위해서입니다.

실험 스크립트에 연결할 때는 &&를 사용하거나 종료 코드를 명시적으로 확인한 뒤 다음 학습 단계를 실행하세요. 검사에 실패한 뒤에도 학습이 계속되어서는 안 됩니다. 데이터를 확정하면 두 파일의 SHA-256, 샘플 수, 길이 검사 결과, 검사기 버전을 보관하고, 이번 학습에서는 검사를 통과한 해당 사본만 읽도록 합니다.

검사의 최종 결과는 “데이터 정상”이라는 한마디가 아니라 재실행할 수 있는 근거여야 합니다. 같은 입력에서는 같은 결론이 나와야 하고, 오류 위치를 찾을 수 있어야 하며, 세트 분할에는 근거가 있고 길이 처리에는 기록이 남아야 합니다. 이 절차를 마친 뒤에 학습 시간을 사용하세요.

자주 묻는 질문

JSONL 검사를 통과하면 바로 미세 조정을 시작해도 되나요?

형식 검사만으로는 충분하지 않습니다. 선택한 모델의 토크나이저와 채팅 템플릿으로 길이를 확인하고 현재 학습 도구가 요구하는 형식도 점검해야 합니다.

JSON 키 순서를 바꾸면 중복 검사를 피할 수 있나요?

아니요. 객체 키를 정렬한 뒤 SHA-256을 계산하므로 같은 메시지는 키 순서와 무관하게 탐지됩니다. 표현이 다르거나 의미만 비슷한 대화는 별도 검사가 필요합니다.

실험 기간에 맞춰 Mac 대여하기

Mac mini M4를 단독으로 이용하세요. 통합 메모리 16GB / SSD 256GB를 제공합니다. 일·주·월·분기 단위로 선결제하며, 기간이 끝나면 자동 갱신 없이 기기가 중지됩니다. 모델과 실행 환경은 직접 준비해야 하며, 실제 이용 가능 여부는 콘솔에 표시되는 최신 상태를 기준으로 합니다.

일 단위로 Mac 대여하기

구성을 확인한 뒤 실험을 시작하세요

모델 가중치, 최대 사용량, 대여 기간을 확인한 다음 이용 기간을 선택하세요. 연결 방법이 궁금하다면 도움말 센터를 먼저 살펴보세요.