Curate a Fine-Tuning Dataset: Deduplicate, Filter, LLM-Judge and Balance Instruction Data
Hands-on lab · IDE in your browser

Curate a Fine-Tuning Dataset: Deduplicate, Filter, LLM-Judge and Balance Instruction Data

Turn a raw 260-row helpdesk export into a clean instruction-tuning set.

Time
50 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit50 min · 5 stepsSession running
0 / 5 steps passingLook before you clean · step 1 of 5
curate.py▶ Run✓ Check
import harness EXPORT = "helpdesk_export.jsonl"POLICY = "policy.md"  # ---------- Step 1: look before you clean ----------def load(path=EXPORT):    """The rows of a JSONL file (one JSON object per line), skipping blank lines."""   def normalize(text):    """Lowercase; drop every character that is not a letter, digit, space or £; collapse runs of whitespace to    one space; strip. "  Where's my ORDER?\\n" -> "wheres my order"."""    def profile(rows):    """{"rows": n, "per_category": {category: count}, "response_words": {"min", "median", "max"}} with    response length measured in words (response.split())."""   
TerminalOutput

The job

Larkspur Outdoor wants a small support model fine-tuned on its helpdesk history. The export is full of duplicates, cut-off replies, phone numbers and confidently wrong answers, and the model would learn every one. You turn it into a training set worth learning from.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Look before you clean

    Larkspur Outdoor, an outdoor-gear shop, wants a small support model fine-tuned on its helpdesk history.

    You writeload()
  2. 2

    Rules

    Some problems are mechanical, and a rule finds them for free: empty or one-word replies, replies cut off mid-sentence, "As an AI" boilerplate, personal data such as email addresses and phone numbers, and replies that just repeat the question.

    You writeproblems()
  3. 3

    Duplicates

    The helpdesk exported some tickets twice, with changed capitals or spacing, and sometimes with one word edited in the reply.

    You writeshingles()
  4. 4

    A judge for what rules cannot see

    The worst rows look perfect: fluent, polite and wrong.

    You writejudge_prompt()
  5. 5

    Balance and ship

    A third of the export is about order status.

    You writebalance()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Look before you clean

Larkspur Outdoor, an outdoor-gear shop, wants a small support model fine-tuned on its helpdesk history. A fine-tuned model learns everything in its training set: duplicated answers it will over-learn, wrong policy it will repeat, and email addresses it will leak. helpdesk_export.jsonl is the raw export, 260 question and reply pairs. Before cleaning anything, find out what is in it.

Do this

1. Write load(), normalize() and profile() in curate.py.

2. Run it. Look at how the rows are split across categories, the shortest reply, and a few raw rows.

curate.py, the file you edit129 lines
"""Larkspur Outdoor is fine-tuning a small support model on its helpdesk history. Whatever is in the training
set, the model will learn: duplicates it will parrot, wrong policy it will repeat, email addresses it will leak.
This file turns the raw export into a training set worth learning from."""
import collections
import concurrent.futures
import json
import random
import re
import statistics

import harness

EXPORT = "helpdesk_export.jsonl"
POLICY = "policy.md"


# ---------- Step 1: look before you clean ----------
def load(path=EXPORT):
    """The rows of a JSONL file (one JSON object per line), skipping blank lines."""
    # TODO (Step 1): json.loads every non-blank line.
    raise NotImplementedError("Step 1: write load()")


def normalize(text):
    """Lowercase; drop every character that is not a letter, digit, space or £; collapse runs of whitespace to
    one space; strip. "  Where's my ORDER?\\n" -> "wheres my order"."""
    # TODO (Step 1): lower(), re.sub away everything but \w, whitespace and £, collapse spaces, strip.
    raise NotImplementedError("Step 1: write normalize()")


def profile(rows):
    """{"rows": n, "per_category": {category: count}, "response_words": {"min", "median", "max"}} with
    response length measured in words (response.split())."""
    # TODO (Step 1): row count, collections.Counter of categories, min/median/max response words.
    raise NotImplementedError("Step 1: write profile()")


# ---------- Step 2: rules ----------
REFUSAL = re.compile(r"\bas an ai\b|\bi'?m (just )?an ai\b|\bi cannot browse\b", re.I)
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
PHONE = re.compile(r"(?:\+44\s?7\d{3}|\b07\d{3})\s?\d{3}\s?\d{3}\b")


def problems(row):
    """The rule problems with a row's response, as a list of names, in this order:
    "empty" (nothing but whitespace: return just ["empty"]), "too_short" (fewer than 4 words), "truncated" (does
    not end in . ! ? ) " or '), "refusal" (matches REFUSAL), "pii" (an email address or UK mobile number),
    "copies_instruction" (normalize(response) == normalize(instruction))."""
    raise NotImplementedError("problems() arrives in Step 2")


