AI Engineering No. 264

Synthetic Data skill for Claude Code

Generated data that beats real-only on real tests. Claude generates synthetic datasets that have to prove themselves. It plans diversity with attribute grids and rotated real seeds, validates every row with code, scores quality with a judge calibrated against humans, and strips exact and near-duplicates plus anything overlapping your eval set using a bundled MinHash script. The data is kept only if it improves results on real held-out data.

  • namesynthetic-data
  • size196 lines · ≈2,200 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Generates synthetic datasets with LLMs for evals, fine-tuning, classifier training, and test fixtures - attribute grids and real seed examples for diversity, structured generation, validity filters and LLM-judge scoring, exact and near-duplicate removal, contamination checks against eval sets, provenance tagging, and proving usefulness by training or testing on real data. Use when there is too little labeled data, when building an eval set from scratch, when generating edge cases or rare classes, when distilling a larger model into a smaller one, or when synthetic data looks repetitive or leaks test items.

Try asking

  • Generate 5,000 realistic support tickets to train our intent classifier
  • Our synthetic eval set feels repetitive. Measure the duplicates and fix the generator
  • Build a distillation dataset so a small model can copy our big model on this task

Use it when

  • There is too little labeled data for training or evaluation
  • You need edge cases or rare classes the logs barely contain
  • Distilling a larger model's behavior into a smaller one
  • Synthetic data looks repetitive or may overlap the test set

Skip it when

How to check the result

Train or evaluate with and without the synthetic rows and compare on a real held-out set; also run scripts/dedupe.py with --against on the eval set to confirm zero contamination.

The common mistake it prevents

Asking the model to make items diverse instead of sampling explicit attribute combinations, which produces thousands of fluent near-duplicates.

~/.claude/skills/synthetic-data/SKILL.md

--- name: synthetic-data description: Generates synthetic datasets with LLMs for evals, fine-tuning, classifier training, and test fixtures - attribute grids and real seed… license: MIT ---

Synthetic data earns its place when a model trained or evaluated with it does better on real held-out data than without it. Volume, fluency, and an LLM judge’s approval do not prove that. The usual failure is a large, fluent, repetitive dataset that teaches the generator’s habits instead of the task.

Ground rules

  • Always keep a real held-out set as the final judge. Never evaluate on synthetic data generated by the same pipeline you are tuning.
  • Diversity is designed, not requested. “Make them diverse” yields near-duplicates; explicit attribute grids and real seeds yield variety.
  • Every row carries provenance: synthetic: true, generator model, prompt version, seed attributes, and timestamp.
  • Filter hard. Expect to discard 20-60% of raw generations.
  • Check provider terms before using model outputs to train other models, and never seed generation with raw personal data you are not allowed to process.

Decide whether synthetic is the right tool

GoalSynthetic helps?Better alternative if not
Bootstrap an eval set before launchYes, as a starting point; replace with real traffic ASAPLog real queries from a beta
Cover rare classes or edge casesYes, targeted generationActive sampling from production logs
Fine-tune a small model to copy a large one (distillation)Yes, with real inputs and model-generated outputs-
Replace real training data entirelyRarely; quality ceiling is the generatorLabel real data (see llm-classification)
Test fixtures and demo dataYes; deterministic factories may be simplertest-data-factories
Privacy-safe copy of a production databaseOnly with formal privacy methods; LLM paraphrase is not anonymizationMasking, differential privacy tooling

Workflow

- [ ] 1. Define the target distribution and the real held-out set
- [ ] 2. Build the diversity plan (attribute grid + real seeds)
- [ ] 3. Generate with structured output and provenance
- [ ] 4. Validate (schema, rules, execution)
- [ ] 5. Score quality (LLM judge calibrated against humans)
- [ ] 6. Deduplicate and check contamination
- [ ] 7. Compare distributions with real data
- [ ] 8. Prove usefulness on real held-out data

1. Target distribution

Write a short spec: task, input types, length range, languages, label set and target proportions, difficulty mix, and what “correct” means. Collect at least 50-200 real examples; they become seeds, calibration data for the judge, and the held-out set (split them; never seed from the held-out part).

