Two Flavors of Parallel Simulation

Overview

In a prior post, we discussed how to use Monte Carlo simulation for power analysis. We kept the total number of iterations relatively low to illustrate the process. However, the real value of simulation emerges with many iterations, because the more iterations you run the better idea you get about the thing you are trying to estimate (see Law of Large Numbers). In the case of estimating the power of an experiment, the more simulated experiments we run the closer we’ll get to the true probability of committing a Type II Error. Simulating the experimental paradigm sequentially is fine but it takes a long time when you increase the number of simulations to 10K or 100K. Any time you come across a task that involves repeated sampling from a distribution – think parallel. The results of one simulation do not feed into or depend on the results of another. Thus we can run many simulated experiments at the same time. This is a common theme of any task that is parallelizable, which might be one of the most challenging words to say. In this post, I’m going to discuss two separate ways to implement a power analysis simulation in R. And although we’ll focus only on parallelism (yes, apparently parallelism is a word) in the context of experimental power, the workflow discussed here can be generalized to almost any task that involves repeated sampling.

Parallel Simulations with Foreach

Before starting let’s get a high-level understanding of the analytical dataset. Researchers conducted a study examining the impact of continued sleep deprivation (defined as receiving only three hours of sleep per night) on reaction time. The study ran for nine days and the researchers found a significant effect for Number of Days. As you can imagine, participants were a lot slower to react on days eight and nine relative to days zero and one. We want to replicate this effect but don’t have the time to wait nine days for a result. Our question, then, is whether we could still detect an effect of sleep deprivation after only three days. The goal is to achieve at least 80% power, which means that if we replicated the experiment 10 times under the exact same conditions, we would find a significant effect (p < 0.05) in at least eight experiments.

We’ll use the findings from the prior study over the first three days as our base data set. The process will be modeled with a mixed effects model with a random intercept for each participant. Our fixed effect – the thing we are interested in – is days of sleep deprivation. Let’s load up our libraries and fit the initial model.

libs = c('foreach', 'doParallel', 'lme4', 
         'dplyr', 'broom', 'ggplot2', 'knitr', 'janitor')
lapply(libs, require, character.only = TRUE)
sleep_df = lme4::sleepstudy %>% 
           clean_names() %>% 
           filter(days <= 3)

fit = lmer(reaction ~ days + (1|subject), data = sleep_df)
confidence_intervals = confint(fit)

Let’s examine the estimate and confidence intervals for the influence days on reaction time.

print(summary(fit))
print(confidence_intervals)
## Linear mixed model fit by REML ['lmerMod']
## Formula: Reaction ~ Days + (1 | Subject)
##    Data: sleep_df
## 
## REML criterion at convergence: 660.4
## 
## Scaled residuals: 
##      Min       1Q   Median       3Q      Max 
## -3.14771 -0.50969 -0.08642  0.48985  2.05082 
## 
## Random effects:
##  Groups   Name        Variance Std.Dev.
##  Subject  (Intercept) 755.7    27.49   
##  Residual             379.1    19.47   
## Number of obs: 72, groups:  Subject, 18
## 
## Fixed effects:
##             Estimate Std. Error t value
## (Intercept)  255.392      7.532   33.91
## Days           7.989      2.052    3.89
## 
## Correlation of Fixed Effects:
##      (Intr)
## Days -0.409

##                  2.5 %    97.5 %
## .sig01       18.702382  39.73719
## .sigma       16.152095  23.58800
## (Intercept) 240.429427 270.35528
## Days          3.931803  12.04555

Our model indicates that after accounting for baseline differences in participant reaction time (i.e., our random intercept), each additional day increases reaction time by about 8 seconds (7.989 to be exact). Our confidence interval for this coefficient indicates a significant effect, as the range does not contain zero. However, the range of our estimate is fairly wide. Let’s determine how this uncertainty affects overall experimental power. We’ll make predictions on our base dataset with the model defined above, and then add noise (defined by our residuals from our initial model fit) to simulate the sampling process.

model_predictions = predict(fit, sleep_df)
standard_deviation = sd(fit@resp$y - fit@resp$mu)
n_simulations = 1000

for loops in R are great for small operations but can be slow for larger operations. Enter foreach. The syntax is a little bit different from your typical for loop. Let’s first see how to implement our power simulation sequentially using foreach. Note that this approach is identical to using a regular for loop.

