---
title: "Controlling how close synthetic data are to the real data"
output:
  rmarkdown::html_vignette:
    toc: true
vignette: >
  %\VignetteIndexEntry{Controlling how close synthetic data are to the real data}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
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

* to lower the disclosure risk of a release (records far from any real
  record),
* to create more diverse training data for machine learning, or
* to build stress-test scenarios in which distributions are shifted, more
  spread out or differently correlated.

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 |

```{r}
library(SynthesizerPlus)
data(Boston, package = "MASS")
fit <- fit_synthesizer(Boston)
```

## One knob: `closeness`

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

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

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

```{r}
scenario <- perturbation(shift = c(crim = 1), scale = c(rm = 1.5), dependence = 1.3)
scenario
stress <- generate(fit, perturb = scenario, seed = 3)
```

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

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

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

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

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