2. Diversity plan

Build an attribute grid and sample combinations rather than asking for variety:

import itertools, random
GRID = {
    "persona": ["new user", "power user", "admin", "non-native speaker", "frustrated customer"],
    "topic": ["billing", "login", "data export", "integrations", "performance"],
    "difficulty": ["simple", "multi-part", "ambiguous", "contains a false premise"],
    "format": ["one line", "long email", "bullet list", "pasted error log"],
}
combos = list(itertools.product(*GRID.values()))
random.Random(7).shuffle(combos)
plan = [dict(zip(GRID, c)) for c in combos[:2000]]

Additional techniques:

  • Seeded generation: give 3-5 real examples per call, rotated, and ask for new items in the same distribution but different content.
  • Two-stage: first generate scenarios (short descriptions), dedupe them, then expand each into a full item. Deduping at the scenario level is cheaper and more effective.
  • Hard negatives: for classifiers and retrieval, generate near-miss items that should get a different label or should not match.
  • Long-tail weighting: oversample rare attribute values on purpose, then reweight to the target proportions when assembling the final set.

3. Generate

import anthropic, json
from pydantic import BaseModel

client = anthropic.Anthropic()

class Item(BaseModel):
    user_message: str
    expected_label: str
    rationale: str

def generate(attrs: dict, seeds: list[str]) -> Item:
    resp = client.messages.parse(
        model="claude-sonnet-5-5",
        max_tokens=1500,
        system=GENERATOR_SPEC,                       # task spec + label definitions, cached
        messages=[{"role": "user", "content":
            "Real examples for style and distribution only, do not copy them:\n"
            + "\n---\n".join(seeds)
            + f"\n\nWrite one new item with these attributes: {json.dumps(attrs)}"}],
        output_format=Item,
    )
    return resp.parsed_output
  • Generate one item per call (or a small list) with explicit attributes; large single calls drift toward repetition after the first dozen items.
  • For thousands of items, submit through a batch API (see llm-batch-jobs), keyed by the plan index so every row maps back to its attributes.
  • When generating labeled data, have the generator target a label, then verify the label independently in step 5. Generator-assigned labels are wrong more often than expected.

4. Validate

Deterministic checks first, they are free:

  • Schema validity, length bounds, language detection, banned content.
  • Label in the allowed set; required fields present.
  • For code: it parses, compiles, and its tests pass in a sandbox.
  • For SQL or API calls: executes against a fixture database or mock.
  • For math or extraction answers: recompute or cross-check against the source.

5. Score quality

Use an LLM judge with a rubric (realism, correctness of the label or answer, adherence to attributes). Calibrate it first: have a human score 50-100 items and confirm the judge agrees (see llm-evals). Use a different model or at least a different prompt from the generator to reduce self-preference. Drop items below the threshold and read a random sample of what passes.

6. Deduplicate and check contamination

python3 scripts/dedupe.py synthetic.jsonl --field user_message \
  --threshold 0.8 --against eval.jsonl --against-field question --out clean.jsonl

The script removes exact duplicates (after normalization) and near-duplicates (word shingle Jaccard via MinHash, default 3-word shingles, threshold 0.8), and flags rows whose 8-gram overlap with any item in --against exceeds --contam-threshold (default 0.5). It writes kept rows to --out and prints counts, the duplicate rate, and example clusters. A duplicate rate above ~15% means the diversity plan is too narrow.

Contamination matters in two directions: synthetic training data must not contain eval items, and an eval set generated by a model may already be memorized by that model family; prefer human-written or production-sourced eval items for final decisions.

7. Distribution comparison

Compare synthetic vs real on simple statistics before training anything:

StatisticRed flag
Length distribution (median, p90)Synthetic much more uniform or longer than real
Label proportionsOff from the target spec
Vocabulary / type-token ratioSynthetic noticeably lower (repetitive)
Top n-gramsGenerator tics (“I hope this helps”, “Certainly”, same opening phrase)
Embedding clustersSynthetic occupies a few tight clusters; real data spreads wider

