Synthetic Data from a Stronger Model
By Syncfusion®, Inc. · Last updated
Fill rare intents with tickets a teacher model writes, filter them five ways, choose a safe mix, and export the v1 dataset kit with a dataset card.
Synthetic Data from a Stronger Model
After cleaning, the team has 447 good examples. But only 32 are about damaged items and 16 are about account problems. They need about 100 more.
Collecting real ones would take months. So they ask a stronger model to write them.
It writes 200 tickets in a few minutes. They look great at first glance. Then the team runs five filters.
114 tickets survive. Only 4 of 88 bad rows slip through, and 110 of 112 good rows are kept. Without the filters, the bad ones would have gone straight into training.
Simple lesson: generated data is a first draft. Filters turn it into a dataset.
A Real-World Example
Think about asking a very experienced colleague to write practice questions for a new hire.
They write fifty in an hour. Most are great. But a few have mistakes. Some are copies of real tickets. Some are the final exam questions by accident. And after a while, they all start to sound the same, because your colleague has a favourite way of writing.
You would still use their questions. You would just check them first, and you would keep plenty of real customer messages in the mix.
Simple lesson: a helper's work still needs checking, and it should not replace the real thing.
What You Will Learn
In this lesson, you will learn:
- When synthetic data helps: filling a known coverage gap.
- How to prompt a teacher model for varied examples.
- Five filters every generated batch needs.
- How much generated data to mix in, and how to check it worked.
- How to export your v1 dataset kit for real training tools.
Before you start
- Lessons 4 and 5 built and cleaned the real data. This lesson adds to it.
- The generated batch is simulated: 200 rows a teacher model might write, with planted problems, so the lab can check your filters.
- The mix results come from the simulated run bank and are labelled as such.
TL;DR
Synthetic data = training examples written by a model instead of collected from real traffic.
Teacher model = a stronger (usually bigger and more expensive) model that writes the examples. The model you fine-tune is the student.
Generate only for a known gap. Filter for schema, eval leaks, copies, sameness and wrong labels. Read a sample. Keep real data the majority.
When Synthetic Data Helps
Synthetic data is good at one job: filling a gap you already measured. In Dataset Design that gap was clear: about 43 more damaged-item and 59 more account examples.
It is a poor fit when:
- You have no real data at all. The teacher's idea of your customers is a guess.
- The task needs facts only you have, like your real order history.
- You want the student to be better than the teacher. It learns the teacher's habits, mistakes included.
A prompt that gets variety
Give the teacher the schema, five real examples of the rare intent, and ask for many different messages: short and long, calm and angry, with and without order numbers, with typos. Ask for small batches (about 20) and change the examples between batches. One big batch from one prompt tends to repeat itself.
Check the provider's terms too. Some do not allow using their model's output to train other models.
Five Filters
Every generated batch goes through these, in order:
- Schema: the answer is valid JSON with every field and allowed values.
- Not in the eval set: generated rows must never match an eval example.
- Not already in the real data: copies add nothing and push towards memorising.
- Variety: keep one row per template, so one phrasing does not dominate.
- Label check: the message and the intent agree.
Here is the schema filter in code.
Filter generated rows by schema
// Filter generated rows: valid ticket JSON with every field and allowed values.
const INTENTS = ["refund", "where", "address", "cancel", "damaged", "account"];
const generated = [
'{"intent":"damaged","order":"2210","priority":"high","summary":"Item arrived damaged"}',
'{"intent":"account","order":"none",priority:"low","summary":"Account access problem"}',
'{"intent":"broken","order":"2311","priority":"high","summary":"Item arrived damaged"}',
'{"intent":"account","order":"none","priority":"urgent","summary":"Account access problem"}',
'{"intent":"account","order":"none","priority":"low","summary":"Account access problem"}',
];
function valid(text: string): string {
let t: Record<string, unknown>;
try { t = JSON.parse(text) as Record<string, unknown>; } catch { return "not JSON"; }
if (!INTENTS.includes(String(t.intent))) return `unknown intent "${t.intent}"`;
if (!["low", "normal", "high"].includes(String(t.priority))) return `bad priority "${t.priority}"`;
if (typeof t.order !== "string" || typeof t.summary !== "string") return "missing field";
return "ok";
}
generated.forEach((g, i) => console.log(`row ${i + 1}: ${valid(g)}`));
Filter generated rows by schema
using System.Text.Json;
// Filter generated rows: valid ticket JSON with every field and allowed values.
string[] INTENTS = ["refund", "where", "address", "cancel", "damaged", "account"];
string[] generated =
[
"""{"intent":"damaged","order":"2210","priority":"high","summary":"Item arrived damaged"}""",
"""{"intent":"account","order":"none",priority:"low","summary":"Account access problem"}""",
"""{"intent":"broken","order":"2311","priority":"high","summary":"Item arrived damaged"}""",
"""{"intent":"account","order":"none","priority":"urgent","summary":"Account access problem"}""",
"""{"intent":"account","order":"none","priority":"low","summary":"Account access problem"}""",
];
// Read a field as text, like JS String(t.field).
string Field(JsonElement t, string name) =>
t.TryGetProperty(name, out var v) ? (v.ValueKind == JsonValueKind.String ? v.GetString()! : v.ToString()) : "undefined";
bool IsString(JsonElement t, string name) =>
t.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String;
string Valid(string text)
{
JsonElement t;
try { t = JsonDocument.Parse(text).RootElement; } catch { return "not JSON"; }
if (!INTENTS.Contains(Field(t, "intent"))) return $"unknown intent \"{Field(t, "intent")}\"";
if (!new[] { "low", "normal", "high" }.Contains(Field(t, "priority"))) return $"bad priority \"{Field(t, "priority")}\"";
if (!IsString(t, "order") || !IsString(t, "summary")) return "missing field";
return "ok";
}
for (int i = 0; i < generated.Length; i++) Console.WriteLine($"row {i + 1}: {Valid(generated[i])}");
Expected output:
row 1: ok
row 2: not JSON
row 3: unknown intent "broken"
row 4: bad priority "urgent"
row 5: ok
Row 2 has a missing quote, row 3 invented an intent, row 4 invented a priority. A model writing JSON makes these small slips often enough that this check always pays off.
Simple lesson: cheap code filters catch most bad rows. A person reads a sample of what passes.
How Much to Mix In
Generated data should fill gaps, not take over. Watch three training mixes.
With real data only, rare intents score 68%. Adding filtered synthetic data (about 20% of the set) lifts them to 90%, and common intents stay the same. With mostly synthetic data, common tickets fall to 88%: the model learned the teacher's style instead of your customers'.
How much generated data fits
// How much generated data can you add and keep real data the majority?
const real = 447;
const maxShare = 0.25; // generated rows: at most 25% of the training set
const maxGenerated = Math.floor((maxShare * real) / (1 - maxShare));
console.log(`real rows: ${real}`);
console.log(`at most ${maxGenerated} generated rows`);
for (const add of [60, 110, 400]) {
const share = add / (real + add);
console.log(`add ${String(add).padStart(3)}: ${(share * 100).toFixed(0)}% generated ${share <= maxShare ? "ok" : "too much"}`);
}
How much generated data fits
// How much generated data can you add and keep real data the majority?
int real = 447;
double maxShare = 0.25; // generated rows: at most 25% of the training set
int maxGenerated = (int)Math.Floor((maxShare * real) / (1 - maxShare));
Console.WriteLine($"real rows: {real}");
Console.WriteLine($"at most {maxGenerated} generated rows");
foreach (int add in new[] { 60, 110, 400 })
{
double share = (double)add / (real + add);
Console.WriteLine($"add {add.ToString().PadLeft(3)}: {share * 100:F0}% generated {(share <= maxShare ? "ok" : "too much")}");
}
Expected output:
real rows: 447
at most 149 generated rows
add 60: 12% generated ok
add 110: 20% generated ok
add 400: 47% generated too much
A simple rule: keep generated rows under about a quarter of the training set. Then score common and rare intents separately, so you can see if either one moved the wrong way.
Export the v1 Dataset Kit
The Fine-tune Lab's Export tab builds a zip from your Dataset tab settings:
- train.jsonl: cleaned real rows plus filtered generated rows, in the chat format.
- eval.jsonl: the held-out eval. Never train on it.
- ticket.schema.json: the answer's JSON schema.
- clean.ts: the cleaning rules as a script you can run on your own export.
- dataset-card.md: counts per intent, the share of generated data, and every rule you applied.
- synthetic-recipe.md: how the generated rows were made and filtered.
Dataset card = a short document that says what is in a dataset, where it came from and how it was cleaned.
The chat JSONL format works with common tools such as Hugging Face TRL, Axolotl and provider fine-tuning APIs. The Choosing the Method lesson picks the method and the settings.
What to ask about generated data
- Which gap does it fill? There should be a coverage table that shows the need.
- What share of the training set is generated? Ask for the number. Under about 25% is a good default.
- Were the filters run, and did a person read a sample?
- Are we allowed to train on this teacher's output? Check the provider's terms.
- Is there a dataset card? It is the record you will need when something goes wrong.
Do's and Don'ts
- Do generate only for a measured gap.
- Do give the teacher real examples and ask for variety.
- Do run all five filters, then read a sample.
- Do keep real data the majority, and score rare and common intents separately.
- Don't let generated rows anywhere near the eval set.
- Don't use one big batch from one prompt.
- Don't skip the dataset card.
Common mistakes
| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| Trusting generated rows as they are | "A stronger model writes correct examples." | Filter every batch, then read a sample. | Bad JSON and wrong labels go into training. |
| Generated rows in the eval | "More eval cases is better." | Keep the eval real and never generated. | The eval measures the teacher's style, not real traffic. |
| Mostly synthetic data | "We can make as much as we need." | Keep generated rows under about a quarter. | The model drifts from how real customers write. |
| One prompt, one big batch | "Ask once for 500 examples." | Small batches, varied examples, check for sameness. | Hundreds of rows that all sound the same. |
| No dataset card | "The data is in the file, that is enough." | Write what is in it and how it was made. | Nobody can explain a bad run later. |
Check Your Understanding
Exercises
What to Remember
- Synthetic data fills a measured gap. It does not replace real data.
- Prompt the teacher with real examples and ask for variety, in small batches.
- Run five filters: schema, not in eval, not already real, variety, label check. Then read a sample.
- Keep generated rows under about a quarter, and score rare and common intents separately.
- Export a dataset kit with a dataset card, so every rule is written down.
Next: Choosing the Method, LoRA, QLoRA, full fine-tuning or preference tuning, and the settings to start with.