seq_start_time = Sys.time()
seq_results = foreach(
                  i = 1:n_simulations,
                  .combine = "rbind",
                  .packages = c("lme4", "broom", "dplyr")) %do% {
                  #generate residuals
                  temporary_residuals = rnorm(nrow(sleep_df), 
                                              mean = 0, 
                                              sd = standard_deviation)
                  #create simulated reaction time
                  sleep_df$simulated_reaction = model_predictions + temporary_residuals
                  #refit our model on the simulated data
                  temp_fit = lmer(simulated_reaction ~ days + (1|subject), 
                                  data = sleep_df)
                  #return confidence interval for the Days coefficient
                  tidy(confint(temp_fit)) %>%
                  dplyr::rename(coefficients = .rownames,
                  lower_bound = X2.5..,
                  upper_bound = X97.5..) %>%
                  dplyr::filter(coefficients == 'days') %>%
                  dplyr::select(lower_bound, upper_bound)
}

seq_end_time = Sys.time()
seq_run_time = seq_end_time - seq_start_time
print(paste0("TOTAL RUN TIME: ", seq_run_time))
## [1] "TOTAL RUN TIME: 10.4966700077057"

Implementing the power simulation sequentially took about 10 minutes on my computer. Let’s compare that to a parallel implementation. All we have to do is change the %do% to %dopar% to shift the execution from sequential to parallel. But first, we’ll have to set up a computing cluster.

# register our cluster
cl = makeCluster(detectCores())
registerDoParallel(cl)

Now that we’ve registered our computing cluster, let’s re-run the above code block but replace %do% with %dopar% and compare the run-time.

para_start_time = Sys.time()
para_results = foreach(
                 i = 1:n_simulations,
                 .combine = "rbind",
                 .packages = c("lme4", "broom", "dplyr")) %dopar% {
                 # generate residuals
                 temporary_residuals = rnorm(nrow(sleep_df),
                                             mean = 0,
                                             sd = standard_deviation)
                 #create simulated reaction time
                 sleep_df$simulated_reaction = model_predictions + temporary_residuals
                 #refit our model on the simulated data
                 temp_fit = lmer(simulated_reaction ~ days + (1|subject), 
                                 data = sleep_df)
                 #return confidence interval for the Days coefficient
                 tidy(confint(temp_fit)) %>%
                 dplyr::rename(coefficients = .rownames,
                 lower_bound = X2.5..,
                 upper_bound = X97.5..) %>%
                 dplyr::filter(coefficients == 'days') %>%
                 dplyr::select(lower_bound, upper_bound)
}

para_end_time = Sys.time()
para_run_time = para_end_time - para_start_time
print(paste0("TOTAL RUN TIME: ", para_run_time))
## [1] "TOTAL RUN TIME: 2.09167686700821"

So that only took 2.09 minutes, which a substantial reduction in runtime! Let’s check and see how our power calculations panned out. Every instance in which we find a zero in our confidence interval for the Days estimate is a type II error.

power_results = para_results %>%
                mutate(row_index = 1:nrow(para_results)) %>%
                group_by(row_index) %>%
                do(result = dplyr::between(0, .$lower_bound, .$upper_bound)) %>%
                mutate(result = as.integer(unlist(result)))
print(paste0("TOTAL POWER: ", (n_simulations - sum(power_results$result))/nrow(power_results) * 100, "%"))
## [1] "TOTAL POWER: 98.9%"

If we ran our experiment under these conditions, we’d detect an effect that we know exists in about 99 of every 100 experiments. So it turns out we can reliably detect an effect with only three days instead of running it for all nine, saving us time and money. Let’s move on to the second approach to parallelism with spark.

Parallel Simulations with Spark

Spark has recently become one the most popular processing engines for data science. It has several APIs for different programming languages, and R has recently been added to growing list of supported languages. The main benefit of Spark is that it allows for the rapid processing and modeling of data at scale (think lots of data). The main drawback, at the moment, is that it does not support all of the built-in R libraries. This is the primary reason why the section on foreach loops was included, as you might want to speed up an operation that cannot be accessed in Spark.

Indeed, in this section,we’ll use a seperate library, nlme, for mixed-effect modeling. We’ll implement the same process described in the previous section, and compare its runtime with foreach. Let’s get started by setting up a “local” spark instance, which means leveraging all of the compute power available on the machine that’s also running our R-script. The real power of Spark is realized in a computing cluster, where the data is spread to and processed by many computers simultaneously. However, we’re going to keep it simple and just run everything on a single machine.

