Getting started with SynthesizerPlus

knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 7,
                      fig.height = 4.2, dpi = 72, out.width = "100%")

SynthesizerPlus generates synthetic data that mimic a real data set: the synthetic records follow the same marginal distributions and the same dependence structure, but they are new records. This vignette introduces the modelling approach and the main functions. Other vignettes cover evaluation, time series, multivariate distributions and file formats.

library(SynthesizerPlus)

The model in one paragraph

By Sklar's theorem every multivariate distribution can be written as a copula (the dependence structure) applied to the marginal distributions. SynthesizerPlus estimates

  1. one marginal model per column — an interpolated empirical quantile function for continuous variables (linear or monotone-spline interpolation between order statistics, so synthetic values fall between observed ones and within the observed range), and the empirical probability table for discrete, categorical, ordinal and logical variables;
  2. a copula — Gaussian (default), Student-t or independence — whose correlations are estimated from the normal scores of the data. Discrete and categorical columns are part of the dependence structure instead of being sampled independently; their correlations are estimated by maximum likelihood (polyserial and polychoric correlations, see ?fit_synthesizer);
  3. a missing-data model — each column with missing values gets a missingness indicator that is also part of the copula, so both the missingness rate and its (monotone) relation to other variables are reproduced.

Generation reverses the steps: draw from the copula, push each uniform through the corresponding quantile function, and restore the original column classes (factor levels, Date, POSIXct with time zone, integer, ...).

Fit once, generate many times

fit <- fit_synthesizer(iris)
fit
syn <- generate(fit, n = 500, seed = 1)
head(syn)

generate() can be called as often as needed; n may be larger or smaller than the training data. The seed argument makes the output reproducible without changing the random-number state of your session.

Fitting is itself slightly random (ties among the pseudo-observations are broken at random, and discrete variables use a randomised transform), so for a fully reproducible workflow give the fit a seed too: fit_synthesizer(mtcars, seed = 1).

For a quick one-off, synthesize() fits and generates in one call:

syn2 <- synthesize(mtcars, n = 100, seed = 2)
str(syn2[, 1:5])

With a file argument, the synthetic data are also written to disk (and returned invisibly). The format follows the file extension; here a CSV file in a temporary folder:

out_csv <- file.path(tempdir(), "mtcars_synthetic.csv")
synthesize(mtcars, n = 100, seed = 2, file = out_csv)
file.exists(out_csv)
readLines(out_csv, n = 3)          # the first lines of the file

read_data() reads the file back. With template = the column classes of the original data are restored, which matters for formats such as CSV that do not store them:

syn_back <- read_data(out_csv, template = mtcars)
str(syn_back[, 1:5])
all.equal(syn_back, syn2, check.attributes = FALSE)   # the file holds the same data

The same works for any supported format, e.g. file = "synthetic.parquet", "synthetic.xlsx" or "synthetic.h5" (see supported_formats() and vignette("file-formats")). A fitted model can also write a new file whenever needed, with write_data(generate(fit, n = 1000), "synthetic.rds").

Supported column types

set.seed(10)
n <- 200
people <- data.frame(
  age = as.integer(round(runif(n, 18, 80))),
  income = round(rlnorm(n, 10, 0.6)),
  sector = factor(sample(c("agri", "industry", "services"), n, TRUE,
                         prob = c(0.1, 0.3, 0.6))),
  education = factor(sample(c("primary", "secondary", "tertiary"), n, TRUE),
                     levels = c("primary", "secondary", "tertiary"), ordered = TRUE),
  region = sample(c("North", "South", "East", "West"), n, TRUE),
  employed = runif(n) < 0.7,
  survey_date = as.Date("2024-01-01") + sample(0:365, n, TRUE),
  stringsAsFactors = FALSE
)
people$income[sample(n, 20)] <- NA

fit_p <- fit_synthesizer(people)
summary(fit_p)
str(generate(fit_p, n = 5, seed = 3))

Numeric columns with at most discrete_threshold (default 20) distinct values are modelled as discrete, so a variable such as the number of cylinders only takes observed values. Continuous values are always within the observed range.

Missing values