8. Prove usefulness

  • Training data: train with real only, synthetic only, and real + synthetic; evaluate all on the real held-out set. Keep synthetic only if real + synthetic beats real only. Typical useful mixes put synthetic at 20-50% of the data; more often hurts.
  • Eval data: check that model rankings on the synthetic eval match rankings on a small real eval. If they disagree, the synthetic eval is measuring something else.

Gotchas

  • Generators default to polite, well-formed text. Real users write typos, fragments, mixed languages, and pasted logs; ask for these explicitly via attributes.
  • Training repeatedly on a model’s own outputs narrows the distribution over generations. Keep real data in every training mix.
  • Paraphrasing real records containing PII does not anonymize them; names, rare facts, and combinations remain identifying.
  • Judges prefer outputs from their own model family. Use a different judge or human spot checks.
  • Reusing the same few seed examples in every call copies their quirks into the whole dataset. Rotate seeds.

Output contract

**Purpose:** <eval / training / edge cases / fixtures>
**Real data:** <n> seeds, <n> held out
**Plan:** grid <attributes>, <n> combos, generator <model>, prompt v<n>
**Funnel:** generated <n> -> valid <n> -> judged <n> -> deduped <n> (dup rate <x>%, contaminated <y>)
**Distribution check:** <notes>
**Usefulness:** real-only <metric> vs real+synthetic <metric> on real held-out

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/dedupe.py 177 lines · 6.4 KB
#!/usr/bin/env python3
"""Exact and near-duplicate removal plus contamination check for JSONL text datasets.

Usage:
    python3 dedupe.py data.jsonl --field text [--threshold 0.8] [--shingle 3]
                      [--against eval.jsonl --against-field question]
                      [--ngram 8] [--contam-threshold 0.5] [--out clean.jsonl]

Steps:
  1. Normalize text (casefold, strip punctuation, collapse whitespace); drop exact duplicates.
  2. Near-duplicates: MinHash (128 hashes) over word shingles, LSH banding to find
     candidates, then exact Jaccard check >= --threshold. Keeps the first row of a cluster.
  3. Contamination (optional): for each kept row, the share of its word n-grams that also
     appear in any --against row. Rows above --contam-threshold are dropped and listed.

Prints a summary and example clusters. Writes kept rows to --out if given.
Exits 1 if the duplicate rate is above --max-dup-rate (default 0.5), 2 on input errors.
"""
import argparse
import hashlib
import json
import re
import sys
from collections import defaultdict

NUM_HASHES = 128
BANDS = 32  # 4 rows per band -> candidate threshold around (1/32)^(1/4) ~ 0.42
PUNCT = re.compile(r"[^\w\s]", re.UNICODE)


def load(path, field):
    rows = []
    with open(path, encoding="utf-8") as fh:
        for n, line in enumerate(fh, 1):
            if not line.strip():
                continue
            try:
                obj = json.loads(line)
            except json.JSONDecodeError as exc:
                print(f"error: {path}:{n}: invalid JSON ({exc})", file=sys.stderr)
                sys.exit(2)
            if field not in obj or not isinstance(obj[field], str):
                print(f"error: {path}:{n}: missing string field '{field}'", file=sys.stderr)
                sys.exit(2)
            rows.append(obj)
    return rows


def normalize(text):
    return " ".join(PUNCT.sub(" ", text.casefold()).split())


def shingles(words, k):
    if len(words) < k:
        return {" ".join(words)} if words else set()
    return {" ".join(words[i:i + k]) for i in range(len(words) - k + 1)}


def h64(s, seed):
    return int.from_bytes(hashlib.blake2b(s.encode(), digest_size=8, salt=seed.to_bytes(8, "little")).digest(), "little")


def minhash(sh):
    if not sh:
        return tuple([0] * NUM_HASHES)
    return tuple(min(h64(s, i) for s in sh) for i in range(NUM_HASHES))