library(sparklyr)
library(nlme)
## create spark context (sc)
sc = spark_connect(master = "local")

We can verify that Spark is using all of available cores for computation.

print(paste0("N Cores in use: ", sc$config$sparklyr.cores.local))
## [1] "N Cores in use: 8"

Now let’s refit the model above and get an estimate of our standard deviation.

fit_nlme = nlme::lme(reaction ~ days, random = ~ 1|subject, sleep_df)
fit_pred = predict(fit_nlme, sleep_df)
standard_deviation = sd(sleep_df$reaction - unname(fit_pred))

Next, we’ll make 1000 copies of our dataset and bind them together. We’ll also generate all the errors for each of the iterations as well.

n_sim = 1000

sleep_df_sim = data.frame(sapply(sleep_df, 
                                  rep.int, 
                                  times = n_sim))
residual_i = c()
for(i in 1:n_sim){
  residual_i = c(residual_i, 
                          rnorm(nrow(sleep_df), 
                                mean = 0, 
                                sd = standard_deviation))
}

sleep_df_sim$iteration = rep(1:n_sim, 
                              each = nrow(sleep_df))
sleep_df_sim$sim_reaction = residual_i + rep(fit_pred, n_sim)

At this point, each study has 72 observations (18 participants with 4 data points each (days 0 - 3). We created 1000 replications of the study, so our total dataset size is now 72000 rows. Each 72 observation “group” is identified by the iteration field. Thus each core should receive approximately 125 iterations with 72 observations per iteration, for a total of ~9000 observations per core (assuming you are also using eight cores). Rarely is data so perfectly balanced, but this should provide an intuition into what’s happening under the hood. Let’s first translate our R DataFrame into a Spark DataFrame.

sleep_tbl = copy_to(sc, 
                    sleep_df_sim, 
                    "sleep_df_sim")

Below we’ll utilize the spark_apply function to implement the power simulation. I frequently use this function in my day-to-day work, as it allows the user to define an arbitrary function and then apply it to the partitioned Spark DataFrame. We are partitioning our DataFrame by the iteration field. We’ll then pull the results back into R with the collect verb and calculate the total number of confidence intervals that contain zero, which indicates a Type II Error.

spark_start_time = Sys.time()
sim_results = spark_apply(sleep_tbl,
                   function(x) broom::tidy(
                               nlme::intervals(
                               nlme::lme(
                                 sim_reaction ~ days, 
                                         random = ~ 1|subject, x
                                        )
                                              )$fixed
                                           ),
                   names = c('.rownames', 
                             'lower', 
                             'est.', 
                             'upper'),
                   group_by = "iteration"
)

sim_ci = sim_results %>% 
         collect() %>% 
         data.frame() %>% 
         filter(.rownames == 'days') %>% 
         select(iteration, lower, upper) %>% 
         mutate(lower_bound = ifelse(lower < 0, 1, 0),
                upper_bound = ifelse(upper > 0, 1, 0)) %>% 
         mutate(type_ii_err = ifelse(lower_bound + upper_bound > 1, 1, 0))
spark_end_time = Sys.time()
spark_run_time = spark_end_time - spark_start_time
print(paste0("TOTAL RUN TIME: ", spark_run_time))
## [1] "TOTAL RUN TIME: 22.3460450172424"

In this case, Spark is considerably faster than foreach, with a runtime of only 22.3 seconds! I retried the code above with the nlme mixed effects model just to make sure that the drastic speed up wasn’t due to using a different library (it made almost no difference). Let’s check the power estimate as well.

print(paste0("TOTAL POWER: ", (nrow(sim_ci) - sum(sim_ci$type_ii_err))/nrow(sim_ci) * 100, "%"))
## [1] "TOTAL POWER: 98.9%"

This simulation gave us an identical estimate relative to the foreach.Taken together, the results from both of these simulations indicate that we wouldn’t need the full nine days to show an effect. When you are finished, remember to shut your cluster down with the following command.

spark_disconnect(sc)

I hope this post reveals how easy it is to add some parallelism to your R code. Time always seems to be in short supply when you are developing, and waiting around for an answer is a total momentum killer. Taking a bit more time up front to understand whether you can run your code in parallel – and avoiding sequential for loops – will save you a ton of time down the line.

Related

comments powered by Disqus