knitr::opts_chunk$set( collapse = TRUE, comment = "#>" )
This vignette covers the entire adversarial random forest (ARF) pipeline, from model training to parameter learning, density estimation, and data synthesis.
The ARF algorithm is an iterative procedure. In the first instance, we generate synthetic data by independently sampling from the marginals of each feature and training a random forest (RF) to distinguish original from synthetic samples. If accuracy is greater than $0.5 + \delta$ (where delta
is a user-controlled tolerance parameter, generally set to 0), we create a new dataset by sampling from the marginals within each leaf and training another RF classifier. The procedure repeats until original and synthetic samples cannot be reliably distinguished. With the default verbose = TRUE
, the algorithm will print accuracy at each iteration.
# Load libraries library(arf) library(data.table) library(ggplot2) # Set seed set.seed(123, "L'Ecuyer-CMRG") # Train ARF arf_iris <- adversarial_rf(iris)
The printouts can be turned off by setting verbose = FALSE
. Accuracy is still stored within the arf
object, so you can evaluate convergence after the fact. The warning appears just once per session. It can be suppressed by setting parallel = FALSE
or registering a parallel backend (more on this below).
# Train ARF with no printouts arf_iris <- adversarial_rf(iris, verbose = FALSE) # Plot accuracy against iterations (model converges when accuracy <= 0.5) tmp <- data.frame('Accuracy' = arf_iris$acc, 'Iteration' = seq_len(length(arf_iris$acc))) ggplot(tmp, aes(Iteration, Accuracy)) + geom_point() + geom_path() + geom_hline(yintercept = 0.5, linetype = 'dashed', color = 'red')
We find a quick drop in accuracy following the resampling procedure, as desired. If the ARF has converged, then resulting splits should form fully factorized leaves, i.e. subregions of the feature space where variables are locally independent.
ARF convergence is asymptotically guaranteed as $n \rightarrow \infty$ (see Watson et al., 2023, Thm. 1). However, this has no implications for finite sample performance. In practice, we often find that adversarial training completes in just one or two rounds, but this may not hold for some datasets. To avoid infinite loops, users can increase the slack parameter delta
or set the max_iters
argument (default = 10). In addition to these failsafes, adversarial_rf
uses early stopping by default (early_stop = TRUE)
, which terminates training if factorization does not improve from one round to the next. This is recommended, since discriminator accuracy rarely falls much lower once it has increased.
For density estimation tasks, we recommend increasing the default number of trees. We generally use 100 in our experiments, though this may be suboptimal for some datasets. Likelihood estimates are not very sensitive to this parameter above a certain threshold, but larger models incur extra costs in time and memory. We can speed up computations by registering a parallel backend, in which case ARF training is distributed across cores using the ranger
package. Much like with ranger
, the default behavior of adversarial_rf
is to compute in parallel if possible. How exactly this is done varies across operating systems. The following code works on Unix machines.
# Register cores - Unix library(doParallel) registerDoParallel(cores = 2)
Windows requires a different setup.
# Register cores - Windows library(doParallel) cl <- makeCluster(2) registerDoParallel(cl)
In either case, we can now execute in parallel.
# Rerun ARF, now in parallel and with more trees arf_iris <- adversarial_rf(iris, num_trees = 100)
The result is an object of class ranger
, which we can input to downstream functions.
The next step is to learn the leaf and distribution parameters using forests for density estimation (FORDE). This function calculates the coverage, bounds, and pdf/pmf parameters for every variable in every leaf. This can be an expensive computation for large datasets, as it requires $\mathcal{O}\big(B \cdot d \cdot n \cdot \log(n)\big)$ operations, where $B$ is the number of trees, $d$ is the data dimensionality, and $n$ is the sample size. Once again, the process is parallelized by default.
# Compute leaf and distribution parameters params_iris <- forde(arf_iris, iris)
Default behavior is to use a truncated normal distribution for continuous data (with boundaries given by the tree's split parameters) and a multinomial distribution for categorical data. We find that this produces stable results in a wide range of settings. You can also use a uniform distribution for continuous features by setting family = 'unif'
, thereby instantiating a piecewise constant density estimator.
# Recompute with uniform density params_unif <- forde(arf_iris, iris, family = 'unif')
This method tends to perform poorly in practice, and we do not recommend it. The option is implemented primarily for benchmarking purposes. Alternative families, e.g. truncated Poisson or beta distributions, may be useful for certain problems. Future releases will expand the range of options for the family
argument.
The alpha
and epsilon
arguments allow for optional regularization of multinomial and uniform distributions, respectively. These help prevent zero likelihood samples when test data fall outside the support of training data. The former is a pseudocount parameter that applies Laplace smoothing within leaves, preventing unobserved values from being assigned zero probability unless splits explicitly rule them out. In other words, we impose a flat Dirichlet prior and report posterior probabilities rather than maximum likelihood estimates. The latter is a slack parameter on empirical bounds that expands the estimated extrema for continuous features by a factor of $1 + \epsilon$.
Compare the results of our original probability estimates for the Species
variable with those obtained by adding a pseudocount of $\alpha = 0.1$.
# Recompute with additive smoothing params_alpha <- forde(arf_iris, iris, alpha = 0.1) # Compare results head(params_iris$cat) head(params_alpha$cat)
Under Laplace smoothing, extreme probabilities only occur when the splits explicitly demand it. Otherwise, all values shrink toward a uniform prior. Note that these two data tables may not have exactly the same rows, as we omit zero probability events to conserve memory. However, we can verify that probabilities sum to unity for each leaf-variable combination.
# Sum probabilities summary(params_iris$cat[, sum(prob), by = .(f_idx, variable)]$V1) summary(params_alpha$cat[, sum(prob), by = .(f_idx, variable)]$V1)
The forde
function outputs a list of length 6, with entries for (1) continuous features; (2) categorical features; (3) leaf parameters; (4) variable metadata; (5) factor levels; and (6) data input class.
params_iris
These parameters can be used for a variety of downstream tasks, such as likelihood estimation and data synthesis.
To calculate log-likelihoods, we pass params
on to the lik
function, along with the data whose likelihood we wish to evaluate. For total evidence queries (i.e., those spanning all variables and no conditioning events), it is faster to also include arf
in the function call.
# Compute likelihood under truncated normal and uniform distributions ll <- lik(params_iris, iris, arf = arf_iris) ll_unif <- lik(params_unif, iris, arf = arf_iris) # Compare average negative log-likelihood (lower is better) -mean(ll) -mean(ll_unif)
Note that the piecewise constant estimator does considerably worse in this experiment.
The lik
function can also be used to compute the likelihood of some partial state, i.e. a setting in which some but not all variable values are specified. Let's take a look at that iris dataset:
head(iris)
Say we want to calculate sample likelihoods using only continuous data. That is, we provide values for the first four variables but exclude the fifth. In this case, the model will have to marginalize over Species
:
# Compute likelihoods after marginalizing over Species iris_without_species <- iris[, -5] ll_cnt <- lik(params_iris, iris_without_species) # Compare results tmp <- data.frame(Total = ll, Partial = ll_cnt) ggplot(tmp, aes(Total, Partial)) + geom_point() + geom_abline(slope = 1, intercept = 0, color = 'red')
We find that likelihoods are almost identical, with slightly higher likelihood on average for partial samples. This is expected, since they have less variation to model.
In this example, we have used the same data throughout. This may lead to overfitting. With sufficient data, it is preferable to use a training set for adversarial_rf
, a validation set for forde
, and a test set for lik
. Alternatively, we can set the oob
argument to TRUE
for either of the latter two functions, in which case computations are performed only on out-of-bag (OOB) data. These are samples that are randomly excluded from a given tree due to the bootstrapping subroutine of the RF classifier. Note that this only works when the dataset x
passed to forde
or lik
is the same one used to train the arf
. Recall that a sample's probability of being excluded from a single tree is $\exp(-1) \approx 0.368$. When using oob = TRUE
, be sure to include enough trees so that every observation is likely to be OOB at least a few times.
For this experiment, we use the smiley
simulation from the mlbench
package, which allows for easy visual assessment. We draw a training set of $n = 1000$ and simulate $1000$ synthetic datapoints. Resulting data are plotted side by side.
# Simulate training data library(mlbench) x <- mlbench.smiley(1000) x <- data.frame(x$x, x$classes) colnames(x) <- c('X', 'Y', 'Class') # Fit ARF arf_smiley <- adversarial_rf(x, mtry = 2) # Estimate parameters params_smiley <- forde(arf_smiley, x) # Simulate data synth <- forge(params_smiley, n_synth = 1000) # Compare structure str(x) str(synth) # Put it all together x$Data <- 'Original' synth$Data <- 'Synthetic' df <- rbind(x, synth) # Plot results ggplot(df, aes(X, Y, color = Class, shape = Class)) + geom_point() + facet_wrap(~ Data)
The general shape is clearly recognizable, even if some stray samples are evident and borders are not always crisp. This can be improved with more training data.
ARFs can also be used to compute likelihoods and synthesize data under conditioning events that specify values or ranges for input features. For instance, say we want to evaluate the likelihood of samples from the iris
dataset under the condition that Species = 'setosa'
. There are several ways to encode evidence, but the simplest is to pass a partial observation.
# Compute conditional likelihoods evi <- data.frame(Species = 'setosa') ll_conditional <- lik(params_iris, query = iris_without_species, evidence = evi) # Compare NLL across species (shifting to positive range for visualization) tmp <- iris tmp$NLL <- -ll_conditional + max(ll_conditional) + 1 ggplot(tmp, aes(Species, NLL, fill = Species)) + geom_boxplot() + scale_y_log10() + ylab('Negative Log-Likelihood') + theme(legend.position = 'none')
As expected, measurements for non-setosa samples appear much less likely under this conditioning event.
The partial observation method of passing evidence requires users to specify unique feature values for each conditioning variable. A more flexible alternative is to construct a data frame of conditioning events, potentially including inequalities. For example, we may want to calculate the likelihood of samples given that Species = 'setosa'
and Petal.Width > 0.3
.
# Data frame of conditioning events evi <- data.frame(variable = c('Species', 'Petal.Width'), relation = c('==', '>'), value = c('setosa', 0.3)) evi
Each row is treated as an extra condition, so we can define intervals by putting multiple constraints on a single variable.
evi <- data.frame(variable = c('Species', 'Petal.Width', 'Petal.Width'), relation = c('==', '>', '<='), value = c('setosa', 0.3, 0.5)) evi
Resulting likelihoods will be computed on the condition that all queries are drawn from setosa flowers with petal width on the interval $(0.3, 0.5]$.
A final method for passing evidence is to directly compute a posterior distribution on leaves. This could be useful for particularly complex conditioning events for which there is currently no inbuilt interface, such as polynomial constraints or arbitrary propositions in disjunctive normal form. In this case, we just require a data frame with columns for f_idx
and wt
. If the latter does not sum to unity, the distribution will be normalized with a warning.
# Drawing random weights evi <- data.frame(f_idx = params_iris$forest$f_idx, wt = rexp(nrow(params_iris$forest))) evi$wt <- evi$wt / sum(evi$wt) head(evi)
Each of these methods can be used for conditional sampling as well.
# Simulate class-conditional data for smiley example evi <- data.frame(Class = 4) synth2 <- forge(params_smiley, n_synth = 250, evidence = evi) # Put it all together synth2$Data <- 'Synthetic' df <- rbind(x, synth2) # Plot results ggplot(df, aes(X, Y, color = Class, shape = Class)) + geom_point() + facet_wrap(~ Data)
By conditioning on Class = 4
, we restrict our sampling to the smile itself, rather than eyes or nose.
Computing conditional expectations is similarly straightforward.
expct(params_smiley, evidence = evi)
These are the average $X, Y$ coordinates for the smile above.
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.