Published on

Day 26: Dataset Preparation cho Instruction Tuning

Authors

Mục tiêu

Sau bài này, bạn cần làm được các việc sau:

  • Hiểu instruction tuning dataset là gì và khác classification dataset ở đâu.
  • Chọn đúng format giữa Alpaca, ShareGPT và ChatML/messages theo context.
  • Thiết kế schema có metadata, source, quality score, PII status và split.
  • Làm cleaning, normalization, deduplication và train/validation/test split.
  • Biết dùng synthetic data có kiểm soát, không biến dataset thành dữ liệu nhiễu.
  • Trả lời được: dataset này dùng được trong production không, và cần điều kiện gì.
  • Chuẩn bị dataset 500 examples cho Day 27 LoRA/QLoRA.

TL;DR

Instruction tuning dataset là tập ví dụ mô tả behavior mà bạn muốn model học. Mỗi record nói với model: khi người dùng hỏi kiểu này, assistant nên trả lời theo format, tone, policy và mức độ chi tiết như thế nào. Data không tốt sẽ tạo behavior không tốt, kể cả khi training code đúng.

Quy tắc quan trọng nhất: quality > quantity. Với fine-tuning nhỏ, 300-1.000 examples tốt, đa dạng và đúng policy thường hữu ích hơn 50.000 examples log thô chứa PII, duplicate, câu trả lời sai hoặc style lộn xộn.

1. Instruction Tuning là gì?

Instruction tuning dạy model ánh xạ từ instruction và context sang response mong muốn:

system policy + user instruction + optional history/input -> assistant response

Khác với pretraining, instruction tuning không nhằm nhồi toàn bộ knowledge mới vào model. Nó phù hợp hơn để dạy model cách hành xử:

  • Trả lời theo format ổn định: JSON, markdown, checklist, code review comment.
  • Giữ tone/style nhất quán: ngắn gọn, lịch sự, chuyên nghiệp, không đổ lỗi.
  • Tuân thủ quy trình: hỏi clarification, escalate, từ chối khi thiếu quyền.
  • Học domain behavior: cách phản hồi billing ticket, cách review PR, cách viết tài liệu nội bộ.
  • Giảm prompt dài nếu cùng một instruction lặp lại ở nhiều request.

Không nên dùng instruction tuning để thay thế RAG khi knowledge thay đổi thường xuyên hoặc cần trích dẫn tài liệu mới nhất. Khi cần cả behavior riêng và knowledge nội bộ, best solution thường là RAG + fine-tuning nhẹ: fine-tuning cho format/style/policy, RAG cho knowledge.

2. Khác gì với Classification Dataset?

Tiêu chíClassificationInstruction tuning
InputText hoặc featureInstruction, context, history, system policy
OutputLabel ngắnText/code/JSON dài
MetricAccuracy, F1, precision, recallFormat accuracy, task success, human preference, regression eval
DebugDễ thấy label saiKhó hơn vì nhiều câu trả lời có thể đúng
RiskLabel noiseHallucination, unsafe policy, PII memorization, style drift
Production concernThreshold, class imbalanceServing format, prompt compatibility, eval leakage, rollback

Ví dụ classification:

{"text":"Tôi bị tính phí 2 lần sau khi nâng cấp.", "label":"billing_issue"}

Ví dụ instruction tuning:

{"messages":[{"role":"user","content":"Tôi bị tính phí 2 lần sau khi nâng cấp."},{"role":"assistant","content":"Mình xin lỗi vì bất tiện này. Mình sẽ kiểm tra giao dịch nâng cấp và chuyển ticket sang billing nếu có dấu hiệu tính phí trùng. Bạn vui lòng gửi mã hóa đơn hoặc thời điểm giao dịch, không gửi số thẻ."}]}

Classification chỉ dạy model phân loại. Instruction tuning dạy model cách phản hồi hoàn chỉnh.

3. Chọn Format Dataset

Không có format tốt nhất cho mọi trường hợp. Format tốt là format gần với cách bạn sẽ train và serve model nhất.

3.1. Alpaca Format

Alpaca format hợp với task single-turn, đơn giản, dễ inspect bằng mắt và dễ convert.

{"instruction":"Tóm tắt ticket sau thành 3 bullet.","input":"Khách báo bị tính phí 2 lần sau khi upgrade.","output":"- Khách bị tính phí 2 lần.\n- Sự cố xảy ra sau khi upgrade.\n- Cần kiểm tra billing và refund nếu đúng chính sách."}

Ưu điểm:

  • Dễ tạo bằng spreadsheet hoặc script.
  • Dễ hiểu với người mới.
  • Hợp cho summarize, rewrite, classify bằng natural language, extraction.

Nhược điểm:

  • Không thể hiện role rõ như system/user/assistant.
  • Không tự nhiên cho multi-turn chat.
  • Nếu production dùng chat API, bạn vẫn phải convert sang messages.

Nên dùng khi: task single-turn, chưa cần system prompt phức tạp, dataset nhỏ cần review nhanh.

3.2. ShareGPT Format

ShareGPT format thường dùng cho conversation multi-turn cũ, với role humangpt.

{"conversations":[{"from":"human","value":"Khách bị tính phí 2 lần, nên trả lời sao?"},{"from":"gpt","value":"Bạn nên xin lỗi ngắn gọn, xác nhận sẽ kiểm tra billing, hỏi thông tin cần thiết và không hứa refund khi chưa có kết quả."}]}

Ưu điểm:

  • Phù hợp khi dữ liệu nguồn đã là hội thoại.
  • Giữ được nhiều turn.
  • Nhiều tool fine-tuning cũ hỗ trợ.

