--- title: "Augmenting small training sets for machine learning" output: rmarkdown::html_vignette: toc: true vignette: > %\VignetteIndexEntry{Augmenting small training sets for machine learning} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} ok <- requireNamespace("rpart", quietly = TRUE) && requireNamespace("MASS", quietly = TRUE) knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 4, dpi = 72, out.width = "100%", eval = ok) ``` Machine-learning models often need more data than we have. A synthesizer fitted to a small sample can generate as many additional training records as needed. This vignette shows how to do that with `augment_data()` and, just as importantly, how to check whether it actually helps. ```{r} library(SynthesizerPlus) library(rpart) ``` ## What augmentation can and cannot do A synthesizer only knows what is in its training sample, so synthetic data **add no new information**. What they provide is a *smoothed* version of the training distribution: new combinations of values that are plausible under the fitted marginals and copula. For flexible, high-variance learners (regression trees, nearest neighbours, boosting, neural networks) trained on a small sample this acts as a regulariser and can reduce the prediction error. For a model that is already smooth, such as linear regression, there is little to gain. Whether it helps must therefore be **measured**, following four rules: 1. split the real data into a training and a test set first; 2. fit the synthesizer on the training set only; 3. evaluate on the held-out *real* test set (never on synthetic data); 4. repeat over several random splits. ## Tabular data: 60 observations We pretend that only 60 records of the Boston housing data are available and predict the median house value `medv`. ```{r} data(Boston, package = "MASS") rmse <- function(pred, obs) sqrt(mean((pred - obs)^2)) tree <- function(f, d) rpart(f, d, control = rpart.control(cp = 0.005, minsplit = 5)) one_split <- function(seed) { set.seed(seed) idx <- sample(nrow(Boston), 60) train <- Boston[idx, ] test <- Boston[-idx, ] big <- augment_data(train, n = 2000, seed = seed) # 60 real + 2000 synthetic c(tree_real = rmse(predict(tree(medv ~ ., train), test), test$medv), tree_augmented = rmse(predict(tree(medv ~ ., big), test), test$medv), lm_real = rmse(predict(lm(medv ~ ., train), test), test$medv), lm_augmented = rmse(predict(lm(medv ~ ., big), test), test$medv)) } res <- t(sapply(1:6, one_split)) round(colMeans(res), 2) mean(res[, "tree_augmented"] < res[, "tree_real"]) ``` The regression tree improves on average, though not in every split (the second line is the share of splits in which augmentation lowered its error); the linear model does not change. The gain for a single small data set is uncertain, which is why the comparison must be repeated over several splits. Before training, it is worth checking that the synthetic training data resemble the real training data: ```{r, fig.height = 4.5} set.seed(1) train <- Boston[sample(nrow(Boston), 60), ] syn <- generate(fit_synthesizer(train), n = 2000, seed = 1) plot_marginals(train, syn, vars = c("medv", "lstat", "rm", "crim")) ``` `augment_data()` accepts all arguments of `fit_synthesizer()`, e.g. `by =` to stratify on a categorical variable, and `label =` to add a column marking the synthetic rows (useful for down-weighting them in training). ## Time series: forecasting from a short history For forecasting, an ML model is trained on windows of lagged values. With one short series there are few windows; `augment_data()` on a `ts` object returns the real series followed by `nsim` synthetic series, and `ts_windows()` stacks the windows of all of them. We use the first 48 months of `ldeaths` for training and forecast the last 24 months one step ahead. ```{r} lags <- 12 n_train <- 48 train_ts <- window(ldeaths, end = time(ldeaths)[n_train]) all_w <- ts_windows(ldeaths, lags = lags) test_w <- all_w[(n_train - lags + 1):nrow(all_w), ] # targets in the test period forecast_rmse <- function(seed) { series <- augment_data(train_ts, nsim = 50, order = 12, seed = seed) real_w <- ts_windows(series[1], lags = lags) aug_w <- ts_windows(series, lags = lags) real_w$series <- aug_w$series <- NULL c(tree_real = rmse(predict(tree(y ~ ., real_w), test_w), test_w$y), tree_augmented = rmse(predict(tree(y ~ ., aug_w), test_w), test_w$y), lm_real = rmse(predict(lm(y ~ ., real_w), test_w), test_w$y)) } round(colMeans(t(sapply(1:6, forecast_rmse))), 1) ``` Two lessons: * Augmentation again helps the tree, but the linear autoregression on the real data is still the best forecaster here. Synthetic data make a flexible model better, not necessarily better than a well-specified simple one. * The synthesizer must reproduce the structure the forecaster relies on. For monthly data with a short history, AIC selects a VAR order below the seasonal period, and the synthetic series then lack the yearly cycle; fixing `order = 12` solves this. Always compare the autocorrelation of real and synthetic series: ```{r} fit_ts <- fit_synthesizer(train_ts, order = 12) plot_acf(train_ts, generate(fit_ts, n = 2000, seed = 3), lag_max = 24) ``` ## Checklist * Fit the synthesizer on training data only; evaluate on real test data. * Compare at least: real only, real + synthetic, and a simple baseline. * Inspect the synthetic training data (`compare_synthetic()`, `plot_marginals()`, `plot_acf()`) before trusting the results. * Tune the amount of synthetic data and the synthesizer settings (`by`, `copula`, `order`) with cross-validation on the training data, not on the test set.