Controlling how close synthetic data are to the real data

ok <- requireNamespace("MASS", quietly = TRUE)
knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 7,
                      fig.height = 4, dpi = 72, out.width = "100%", eval = ok)

Sometimes synthetic data should be as faithful as possible; sometimes they should deliberately be further from the real data, for example

SynthesizerPlus offers three levels of control:

| Tool | What it does | |---|---| | generate(..., closeness = ) | one knob from 1 (as close as possible) to 0 (far) | | generate(..., perturb = perturbation(...)) | separate record-level and distribution-level settings | | calibrate_closeness() | finds the closeness that reaches a target distance |

library(SynthesizerPlus)
data(Boston, package = "MASS")
fit <- fit_synthesizer(Boston)

One knob: closeness

grid <- c(1, 0.8, 0.6, 0.4, 0.2, 0)
dist <- t(sapply(grid, function(cl) {
  syn <- generate(fit, closeness = cl, seed = 1)
  c(closeness = cl,
    `discriminator AUC` = discriminator_auc(Boston, syn, seed = 1),
    `DCR ratio` = dcr(Boston, syn, seed = 1)$ratio,
    `marginal distance` = mean(marginal_metrics(Boston, syn)$distance),
    `association error` = mean(abs(association_matrix(syn) - association_matrix(Boston))))
}))
round(dist, 3)

As closeness decreases, a classifier distinguishes synthetic from real records more easily (higher AUC, which levels off close to 1 once the data are clearly different), synthetic records are further from their nearest real record (higher DCR ratio, see ?dcr), and the marginal distributions and associations drift away from the real ones.

Note that even at closeness = 1 the discriminator AUC is above 0.5: the synthesizer is a model of the data and the classifier, which uses squares and interactions of all variables, picks up the remaining differences. closeness = 1 is the closest the model can get; it is not a copy of the data.

Internally, closeness (c) is translated into a perturbation with (d = 1 - c):

| setting | value | effect | |---|---|---| | noise | (1.5\,d) | smoothing of continuous variables | | swap | (0.5\,d) | re-drawing of categorical/discrete values | | temperature | (1 + d) | flatter category probabilities | | dependence | (c) | weaker dependence between variables |

Record level: further from real records, same distributions

noise replaces each numeric value by a smoothed version that approximately keeps the mean and variance of the variable; swap re-draws categorical and discrete values from their marginal distribution. The distributions stay (nearly) the same, but individual synthetic records move away from the real ones.

near <- generate(fit, seed = 2)
far <- generate(fit, perturb = perturbation(noise = 0.6, swap = 0.3), seed = 2)
c(near = dcr(Boston, near, seed = 1)$ratio, far = dcr(Boston, far, seed = 1)$ratio)
round(rbind(real = sapply(Boston[c("medv", "lstat", "rm")], sd),
            near = sapply(near[c("medv", "lstat", "rm")], sd),
            far = sapply(far[c("medv", "lstat", "rm")], sd)), 2)
plot_dcr(dcr(Boston, far, seed = 1))

Distribution level: scenarios

shift, scale, temperature and dependence change the distributions on purpose. They accept a single number for all eligible variables or a named vector for specific variables. Here is a scenario with higher crime rates, more variable room counts and a stronger dependence between variables:

scenario <- perturbation(shift = c(crim = 1), scale = c(rm = 1.5), dependence = 1.3)
scenario
stress <- generate(fit, perturb = scenario, seed = 3)
plot_marginals(Boston, stress, vars = c("crim", "rm", "medv"))

By default (bounds = "sign"), variables that are never negative in the real data, such as crim, stay non-negative; use bounds = "observed" to stay within the observed range, or "none" for no restriction.

For categorical variables, temperature > 1 makes the categories more equally frequent and temperature < 1 concentrates the data on the most frequent ones:

fit_i <- fit_synthesizer(iris[c(1:50, 51:70, 101:105), ])   # unbalanced species
round(rbind(
  real = prop.table(table(iris[c(1:50, 51:70, 101:105), "Species"])),
  `T = 1` = prop.table(table(generate(fit_i, n = 5000, seed = 1)$Species)),
  `T = 3` = prop.table(table(generate(fit_i, n = 5000, seed = 1,
                                      perturb = perturbation(temperature = 3))$Species))
), 2)

closeness and perturb can be used together: their record-level effects add up, so generate(fit, closeness = 0.8, perturb = scenario) produces a smoothed version of the scenario.

Hitting a target distance

Rather than guessing a value of closeness, state the distance you want. calibrate_closeness() evaluates a metric over a grid of closeness values (with the same random numbers each time), refines the solution by bisection and finishes with a linear interpolation step. Here we ask for synthetic data that a classifier can distinguish from the real data with an AUC of 0.85:

cal <- calibrate_closeness(fit, Boston, target = 0.85, metric = "auc",
                           grid = seq(0, 1, by = 0.1), refine = 3)
cal
plot(cal)

The metric is itself an estimate, so a check with a fresh seed gives a value close to, but not exactly at, the target:

syn <- generate(fit, closeness = cal$closeness, seed = 99)
discriminator_auc(Boston, syn, seed = 5)

Other targets are the DCR ratio (metric = "dcr", useful as a disclosure risk threshold), the pMSE ratio and the mean marginal distance. Directional perturbations can be kept fixed while calibrating: calibrate_closeness(fit, Boston, 0.9, perturb = scenario).

Use with augmentation

Lower closeness yields more diverse synthetic training data for machine learning. augment_data() passes closeness and perturb on:

big <- augment_data(Boston[1:60, ], n = 1000, closeness = 0.8, seed = 1)
dim(big)

Whether more diversity helps a given model is an empirical question; tune closeness by cross-validation on the training data as shown in vignette("regularisation").



Try the SynthesizerPlus package in your browser

Any scripts or data that you put into this service are public.

SynthesizerPlus documentation built on Oct. 16, 2026, 5:10 p.m.