Nhược điểm:

  • Role không chuẩn với chat API hiện đại.
  • Thiếu system role nếu không mở rộng schema.
  • Cần convert cẩn thận để không đảo role hoặc mất turn.

Nên dùng khi: bạn có conversation logs đã được redact và muốn giữ multi-turn context.

3.3. ChatML / Messages Format

Messages format gần với production chat API nhất. Mỗi record có list message với rolecontent.

{"messages":[{"role":"system","content":"You are a support assistant. Answer in Vietnamese, concise, no refund promise."},{"role":"user","content":"Khách bị tính phí 2 lần sau khi upgrade."},{"role":"assistant","content":"Mình xin lỗi vì bất tiện này. Mình sẽ giúp kiểm tra giao dịch upgrade và chuyển ticket sang billing nếu có dấu hiệu tính phí trùng. Bạn vui lòng gửi mã hóa đơn hoặc thời điểm giao dịch, không gửi số thẻ."}]}

Ưu điểm:

  • Gần production serving nhất.
  • Có role rõ: system, user, assistant.
  • Phù hợp multi-turn, policy, refusal, tool-style behavior.
  • Dễ mask loss để chỉ train trên assistant response nếu trainer hỗ trợ.

Nhược điểm:

  • Dài hơn Alpaca.
  • Cần validate role order nghiêm túc.
  • Một số trainer cần apply chat template trước khi train.

Nên dùng khi: production là chat assistant, cần system instruction, cần multi-turn, hoặc muốn giảm mismatch giữa training và inference.

3.4. Decision Rule

Nhu cầuFormat nên dùng
Single-turn, task hẹp, dễ reviewAlpaca
Có conversation history nhiều turnShareGPT hoặc messages
Production dùng chat APIChatML/messages
Cần system instruction rõChatML/messages
Cần convert qua nhiều toolAlpaca hoặc messages
Cần tool calling sau nàyMessages với metadata/tool fields riêng

Best solution cho đa số production chat assistant: lưu canonical dataset ở messages format, sau đó viết converter sang Alpaca/ShareGPT nếu trainer yêu cầu.

4. Schema nên dùng

Một record production-friendly nên có cả payload training và metadata audit.

{"id":"support_000001","domain":"customer_support","task":"billing_reply","source":"human_seed","quality_score":5,"pii_status":"redacted","license":"internal_training_allowed","group_id":"ticket_family_2026_001","messages":[{"role":"system","content":"You are a support assistant for a SaaS product. Answer in Vietnamese, concise, and do not promise refunds without billing verification."},{"role":"user","content":"Tôi bị tính phí 2 lần sau khi upgrade, email của tôi là [EMAIL]."},{"role":"assistant","content":"Mình xin lỗi vì bất tiện này. Mình sẽ kiểm tra giao dịch upgrade và chuyển ticket sang billing nếu có dấu hiệu tính phí trùng. Bạn không cần gửi thêm thông tin thẻ qua chat."}]}

Field nên có:

FieldBắt buộcÝ nghĩa
idStable id để trace, review, rollback
domainDomain như customer_support, code_review, technical_writing
taskLoại task cụ thể như billing_reply, bug_summary
sourcehuman_seed, synthetic_reviewed, production_redacted
quality_scoreĐiểm 1-5, chỉ train record đạt ngưỡng
pii_statusclean, redacted, needs_review
licenseQuyền dùng data để train
group_idNên cóDùng để split không leakage theo ticket/document/customer group
messagesCó nếu dùng chatList role/content
splitSau khi splittrain, validation, test

Không nên chỉ lưu text đã render thành prompt duy nhất. Bạn sẽ mất khả năng kiểm tra role, redact từng phần, convert format và audit source.

5. Data Guideline trước khi tạo data

Dataset guideline giống API contract. Nếu guideline mơ hồ, reviewer và synthetic generator sẽ tạo output không nhất quán.

Ví dụ guideline cho customer support:

  1. Trả lời bằng tiếng Việt có dấu, ngắn gọn, lịch sự.
  2. Mở đầu bằng việc ghi nhận vấn đề, không đổ lỗi người dùng.
  3. Không hứa refund, mở khóa tài khoản, xóa dữ liệu hoặc thay đổi billing nếu chưa có tool result.
  4. Nếu thiếu thông tin, hỏi đúng một hoặc hai thông tin cần thiết.
  5. Không yêu cầu người dùng gửi password, OTP, số thẻ, secret hoặc token.
  6. Với billing/security/legal risk, escalate human hoặc team chuyên trách.
  7. Không invent policy, SLA, nguyên nhân lỗi hoặc kết quả kiểm tra.
  8. Nếu user đưa PII, không lặp lại PII trong response.
  9. Với yêu cầu ngoài phạm vi, từ chối ngắn và đưa hướng xử lý an toàn.
  10. Response nên có next step rõ ràng.

Example tốt:

{"messages":[{"role":"user","content":"Tài khoản của tôi bị khóa sau khi đổi mật khẩu."},{"role":"assistant","content":"Mình sẽ giúp bạn kiểm tra. Vì đây là vấn đề bảo mật, mình không thể mở khóa trực tiếp qua chat khi chưa xác minh tài khoản. Mình sẽ tạo ticket cho nhóm support và hướng dẫn bạn các bước xác minh tiếp theo."}]}

Example xấu:

{"messages":[{"role":"user","content":"Tài khoản của tôi bị khóa sau khi đổi mật khẩu."},{"role":"assistant","content":"Chắc hệ thống lỗi, mình sẽ mở khóa ngay cho bạn."}]}