def jaccard(a, b):
    if not a and not b:
        return 1.0
    return len(a & b) / len(a | b)


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("data")
    ap.add_argument("--field", required=True)
    ap.add_argument("--threshold", type=float, default=0.8)
    ap.add_argument("--shingle", type=int, default=3)
    ap.add_argument("--against")
    ap.add_argument("--against-field")
    ap.add_argument("--ngram", type=int, default=8)
    ap.add_argument("--contam-threshold", type=float, default=0.5)
    ap.add_argument("--max-dup-rate", type=float, default=0.5)
    ap.add_argument("--out")
    ap.add_argument("--examples", type=int, default=3)
    args = ap.parse_args()

    rows = load(args.data, args.field)
    if not rows:
        print("error: no rows", file=sys.stderr)
        sys.exit(2)

    # 1. exact
    seen, kept, exact_dups = {}, [], 0
    for i, r in enumerate(rows):
        key = normalize(r[args.field])
        if key in seen:
            exact_dups += 1
            continue
        seen[key] = i
        kept.append(i)

    # 2. near-duplicates
    norm_words = {i: normalize(rows[i][args.field]).split() for i in kept}
    sh = {i: shingles(norm_words[i], args.shingle) for i in kept}
    sigs = {i: minhash(sh[i]) for i in kept}
    rows_per_band = NUM_HASHES // BANDS
    buckets = defaultdict(list)
    for i in kept:
        for b in range(BANDS):
            buckets[(b, sigs[i][b * rows_per_band:(b + 1) * rows_per_band])].append(i)

    removed, clusters = set(), defaultdict(list)
    for i in kept:  # input order: first occurrence wins
        if i in removed:
            continue
        cands = set()
        for b in range(BANDS):
            cands.update(buckets[(b, sigs[i][b * rows_per_band:(b + 1) * rows_per_band])])
        for j in sorted(cands):
            if j <= i or j in removed:
                continue
            if jaccard(sh[i], sh[j]) >= args.threshold:
                removed.add(j)
                clusters[i].append(j)
    kept = [i for i in kept if i not in removed]

    # 3. contamination
    contaminated = []
    if args.against:
        afield = args.against_field or args.field
        eval_ngrams = set()
        for r in load(args.against, afield):
            eval_ngrams |= shingles(normalize(r[afield]).split(), args.ngram)
        still = []
        for i in kept:
            grams = shingles(norm_words[i], args.ngram)
            overlap = len(grams & eval_ngrams) / len(grams) if grams else 0.0
            if overlap > args.contam_threshold:
                contaminated.append((i, overlap))
            else:
                still.append(i)
        kept = still

    total = len(rows)
    dup_rate = (exact_dups + len(removed)) / total
    print(f"rows in:            {total}")
    print(f"exact duplicates:   {exact_dups}")
    print(f"near duplicates:    {len(removed)} (jaccard >= {args.threshold}, {args.shingle}-word shingles)")
    if args.against:
        print(f"contaminated:       {len(contaminated)} (> {args.contam_threshold:.0%} of {args.ngram}-grams in {args.against})")
    print(f"rows kept:          {len(kept)}")
    print(f"duplicate rate:     {dup_rate:.1%}")

    for rep, members in sorted(clusters.items(), key=lambda kv: -len(kv[1]))[: args.examples]:
        print(f"\ncluster of {len(members) + 1} (kept row {rep + 1}):")
        print(f"  keep: {rows[rep][args.field][:100]!r}")
        for j in members[:3]:
            print(f"  drop: {rows[j][args.field][:100]!r}")
    for i, ov in contaminated[: args.examples]:
        print(f"\ncontaminated row {i + 1} ({ov:.0%} overlap): {rows[i][args.field][:100]!r}")

    if args.out:
        with open(args.out, "w", encoding="utf-8") as fh:
            for i in kept:
                fh.write(json.dumps(rows[i], ensure_ascii=False) + "\n")
        print(f"\nwrote {len(kept)} rows to {args.out}")

    if dup_rate > args.max_dup_rate:
        print(f"FAIL: duplicate rate {dup_rate:.1%} above {args.max_dup_rate:.0%}; widen the diversity plan")
        sys.exit(1)


if __name__ == "__main__":
    main()

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc