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.
- name
synthetic-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
- You need deterministic fixtures for unit tests — use test-data-factories
- You can label real data affordably — use llm-classification to label it
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.
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
| Goal | Synthetic helps? | Better alternative if not |
|---|---|---|
| Bootstrap an eval set before launch | Yes, as a starting point; replace with real traffic ASAP | Log real queries from a beta |
| Cover rare classes or edge cases | Yes, targeted generation | Active 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 entirely | Rarely; quality ceiling is the generator | Label real data (see llm-classification) |
| Test fixtures and demo data | Yes; deterministic factories may be simpler | test-data-factories |
| Privacy-safe copy of a production database | Only with formal privacy methods; LLM paraphrase is not anonymization | Masking, 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:
| Statistic | Red flag |
|---|---|
| Length distribution (median, p90) | Synthetic much more uniform or longer than real |
| Label proportions | Off from the target spec |
| Vocabulary / type-token ratio | Synthetic noticeably lower (repetitive) |
| Top n-grams | Generator tics (“I hope this helps”, “Certainly”, same opening phrase) |
| Embedding clusters | Synthetic 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
#!/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.