Lý do xấu: invent nguyên nhân, hứa action không có permission, bỏ qua xác minh bảo mật.

6. Cleaning và Normalization

Cleaning tối thiểu:

  • Parse được từng dòng JSONL.
  • Không có instruction, user message hoặc assistant response rỗng.
  • Normalize whitespace, line ending và ký tự control.
  • Loại HTML/script/boilerplate nếu không phải signal cần học.
  • Loại hoặc sửa record sai role order.
  • Loại record quá dài vượt context budget.
  • Check language mismatch nếu dataset yêu cầu tiếng Việt.
  • Redact PII: email, phone, API key, token, số thẻ, access token, customer id nhạy cảm.
  • Gắn pii_status sau redaction: clean, redacted, hoặc needs_review.
  • Loại response chứa policy sai hoặc hallucination nghiêm trọng.

Không nên clean quá tay. Nếu production user thường viết tắt, sai chính tả hoặc trộn tiếng Anh, bạn có thể giữ một phần để model học robust. Nhưng response của assistant nên sạch, đúng chính sách và có dấu.

7. Deduplication

Duplicate làm model overfit và làm eval đẹp giả. Dedup nên chạy trước split.

Các mức dedup:

  • Exact duplicate: hash normalized text.
  • Near duplicate: similarity theo n-gram, MinHash hoặc embedding khi dataset lớn.
  • Group duplicate: cùng group_id, cùng ticket family, cùng document version.
  • Template duplicate: synthetic data chỉ thay vài từ nhưng response gần như giống nhau.

Với dataset 500 examples, exact dedup + manual review near-duplicate thường đủ. Với dataset lớn hơn, nên thêm MinHash hoặc embedding clustering.

Trade-off: dedup mạnh quá có thể xóa những biến thể hữu ích; dedup yếu quá làm model học lặp pattern và làm validation/test bị leakage.

8. Train/Validation/Test Split

Split gợi ý:

SplitTỷ lệMục đích
Train80-90%Train adapter/model
Validation5-10%Chọn checkpoint, hyperparameter, early stopping
Test/Golden5-10%Báo cáo before/after, không tune vào đây

Quy tắc production: split theo group/source nếu có. Không để cùng một ticket, cùng customer thread, cùng document paragraph hoặc cùng synthetic template xuất hiện ở cả train và test.

Ví dụ xấu:

train: ticket_123 turn 1
test:  ticket_123 turn 2

Metric sẽ cao giả vì model đã thấy cùng context.

Ví dụ tốt:

train: ticket_family_001..400
validation: ticket_family_401..450
test: ticket_family_451..500

9. Quality > Quantity

Dataset tốt có các đặc điểm sau:

  • Mỗi example đúng policy.
  • Task đa dạng nhưng vẫn cùng behavior mục tiêu.
  • Response có format ổn định.
  • Có edge cases: thiếu thông tin, user tức giận, request không an toàn, PII, escalation.
  • Có negative/refusal examples vừa đủ.
  • Có metadata rõ để audit.
  • Có test set không bị dùng để tune.

Dataset kém thường có:

  • Nhiều duplicate.
  • Response dài lan man.
  • Synthetic pattern lặp.
  • Log thô chứa PII.
  • Mixing style: lúc thân mật, lúc quá formal, lúc tiếng Anh, lúc tiếng Việt không dấu.
  • Output sai quyền hạn: hứa refund, hứa xóa dữ liệu, tự tạo kết quả kiểm tra.

10. Synthetic Data

Synthetic data hữu ích khi bạn chưa có đủ examples, nhưng phải có guardrails.

Workflow nên dùng:

Guideline rõ ràng
  -> 30-50 human seed examples chất lượng cao
  -> generate variants theo task/domain/edge case
  -> validate schema
  -> redact PII giả hoặc thật
  -> dedup
  -> human review sample
  -> tag source = synthetic_reviewed
  -> split theo group/template

Nên dùng synthetic cho:

  • Format variants.
  • Edge cases hiếm.
  • Tone/style examples.
  • Refusal behavior.
  • Domain template có source rõ.

Không nên dùng synthetic khi:

  • Không có human seed examples.
  • Generator invent facts, policy hoặc product behavior.
  • Không có human review.
  • Synthetic output đến từ chính model sẽ được dùng để eval.
  • Dataset bị thống trị bởi một template.

Best practice: với 500 examples đầu tiên, dùng 30-50 human seed, sinh thêm 450-470 synthetic candidates, sau validation/dedup/review chỉ giữ 500 record tốt nhất. Nếu có nguồn production đã redact và được phép train, trộn thêm để tăng tính thực tế.

11. Privacy, License và Data Ownership

Fine-tuning có risk memorization. Nếu một chuỗi không được phép xuất hiện trong output, đừng đưa vào train set.

Privacy checklist:

  • Data có consent hoặc quyền hợp pháp để train không?
  • Có PII nào cần redact không: email, phone, address, full name, IP, account id?
  • Có secret/token/API key/log nội bộ không?
  • Có customer confidential data không?
  • Có chính sách retention/deletion cho dataset và model artifact không?
  • Adapter/model có được share public không?
  • License của public dataset có cho commercial training không?
  • Có thể trace từ model version về dataset version không?

Production rule: pii_status = needs_review không được vào train. Public data không mặc định được phép dùng cho commercial fine-tuning.

12. Performance và Cost Concern