def apply_rules(rows):
    """(kept rows, {problem name: [ids]}): a row with any problem is removed and listed under each of them."""
    raise NotImplementedError("apply_rules() arrives in Step 2")


# ---------- Step 3: duplicates ----------
def shingles(text, n=3):
    """The set of n-word tuples in normalize(text); a text shorter than n words gives one tuple of all its words."""
    raise NotImplementedError("shingles() arrives in Step 3")


def jaccard(a, b):
    """|a & b| / |a | b|; 1.0 when both are empty."""
    raise NotImplementedError("jaccard() arrives in Step 3")


def dedupe(rows, threshold=0.9):
    """Keep rows in order; drop a row whose instruction shingles have jaccard >= threshold with an instruction
    already kept. Returns (kept rows, {dropped id: the id of the kept row it duplicates})."""
    raise NotImplementedError("dedupe() arrives in Step 3")


# ---------- Step 4: a judge for what rules cannot see ----------
RUBRIC = """Score the reply from 1 to 5:
5 = answers the question, fully consistent with the policy, specific
4 = answers it correctly with a minor gap
3 = answers only part of the question
2 = too general to act on, mostly misses the question, or says something the policy does not support
1 = wrong about the policy, answers a different question, or empty of content
Reply with the score digit only."""
THRESHOLD = 1


def judge_prompt(row, policy):
    """The grading prompt: who the examples are for, the policy card, the customer message, the support reply,
    then RUBRIC last."""
    raise NotImplementedError("judge_prompt() arrives in Step 4")


def parse_score(text):
    """The first digit 1-5 in the judge's answer as an int, or None if there is none."""
    raise NotImplementedError("parse_score() arrives in Step 4")


def score_rows(rows, policy, workers=6):
    """{id: score} for every row, judged in parallel with `workers` threads via harness.ask_judge()."""
    raise NotImplementedError("score_rows() arrives in Step 4")


def agreement(scores, labels, threshold):
    """Share of labelled rows where (score >= threshold) matches the human's keep decision. labels: {id: keep}.
    A missing score (None) counts as not kept."""
    raise NotImplementedError("agreement() arrives in Step 4")


def choose_threshold(scores, labels, options=(2, 3, 4, 5)):
    """The option with the highest agreement(); on a tie, the higher one. A wrong reply that stays in the training
    set is worse than a good one that goes: the model learns to repeat it."""
    raise NotImplementedError("choose_threshold() arrives in Step 4")


# ---------- Step 5: balance and ship ----------
def balance(rows, scores, cap):
    """At most `cap` rows per category: the highest-scored first, ties kept in their original order. Returns the
    chosen rows in their original order."""
    raise NotImplementedError("balance() arrives in Step 5")


def split(rows, eval_share=0.1, seed=0):
    """(train, eval): within each category, shuffle with random.Random(seed) and put round(n * eval_share) rows
    (at least 1) in eval. Categories in sorted order; rows keep their shuffled order."""
    raise NotImplementedError("split() arrives in Step 5")


def to_chat(row):
    """A training example in the chat format fine-tuning tools read."""
    return {"messages": [{"role": "user", "content": row["instruction"]},
                         {"role": "assistant", "content": row["response"]}]}
Provided for you:audit_labels.jsonlharness.pyhelpdesk_export.jsonlpolicy.mdtry_it.py

Frequently asked questions

How do you deduplicate a fine-tuning dataset?

Normalise the text, cut it into overlapping word n-grams (shingles), and treat two examples as duplicates when their Jaccard similarity passes a threshold. That catches copies with changed case, spacing or a word or two, which exact matching misses.

How do you use an LLM to score training data quality?

Give a strong model the reference facts, the example and a rubric with a numeric scale, then measure its agreement with human labels on a sample before trusting its scores and choosing a cut-off.

Why balance categories in instruction data?

A model fine-tuned on a dataset dominated by one kind of request drifts toward treating every request that way. Capping each category, keeping its best examples, keeps the behaviour even.

Cleaning instruction data before fine-tuning

Fine-tuned models learn whatever their training data contains, so dataset curation often matters more than the training settings. In this lab you clean a realistic instruction dataset step by step: profiling, rule filters for empty, truncated, boilerplate and personal data, near-duplicate removal with shingles and Jaccard similarity, an LLM judge with a rubric and a policy card, calibrated against human labels, and category balancing with train and evaluation splits.