Writing · · 7 min read
The Synthetic Data Trade
When real data is scarce, generate it. Why synthetic data works for some tasks, degrades for others, and what the trade actually involves.
#data#synthetic-data#machine-learning#evaluation
Synthetic data is data produced by a model, a system, or a simulation, used to train another model. It is attractive because real data is expensive, slow, and sometimes unavailable. It is risky because the synthetic data is not the real thing, and the difference matters.
The trade is not whether synthetic data works. It works in some cases and fails in others. The question is which cases, and why.
This note starts with a two-line generator, derives the distribution gap from first principles, then examines each failure mode with the measurement that exposes it.
The naïve version: sample from the model, train on the samples
The simplest generator is a fitted model that its authors already have. Fit a distribution to a handful of real examples, sample new ones, and hand the result to the next training run:
import random
def gaussian(mean, sigma):
while True:
u1 = random.random()
u2 = random.random()
r = (-2.0 * (1.0 - u1) * (1.0 - u1)) ** 0.5
theta = 2.0 * 3.14159265 * u2
value = mean + sigma * r * theta
if value >= 0:
return value
def generate(n, mean=60.0, sigma=10.0):
return [gaussian(mean, sigma) for _ in range(n)]
samples = generate(10)
print([round(s, 1) for s in samples])The mechanism is transparent: every synthetic sample is a point from the generator's distribution. Nothing is added. Whatever the generator knows, the synthetic set contains; whatever the generator is missing, the synthetic set inherits as absence. That property is the entire trade.
First principles: the gap is a distance between distributions
Let be the real distribution and the generator's distribution. Training on synthetic data minimises the risk under ; deployment measures the risk under . The student's error decomposes into the generator's error plus a term proportional to the distance between the two distributions:
where is any divergence between the distributions. A small gap makes the trade safe; a large gap makes the student optimised for a world that does not exist. The gap cannot be closed by generating more samples from the same generator — that reduces estimation error and sharpens the wrong target.
Three quantities matter, and each maps to a concrete mechanism:
def kl_divergence(p, q):
"""KL(P || Q) over discrete histograms."""
import math
total = 0.0
for pi, qi in zip(p, q):
if pi > 0:
total += pi * math.log(pi / max(qi, 1e-12))
return total
def total_variation(p, q):
return 0.5 * sum(abs(pi - qi) for pi, qi in zip(p, q))
def tail_coverage(real_samples, synthetic_samples, threshold):
real_tail = [s for s in real_samples if s >= threshold]
synth_tail = [s for s in synthetic_samples if s >= threshold]
return len(real_tail), len(synth_tail)Bottleneck one: the generator concentrates bias
A model that produces synthetic data inherits the biases of its own training. If the generator overrepresents a pattern, the student learns that pattern more strongly. Synthetic data does not remove bias; it concentrates it, because the generator applies its biases at every sample without the noise that real data supplies.
The mechanism is visible in any conditional generator. Ask for samples from a class and the generator returns the mode of its learned conditional distribution:
def generate_class_conditional(generator, label, n):
"""Returns samples from the generator's learned conditional."""
return [generator.sample(label=label) for _ in range(n)]
def majority_fraction(samples, value, key):
return sum(1 for s in samples if key(s) == value) / len(samples)
samples = generate_class_conditional(teacher, "engineer", 1000)
print("fraction using 'he':", majority_fraction(samples, "he",
lambda s: s["pronoun"]))
print("fraction using 'she':", majority_fraction(samples, "she",
lambda s: s["pronoun"]))A generator trained on skewed data reproduces the skew in every batch, and the skew compounds: the student sees fewer counterexamples than the teacher's training set contained, because the generator's sampling already thinned them. The fix is to measure the class balance of the generator's output before training, and to rebalance by construction:
def stratified_generate(generator, labels, per_label):
output = []
for label in labels:
output.extend(generator.sample(label=label)
for _ in range(per_label))
return output
def check_balance(samples, key, expected):
observed = {}
for s in samples:
observed[key(s)] = observed.get(key(s), 0) + 1
total = len(samples)
return {k: observed.get(k, 0) / total for k in expected}Stratified generation forces the marginal the designer wants. The remaining bias lives inside each stratum, where the generator's conditional distribution still governs which variations appear.
Bottleneck two: the tails go missing
Generative models tend to underrepresent the tails. Synthetic data contains fewer rare cases, fewer unusual presentations, fewer outliers, and the student trained on it is worse on exactly the cases where real data is most valuable.
The reason is compositional. A generator trained by likelihood maximisation fits the bulk of the distribution first; the tail samples carry small gradients and are fitted last, if at all. Sampling from the fitted distribution then draws from the bulk shape, and the fitted tail — already thinner than the real one — appears rarely even in large synthetic sets.
def bins(samples, edges):
counts = [0] * (len(edges) - 1)
for s in samples:
for i in range(len(edges) - 1):
if edges[i] <= s < edges[i + 1]:
counts[i] += 1
break
return counts
edges = [0, 20, 40, 60, 80, 100, 200]
real_counts = bins(real_samples, edges)
synth_counts = bins(synth_samples, edges)
for i, (r, s) in enumerate(zip(real_counts, synth_counts)):
print(f"[{edges[i]}, {edges[i+1]}): real={r}, synth={s}")A histogram comparison exposes the truncation directly. The highest bin, where real rare cases live, is empty or nearly empty in the synthetic column.
def max_sample(samples):
return max(samples) if samples else None
print("real max:", max_sample(real_samples))
print("synthetic max:", max_sample(synth_samples))
print("real 99th percentile:", percentile(real_samples, 99))
print("synthetic 99th percentile:", percentile(synth_samples, 99))The fix is explicit tail injection. Domain knowledge identifies the rare cases the task cares about, and a targeted generator or a perturbation procedure produces them:
def inject_tails(synthetic_samples, tail_generator, n_tails):
return synthetic_samples + [tail_generator() for _ in range(n_tails)]
def verify_tail(synthetic_samples, threshold):
tails = [s for s in synthetic_samples if s >= threshold]
return len(tails) / len(synthetic_samples)Unconditional scale does not substitute. A billion synthetic samples from the fitted bulk still contain few tail cases, and the rare ones appear at the fitted rate, not the real rate.
Bottleneck three: the loop narrows
Training a model on data produced by a similar model amplifies the shared errors. Iterating the loop narrows the output distribution further each round, because each generation's model fits the previous generation's bulk and drops more of the tail.
The recursion is simple to simulate with a discrete distribution:
def iterate(generator, n_rounds, n_samples):
distributions = []
current = generator
for _ in range(n_rounds):
samples = current.sample(n_samples)
distributions.append(distribution_of(samples))
current = fit(samples)
return distributions
def support_size(distribution, eps=1e-6):
return sum(1 for p in distribution.values() if p >= eps)Fitting the samples is the step that discards mass: categories whose probability fell below the fitting threshold disappear from the output distribution, and the next round's samples contain none of them. Mode collapse across iterations is a property of fitting, not of sampling.
The mitigation is real data in the loop. Replacing a fraction of each round's synthetic samples with freshly collected real data anchors the distribution, and keeping the fraction constant prevents the drift that a pure synthetic chain guarantees. Mixing ratios are task-dependent; the mechanism that makes them necessary is not.
def mix(real_batch, synthetic_batch, real_fraction):
n = len(real_batch) + len(synthetic_batch)
n_real = int(n * real_fraction)
return real_batch[:n_real] + synthetic_batch[: n - n_real]
def train_mixed(model, real_data, synth_data, epochs, real_fraction=0.5):
for _ in range(epochs):
model.fit(mix(real_data, synth_data, real_fraction))Bottleneck four: the gap is judged on the wrong distribution
A student trained on synthetic data is evaluated on synthetic held-out data in the naïve workflow, and the score measures the generator, not the student. Performance on synthetic data confirms that the student learned the generator's distribution. Performance on real data is the only measure of the trade.
def evaluate(model, real_test, synth_test):
real_score = model.score(real_test)
synth_score = model.score(synth_test)
return {"real": real_score, "synthetic": synth_score,
"gap": synth_score - real_score}A large positive gap is the signature: the student performs well where the generator lives and poorly where deployment lives. The gap, not the synthetic score, is the number that decides whether synthetic data was a bargain.
Where it works
Synthetic data helps most when the task is well-specified and the generator is close to the true process.
- Simulation. Physics-based simulators for robotics, control, and reinforcement learning. The simulator is not reality, but it is a good model of the mechanism, and domain randomization covers the gap:
def randomize_domain(rng):
return {
"friction": rng.uniform(0.1, 1.0),
"mass": rng.uniform(0.5, 2.0),
"lighting": rng.uniform(0.5, 1.5),
"texture_seed": rng.randint(0, 10**6),
}
def simulated_episode(simulator, params):
return simulator.run(friction=params["friction"],
mass=params["mass"],
lighting=params["lighting"],
seed=params["texture_seed"])Randomizing the parameters forces the policy to be robust to the variation the real world supplies. The gap being closed is the simulation-to-reality gap, and randomization covers it by training on the whole neighbourhood of the target rather than a single point.
- Augmentation. Applying known transformations to real data. Rotation, colour shift, cropping. The transformation is specified; the labels are inherited:
def transform(image, kind, rng):
if kind == "rotate":
return rotate(image, rng.uniform(-15, 15))
if kind == "shift_colour":
return shift_hue(image, rng.uniform(-0.1, 0.1))
if kind == "crop":
return random_crop(image, scale=rng.uniform(0.8, 1.0))
return image
def augment_dataset(dataset, transforms, per_sample):
rng = __import__("random").Random(0)
output = []
for image, label in dataset:
output.append((image, label))
for _ in range(per_sample):
output.append((transform(image, rng.choice(transforms), rng),
label))
return outputAugmentation uses real inputs, so the input distribution stays anchored; only the variation around each real point is synthetic. This is the safest form of the trade because the marginal distribution of inputs remains the real one.
- Distillation. A large model generates soft labels for unlabeled real data. The student learns the teacher's function. This is synthetic in the labels, not in the inputs:
def pseudo_label(teacher, inputs, threshold=0.9):
labelled = []
for x in inputs:
probs = teacher.predict_proba(x)
best = max(probs)
if best >= threshold:
labelled.append((x, probs.index(best), best))
return labelledThe inputs are real, so the student sees the true input distribution. The labels are synthetic, so the student inherits the teacher's decision boundaries rather than ground truth. The threshold controls the tradeoff between label quantity and label accuracy.
- Controlled generation. Generating examples of a specific class or difficulty. This works when the class is well-defined and the generator can produce it reliably:
def generate_with_spec(generator, spec, n):
"""spec: dict of attributes, e.g. class, difficulty, style."""
samples = []
while len(samples) < n:
candidate = generator.sample(spec)
if generator.checks(candidate, spec):
samples.append(candidate)
return samplesThe check step is the load-bearing part. Generating and verifying against a specification produces a dataset whose properties are known by construction, which is the advantage synthetic data holds over real data when the specification is exact.
Synthetic data fails most when the task requires capturing the real distribution: medical imaging, natural language, any domain where the tail matters more than the mean.
The trade
Synthetic data is a bet that the generator is close enough to the target that the cost of the gap is smaller than the cost of not having the data. The bet pays off in simulation and augmentation. It pays off less in domains where the output is judged against real distribution.
The right question is not "synthetic or real." It is "what is the gap, for this task, and does the gap matter for the metric?" If the gap is small, synthetic data is a bargain. If the gap is the task, it is not.
What to measure
The checklist for any synthetic data pipeline:
def pipeline_report(gap, tail_coverage, real_score, synth_score):
verdict = []
if gap > 0.10:
verdict.append("gap too large: change the generator or the task")
if tail_coverage < 0.50:
verdict.append("tail truncation: inject rare cases explicitly")
if synth_score - real_score > 0.05:
verdict.append("evaluation is flattering: report real score only")
if not verdict:
verdict.append("gap acceptable for this task")
return verdict- Compute the distribution gap on a held-out real sample. Any divergence between the real and synthetic histograms above noise level is a veto on the training set.
- Plot tail coverage separately. A generator can match the bulk and truncate the tail, and the bulk number will hide it.
- Evaluate on real held-out data only. Synthetic held-out performance measures the generator.
- Track class balance in every synthetic batch. The generator's skew compounds silently across rounds.
- Fix the real-to-synthetic mixing ratio and hold it constant across rounds of any feedback loop. Drift is the default outcome of an unconstrained loop.
- Measure rare-class accuracy after training, not overall accuracy. The cost of synthetic data is paid in the tail, and the mean will not show the debt.
A model trained on synthetic data should be evaluated on real data. That is not optional. The performance on synthetic held-out data is a measure of the generator, not the student.