Dataset preparation ảnh hưởng trực tiếp đến training cost:

  • Sequence length dài làm VRAM và thời gian train tăng mạnh. Theo dõi p50/p95 token length.
  • Response ngắn, đúng trọng tâm thường tốt hơn response dài nhưng lan man.
  • Duplicate làm tốn compute và tăng overfit.
  • Packing nhiều short examples vào một sequence có thể tăng throughput nếu trainer hỗ trợ.
  • Dataset 500 examples phù hợp để học format/style nhỏ, không đủ để học knowledge rộng.
  • Với QLoRA Day 27, nên giữ max length thực tế trong khoảng 512-2.048 tokens tùy GPU và model.
  • Chọn messages format có thể cần apply chat template, làm token length tăng so với Alpaca.

Trade-off: cắt ngắn dữ liệu giúp giảm cost nhưng có thể mất context quan trọng; giữ context dài giúp học multi-turn tốt hơn nhưng tốn VRAM và dễ học noise.

13. Dùng được trong production không?

Có, dataset từ bài này dùng được làm đầu vào production fine-tuning nếu thỏa các điều kiện sau:

  • Có quyền dùng data để train và deploy model/adapters.
  • Không có PII/secret hoặc đã redact và review.
  • Schema validate tự động, không có record sai role/order/field.
  • Dedup trước split và split theo group để giảm leakage.
  • Có dataset card, metadata, version, changelog và mapping tới model artifact.
  • Có golden test set riêng không dùng để tune.
  • Có human review cho sample đại diện, đặc biệt synthetic data và edge cases.
  • Có eval trước/sau fine-tune ở Day 28: format accuracy, task success, safety regression, latency/cost.
  • Có rollback plan nếu adapter tạo behavior xấu.

Nếu thiếu các điều kiện này, dataset vẫn có thể dùng cho lab hoặc prototype, nhưng chưa nên dùng để train model đi production.

14. Checklist nhanh

  • Chọn domain và behavior cần học.
  • Viết data guideline trước khi tạo data.
  • Chọn canonical format: Alpaca, ShareGPT hoặc messages.
  • Có ít nhất 30 human seed examples.
  • Có schema với id, domain, task, source, quality_score, pii_status, license, group_id.
  • Validate JSONL tự động.
  • Redact PII/secret.
  • Dedup trước split.
  • Split train/validation/test không leakage rõ ràng.
  • Có dataset card và metadata.
  • Có 20-50 golden examples cho Day 28.
  • Có câu trả lời production readiness rõ ràng.

15. Quiz tự kiểm tra

  1. Instruction tuning dataset khác classification dataset ở điểm nào?
  2. Khi nào nên chọn Alpaca thay vì messages format?
  3. Vì sao canonical dataset cho chat assistant nên giữ role system, user, assistant?
  4. Vì sao dedup phải chạy trước split?
  5. Near-duplicate trong synthetic data gây hại như thế nào?
  6. Tại sao không nên train trên raw customer logs chưa redact?
  7. quality_score nên dùng để làm gì?
  8. Khi nào grouped split tốt hơn random split?
  9. Fine-tuning có nên dùng để nhồi tài liệu nội bộ không? Vì sao?
  10. Dataset 500 examples có thể production được trong trường hợp nào?

Tài liệu

1. Cấu trúc thư mục khuyến nghị

instruction_dataset/
  raw/
    human_seed.jsonl
    synthetic_candidates.jsonl
  processed/
    dataset_clean.jsonl
    dataset_split.jsonl
  reports/
    validation_report.md
    review_sample.md
  dataset_card.md
  metadata.json
  prepare_dataset.py

Nguyên tắc:

  • raw/ giữ dữ liệu gốc đã được phép xử lý, nhưng không commit PII/secret.
  • processed/ là dữ liệu đã normalize, redact, dedup và split.
  • reports/ lưu kết quả validation, lỗi bị loại, sample cần human review.
  • dataset_card.md giải thích mục đích, nguồn, license, privacy, known limitations.
  • metadata.json gắn dataset version với model/adapters ở Day 27.

2. Canonical JSONL schema

Mỗi dòng là một JSON object.

{"id":"support_000001","domain":"customer_support","task":"billing_reply","source":"human_seed","quality_score":5,"pii_status":"clean","license":"internal_training_allowed","group_id":"ticket_family_000001","messages":[{"role":"system","content":"You are a support assistant for a SaaS product. Answer in Vietnamese, concise, and do not promise refunds without billing verification."},{"role":"user","content":"Tôi bị tính phí 2 lần sau khi upgrade."},{"role":"assistant","content":"Mình xin lỗi vì bất tiện này. Mình sẽ kiểm tra giao dịch upgrade và chuyển ticket sang billing nếu có dấu hiệu tính phí trùng. Bạn vui lòng gửi mã hóa đơn hoặc thời điểm giao dịch, không gửi số thẻ."}]}

Required fields:

  • id: unique stable id.
  • domain: domain của dataset.
  • task: task cụ thể.
  • source: human_seed, synthetic_reviewed, production_redacted.
  • quality_score: integer 1-5.
  • pii_status: clean, redacted, needs_review.
  • license: quyền dùng data.
  • group_id: dùng cho grouped split.
  • messages: list message với role hợp lệ.

Allowed roles: system, user, assistant.

Role order hợp lệ:

  • Optional first system.
  • Sau đó phải là cặp user -> assistant.
  • Record phải kết thúc bằng assistant.
  • Không có assistant rỗng.

3. Script chuẩn bị dataset gần production

Script dưới đây dùng Python standard library để dễ chạy. Nó validate schema, normalize record, redact PII phổ biến, deduplicate, split theo group_id và xuất dataset card/metadata cơ bản.

Lưu thành instruction_dataset/prepare_dataset.py, đặt input ở raw/input.jsonl, rồi chạy:

python3 prepare_dataset.py --input raw/input.jsonl --out-dir processed --dataset-name support_instruction_v1
from __future__ import annotations

import argparse
import hashlib
import json
import random
import re
from collections import Counter, defaultdict
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

ALLOWED_ROLES = {"system", "user", "assistant"}
ALLOWED_PII_STATUS = {"clean", "redacted", "needs_review"}
ALLOWED_SOURCES = {"human_seed", "synthetic_reviewed", "production_redacted"}
ALLOWED_LICENSES = {"internal_training_allowed", "public_commercial_allowed", "research_only"}

EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
PHONE_RE = re.compile(r"\b(?:\+?\d[\d .-]{8,}\d)\b")
API_KEY_RE = re.compile(r"\b(?:sk|pk|api|token|key)[-_]?[A-Za-z0-9]{16,}\b", re.IGNORECASE)
CREDIT_CARD_RE = re.compile(r"\b(?:\d[ -]*?){13,19}\b")
CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
WHITESPACE_RE = re.compile(r"[ \t]+")


@dataclass
class PreparedDataset:
    rows: list[dict[str, Any]]
    errors: list[str]
    warnings: list[str]
    counters: Counter[str]


def load_jsonl(path: Path) -> tuple[list[dict[str, Any]], list[str]]:
    rows: list[dict[str, Any]] = []
    errors: list[str] = []
    for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        if not line.strip():
            continue
        try:
            value = json.loads(line)
        except json.JSONDecodeError as exc:
            errors.append(f"line {line_no}: invalid JSON: {exc.msg}")
            continue
        if not isinstance(value, dict):
            errors.append(f"line {line_no}: row must be an object")
            continue
        value["_line_no"] = line_no
        rows.append(value)
    return rows, errors


def normalize_text(text: str) -> str:
    text = CONTROL_CHARS_RE.sub("", text)
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    text = "\n".join(WHITESPACE_RE.sub(" ", part).strip() for part in text.split("\n"))
    return text.strip()


def redact_pii(text: str) -> tuple[str, bool]:
    original = text
    text = EMAIL_RE.sub("[EMAIL]", text)
    text = PHONE_RE.sub("[PHONE]", text)
    text = API_KEY_RE.sub("[SECRET]", text)
    text = CREDIT_CARD_RE.sub("[CARD]", text)
    return text, text != original


def validate_messages(row: dict[str, Any], row_id: str) -> list[str]:
    errors: list[str] = []
    messages = row.get("messages")
    if not isinstance(messages, list) or not messages:
        return [f"{row_id}: messages must be a non-empty list"]

    roles: list[str] = []
    for idx, message in enumerate(messages):
        if not isinstance(message, dict):
            errors.append(f"{row_id}: messages[{idx}] must be an object")
            continue
        role = message.get("role")
        content = message.get("content")
        if role not in ALLOWED_ROLES:
            errors.append(f"{row_id}: messages[{idx}].role is invalid: {role!r}")
        if not isinstance(content, str) or not content.strip():
            errors.append(f"{row_id}: messages[{idx}].content is empty")
        roles.append(role)

    start = 1 if roles and roles[0] == "system" else 0
    expected = "user"
    for idx in range(start, len(roles)):
        if roles[idx] != expected:
            errors.append(f"{row_id}: expected role {expected!r} at messages[{idx}], got {roles[idx]!r}")
            break
        expected = "assistant" if expected == "user" else "user"
    if roles and roles[-1] != "assistant":
        errors.append(f"{row_id}: last message must be assistant")
    return errors


def validate_required_fields(row: dict[str, Any]) -> list[str]:
    row_id = str(row.get("id") or f"line_{row.get('_line_no', 'unknown')}")
    errors: list[str] = []
    required_string_fields = ["id", "domain", "task", "source", "pii_status", "license", "group_id"]
    for field in required_string_fields:
        if not isinstance(row.get(field), str) or not row[field].strip():
            errors.append(f"{row_id}: {field} must be a non-empty string")

    quality_score = row.get("quality_score")
    if not isinstance(quality_score, int) or not 1 <= quality_score <= 5:
        errors.append(f"{row_id}: quality_score must be an integer from 1 to 5")
    if row.get("source") not in ALLOWED_SOURCES:
        errors.append(f"{row_id}: source must be one of {sorted(ALLOWED_SOURCES)}")
    if row.get("pii_status") not in ALLOWED_PII_STATUS:
        errors.append(f"{row_id}: pii_status must be one of {sorted(ALLOWED_PII_STATUS)}")
    if row.get("license") not in ALLOWED_LICENSES:
        errors.append(f"{row_id}: license must be one of {sorted(ALLOWED_LICENSES)}")
    errors.extend(validate_messages(row, row_id))
    return errors


def canonical_training_text(row: dict[str, Any]) -> str:
    parts = []
    for message in row["messages"]:
        parts.append(f"{message['role']}:{normalize_text(message['content']).lower()}")
    return "\n".join(parts)


def normalize_and_redact_row(row: dict[str, Any]) -> tuple[dict[str, Any], bool]:
    clean = {key: value for key, value in row.items() if not key.startswith("_")}
    redacted_any = False
    for key in ["id", "domain", "task", "source", "pii_status", "license", "group_id"]:
        if isinstance(clean.get(key), str):
            clean[key] = normalize_text(clean[key])
    normalized_messages = []
    for message in clean["messages"]:
        content = normalize_text(message["content"])
        content, redacted = redact_pii(content)
        redacted_any = redacted_any or redacted
        normalized_messages.append({"role": message["role"], "content": content})
    clean["messages"] = normalized_messages
    if redacted_any:
        clean["pii_status"] = "redacted"
    return clean, redacted_any


