---
title: "Getting started with SynthesizerPlus"
output:
  rmarkdown::html_vignette:
    toc: true
vignette: >
  %\VignetteIndexEntry{Getting started with SynthesizerPlus}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
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](evaluating-synthetic-data.html),
[time series](time-series.html),
[multivariate distributions](multivariate-distributions.html) and
[file formats](file-formats.html).

```{r}
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

```{r}
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:

```{r}
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:

```{r}
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:

```{r}
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

```{r}
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:

```{r}
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:

```{r}
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:

```{r}
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)
```

```{r, fig.height = 5}
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:

```{r}
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}
r <- as.data.frame(diff(log(EuStockMarkets)))
fit_t <- fit_synthesizer(r, copula = "t")
fit_t
```

## Identifiers, model size and privacy

* Columns listed in `id_cols` are not modelled; synthetic records get fresh
  identifiers.
* By default the continuous marginals store all observed values.
  `knots = 200`, say, compresses each quantile function to 200 quantiles:
  the model gets smaller and no longer contains the original values.

```{r}
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()`:

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

## Where next

* `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")`.