The default missing = "joint" reproduces the share of missing values and how missingness depends on the other variables, as long as that dependence is monotone (missing more often for larger, or for smaller, values of another variable). Here income is missing more often for older people:

set.seed(11)
age <- round(runif(2000, 18, 80))
income <- exp(7 + 0.01 * age + rnorm(2000, sd = 0.5))
income[runif(2000) < plogis(-4 + 0.05 * age)] <- NA
svy <- data.frame(age, income)
syn_svy <- generate(fit_synthesizer(svy, seed = 1), n = 20000, seed = 1)
bands <- function(a) cut(a, c(17, 30, 45, 60, 80))
rbind(real = tapply(is.na(svy$income), bands(svy$age), mean),
      synthetic = tapply(is.na(syn_svy$income), bands(syn_svy$age), mean))

A single copula cannot represent a non-monotone pattern. In airquality, ozone is missing far more often in June than in the months before and after it; the pooled model can only spread that excess into a trend over the months. Stratifying by month reproduces the pattern:

aq_pooled <- generate(fit_synthesizer(airquality, seed = 1), n = 5000, seed = 4)
aq_strat <- generate(fit_synthesizer(airquality, by = "Month", seed = 1), n = 5000, seed = 4)
miss_by_month <- function(d) tapply(is.na(d$Ozone), d$Month, mean)
round(rbind(real = miss_by_month(airquality), pooled = miss_by_month(aq_pooled),
            `by = "Month"` = miss_by_month(aq_strat)), 2)

Missingness that depends on the unobserved value itself (e.g. high incomes are not reported because they are high) cannot be learned from the data, and is not reproduced.

Use missing = "independent" to reproduce only the rates, or "drop" to generate complete data.

Stratified models

A single Gaussian copula cannot represent every kind of dependence. Strong, non-monotone relations with a categorical variable — such as the species clusters in iris — are attenuated. Fitting separate models within strata solves this:

syn_pooled <- generate(fit_synthesizer(iris), seed = 1)
syn_strat <- generate(fit_synthesizer(iris, by = "Species"), seed = 1)
quality <- rbind(
  pooled = summary(compare_synthetic(iris, syn_pooled, seed = 1))$value[c(3, 5)],
  stratified = summary(compare_synthetic(iris, syn_strat, seed = 1))$value[c(3, 5)]
)
colnames(quality) <- c("mean |assoc. diff|", "discriminator AUC")
round(quality, 3)
plot_pairs(iris, syn_strat, vars = c("Sepal.Length", "Petal.Length", "Petal.Width"))

Strata smaller than min_stratum_size (default 20) fall back to a pooled model, which avoids over-fitting (and reproducing) tiny groups. Stratum sizes in the synthetic data are drawn from a multinomial distribution, or set proportionally with generate(..., stratum_sizes = "proportional").

Controlling the dependence

dependence shrinks the copula correlation matrix towards the identity without changing any marginal distribution — useful for sensitivity analyses or to reduce disclosure risk:

fit_mt <- fit_synthesizer(mtcars)
sapply(c(0, 0.5, 1), function(l) {
  s <- generate(fit_mt, n = 2000, dependence = l, seed = 5)
  cor(s$mpg, s$wt, method = "spearman")
})
cor(mtcars$mpg, mtcars$wt, method = "spearman")

Heavy tails: the t copula

For data with joint extremes (e.g. financial returns) the Gaussian copula underestimates the probability of simultaneous large values. The t copula estimates its degrees of freedom by profile likelihood:

r <- as.data.frame(diff(log(EuStockMarkets)))
fit_t <- fit_synthesizer(r, copula = "t")
fit_t

Identifiers, model size and privacy

d <- data.frame(id = sprintf("R%04d", 1:1000), x = rexp(1000))
fit_k <- fit_synthesizer(d, id_cols = "id", knots = 200)
head(generate(fit_k, n = 3, seed = 1))
format(object.size(fit_k), units = "Kb")

Multiple synthetic data sets

To reflect the uncertainty of the synthesis (for inference with multiple synthetic data sets), draw several independent copies with simulate():

sims <- simulate(fit_mt, nsim = 5, seed = 1)
sapply(sims, function(s) coef(lm(mpg ~ wt, data = s))[["wt"]])

Where next



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.