def split_by_group(rows: list[dict[str, Any]], seed: int) -> list[dict[str, Any]]:
    grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        grouped[row["group_id"]].append(row)

    groups = list(grouped)
    random.Random(seed).shuffle(groups)
    total_rows = len(rows)
    train_limit = int(total_rows * 0.8)
    validation_limit = int(total_rows * 0.9)

    output: list[dict[str, Any]] = []
    seen_count = 0
    for group_id in groups:
        if seen_count < train_limit:
            split = "train"
        elif seen_count < validation_limit:
            split = "validation"
        else:
            split = "test"
        for row in grouped[group_id]:
            row["split"] = split
            output.append(row)
        seen_count += len(grouped[group_id])
    return output


def prepare(rows: list[dict[str, Any]], parse_errors: list[str], seed: int, min_quality: int) -> PreparedDataset:
    errors = list(parse_errors)
    warnings: list[str] = []
    counters: Counter[str] = Counter()
    valid_rows: list[dict[str, Any]] = []
    seen_ids: set[str] = set()
    seen_hashes: set[str] = set()

    for raw in rows:
        row_id = str(raw.get("id") or f"line_{raw.get('_line_no', 'unknown')}")
        field_errors = validate_required_fields(raw)
        if field_errors:
            errors.extend(field_errors)
            counters["invalid_schema"] += 1
            continue
        if raw["id"] in seen_ids:
            errors.append(f"{row_id}: duplicate id")
            counters["duplicate_id"] += 1
            continue
        seen_ids.add(raw["id"])

        row, redacted = normalize_and_redact_row(raw)
        if row["quality_score"] < min_quality:
            warnings.append(f"{row_id}: dropped because quality_score < {min_quality}")
            counters["dropped_low_quality"] += 1
            continue
        if row["pii_status"] == "needs_review":
            warnings.append(f"{row_id}: dropped because pii_status is needs_review")
            counters["dropped_needs_review"] += 1
            continue
        if row["license"] == "research_only":
            warnings.append(f"{row_id}: dropped because license is research_only")
            counters["dropped_license"] += 1
            continue
        if redacted:
            counters["redacted"] += 1

        digest = hashlib.sha256(canonical_training_text(row).encode("utf-8")).hexdigest()
        if digest in seen_hashes:
            warnings.append(f"{row_id}: dropped exact duplicate")
            counters["dropped_duplicate"] += 1
            continue
        seen_hashes.add(digest)
        valid_rows.append(row)

    split_rows = split_by_group(valid_rows, seed)
    counters.update(Counter(row["split"] for row in split_rows))
    return PreparedDataset(rows=split_rows, errors=errors, warnings=warnings, counters=counters)


def write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
    path.write_text(
        "\n".join(json.dumps(row, ensure_ascii=False, sort_keys=True) for row in rows) + ("\n" if rows else ""),
        encoding="utf-8",
    )


def write_report(path: Path, result: PreparedDataset) -> None:
    lines = ["# Validation Report", ""]
    lines.append("## Counters")
    for key, value in sorted(result.counters.items()):
        lines.append(f"- {key}: {value}")
    lines.extend(["", "## Errors"])
    lines.extend(f"- {error}" for error in result.errors[:200])
    if len(result.errors) > 200:
        lines.append(f"- ... truncated {len(result.errors) - 200} more errors")
    lines.extend(["", "## Warnings"])
    lines.extend(f"- {warning}" for warning in result.warnings[:200])
    if len(result.warnings) > 200:
        lines.append(f"- ... truncated {len(result.warnings) - 200} more warnings")
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def write_dataset_card(path: Path, dataset_name: str, result: PreparedDataset) -> None:
    split_counts = Counter(row["split"] for row in result.rows)
    source_counts = Counter(row["source"] for row in result.rows)
    task_counts = Counter(row["task"] for row in result.rows)
    lines = [
        f"# Dataset Card: {dataset_name}",
        "",
        "## Purpose",
        "Instruction tuning dataset for a domain assistant. Replace this section with the exact behavior goal before training.",
        "",
        "## Format",
        "JSONL messages format with role/content pairs and metadata fields.",
        "",
        "## Size",
        f"- total: {len(result.rows)}",
        f"- train: {split_counts.get('train', 0)}",
        f"- validation: {split_counts.get('validation', 0)}",
        f"- test: {split_counts.get('test', 0)}",
        "",
        "## Sources",
    ]
    lines.extend(f"- {source}: {count}" for source, count in sorted(source_counts.items()))
    lines.extend(["", "## Tasks"])
    lines.extend(f"- {task}: {count}" for task, count in sorted(task_counts.items()))
    lines.extend(
        [
            "",
            "## Privacy",
            "PII is either absent or redacted by the preparation script. Records marked needs_review are excluded.",
            "",
            "## License",
            "Only rows with training-allowed licenses are included. Verify this before production use.",
            "",
            "## Known Limitations",
            "- Regex redaction does not catch every possible PII type.",
            "- Near-duplicate detection still requires human review or a stronger similarity pipeline for large datasets.",
            "- Synthetic data quality depends on seed examples and review process.",
        ]
    )
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def write_metadata(path: Path, dataset_name: str, input_path: Path, result: PreparedDataset, seed: int) -> None:
    metadata = {
        "dataset_name": dataset_name,
        "created_at": datetime.now(timezone.utc).isoformat(),
        "input_path": str(input_path),
        "format": "messages_jsonl",
        "seed": seed,
        "rows": len(result.rows),
        "counters": dict(result.counters),
        "production_ready": len(result.errors) == 0 and len(result.rows) > 0,
    }
    path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--input", required=True, type=Path)
    parser.add_argument("--out-dir", required=True, type=Path)
    parser.add_argument("--dataset-name", required=True)
    parser.add_argument("--seed", type=int, default=42)
    parser.add_argument("--min-quality", type=int, default=4)
    args = parser.parse_args()

    args.out_dir.mkdir(parents=True, exist_ok=True)
    rows, parse_errors = load_jsonl(args.input)
    result = prepare(rows, parse_errors, seed=args.seed, min_quality=args.min_quality)

    write_jsonl(args.out_dir / "dataset_split.jsonl", result.rows)
    write_report(args.out_dir / "validation_report.md", result)
    write_dataset_card(args.out_dir / "dataset_card.md", args.dataset_name, result)
    write_metadata(args.out_dir / "metadata.json", args.dataset_name, args.input, result, args.seed)

    print(f"rows_in: {len(rows)}")
    print(f"rows_out: {len(result.rows)}")
    print(f"errors: {len(result.errors)}")
    print(f"warnings: {len(result.warnings)}")
    for key, value in sorted(result.counters.items()):
        print(f"{key}: {value}")


