This post outlines a Shiny app to calculate the required sample size for a two-group linear mixed model.

It uses the `power.mmrm` function from the `longpower` package (see Lu, Luo, & Chen, 2008, for more information).

The input is simplified, whereby users are limited to five time-points, the correlation matrix is a compound symmetry correlation structure that is identical for both groups, and the attrition rate is assumed to be the same for both groups.

# Shiny App

https://sammancuso.shinyapps.io/power

# Code

The code is provided in case you want to run the code locally.

```# Load the shiny and longpower packages
library(shiny)
library(shinythemes)
library(longpower)

# UI ----------------------------------------------------------------------
ui <- fluidPage(
# Custom theme
theme = shinytheme("flatly"),
titlePanel("Linear Mixed Model Sample Size Calculations"),

sidebarLayout(
# Input
sidebarPanel(
selectInput(
"typeInput",
"Use effect size (Cohen's d) or mean difference:",
choices = c("Effect size", "Mean difference")
),
conditionalPanel(
condition = "input.typeInput == 'Effect size'",
numericInput(
"numDelta",
"Cohen's d:",
min = 0,
step = 0.1,
value = 0.5
)
),
conditionalPanel(
condition = "input.typeInput == 'Mean difference'",
numericInput(
"numDelta",
"Mean difference:",
min = 0,
step = 0.1,
value = 0.5
),
numericInput("sdA",
"SD for Group A:",
min = 0,
value = 1
),
numericInput("sdB",
"SD for Group B:",
min = 0,
value = 1
)
),
numericInput(
"numLambda",
"Group allocation ratio (N1:N2):",
value = 1,
min = 0.01,
step = 0.1
),
numericInput(
"numTime",
"Number of time-points:",
min = 2,
max = 5,
value = 2
),
numericInput(
"corTime",
"Correlation between time-points:",
min = 0.1,
max = 0.9,
step = 0.1,
value = 0.5
),
numericInput(
"numAlpha",
"Alpha:",
min = 0,
max = 1,
step = 0.01,
value = 0.05
),
selectInput(
"sideInput",
"One- or two-sided test:",
choices = c("Two-sided", "One-sided")
),
numericInput(
"numPower",
"Power:",
min = 0.8,
max = 1,
step = 0.1,
value = 0.8
),
# Attrition
strong("Attrition"),
p("Specify attrition rate between the time-points as a proportion."),
conditionalPanel(
condition = "input.numTime >= 2",
numericInput(
"numAttr1",
"Time 1 to 2:",
min = 0,
max = 0.99,
step = 0.1,
value = 0
)
),
conditionalPanel(
condition = "input.numTime >= 3",
numericInput(
"numAttr2",
"Time 1 to 3:",
min = 0,
max = 0.99,
step = 0.1,
value = 0
)
),
conditionalPanel(
condition = "input.numTime >= 4",
numericInput(
"numAttr3",
"Time 1 to 4:",
min = 0,
max = 0.99,
step = 0.1,
value = 0
)
),
conditionalPanel(
condition = "input.numTime == 5",
numericInput(
"numAttr4",
"Time 1 to 5:",
min = 0,
max = 1,
step = 0.1,
value = 0
)
)
),

# Output
mainPanel(
h2("Overview"),
tags\$p(
"This function calulates the sample size for a mixed model of repeated measures with a compound symmetry correlation structure."
),
tags\$p(
"It uses the",
tags\$code("power.mmrm"),
"function from the",
tags\$a(tags\$code("longpower"), href = "https://cran.r-project.org/web/packages/longpower/index.html"),
tags\$p("The function has been modified to round the sample size estimate up to the nearest integer."),
h3("Important Information"),
tags\$p("The calculations assume that the correlation structure and attrition rate are the same for both groups."),
h2("Results"),
verbatimTextOutput("console"),
h2("Sample Size Paragraph for Studies or Grants"),
textOutput("samp_paragraph"),
h3("Reference"),
tags\$p(
"Lu, K., Luo, X., Chen, P.-Y. (2008). Sample size estimation for repeated measures analysis in randomized clinical trials with missing data.",
tags\$i("International Journal of Biostatistics"), ", ", tags\$i("4"), "(1)"
),
h2("Disclaimer"),
tags\$p(tags\$b("Notice to any user of this website")),
p("The author of this website makes no warranty, expressed or implied, as to the results obtained from the use of the information on the website."),
p("The author of this website shall have no liability for the accuracy of the information and cannot be held liable for any third-party claims or losses of any damages."),
p("The author of this website may, at any time, revise the information on this website without notice and makes no commitment to update this information.")
),
)
)

# Server ------------------------------------------------------------------
server <- function(input, output, session) {
# Updates the attrition so it's equal to the previous timepoint
# Done before user specifies the value

observe({
numAttr1 <- as.numeric(input\$numAttr1)
numAttr2 <- as.numeric(input\$numAttr2)
numAttr3 <- as.numeric(input\$numAttr3)
numAttr4 <- as.numeric(input\$numAttr4)

if (numAttr2 < numAttr1) {
updateNumericInput(session, "numAttr2", value = numAttr1)
}

if (numAttr3 < numAttr2) {
updateNumericInput(session, "numAttr3", value = numAttr2)
}

if (numAttr4 < numAttr3) {
updateNumericInput(session, "numAttr4", value = numAttr3)
}
})

powerout <- reactive({
rho <- as.numeric(input\$corTime)
k <- as.numeric(input\$numTime)
lambda <- as.numeric(input\$numLambda)

# Correlation matrix
Ra <- matrix(rho,
nrow = k,
ncol = k
)

diag(Ra) <- 1

# Delta
delta <- as.numeric(input\$numDelta)

# Standard deviations
if (input\$typeInput == "Effect size") {
sigmaa <- 1
sigmab <- 1
} else {
sigmaa <- as.numeric(input\$sdA)
sigmab <- as.numeric(input\$sdB)
}

power <- as.numeric(input\$numPower)
sig.level <- as.numeric(input\$numAlpha)
alternative <- ifelse(input\$sideInput == "Two-sided",
"two.sided",
"one.sided"
)

# Attrition vector
ra <- vector("numeric", length = k)

for (i in 1:k) {
if (i == 1) {
ra[i] <- 1
} else {
ra[i] <- 1 - input[[paste0("numAttr", i - 1)]]
}
}

samp <- power.mmrm(
N = NULL,
Ra = Ra,
Rb = Ra,
ra = ra,
rb = ra,
delta = delta,
sigmaa = sigmaa,
sigmab = sigmab,
power = power,
sig.level = sig.level,
lambda = lambda,
alternative = alternative
)

# Round up the sample sizes to nearest integer
samp\$n1 <- ceiling(samp\$n1)
samp\$n2 <- ceiling(samp\$n2)

return(
list(
samp = samp,
n1 = samp\$n1,
n2 = samp\$n2,
attr = round(100 * (1 - ra), 2)
)
)

})

# Display output from powr.mrmm
output\$console <- renderPrint({
powerout()\$samp
})

output\$samp_paragraph <- renderPrint({

# Helper function for collapsing character vectors
fPaste <- function(vec) sub(",\\s+([^,]+)\$", " and \\1", toString(vec))

tplParagraph <- "A sample size of %s will be required to test the primary outcome of interest. The calculation was based on a mixed model of repeated measures with a general correlation structure (Lu et al., 2008). The calculations assumed a group allocation of %s, alpha of %s, %s%% power, %s assessment points, a between-groups %s at post-intervention, a compound symmetry correlation matrix (rho = %s), and %s."

# Number of time-points in written form
num_string <- c("one", "two", "three", "four", "five")

# Group allocation
if (input\$numLambda != 1) {

n_text <- paste0(powerout()\$n1, " and ", powerout()\$n2, " in each respective group (", sum(powerout()\$n1, powerout()\$n2), " in total)")

n_alloc <- paste0(input\$numLambda, ":1")

} else {

n_text <- paste0(powerout()\$n1, " per group (", sum(powerout()\$n1, powerout()\$n2), " in total)")

n_alloc <- "1:1"
}

# Mean difference or effect size
if (input\$typeInput == "Mean difference") {

es_text <- paste0("mean difference of ", input\$numDelta, " (SD Group A = ", input\$sdA, " and SD Group B = ", input\$sdB, ")")

} else {

es_text <- paste0("effect size of Cohen's d = ", input\$numDelta)
}

# Attrition Text
if (all(powerout()\$attr == 0)) {

att_text <- "no attrition between the assessment points"

} else {

att_text <- paste0("attrition rates of ", fPaste(paste0(powerout()\$attr[-1], "%")), " between assessment points")

}

cat(
"\n",
sprintf(
tplParagraph,
n_text,
n_alloc,
input\$numAlpha,
round(100 * input\$numPower, 1),
num_string[input\$numTime],
es_text,
input\$corTime,
att_text
),
"\n",
sep = ""
)

})
}

shinyApp(ui, server)
```