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)
By Sklar's theorem every multivariate distribution can be written as a copula (the dependence structure) applied to the marginal distributions. SynthesizerPlus estimates
?fit_synthesizer);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 <- 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").
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.
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.
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").
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")
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
id_cols are not modelled; synthetic records get fresh
identifiers.knots = 200, say, compresses each quantile function to 200 quantiles:
the model gets smaller and no longer contains the original values.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")
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"]])
compare_synthetic() and the plotting functions assess the quality of the
synthetic data — see vignette("evaluating-synthetic-data").generate_to_file() writes very large synthetic data sets in chunks to
CSV, Parquet, HDF5 and other formats — see vignette("file-formats").Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.