if __name__ == "__main__":
    main()

4. Dataset card template

# Dataset Card: support_instruction_v1

## Purpose
Instruction tuning cho support assistant xử lý billing/account/how-to bằng tiếng Việt.

## Behavior Goal
- Trả lời ngắn gọn, lịch sự, có next step.
- Không hứa refund, mở khóa, xóa dữ liệu hoặc thay đổi billing nếu chưa có tool result.
- Escalate billing/security/legal risk.

## Format
JSONL messages format.

## Size
- Total: 500
- Train: 400
- Validation: 50
- Test: 50

## Sources
- Human seed: 50
- Synthetic reviewed: 350
- Production redacted: 100

## Privacy
- PII redacted.
- No password, OTP, API key, access token or raw customer identifier.
- Records marked needs_review are excluded.

## License and Ownership
Internal training allowed. Adapter is not approved for public release unless legal review approves.

## Quality Process
- Schema validation passed.
- Exact dedup completed before split.
- 50 random examples manually reviewed.
- 20 golden examples reserved for Day 28 eval.

## Known Limitations
- Regex redaction may miss rare PII patterns.
- Near-duplicate synthetic variants require manual review.
- Dataset teaches response behavior, not product knowledge.

5. Metadata template

{
  "dataset_name": "support_instruction_v1",
  "format": "messages_jsonl",
  "version": "2026-05-10",
  "owner": "ai-engineer-course",
  "intended_model_family": "Qwen/LLaMA-compatible chat model",
  "rows": 500,
  "splits": {
    "train": 400,
    "validation": 50,
    "test": 50
  },
  "sources": {
    "human_seed": 50,
    "synthetic_reviewed": 350,
    "production_redacted": 100
  },
  "privacy": {
    "pii_redacted": true,
    "needs_review_excluded": true,
    "secret_scan_required": true
  },
  "training_allowed": true,
  "notes": "Use for behavior/style fine-tuning, not as knowledge source."
}

6. Review checklist cho production

  • Có quyền train và deploy trên data.
  • Không có raw PII/secret trong train/validation/test.
  • research_only hoặc unclear license đã bị loại.
  • Mọi record validate schema.
  • Role order đúng và record kết thúc bằng assistant.
  • Assistant response đúng policy.
  • Dedup chạy trước split.
  • Split theo group_id, không leakage giữa train và test.
  • Validation/test không được dùng để viết lại prompt hoặc tune hyperparameter nhiều vòng.
  • Có dataset card và metadata.
  • Có model rollback plan sau Day 27.
  • Có eval trước/sau fine-tune ở Day 28.

7. Trade-off thường gặp

Quyết địnhLợi íchRủi roBest solution theo context
Messages formatGần production chat API, role rõToken dài hơn, cần chat templateDùng làm canonical cho chat assistant
Alpaca formatĐơn giản, dễ inspectKém multi-turn và policy roleDùng cho single-turn task hoặc converter output
Synthetic nhiềuScale nhanh, phủ edge casesPattern lặp, hallucinated policyGiữ synthetic dưới kiểm soát, tag source, review sample
Raw production logsRất thực tếPII, consent, noise, unsafe responseChỉ dùng sau redaction, license check, quality filtering
Random splitNhanhLeakage theo ticket/documentChỉ dùng khi không có group và dataset độc lập
Grouped splitEval thật hơnSplit ratio có thể lệchDùng cho production dataset
Cắt max length thấpGiảm VRAM/costMất context multi-turnĐo p95 token length rồi chọn limit

8. Production answer

Dùng được trong production không? Có, nếu dataset pass schema validation, privacy/license review, dedup, grouped split, human review và eval trước/sau fine-tune. Nếu chỉ mới có dataset tạo nhanh từ synthetic data chưa review, nó chỉ phù hợp prototype hoặc lab.


Bài tập

Mục tiêu

Tạo một dataset 500 examples cho một domain, sẵn sàng dùng ở Day 27 LoRA/QLoRA và Day 28 evaluation.

Bạn chọn một trong bốn domain:

  • Customer support.
  • Code review.
  • Technical writing.
  • Internal policy Q&A.

Output cuối cùng:

instruction_dataset/
  raw/input.jsonl
  processed/dataset_split.jsonl
  processed/dataset_card.md
  processed/metadata.json
  processed/validation_report.md

Phần 1: Viết behavior guideline

Viết 10 dòng guideline cho assistant. Template:

# Behavior Guideline

Domain: customer_support
Language: Vietnamese with English technical terms when needed

1. ...
2. ...
10. ...

Checklist:

  • Có tone/style.
  • Có format response.
  • Có điều kiện hỏi clarification.
  • Có điều kiện escalate.
  • Có điều cấm: không invent facts, không yêu cầu password/OTP/secret.
  • Có privacy rule.

Phần 2: Tạo 30-50 human seed examples

Mỗi example dùng messages format:

{"id":"support_seed_0001","domain":"customer_support","task":"billing_reply","source":"human_seed","quality_score":5,"pii_status":"clean","license":"internal_training_allowed","group_id":"support_seed_0001","messages":[{"role":"system","content":"You are a support assistant for a SaaS product. Answer in Vietnamese, concise, and do not promise refunds without billing verification."},{"role":"user","content":"Tôi bị tính phí 2 lần sau khi upgrade."},{"role":"assistant","content":"Mình xin lỗi vì bất tiện này. Mình sẽ kiểm tra giao dịch upgrade và chuyển ticket sang billing nếu có dấu hiệu tính phí trùng. Bạn vui lòng gửi mã hóa đơn hoặc thời điểm giao dịch, không gửi số thẻ."}]}

Phân bổ gợi ý cho customer support:

TaskSố examples
billing_reply10
account_access8
how_to8
bug_report6
cancellation5
security_escalation5
refusal_or_out_of_scope3

Phần 3: Sinh synthetic candidates

Dùng guideline và seed examples để sinh thêm candidates. Không giữ tất cả ngay. Mỗi candidate cần có:

  • source = synthetic_reviewed chỉ sau khi đã review.
  • quality_score >= 4.
  • group_id theo template hoặc scenario family.
  • Không có PII thật.
  • Không invent product policy.

Prompt gợi ý để sinh synthetic data:

You are creating instruction tuning data for a Vietnamese SaaS support assistant.

Use this behavior guideline:
<paste guideline>

Generate 20 JSONL records in messages format.
Requirements:
- Vietnamese with proper diacritics.
- Include metadata fields: id, domain, task, source, quality_score, pii_status, license, group_id, messages.
- source must be synthetic_reviewed.
- pii_status must be clean.
- license must be internal_training_allowed.
- Vary user wording, emotion, missing information, and escalation cases.
- Assistant must not promise refund, account unlock, data deletion, or billing changes without verification.
- Output valid JSONL only, one object per line.

Review sau khi sinh:

  • Loại record lặp template quá rõ.
  • Loại response dài lan man.
  • Loại response invent policy, SLA, nguyên nhân lỗi.
  • Loại record thiếu escalation khi có security/billing/legal risk.
  • Loại record có PII thật.

Phần 4: Validate, redact, dedup và split

  1. Tạo thư mục:
mkdir -p instruction_dataset/raw instruction_dataset/processed
  1. Lưu toàn bộ JSONL vào:
instruction_dataset/raw/input.jsonl
  1. Dùng script trong document.md và chạy:
cd instruction_dataset
python3 prepare_dataset.py --input raw/input.jsonl --out-dir processed --dataset-name support_instruction_v1
  1. Kiểm tra output:
wc -l processed/dataset_split.jsonl
sed -n '1,120p' processed/validation_report.md
sed -n '1,120p' processed/dataset_card.md

Dataset chỉ đạt khi:

  • Có khoảng 500 rows sau dedup/filter.
  • errors: 0.
  • Không có record needs_review.
  • Có đủ train/validation/test.
  • Split không trộn cùng group_id.
  • Dataset card không còn placeholder quan trọng.

Phần 5: Tạo golden set cho Day 28

Chọn 20-50 examples từ test split làm golden set. Không dùng golden set để sửa prompt hoặc tune hyperparameter lặp lại.

Golden set nên có:

  • Case bình thường.
  • Case thiếu thông tin.
  • Case user tức giận.
  • Case billing/security/legal cần escalate.
  • Case có PII đã redact.
  • Case assistant phải từ chối an toàn.
  • Case cần output format cố định.

Phần 6: Câu hỏi bắt buộc

Trả lời trong validation_report.md hoặc file riêng:

  1. Dataset này dùng để dạy behavior gì, không dạy knowledge gì?
  2. Vì sao bạn chọn Alpaca, ShareGPT hoặc messages format?
  3. Có bao nhiêu rows từ human seed, synthetic reviewed, production redacted?
  4. Bạn đã làm gì để tránh PII/secret?
  5. Bạn dedup trước hay sau split? Vì sao?
  6. Split có theo group_id không?
  7. Dataset này dùng được trong production không? Nếu có thì cần điều kiện gì?
  8. Rủi ro còn lại lớn nhất là gì?

Rubric tự chấm

Hạng mụcĐiểm
Guideline rõ, đúng domain, có policy an toàn15
Schema đầy đủ metadata15
500 examples đa dạng, quality_score hợp lý20
Cleaning/redaction/dedup/split chạy được20
Dataset card và metadata đầy đủ10
Golden set chuẩn bị tốt cho Day 2810
Trả lời production readiness và trade-off rõ10

Tổng: 100 điểm.

Đáp án mẫu ngắn cho production readiness

Dùng được trong production nếu dataset có quyền train/deploy, không còn PII/secret, đã validate schema, dedup trước split, split theo group, synthetic data đã review, có dataset card/metadata và pass evaluation trước/sau fine-tune. Nếu chỉ là synthetic data sinh nhanh chưa review hoặc còn needs_review, chỉ dùng cho lab.